@xano/cli 1.0.3-beta.9 → 1.0.4-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.
- package/dist/base-command.js +45 -1
- package/dist/commands/tenant/snapshot/create/index.d.ts +17 -0
- package/dist/commands/tenant/snapshot/create/index.js +78 -0
- package/dist/commands/tenant/snapshot/delete/index.d.ts +19 -0
- package/dist/commands/tenant/snapshot/delete/index.js +102 -0
- package/dist/commands/tenant/snapshot/list/index.d.ts +16 -0
- package/dist/commands/tenant/snapshot/list/index.js +96 -0
- package/dist/commands/tenant/snapshot/swap/index.d.ts +19 -0
- package/dist/commands/tenant/snapshot/swap/index.js +103 -0
- package/dist/utils/multidoc-push.js +77 -6
- package/oclif.manifest.json +2963 -2577
- package/package.json +3 -2
package/dist/base-command.js
CHANGED
|
@@ -3,11 +3,46 @@ import * as yaml from 'js-yaml';
|
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
+
import { Agent } from 'undici';
|
|
6
7
|
import { checkForUpdate } from './update-check.js';
|
|
7
8
|
import { applyLocalOverrides, findLocalProfilePath, formatLocalProfileBanner, parseLocalProfile, resolveProfileSelection, } from './utils/local-config.js';
|
|
8
9
|
export function buildUserAgent(version) {
|
|
9
10
|
return `xano-cli/${version} (${process.platform}; ${process.arch}) node/${process.version}`;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Default per-request timeout for Metadata API calls, in milliseconds (15 min).
|
|
14
|
+
*
|
|
15
|
+
* Node's built-in fetch (undici) defaults to a 300s `headersTimeout` and throws
|
|
16
|
+
* UND_ERR_HEADERS_TIMEOUT when a slow endpoint (e.g. a large multidoc push) takes
|
|
17
|
+
* longer than that to produce response headers — even when the server, ingress
|
|
18
|
+
* (3600s), and load balancer would happily wait. We replace that hidden 300s
|
|
19
|
+
* ceiling with a single, generous bound applied to every request. Override with
|
|
20
|
+
* the XANO_CLI_REQUEST_TIMEOUT_MS env var; set it to 0 to disable the timeout.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
|
23
|
+
function resolveRequestTimeoutMs(env = process.env) {
|
|
24
|
+
const raw = env.XANO_CLI_REQUEST_TIMEOUT_MS;
|
|
25
|
+
if (raw === undefined || raw === '')
|
|
26
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
27
|
+
const parsed = Number(raw);
|
|
28
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
29
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Lazily-built undici dispatcher whose header/body inactivity timeouts match our
|
|
34
|
+
* request timeout, so undici's internal 300s default never fires first. Built
|
|
35
|
+
* once and reused across requests. `timeout === 0` means "no bound".
|
|
36
|
+
*/
|
|
37
|
+
let sharedDispatcher;
|
|
38
|
+
function getRequestDispatcher(timeoutMs) {
|
|
39
|
+
if (timeoutMs === 0) {
|
|
40
|
+
sharedDispatcher ??= new Agent({ bodyTimeout: 0, headersTimeout: 0 });
|
|
41
|
+
return sharedDispatcher;
|
|
42
|
+
}
|
|
43
|
+
sharedDispatcher ??= new Agent({ bodyTimeout: timeoutMs, headersTimeout: timeoutMs });
|
|
44
|
+
return sharedDispatcher;
|
|
45
|
+
}
|
|
11
46
|
/**
|
|
12
47
|
* Resolve the credentials file path from flag, env var, or default.
|
|
13
48
|
* Checks (in order): explicit configPath arg, XANO_CONFIG env var, ~/.xano/credentials.yaml
|
|
@@ -288,7 +323,16 @@ export default class BaseCommand extends Command {
|
|
|
288
323
|
'User-Agent': buildUserAgent(this.config.version),
|
|
289
324
|
...options.headers,
|
|
290
325
|
};
|
|
291
|
-
const
|
|
326
|
+
const timeoutMs = resolveRequestTimeoutMs();
|
|
327
|
+
const fetchOptions = {
|
|
328
|
+
...options,
|
|
329
|
+
// undici dispatcher: lifts the hidden 300s headers/body timeout to our bound
|
|
330
|
+
dispatcher: getRequestDispatcher(timeoutMs),
|
|
331
|
+
headers,
|
|
332
|
+
// belt-and-suspenders hard ceiling that also covers DNS/connect stalls;
|
|
333
|
+
// surfaces as a clean AbortError (see describeNetworkError). 0 = no bound.
|
|
334
|
+
...(timeoutMs > 0 && !options.signal ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
|
335
|
+
};
|
|
292
336
|
const contentType = headers['Content-Type'] || 'application/json';
|
|
293
337
|
if (verbose) {
|
|
294
338
|
this.log('');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import BaseCommand from '../../../../base-command.js';
|
|
2
|
+
export default class TenantSnapshotCreate extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
tenant_name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
label: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
};
|
|
16
|
+
run(): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../../base-command.js';
|
|
3
|
+
export default class TenantSnapshotCreate extends BaseCommand {
|
|
4
|
+
static args = {
|
|
5
|
+
tenant_name: Args.string({
|
|
6
|
+
description: 'Tenant name to snapshot',
|
|
7
|
+
required: true,
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
static description = "Create a database snapshot for a tenant (an instant clone of the tenant's database)";
|
|
11
|
+
static examples = [
|
|
12
|
+
`$ xano tenant snapshot create t1234-abcd-xyz1 --label before-v2
|
|
13
|
+
Created snapshot t1234-abcd-xyz1_bk_20260603_203614 for tenant t1234-abcd-xyz1
|
|
14
|
+
`,
|
|
15
|
+
`$ xano tenant snapshot create t1234-abcd-xyz1 -l before-v2 -o json`,
|
|
16
|
+
];
|
|
17
|
+
static flags = {
|
|
18
|
+
...BaseCommand.baseFlags,
|
|
19
|
+
label: Flags.string({
|
|
20
|
+
char: 'l',
|
|
21
|
+
default: '',
|
|
22
|
+
description: 'Optional label appended to the snapshot description (alphanumeric)',
|
|
23
|
+
required: false,
|
|
24
|
+
}),
|
|
25
|
+
output: Flags.string({
|
|
26
|
+
char: 'o',
|
|
27
|
+
default: 'summary',
|
|
28
|
+
description: 'Output format',
|
|
29
|
+
options: ['summary', 'json'],
|
|
30
|
+
required: false,
|
|
31
|
+
}),
|
|
32
|
+
workspace: Flags.string({
|
|
33
|
+
char: 'w',
|
|
34
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
35
|
+
required: false,
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
async run() {
|
|
39
|
+
const { args, flags } = await this.parse(TenantSnapshotCreate);
|
|
40
|
+
const { profile } = this.resolveProfile(flags);
|
|
41
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
42
|
+
if (!workspaceId) {
|
|
43
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
44
|
+
}
|
|
45
|
+
const tenantName = args.tenant_name;
|
|
46
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/snapshot`;
|
|
47
|
+
try {
|
|
48
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
49
|
+
body: JSON.stringify({ label: flags.label }),
|
|
50
|
+
headers: {
|
|
51
|
+
'accept': 'application/json',
|
|
52
|
+
'Authorization': `Bearer ${profile.access_token}`,
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
},
|
|
55
|
+
method: 'POST',
|
|
56
|
+
}, flags.verbose, profile.access_token);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const errorText = await response.text();
|
|
59
|
+
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
60
|
+
}
|
|
61
|
+
const result = (await response.json());
|
|
62
|
+
if (flags.output === 'json') {
|
|
63
|
+
this.log(JSON.stringify(result, null, 2));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
this.log(`Created snapshot ${result.backup} for tenant ${tenantName}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof Error) {
|
|
71
|
+
this.error(`Failed to create snapshot: ${error.message}`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
this.error(`Failed to create snapshot: ${String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import BaseCommand from '../../../../base-command.js';
|
|
2
|
+
export default class TenantSnapshotDelete extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
tenant_name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
snapshot: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
};
|
|
17
|
+
run(): Promise<void>;
|
|
18
|
+
private confirm;
|
|
19
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import * as readline from 'node:readline';
|
|
3
|
+
import BaseCommand from '../../../../base-command.js';
|
|
4
|
+
export default class TenantSnapshotDelete extends BaseCommand {
|
|
5
|
+
static args = {
|
|
6
|
+
tenant_name: Args.string({
|
|
7
|
+
description: 'Tenant name that owns the snapshot',
|
|
8
|
+
required: true,
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
static description = '[CRITICAL] NEVER delete a snapshot without explicit user confirmation; this permanently drops the snapshot database. The live and original databases cannot be deleted.';
|
|
12
|
+
static examples = [
|
|
13
|
+
`$ xano tenant snapshot delete t1234-abcd-xyz1 --snapshot t1234-abcd-xyz1_bk_20260603_203614
|
|
14
|
+
Are you sure you want to delete snapshot "t1234-abcd-xyz1_bk_20260603_203614"? This action cannot be undone. (y/N) y
|
|
15
|
+
Deleted snapshot t1234-abcd-xyz1_bk_20260603_203614
|
|
16
|
+
`,
|
|
17
|
+
`$ xano tenant snapshot delete t1234-abcd-xyz1 --snapshot t1234-abcd-xyz1_bk_20260603_203614 --force`,
|
|
18
|
+
];
|
|
19
|
+
static flags = {
|
|
20
|
+
...BaseCommand.baseFlags,
|
|
21
|
+
force: Flags.boolean({
|
|
22
|
+
char: 'f',
|
|
23
|
+
default: false,
|
|
24
|
+
description: '[CRITICAL] Skips the confirmation prompt.',
|
|
25
|
+
required: false,
|
|
26
|
+
}),
|
|
27
|
+
output: Flags.string({
|
|
28
|
+
char: 'o',
|
|
29
|
+
default: 'summary',
|
|
30
|
+
description: 'Output format',
|
|
31
|
+
options: ['summary', 'json'],
|
|
32
|
+
required: false,
|
|
33
|
+
}),
|
|
34
|
+
snapshot: Flags.string({
|
|
35
|
+
description: 'Snapshot database name to delete',
|
|
36
|
+
required: true,
|
|
37
|
+
}),
|
|
38
|
+
workspace: Flags.string({
|
|
39
|
+
char: 'w',
|
|
40
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
41
|
+
required: false,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
async run() {
|
|
45
|
+
const { args, flags } = await this.parse(TenantSnapshotDelete);
|
|
46
|
+
const { profile } = this.resolveProfile(flags);
|
|
47
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
48
|
+
if (!workspaceId) {
|
|
49
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
50
|
+
}
|
|
51
|
+
const tenantName = args.tenant_name;
|
|
52
|
+
const { snapshot } = flags;
|
|
53
|
+
if (!flags.force) {
|
|
54
|
+
const confirmed = await this.confirm(`Are you sure you want to delete snapshot "${snapshot}"? This action cannot be undone.`);
|
|
55
|
+
if (!confirmed) {
|
|
56
|
+
this.log('Deletion cancelled.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const queryParams = new URLSearchParams({ snapshot });
|
|
61
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/snapshot?${queryParams.toString()}`;
|
|
62
|
+
try {
|
|
63
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
64
|
+
headers: {
|
|
65
|
+
accept: 'application/json',
|
|
66
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
67
|
+
},
|
|
68
|
+
method: 'DELETE',
|
|
69
|
+
}, flags.verbose, profile.access_token);
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const errorText = await response.text();
|
|
72
|
+
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
73
|
+
}
|
|
74
|
+
if (flags.output === 'json') {
|
|
75
|
+
this.log(JSON.stringify({ deleted: true, snapshot, tenant_name: tenantName }, null, 2));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
this.log(`Deleted snapshot ${snapshot}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof Error) {
|
|
83
|
+
this.error(`Failed to delete snapshot: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
this.error(`Failed to delete snapshot: ${String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async confirm(message) {
|
|
91
|
+
const rl = readline.createInterface({
|
|
92
|
+
input: process.stdin,
|
|
93
|
+
output: process.stdout,
|
|
94
|
+
});
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
rl.question(`${message} (y/N) `, (answer) => {
|
|
97
|
+
rl.close();
|
|
98
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import BaseCommand from '../../../../base-command.js';
|
|
2
|
+
export default class TenantSnapshotList extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
tenant_name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
};
|
|
15
|
+
run(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../../base-command.js';
|
|
3
|
+
export default class TenantSnapshotList extends BaseCommand {
|
|
4
|
+
static args = {
|
|
5
|
+
tenant_name: Args.string({
|
|
6
|
+
description: 'Tenant name to list snapshots for',
|
|
7
|
+
required: true,
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
static description = 'List database snapshots for a tenant';
|
|
11
|
+
static examples = [
|
|
12
|
+
`$ xano tenant snapshot list t1234-abcd-xyz1
|
|
13
|
+
Snapshots for tenant t1234-abcd-xyz1:
|
|
14
|
+
- t1234-abcd-xyz1 (25 MB) [ORIGINAL]
|
|
15
|
+
- t1234-abcd-xyz1_bk_20260603_203614 (25 MB, 2026-06-03 20:36:14) [LIVE]
|
|
16
|
+
`,
|
|
17
|
+
`$ xano tenant snapshot list t1234-abcd-xyz1 -o json`,
|
|
18
|
+
];
|
|
19
|
+
static flags = {
|
|
20
|
+
...BaseCommand.baseFlags,
|
|
21
|
+
output: Flags.string({
|
|
22
|
+
char: 'o',
|
|
23
|
+
default: 'summary',
|
|
24
|
+
description: 'Output format',
|
|
25
|
+
options: ['summary', 'json'],
|
|
26
|
+
required: false,
|
|
27
|
+
}),
|
|
28
|
+
workspace: Flags.string({
|
|
29
|
+
char: 'w',
|
|
30
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
31
|
+
required: false,
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
async run() {
|
|
35
|
+
const { args, flags } = await this.parse(TenantSnapshotList);
|
|
36
|
+
const { profile } = this.resolveProfile(flags);
|
|
37
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
38
|
+
if (!workspaceId) {
|
|
39
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
40
|
+
}
|
|
41
|
+
const tenantName = args.tenant_name;
|
|
42
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/snapshot`;
|
|
43
|
+
try {
|
|
44
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
45
|
+
headers: {
|
|
46
|
+
'accept': 'application/json',
|
|
47
|
+
'Authorization': `Bearer ${profile.access_token}`,
|
|
48
|
+
},
|
|
49
|
+
method: 'GET',
|
|
50
|
+
}, flags.verbose, profile.access_token);
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const errorText = await response.text();
|
|
53
|
+
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
54
|
+
}
|
|
55
|
+
const data = (await response.json());
|
|
56
|
+
let snapshots;
|
|
57
|
+
if (Array.isArray(data)) {
|
|
58
|
+
snapshots = data;
|
|
59
|
+
}
|
|
60
|
+
else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
|
|
61
|
+
snapshots = data.items;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
this.error('Unexpected API response format');
|
|
65
|
+
}
|
|
66
|
+
if (flags.output === 'json') {
|
|
67
|
+
this.log(JSON.stringify(snapshots, null, 2));
|
|
68
|
+
}
|
|
69
|
+
else if (snapshots.length === 0) {
|
|
70
|
+
this.log(`No snapshots found for tenant ${tenantName}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.log(`Snapshots for tenant ${tenantName}:`);
|
|
74
|
+
for (const snapshot of snapshots) {
|
|
75
|
+
const size = snapshot.size_pretty || (snapshot.size_bytes ? `${snapshot.size_bytes} bytes` : 'unknown');
|
|
76
|
+
const meta = snapshot.created ? `${size}, ${snapshot.created}` : size;
|
|
77
|
+
const tags = [];
|
|
78
|
+
if (snapshot.is_original)
|
|
79
|
+
tags.push('ORIGINAL');
|
|
80
|
+
if (snapshot.is_live)
|
|
81
|
+
tags.push('LIVE');
|
|
82
|
+
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
|
83
|
+
this.log(` - ${snapshot.name} (${meta})${tagStr}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof Error) {
|
|
89
|
+
this.error(`Failed to list snapshots: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
this.error(`Failed to list snapshots: ${String(error)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import BaseCommand from '../../../../base-command.js';
|
|
2
|
+
export default class TenantSnapshotSwap extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
tenant_name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
snapshot: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
};
|
|
17
|
+
run(): Promise<void>;
|
|
18
|
+
private confirm;
|
|
19
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import * as readline from 'node:readline';
|
|
3
|
+
import BaseCommand from '../../../../base-command.js';
|
|
4
|
+
export default class TenantSnapshotSwap extends BaseCommand {
|
|
5
|
+
static args = {
|
|
6
|
+
tenant_name: Args.string({
|
|
7
|
+
description: 'Tenant name to swap',
|
|
8
|
+
required: true,
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
static description = "Swap a tenant's live database to a snapshot. This repoints the tenant; the current live database is left untouched, so you can swap back. To roll back, swap to the original database name.";
|
|
12
|
+
static examples = [
|
|
13
|
+
`$ xano tenant snapshot swap t1234-abcd-xyz1 --snapshot t1234-abcd-xyz1_bk_20260603_203614
|
|
14
|
+
Swapped tenant t1234-abcd-xyz1 from t1234-abcd-xyz1 to t1234-abcd-xyz1_bk_20260603_203614
|
|
15
|
+
`,
|
|
16
|
+
`$ xano tenant snapshot swap t1234-abcd-xyz1 --snapshot t1234-abcd-xyz1 --force`,
|
|
17
|
+
];
|
|
18
|
+
static flags = {
|
|
19
|
+
...BaseCommand.baseFlags,
|
|
20
|
+
force: Flags.boolean({
|
|
21
|
+
char: 'f',
|
|
22
|
+
default: false,
|
|
23
|
+
description: 'Skips the confirmation prompt.',
|
|
24
|
+
required: false,
|
|
25
|
+
}),
|
|
26
|
+
output: Flags.string({
|
|
27
|
+
char: 'o',
|
|
28
|
+
default: 'summary',
|
|
29
|
+
description: 'Output format',
|
|
30
|
+
options: ['summary', 'json'],
|
|
31
|
+
required: false,
|
|
32
|
+
}),
|
|
33
|
+
snapshot: Flags.string({
|
|
34
|
+
description: 'Snapshot database name to swap to (use the original tenant name to roll back)',
|
|
35
|
+
required: true,
|
|
36
|
+
}),
|
|
37
|
+
workspace: Flags.string({
|
|
38
|
+
char: 'w',
|
|
39
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
40
|
+
required: false,
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
async run() {
|
|
44
|
+
const { args, flags } = await this.parse(TenantSnapshotSwap);
|
|
45
|
+
const { profile } = this.resolveProfile(flags);
|
|
46
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
47
|
+
if (!workspaceId) {
|
|
48
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
49
|
+
}
|
|
50
|
+
const tenantName = args.tenant_name;
|
|
51
|
+
const { snapshot } = flags;
|
|
52
|
+
if (!flags.force) {
|
|
53
|
+
const confirmed = await this.confirm(`Swap tenant ${tenantName} to "${snapshot}"? This repoints the tenant's live database.`);
|
|
54
|
+
if (!confirmed) {
|
|
55
|
+
this.log('Swap cancelled.');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/snapshot/swap`;
|
|
60
|
+
try {
|
|
61
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
62
|
+
body: JSON.stringify({ snapshot }),
|
|
63
|
+
headers: {
|
|
64
|
+
'accept': 'application/json',
|
|
65
|
+
'Authorization': `Bearer ${profile.access_token}`,
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
},
|
|
68
|
+
method: 'POST',
|
|
69
|
+
}, flags.verbose, profile.access_token);
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const errorText = await response.text();
|
|
72
|
+
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
73
|
+
}
|
|
74
|
+
const result = (await response.json());
|
|
75
|
+
if (flags.output === 'json') {
|
|
76
|
+
this.log(JSON.stringify(result, null, 2));
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
this.log(`Swapped tenant ${tenantName} from ${result.from ?? 'unknown'} to ${result.to ?? snapshot}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof Error) {
|
|
84
|
+
this.error(`Failed to swap snapshot: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
this.error(`Failed to swap snapshot: ${String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async confirm(message) {
|
|
92
|
+
const rl = readline.createInterface({
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
output: process.stdout,
|
|
95
|
+
});
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
rl.question(`${message} (y/N) `, (answer) => {
|
|
98
|
+
rl.close();
|
|
99
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -645,18 +645,89 @@ export async function executePush(ctx, target, flags) {
|
|
|
645
645
|
catch (error) {
|
|
646
646
|
if (error instanceof Error && 'oclif' in error)
|
|
647
647
|
throw error;
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
else {
|
|
652
|
-
command.error(`Failed to push multidoc: ${String(error)}`);
|
|
653
|
-
}
|
|
648
|
+
const elapsedMs = Date.now() - startTime;
|
|
649
|
+
command.error(`Failed to push multidoc: ${describeNetworkError(error, apiUrl, elapsedMs)}`);
|
|
654
650
|
}
|
|
655
651
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
656
652
|
const pushedCount = multidoc.split('\n---\n').length;
|
|
657
653
|
log(`Pushed ${pushedCount} documents to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
|
|
658
654
|
}
|
|
659
655
|
// ── Error Handlers ──────────────────────────────────────────────────────────
|
|
656
|
+
/**
|
|
657
|
+
* Turn a thrown fetch/network error into an actionable message.
|
|
658
|
+
*
|
|
659
|
+
* Node's native fetch throws a TypeError with the unhelpful message "fetch
|
|
660
|
+
* failed" for all transport-level failures (DNS, connection refused, TLS,
|
|
661
|
+
* resets, timeouts). The real reason lives in `error.cause` as a system error
|
|
662
|
+
* with a `code` (ECONNREFUSED, ENOTFOUND, ETIMEDOUT, etc.). This unwraps it so
|
|
663
|
+
* the user sees what actually went wrong and where.
|
|
664
|
+
*
|
|
665
|
+
* `elapsedMs` is appended so the user can see how long the request ran before
|
|
666
|
+
* failing — a failure landing near a round boundary (e.g. ~300s) is a strong
|
|
667
|
+
* signal of a server-side or proxy timeout rather than a local network blip.
|
|
668
|
+
*/
|
|
669
|
+
function describeNetworkError(error, url, elapsedMs) {
|
|
670
|
+
if (!(error instanceof Error))
|
|
671
|
+
return String(error);
|
|
672
|
+
let host = url;
|
|
673
|
+
try {
|
|
674
|
+
host = new URL(url).host;
|
|
675
|
+
}
|
|
676
|
+
catch { }
|
|
677
|
+
// AbortSignal.timeout() fires our explicit request-timeout ceiling.
|
|
678
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
679
|
+
return `request to ${host} exceeded the CLI timeout. Raise it with XANO_CLI_REQUEST_TIMEOUT_MS (ms; 0 disables), or split the push into smaller batches.${formatFailureDuration(elapsedMs)}`;
|
|
680
|
+
}
|
|
681
|
+
const { cause } = error;
|
|
682
|
+
const code = cause && typeof cause === 'object' && 'code' in cause ? String(cause.code) : undefined;
|
|
683
|
+
const causeMessage = cause instanceof Error ? cause.message : undefined;
|
|
684
|
+
const hints = {
|
|
685
|
+
ECONNREFUSED: `Connection refused by ${host}. The instance may be down or starting up.`,
|
|
686
|
+
ECONNRESET: `Connection to ${host} was reset. The request may have been too large or the server restarted mid-push.`,
|
|
687
|
+
ENOTFOUND: `Could not resolve host "${host}". Check the instance origin and your network/DNS.`,
|
|
688
|
+
ETIMEDOUT: `Connection to ${host} timed out. Check your network or VPN, then retry.`,
|
|
689
|
+
UND_ERR_CONNECT_TIMEOUT: `Connection to ${host} timed out. Check your network or VPN, then retry.`,
|
|
690
|
+
UND_ERR_HEADERS_TIMEOUT: `${host} accepted the connection but did not respond in time. The push may be too large; try splitting it or retrying.`,
|
|
691
|
+
};
|
|
692
|
+
let base;
|
|
693
|
+
if (code && hints[code]) {
|
|
694
|
+
base = `${hints[code]} (${code})`;
|
|
695
|
+
}
|
|
696
|
+
else if (code?.startsWith('ERR_TLS') || code?.startsWith('CERT_') || /certificate|tls|ssl/i.test(error.message)) {
|
|
697
|
+
// TLS/cert failures surface their reason on the cause message.
|
|
698
|
+
base = `TLS/certificate error connecting to ${host}: ${causeMessage ?? error.message}`;
|
|
699
|
+
}
|
|
700
|
+
else if (error.message === 'fetch failed') {
|
|
701
|
+
// "fetch failed" with no recognized code — surface the underlying cause if any.
|
|
702
|
+
base = causeMessage
|
|
703
|
+
? `network error connecting to ${host}: ${causeMessage}${code ? ` (${code})` : ''}`
|
|
704
|
+
: `network error connecting to ${host}${code ? ` (${code})` : ''}. Run with --verbose for more detail.`;
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
base = error.message;
|
|
708
|
+
}
|
|
709
|
+
return base + formatFailureDuration(elapsedMs);
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Render how long the request ran before failing, e.g. " (after 5m 0s)".
|
|
713
|
+
* Flags durations sitting near a common timeout boundary (30/60/120/300/600s),
|
|
714
|
+
* which usually points at a server-side or proxy/load-balancer timeout rather
|
|
715
|
+
* than a local network problem.
|
|
716
|
+
*/
|
|
717
|
+
function formatFailureDuration(elapsedMs) {
|
|
718
|
+
if (elapsedMs === undefined || elapsedMs < 0)
|
|
719
|
+
return '';
|
|
720
|
+
const totalSeconds = elapsedMs / 1000;
|
|
721
|
+
const human = totalSeconds < 60
|
|
722
|
+
? `${totalSeconds.toFixed(1)}s`
|
|
723
|
+
: `${Math.floor(totalSeconds / 60)}m ${Math.round(totalSeconds % 60)}s`;
|
|
724
|
+
// Within 5% of a common timeout boundary → likely a hard cutoff, not a blip.
|
|
725
|
+
const boundaries = [30, 60, 120, 300, 600];
|
|
726
|
+
const nearTimeout = boundaries.some((b) => Math.abs(totalSeconds - b) <= b * 0.05);
|
|
727
|
+
return nearTimeout
|
|
728
|
+
? ` (failed after ~${human}, near a common ${boundaries.find((b) => Math.abs(totalSeconds - b) <= b * 0.05)}s timeout — likely a server or proxy cutoff)`
|
|
729
|
+
: ` (failed after ${human})`;
|
|
730
|
+
}
|
|
660
731
|
async function handleDryRunError(response, command, flags, target) {
|
|
661
732
|
const log = command.log.bind(command);
|
|
662
733
|
if (response.status === 404) {
|