@xano/cli 1.2.0 → 1.3.0
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 +112 -3
- package/dist/base-command.d.ts +43 -36
- package/dist/base-command.js +223 -160
- package/dist/commands/auth/index.d.ts +1 -1
- package/dist/commands/auth/index.js +7 -7
- package/dist/commands/branch/create/index.d.ts +1 -1
- package/dist/commands/branch/create/index.js +1 -1
- package/dist/commands/ephemeral/create/index.d.ts +18 -0
- package/dist/commands/ephemeral/create/index.js +103 -0
- package/dist/commands/ephemeral/delete/index.d.ts +18 -0
- package/dist/commands/ephemeral/delete/index.js +99 -0
- package/dist/commands/ephemeral/edit/index.d.ts +18 -0
- package/dist/commands/ephemeral/edit/index.js +109 -0
- package/dist/commands/ephemeral/get/index.d.ts +21 -0
- package/dist/commands/ephemeral/get/index.js +125 -0
- package/dist/commands/ephemeral/impersonate/index.d.ts +20 -0
- package/dist/commands/ephemeral/impersonate/index.js +120 -0
- package/dist/commands/ephemeral/list/index.d.ts +19 -0
- package/dist/commands/ephemeral/list/index.js +123 -0
- package/dist/commands/ephemeral/pull/index.d.ts +19 -0
- package/dist/commands/ephemeral/pull/index.js +163 -0
- package/dist/commands/ephemeral/push/index.d.ts +35 -0
- package/dist/commands/ephemeral/push/index.js +223 -0
- package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +20 -0
- package/dist/commands/ephemeral/static_host/build/delete/index.js +121 -0
- package/dist/commands/ephemeral/static_host/build/get/index.d.ts +18 -0
- package/dist/commands/ephemeral/static_host/build/get/index.js +137 -0
- package/dist/commands/ephemeral/static_host/build/list/index.d.ts +18 -0
- package/dist/commands/ephemeral/static_host/build/list/index.js +137 -0
- package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +53 -0
- package/dist/commands/ephemeral/static_host/build/pull/index.js +308 -0
- package/dist/commands/ephemeral/static_host/build/push/index.d.ts +24 -0
- package/dist/commands/ephemeral/static_host/build/push/index.js +250 -0
- package/dist/commands/ephemeral/static_host/create/index.d.ts +18 -0
- package/dist/commands/ephemeral/static_host/create/index.js +92 -0
- package/dist/commands/ephemeral/static_host/deploy/index.d.ts +19 -0
- package/dist/commands/ephemeral/static_host/deploy/index.js +112 -0
- package/dist/commands/ephemeral/static_host/edit/index.d.ts +24 -0
- package/dist/commands/ephemeral/static_host/edit/index.js +158 -0
- package/dist/commands/ephemeral/static_host/get/index.d.ts +19 -0
- package/dist/commands/ephemeral/static_host/get/index.js +101 -0
- package/dist/commands/ephemeral/static_host/list/index.d.ts +17 -0
- package/dist/commands/ephemeral/static_host/list/index.js +132 -0
- package/dist/commands/flatten/index.d.ts +34 -0
- package/dist/commands/flatten/index.js +140 -0
- package/dist/commands/function/create/index.js +1 -1
- package/dist/commands/function/edit/index.js +26 -32
- package/dist/commands/function/get/index.js +2 -3
- package/dist/commands/function/run/index.d.ts +1 -0
- package/dist/commands/function/run/index.js +19 -6
- package/dist/commands/profile/create/index.d.ts +1 -1
- package/dist/commands/profile/create/index.js +7 -7
- package/dist/commands/profile/me/index.js +1 -1
- package/dist/commands/profile/use/index.js +0 -4
- package/dist/commands/profile/wizard/index.d.ts +1 -1
- package/dist/commands/profile/wizard/index.js +12 -10
- package/dist/commands/profile/workspace/set/index.js +1 -1
- package/dist/commands/release/edit/index.js +2 -2
- package/dist/commands/release/export/index.js +1 -2
- package/dist/commands/release/import/index.js +1 -1
- package/dist/commands/release/pull/index.d.ts +0 -6
- package/dist/commands/release/pull/index.js +13 -99
- package/dist/commands/release/push/index.js +2 -2
- package/dist/commands/sandbox/env/get_all/index.js +1 -1
- package/dist/commands/sandbox/env/set_all/index.js +1 -1
- package/dist/commands/sandbox/get/index.js +1 -1
- package/dist/commands/sandbox/license/get/index.js +1 -1
- package/dist/commands/sandbox/license/set/index.js +1 -1
- package/dist/commands/sandbox/pull/index.d.ts +0 -1
- package/dist/commands/sandbox/pull/index.js +13 -81
- package/dist/commands/sandbox/push/index.d.ts +1 -1
- package/dist/commands/sandbox/push/index.js +5 -5
- package/dist/commands/static_host/build/create/index.d.ts +1 -1
- package/dist/commands/static_host/build/create/index.js +2 -2
- package/dist/commands/static_host/build/pull/index.js +1 -1
- package/dist/commands/static_host/build/push/index.js +1 -1
- package/dist/commands/static_host/edit/index.js +1 -1
- package/dist/commands/tenant/backup/export/index.js +1 -2
- package/dist/commands/tenant/backup/import/index.js +1 -1
- package/dist/commands/tenant/cluster/create/index.js +1 -1
- package/dist/commands/tenant/cluster/license/get/index.js +1 -1
- package/dist/commands/tenant/cluster/license/set/index.js +1 -1
- package/dist/commands/tenant/create/index.d.ts +1 -1
- package/dist/commands/tenant/create/index.js +6 -6
- package/dist/commands/tenant/deploy_platform/index.js +1 -1
- package/dist/commands/tenant/edit/index.js +7 -7
- package/dist/commands/tenant/env/get_all/index.js +1 -1
- package/dist/commands/tenant/env/set_all/index.js +1 -1
- package/dist/commands/tenant/impersonate/index.d.ts +1 -1
- package/dist/commands/tenant/impersonate/index.js +14 -14
- package/dist/commands/tenant/license/get/index.js +1 -1
- package/dist/commands/tenant/license/set/index.js +1 -1
- package/dist/commands/tenant/pull/index.d.ts +0 -6
- package/dist/commands/tenant/pull/index.js +14 -100
- package/dist/commands/workspace/git/pull/index.d.ts +4 -4
- package/dist/commands/workspace/git/pull/index.js +158 -97
- package/dist/commands/workspace/pull/index.d.ts +1 -7
- package/dist/commands/workspace/pull/index.js +17 -103
- package/dist/help.js +4 -2
- package/dist/update-check.d.ts +1 -1
- package/dist/update-check.js +1 -1
- package/dist/utils/document-parser.d.ts +116 -0
- package/dist/utils/document-parser.js +311 -4
- package/dist/utils/flatten.d.ts +36 -0
- package/dist/utils/flatten.js +68 -0
- package/dist/utils/knowledge-sync.d.ts +1 -0
- package/dist/utils/knowledge-sync.js +11 -6
- package/dist/utils/microservice-wait.d.ts +53 -0
- package/dist/utils/microservice-wait.js +84 -0
- package/dist/utils/multidoc-push.d.ts +88 -0
- package/dist/utils/multidoc-push.js +378 -15
- package/dist/utils/paging.d.ts +2 -1
- package/dist/utils/paging.js +0 -1
- package/dist/utils/reference-checker.js +14 -3
- package/oclif.manifest.json +5264 -3170
- package/package.json +10 -1
package/dist/base-command.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Command, 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';
|
|
5
|
-
import
|
|
5
|
+
import path from 'node:path';
|
|
6
6
|
import { Agent } from 'undici';
|
|
7
7
|
import { checkForUpdate } from './update-check.js';
|
|
8
8
|
import { applyLocalOverrides, findLocalProfilePath, formatLocalProfileBanner, parseLocalProfile, resolveProfileSelection, } from './utils/local-config.js';
|
|
@@ -29,13 +29,46 @@ function resolveRequestTimeoutMs(env = process.env) {
|
|
|
29
29
|
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
30
30
|
return parsed;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether the built-in `fetch` on this runtime accepts a dispatcher built from
|
|
34
|
+
* our bundled userland `undici`. Node <= 24 accepts it; Node 26 rejects a
|
|
35
|
+
* foreign dispatcher with UND_ERR_INVALID_ARG, which `fetch` surfaces only as
|
|
36
|
+
* the generic "fetch failed" — breaking *every* command (DEV-7773).
|
|
37
|
+
*
|
|
38
|
+
* Starts optimistic (`true`); flipped to `false` the first time a request fails
|
|
39
|
+
* with UND_ERR_INVALID_ARG (see `verboseFetch`, which then retries without the
|
|
40
|
+
* dispatcher). Once flipped, every later request skips the dispatcher outright.
|
|
41
|
+
*/
|
|
42
|
+
let dispatcherSupported = true;
|
|
43
|
+
/**
|
|
44
|
+
* True when `error` is the "foreign dispatcher rejected by built-in fetch"
|
|
45
|
+
* failure (Node 26+). The real code lives on `error.cause.code`.
|
|
46
|
+
*/
|
|
47
|
+
export function isInvalidDispatcherError(error) {
|
|
48
|
+
if (!(error instanceof Error))
|
|
49
|
+
return false;
|
|
50
|
+
const { cause } = error;
|
|
51
|
+
const code = cause && typeof cause === 'object' && 'code' in cause
|
|
52
|
+
? cause.code
|
|
53
|
+
: error.code;
|
|
54
|
+
return code === 'UND_ERR_INVALID_ARG';
|
|
55
|
+
}
|
|
32
56
|
/**
|
|
33
57
|
* Lazily-built undici dispatcher whose header/body inactivity timeouts match our
|
|
34
58
|
* request timeout, so undici's internal 300s default never fires first. Built
|
|
35
59
|
* once and reused across requests. `timeout === 0` means "no bound".
|
|
60
|
+
*
|
|
61
|
+
* Returns `undefined` when this runtime has been found to reject a foreign
|
|
62
|
+
* dispatcher (Node 26+, DEV-7773). Dropping the dispatcher does NOT regress the
|
|
63
|
+
* large-push timeout: the per-request `AbortSignal.timeout(timeoutMs)` in
|
|
64
|
+
* `verboseFetch` already bounds the whole request (headers included) to the same
|
|
65
|
+
* 15-min ceiling the dispatcher enforced, so undici's hidden 300s
|
|
66
|
+
* `headersTimeout` is subsumed by it.
|
|
36
67
|
*/
|
|
37
68
|
let sharedDispatcher;
|
|
38
69
|
function getRequestDispatcher(timeoutMs) {
|
|
70
|
+
if (!dispatcherSupported)
|
|
71
|
+
return undefined;
|
|
39
72
|
if (timeoutMs === 0) {
|
|
40
73
|
sharedDispatcher ??= new Agent({ bodyTimeout: 0, headersTimeout: 0 });
|
|
41
74
|
return sharedDispatcher;
|
|
@@ -103,76 +136,6 @@ export default class BaseCommand extends Command {
|
|
|
103
136
|
// Null when none was found or when an explicit -p/XANO_PROFILE overrides it.
|
|
104
137
|
localProfile = null;
|
|
105
138
|
updateNotice = null;
|
|
106
|
-
async init() {
|
|
107
|
-
await super.init();
|
|
108
|
-
this.localProfile = this.loadLocalProfile();
|
|
109
|
-
this.applyInsecureFromProfile();
|
|
110
|
-
this.maybePrintLocalProfileBanner();
|
|
111
|
-
const forceUpdateCheck = process.env.XANO_FORCE_UPDATE_CHECK === '1';
|
|
112
|
-
this.updateNotice = checkForUpdate(this.config.version, forceUpdateCheck);
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Find and parse the nearest project-local profile.yaml, unless an explicit
|
|
116
|
-
* -p/XANO_PROFILE was given (in which case the local file is ignored).
|
|
117
|
-
*/
|
|
118
|
-
loadLocalProfile() {
|
|
119
|
-
if (argvHasProfileFlag(process.argv, process.env)) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
// Walks up to the filesystem root (git-style). parseLocalProfile returns
|
|
123
|
-
// null for a profile.yaml with no recognized keys, so an unrelated file
|
|
124
|
-
// belonging to another tool is ignored rather than hijacked.
|
|
125
|
-
const filePath = findLocalProfilePath(process.cwd());
|
|
126
|
-
if (!filePath) {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
let config;
|
|
130
|
-
try {
|
|
131
|
-
config = parseLocalProfile(fs.readFileSync(filePath, 'utf8'));
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
this.error(`${filePath}: ${error.message}`);
|
|
135
|
-
}
|
|
136
|
-
if (!config) {
|
|
137
|
-
this.warn(`Ignoring ${filePath}: no recognized profile keys found.`);
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
return { config, path: filePath };
|
|
141
|
-
}
|
|
142
|
-
/** Print the one-line target banner when a local profile.yaml is in effect. */
|
|
143
|
-
maybePrintLocalProfileBanner() {
|
|
144
|
-
if (!this.localProfile || this.isJsonOutput()) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
// Credential-management commands (the `profile` topic) operate on the
|
|
148
|
-
// credentials store directly and intentionally ignore the project-local
|
|
149
|
-
// pin, so the banner would be misleading for them.
|
|
150
|
-
if (this.id?.startsWith('profile')) {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
const { config, path: filePath } = this.localProfile;
|
|
154
|
-
const profileName = config.profile ?? this.getDefaultProfile();
|
|
155
|
-
const relativePath = path.relative(process.cwd(), filePath) || path.basename(filePath);
|
|
156
|
-
this.log(formatLocalProfileBanner(profileName, config.workspace, relativePath));
|
|
157
|
-
}
|
|
158
|
-
async finally(_) {
|
|
159
|
-
if (this.updateNotice && !this.isJsonOutput()) {
|
|
160
|
-
this.log(this.updateNotice);
|
|
161
|
-
}
|
|
162
|
-
await super.finally(_);
|
|
163
|
-
}
|
|
164
|
-
isJsonOutput() {
|
|
165
|
-
const args = process.argv;
|
|
166
|
-
for (let i = 0; i < args.length; i++) {
|
|
167
|
-
if (args[i] === '--output' && args[i + 1] === 'json')
|
|
168
|
-
return true;
|
|
169
|
-
if (args[i] === '-o' && args[i + 1] === 'json')
|
|
170
|
-
return true;
|
|
171
|
-
if (args[i] === '--output=json' || args[i] === '-o=json')
|
|
172
|
-
return true;
|
|
173
|
-
}
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
139
|
/**
|
|
177
140
|
* Apply insecure TLS mode if the active profile has insecure: true.
|
|
178
141
|
* Sets NODE_TLS_REJECT_UNAUTHORIZED=0 so all fetch() calls skip cert verification.
|
|
@@ -193,22 +156,11 @@ export default class BaseCommand extends Command {
|
|
|
193
156
|
// Don't fail the command if we can't read the profile for insecure check
|
|
194
157
|
}
|
|
195
158
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const credentials = this.loadCredentialsFile();
|
|
200
|
-
if (credentials?.default) {
|
|
201
|
-
return credentials.default;
|
|
202
|
-
}
|
|
203
|
-
return 'default';
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
return 'default';
|
|
159
|
+
async finally(_) {
|
|
160
|
+
if (this.updateNotice && !this.isJsonOutput()) {
|
|
161
|
+
this.log(this.updateNotice);
|
|
207
162
|
}
|
|
208
|
-
|
|
209
|
-
// Helper method to get the profile flag value
|
|
210
|
-
getProfile() {
|
|
211
|
-
return this.flags?.profile;
|
|
163
|
+
await super.finally(_);
|
|
212
164
|
}
|
|
213
165
|
/**
|
|
214
166
|
* Get the resolved credentials file path, respecting --config flag and XANO_CONFIG env var.
|
|
@@ -225,17 +177,18 @@ export default class BaseCommand extends Command {
|
|
|
225
177
|
}
|
|
226
178
|
return resolveCredentialsPath();
|
|
227
179
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
180
|
+
// Helper method to get the default profile from credentials file
|
|
181
|
+
getDefaultProfile() {
|
|
182
|
+
try {
|
|
183
|
+
const credentials = this.loadCredentialsFile();
|
|
184
|
+
if (credentials?.default) {
|
|
185
|
+
return credentials.default;
|
|
186
|
+
}
|
|
187
|
+
return 'default';
|
|
232
188
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (parsed && typeof parsed === 'object' && 'profiles' in parsed) {
|
|
236
|
-
return parsed;
|
|
189
|
+
catch {
|
|
190
|
+
return 'default';
|
|
237
191
|
}
|
|
238
|
-
return null;
|
|
239
192
|
}
|
|
240
193
|
/**
|
|
241
194
|
* Get or create the singleton sandbox environment for the authenticated user.
|
|
@@ -256,6 +209,92 @@ export default class BaseCommand extends Command {
|
|
|
256
209
|
}
|
|
257
210
|
return (await response.json());
|
|
258
211
|
}
|
|
212
|
+
// Helper method to get the profile flag value
|
|
213
|
+
getProfile() {
|
|
214
|
+
return this.flags?.profile;
|
|
215
|
+
}
|
|
216
|
+
async init() {
|
|
217
|
+
await super.init();
|
|
218
|
+
this.localProfile = this.loadLocalProfile();
|
|
219
|
+
this.applyInsecureFromProfile();
|
|
220
|
+
this.maybePrintLocalProfileBanner();
|
|
221
|
+
const forceUpdateCheck = process.env.XANO_FORCE_UPDATE_CHECK === '1';
|
|
222
|
+
this.updateNotice = checkForUpdate(this.config.version, forceUpdateCheck);
|
|
223
|
+
}
|
|
224
|
+
loadCredentialsFile() {
|
|
225
|
+
const credentialsPath = this.getCredentialsPath();
|
|
226
|
+
if (!fs.existsSync(credentialsPath)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const fileContent = fs.readFileSync(credentialsPath, 'utf8');
|
|
230
|
+
const parsed = yaml.load(fileContent);
|
|
231
|
+
if (parsed && typeof parsed === 'object' && 'profiles' in parsed) {
|
|
232
|
+
return parsed;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Poll a static-host build until it reaches a terminal status (ok | error),
|
|
238
|
+
* showing a live, ticking spinner with the current stage and elapsed time —
|
|
239
|
+
* mirroring the UI's build progress for async (package.json) builds, which
|
|
240
|
+
* keep running after the upload returns.
|
|
241
|
+
*
|
|
242
|
+
* On a TTY it renders an animated spinner via ux.action; when quiet (JSON
|
|
243
|
+
* output) or non-interactive it falls back to plain one-line status updates.
|
|
244
|
+
*
|
|
245
|
+
* Returns the final status. Resolves to the last-seen status on timeout.
|
|
246
|
+
*/
|
|
247
|
+
async logStaticHostUrls(opts) {
|
|
248
|
+
const { profile, staticHost, tenantName, verbose, workspaceId } = opts;
|
|
249
|
+
// When a tenant is scoped, target the tenant's static hosting; otherwise the
|
|
250
|
+
// workspace-level static host (default, unchanged behavior).
|
|
251
|
+
const scope = tenantName ? `/tenant/${tenantName}` : '';
|
|
252
|
+
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}${scope}/static_host/${staticHost}`;
|
|
253
|
+
try {
|
|
254
|
+
const response = await this.verboseFetch(url, {
|
|
255
|
+
headers: { accept: 'application/json', Authorization: `Bearer ${profile.access_token}` },
|
|
256
|
+
method: 'GET',
|
|
257
|
+
}, verbose, profile.access_token);
|
|
258
|
+
if (!response.ok)
|
|
259
|
+
return;
|
|
260
|
+
const host = (await response.json());
|
|
261
|
+
if (host.dev?.default_url)
|
|
262
|
+
this.log(`Dev URL: ${host.dev.default_url}`);
|
|
263
|
+
if (host.dev?.custom_url)
|
|
264
|
+
this.log(`Dev Custom URL: ${host.dev.custom_url}`);
|
|
265
|
+
if (host.prod?.default_url)
|
|
266
|
+
this.log(`Prod URL: ${host.prod.default_url}`);
|
|
267
|
+
if (host.prod?.custom_url)
|
|
268
|
+
this.log(`Prod Custom URL: ${host.prod.custom_url}`);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Non-fatal — the build succeeded, we just can't show the URL.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Parse an API error response and return a clean error message.
|
|
276
|
+
* Extracts the message from JSON responses and adds context for common errors.
|
|
277
|
+
*/
|
|
278
|
+
async parseApiError(response, fallbackPrefix) {
|
|
279
|
+
const errorText = await response.text();
|
|
280
|
+
let message = `${fallbackPrefix} (${response.status})`;
|
|
281
|
+
try {
|
|
282
|
+
const errorJson = JSON.parse(errorText);
|
|
283
|
+
if (errorJson.message) {
|
|
284
|
+
message = errorJson.message;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
if (errorText) {
|
|
289
|
+
message += `\n${errorText}`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Provide guidance when sandbox access is denied (free plan restriction)
|
|
293
|
+
if (response.status === 500 && message === 'Access Denied.') {
|
|
294
|
+
message = 'Sandbox is not available on the Free plan. Upgrade your plan to use sandbox features.';
|
|
295
|
+
}
|
|
296
|
+
return message;
|
|
297
|
+
}
|
|
259
298
|
/**
|
|
260
299
|
* Resolve the profile from flags and any project-local profile.yaml,
|
|
261
300
|
* validating instance_origin and access_token exist.
|
|
@@ -289,30 +328,6 @@ export default class BaseCommand extends Command {
|
|
|
289
328
|
}
|
|
290
329
|
return { profile, profileName };
|
|
291
330
|
}
|
|
292
|
-
/**
|
|
293
|
-
* Parse an API error response and return a clean error message.
|
|
294
|
-
* Extracts the message from JSON responses and adds context for common errors.
|
|
295
|
-
*/
|
|
296
|
-
async parseApiError(response, fallbackPrefix) {
|
|
297
|
-
const errorText = await response.text();
|
|
298
|
-
let message = `${fallbackPrefix} (${response.status})`;
|
|
299
|
-
try {
|
|
300
|
-
const errorJson = JSON.parse(errorText);
|
|
301
|
-
if (errorJson.message) {
|
|
302
|
-
message = errorJson.message;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
catch {
|
|
306
|
-
if (errorText) {
|
|
307
|
-
message += `\n${errorText}`;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
// Provide guidance when sandbox access is denied (free plan restriction)
|
|
311
|
-
if (response.status === 500 && message === 'Access Denied.') {
|
|
312
|
-
message = 'Sandbox is not available on the Free plan. Upgrade your plan to use sandbox features.';
|
|
313
|
-
}
|
|
314
|
-
return message;
|
|
315
|
-
}
|
|
316
331
|
/**
|
|
317
332
|
* Make an HTTP request with optional verbose logging.
|
|
318
333
|
* Use this for all Metadata API calls to support the --verbose flag.
|
|
@@ -324,15 +339,20 @@ export default class BaseCommand extends Command {
|
|
|
324
339
|
...options.headers,
|
|
325
340
|
};
|
|
326
341
|
const timeoutMs = resolveRequestTimeoutMs();
|
|
327
|
-
|
|
342
|
+
// Build the fetch options fresh each attempt: a retry needs a new
|
|
343
|
+
// AbortSignal (the first attempt's may already be aborted/consumed) and,
|
|
344
|
+
// when the dispatcher is the culprit, no dispatcher at all.
|
|
345
|
+
const buildFetchOptions = (withDispatcher) => ({
|
|
328
346
|
...options,
|
|
329
347
|
// undici dispatcher: lifts the hidden 300s headers/body timeout to our bound
|
|
330
|
-
dispatcher: getRequestDispatcher(timeoutMs),
|
|
348
|
+
...(withDispatcher ? { dispatcher: getRequestDispatcher(timeoutMs) } : {}),
|
|
331
349
|
headers,
|
|
332
350
|
// belt-and-suspenders hard ceiling that also covers DNS/connect stalls;
|
|
333
351
|
// surfaces as a clean AbortError (see describeNetworkError). 0 = no bound.
|
|
352
|
+
// This alone bounds the whole request, so dropping the dispatcher (Node 26)
|
|
353
|
+
// does not lose the large-push timeout protection.
|
|
334
354
|
...(timeoutMs > 0 && !options.signal ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
|
335
|
-
};
|
|
355
|
+
});
|
|
336
356
|
const contentType = headers['Content-Type'] || 'application/json';
|
|
337
357
|
if (verbose) {
|
|
338
358
|
this.log('');
|
|
@@ -349,7 +369,26 @@ export default class BaseCommand extends Command {
|
|
|
349
369
|
}
|
|
350
370
|
}
|
|
351
371
|
const startTime = Date.now();
|
|
352
|
-
|
|
372
|
+
let response;
|
|
373
|
+
try {
|
|
374
|
+
response = await fetch(url, buildFetchOptions(dispatcherSupported));
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
// Node 26+ rejects our userland-undici dispatcher with UND_ERR_INVALID_ARG,
|
|
378
|
+
// surfaced only as "fetch failed" (DEV-7773). Disable it for the rest of the
|
|
379
|
+
// process and retry once without it; the AbortSignal.timeout ceiling above
|
|
380
|
+
// still bounds the request, so the large-push protection is preserved.
|
|
381
|
+
if (dispatcherSupported && isInvalidDispatcherError(error)) {
|
|
382
|
+
dispatcherSupported = false;
|
|
383
|
+
if (verbose) {
|
|
384
|
+
this.log(' (this Node runtime rejected undici dispatcher; retrying without it — see DEV-7773)');
|
|
385
|
+
}
|
|
386
|
+
response = await fetch(url, buildFetchOptions(false));
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
353
392
|
const elapsed = Date.now() - startTime;
|
|
354
393
|
if (verbose) {
|
|
355
394
|
this.log(`← ${response.status} ${response.statusText} (${elapsed}ms)`);
|
|
@@ -358,47 +397,15 @@ export default class BaseCommand extends Command {
|
|
|
358
397
|
}
|
|
359
398
|
return response;
|
|
360
399
|
}
|
|
361
|
-
/**
|
|
362
|
-
* Poll a static-host build until it reaches a terminal status (ok | error),
|
|
363
|
-
* showing a live, ticking spinner with the current stage and elapsed time —
|
|
364
|
-
* mirroring the UI's build progress for async (package.json) builds, which
|
|
365
|
-
* keep running after the upload returns.
|
|
366
|
-
*
|
|
367
|
-
* On a TTY it renders an animated spinner via ux.action; when quiet (JSON
|
|
368
|
-
* output) or non-interactive it falls back to plain one-line status updates.
|
|
369
|
-
*
|
|
370
|
-
* Returns the final status. Resolves to the last-seen status on timeout.
|
|
371
|
-
*/
|
|
372
|
-
async logStaticHostUrls(opts) {
|
|
373
|
-
const { profile, staticHost, verbose, workspaceId } = opts;
|
|
374
|
-
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host/${staticHost}`;
|
|
375
|
-
try {
|
|
376
|
-
const response = await this.verboseFetch(url, {
|
|
377
|
-
headers: { accept: 'application/json', Authorization: `Bearer ${profile.access_token}` },
|
|
378
|
-
method: 'GET',
|
|
379
|
-
}, verbose, profile.access_token);
|
|
380
|
-
if (!response.ok)
|
|
381
|
-
return;
|
|
382
|
-
const host = (await response.json());
|
|
383
|
-
if (host.dev?.default_url)
|
|
384
|
-
this.log(`Dev URL: ${host.dev.default_url}`);
|
|
385
|
-
if (host.dev?.custom_url)
|
|
386
|
-
this.log(`Dev Custom URL: ${host.dev.custom_url}`);
|
|
387
|
-
if (host.prod?.default_url)
|
|
388
|
-
this.log(`Prod URL: ${host.prod.default_url}`);
|
|
389
|
-
if (host.prod?.custom_url)
|
|
390
|
-
this.log(`Prod Custom URL: ${host.prod.custom_url}`);
|
|
391
|
-
}
|
|
392
|
-
catch {
|
|
393
|
-
// Non-fatal — the build succeeded, we just can't show the URL.
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
400
|
async waitForBuild(opts) {
|
|
397
|
-
const { buildId, profile, quiet, staticHost, verbose, workspaceId } = opts;
|
|
401
|
+
const { buildId, profile, quiet, staticHost, tenantName, verbose, workspaceId } = opts;
|
|
398
402
|
const intervalMs = opts.intervalMs ?? 2000;
|
|
399
403
|
const timeoutMs = opts.timeoutMs ?? 600_000; // 10 min
|
|
400
404
|
const terminal = new Set(['error', 'ok']);
|
|
401
|
-
|
|
405
|
+
// When a tenant is scoped, target the tenant's static hosting; otherwise the
|
|
406
|
+
// workspace-level static host (default, unchanged behavior).
|
|
407
|
+
const scope = tenantName ? `/tenant/${tenantName}` : '';
|
|
408
|
+
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}${scope}/static_host/${staticHost}/build/${buildId}`;
|
|
402
409
|
// Spinner only on an interactive TTY and when not emitting JSON. Verbose mode
|
|
403
410
|
// also disables it (the spinner would interleave with request/response logs).
|
|
404
411
|
const animate = Boolean(process.stdout.isTTY) && !quiet && !verbose;
|
|
@@ -461,6 +468,62 @@ export default class BaseCommand extends Command {
|
|
|
461
468
|
conclude(`stopped waiting after ${Math.round(timeoutMs / 1000)}s (last status: ${stage || 'unknown'})`);
|
|
462
469
|
return stage;
|
|
463
470
|
}
|
|
471
|
+
isJsonOutput() {
|
|
472
|
+
const args = process.argv;
|
|
473
|
+
for (let i = 0; i < args.length; i++) {
|
|
474
|
+
if (args[i] === '--output' && args[i + 1] === 'json')
|
|
475
|
+
return true;
|
|
476
|
+
if (args[i] === '-o' && args[i + 1] === 'json')
|
|
477
|
+
return true;
|
|
478
|
+
if (args[i] === '--output=json' || args[i] === '-o=json')
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Find and parse the nearest project-local profile.yaml, unless an explicit
|
|
485
|
+
* -p/XANO_PROFILE was given (in which case the local file is ignored).
|
|
486
|
+
*/
|
|
487
|
+
loadLocalProfile() {
|
|
488
|
+
if (argvHasProfileFlag(process.argv, process.env)) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
// Walks up to the filesystem root (git-style). parseLocalProfile returns
|
|
492
|
+
// null for a profile.yaml with no recognized keys, so an unrelated file
|
|
493
|
+
// belonging to another tool is ignored rather than hijacked.
|
|
494
|
+
const filePath = findLocalProfilePath(process.cwd());
|
|
495
|
+
if (!filePath) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
let config;
|
|
499
|
+
try {
|
|
500
|
+
config = parseLocalProfile(fs.readFileSync(filePath, 'utf8'));
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
this.error(`${filePath}: ${error.message}`);
|
|
504
|
+
}
|
|
505
|
+
if (!config) {
|
|
506
|
+
this.warn(`Ignoring ${filePath}: no recognized profile keys found.`);
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
return { config, path: filePath };
|
|
510
|
+
}
|
|
511
|
+
/** Print the one-line target banner when a local profile.yaml is in effect. */
|
|
512
|
+
maybePrintLocalProfileBanner() {
|
|
513
|
+
if (!this.localProfile || this.isJsonOutput()) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// Credential-management commands (the `profile` topic) operate on the
|
|
517
|
+
// credentials store directly and intentionally ignore the project-local
|
|
518
|
+
// pin, so the banner would be misleading for them.
|
|
519
|
+
if (this.id?.startsWith('profile')) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const { config, path: filePath } = this.localProfile;
|
|
523
|
+
const profileName = config.profile ?? this.getDefaultProfile();
|
|
524
|
+
const relativePath = path.relative(process.cwd(), filePath) || path.basename(filePath);
|
|
525
|
+
this.log(formatLocalProfileBanner(profileName, config.workspace, relativePath));
|
|
526
|
+
}
|
|
464
527
|
}
|
|
465
528
|
/** Human-friendly label for a build status stage. */
|
|
466
529
|
function stageLabel(status) {
|
|
@@ -48,10 +48,10 @@ export default class Auth extends Command {
|
|
|
48
48
|
run(): Promise<AuthResult>;
|
|
49
49
|
protected toErrorJson(err: unknown): unknown;
|
|
50
50
|
private acquireToken;
|
|
51
|
-
private getHeaders;
|
|
52
51
|
private fetchBranches;
|
|
53
52
|
private fetchInstances;
|
|
54
53
|
private fetchWorkspaces;
|
|
54
|
+
private getHeaders;
|
|
55
55
|
private promptForToken;
|
|
56
56
|
private promptProfileName;
|
|
57
57
|
private readTokenFromStdin;
|
|
@@ -212,13 +212,6 @@ To authenticate, open the following URL in any browser:
|
|
|
212
212
|
}
|
|
213
213
|
return noBrowser ? this.promptForToken(origin) : this.startAuthServer(origin);
|
|
214
214
|
}
|
|
215
|
-
getHeaders(accessToken) {
|
|
216
|
-
return {
|
|
217
|
-
'User-Agent': buildUserAgent(this.config.version),
|
|
218
|
-
accept: 'application/json',
|
|
219
|
-
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
215
|
async fetchBranches(accessToken, origin, workspaceId) {
|
|
223
216
|
try {
|
|
224
217
|
const response = await fetch(`${origin}/api:meta/workspace/${workspaceId}/branch`, {
|
|
@@ -288,6 +281,13 @@ To authenticate, open the following URL in any browser:
|
|
|
288
281
|
return [];
|
|
289
282
|
}
|
|
290
283
|
}
|
|
284
|
+
getHeaders(accessToken) {
|
|
285
|
+
return {
|
|
286
|
+
accept: 'application/json',
|
|
287
|
+
'User-Agent': buildUserAgent(this.config.version),
|
|
288
|
+
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
291
|
async promptForToken(origin) {
|
|
292
292
|
// Headless flow: no local callback server. The login page, when opened
|
|
293
293
|
// without a `callback` param, renders the access token on screen for the
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import BaseCommand from '../../../base-command.js';
|
|
2
2
|
export default class BranchCreate extends BaseCommand {
|
|
3
|
-
static description: string;
|
|
4
3
|
static args: {
|
|
5
4
|
label: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
6
5
|
};
|
|
6
|
+
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
9
|
color: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import BaseCommand from '../../../base-command.js';
|
|
3
3
|
export default class BranchCreate extends BaseCommand {
|
|
4
|
-
static description = 'Create a new branch by cloning from an existing branch';
|
|
5
4
|
static args = {
|
|
6
5
|
label: Args.string({
|
|
7
6
|
description: 'Label for the new branch',
|
|
8
7
|
required: true,
|
|
9
8
|
}),
|
|
10
9
|
};
|
|
10
|
+
static description = 'Create a new branch by cloning from an existing branch';
|
|
11
11
|
static examples = [
|
|
12
12
|
`$ xano branch create dev
|
|
13
13
|
Created branch: dev
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class EphemeralCreate extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
display: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
'expires-hours': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
output: 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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
export default class EphemeralCreate extends BaseCommand {
|
|
4
|
+
static args = {
|
|
5
|
+
display: Args.string({
|
|
6
|
+
description: 'Display name for the ephemeral tenant',
|
|
7
|
+
required: true,
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Create a new ephemeral (short-lived, auto-expiring) tenant in a workspace';
|
|
11
|
+
static examples = [
|
|
12
|
+
`$ xano ephemeral create "PR preview"
|
|
13
|
+
Created ephemeral tenant: PR preview (e4f2-9ab1-...) - ID: 42
|
|
14
|
+
Expires: in 1 hour
|
|
15
|
+
`,
|
|
16
|
+
`$ xano ephemeral create "Demo" --expires-hours 24 -w 114`,
|
|
17
|
+
`$ xano ephemeral create "Load test" --description "overnight soak" --expires-hours 24 -o json`,
|
|
18
|
+
];
|
|
19
|
+
static flags = {
|
|
20
|
+
...BaseCommand.baseFlags,
|
|
21
|
+
description: Flags.string({
|
|
22
|
+
char: 'd',
|
|
23
|
+
description: 'Ephemeral tenant description',
|
|
24
|
+
required: false,
|
|
25
|
+
}),
|
|
26
|
+
'expires-hours': Flags.integer({
|
|
27
|
+
default: 1,
|
|
28
|
+
description: 'Hours until the tenant auto-expires (1-24)',
|
|
29
|
+
max: 24,
|
|
30
|
+
min: 1,
|
|
31
|
+
required: false,
|
|
32
|
+
}),
|
|
33
|
+
output: Flags.string({
|
|
34
|
+
char: 'o',
|
|
35
|
+
default: 'summary',
|
|
36
|
+
description: 'Output format',
|
|
37
|
+
options: ['summary', 'json'],
|
|
38
|
+
required: false,
|
|
39
|
+
}),
|
|
40
|
+
workspace: Flags.string({
|
|
41
|
+
char: 'w',
|
|
42
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
43
|
+
required: false,
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
async run() {
|
|
47
|
+
const { args, flags } = await this.parse(EphemeralCreate);
|
|
48
|
+
const { profile } = this.resolveProfile(flags);
|
|
49
|
+
// Ephemeral tenants REQUIRE a workspace (unlike sandbox, which is workspace-agnostic).
|
|
50
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
51
|
+
if (!workspaceId) {
|
|
52
|
+
this.error('No workspace ID provided. Ephemeral tenants require a workspace — use --workspace or set one in your profile.');
|
|
53
|
+
}
|
|
54
|
+
const body = {
|
|
55
|
+
display: args.display,
|
|
56
|
+
expires_hours: flags['expires-hours'],
|
|
57
|
+
tag: [],
|
|
58
|
+
};
|
|
59
|
+
if (flags.description)
|
|
60
|
+
body.description = flags.description;
|
|
61
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/ephemeral`;
|
|
62
|
+
try {
|
|
63
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
headers: {
|
|
66
|
+
accept: 'application/json',
|
|
67
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
},
|
|
70
|
+
method: 'POST',
|
|
71
|
+
}, flags.verbose, profile.access_token);
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const errorText = await response.text();
|
|
74
|
+
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
75
|
+
}
|
|
76
|
+
const tenant = (await response.json());
|
|
77
|
+
if (flags.output === 'json') {
|
|
78
|
+
this.log(JSON.stringify(tenant, null, 2));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
this.log(`Created ephemeral tenant: ${tenant.display || tenant.name} (${tenant.name}) - ID: ${tenant.id}`);
|
|
82
|
+
if (tenant.state) {
|
|
83
|
+
this.log(` State: ${tenant.state}`);
|
|
84
|
+
}
|
|
85
|
+
if (tenant.ephemeral_expires_at) {
|
|
86
|
+
// The API serializes the timestamp field as a date string
|
|
87
|
+
// ("2026-07-24 19:49:15+0000"); tolerate a raw unix-epoch number too.
|
|
88
|
+
const raw = tenant.ephemeral_expires_at;
|
|
89
|
+
const expiresMs = typeof raw === 'number' ? raw * 1000 : Date.parse(String(raw).replace(' ', 'T'));
|
|
90
|
+
this.log(` Expires: ${Number.isNaN(expiresMs) ? String(raw) : new Date(expiresMs).toISOString()}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof Error) {
|
|
96
|
+
this.error(`Failed to create ephemeral tenant: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
this.error(`Failed to create ephemeral tenant: ${String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|