@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.
@@ -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 fetchOptions = { ...options, headers };
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
- if (error instanceof Error) {
649
- command.error(`Failed to push multidoc: ${error.message}`);
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) {