@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.
Files changed (116) hide show
  1. package/README.md +112 -3
  2. package/dist/base-command.d.ts +43 -36
  3. package/dist/base-command.js +223 -160
  4. package/dist/commands/auth/index.d.ts +1 -1
  5. package/dist/commands/auth/index.js +7 -7
  6. package/dist/commands/branch/create/index.d.ts +1 -1
  7. package/dist/commands/branch/create/index.js +1 -1
  8. package/dist/commands/ephemeral/create/index.d.ts +18 -0
  9. package/dist/commands/ephemeral/create/index.js +103 -0
  10. package/dist/commands/ephemeral/delete/index.d.ts +18 -0
  11. package/dist/commands/ephemeral/delete/index.js +99 -0
  12. package/dist/commands/ephemeral/edit/index.d.ts +18 -0
  13. package/dist/commands/ephemeral/edit/index.js +109 -0
  14. package/dist/commands/ephemeral/get/index.d.ts +21 -0
  15. package/dist/commands/ephemeral/get/index.js +125 -0
  16. package/dist/commands/ephemeral/impersonate/index.d.ts +20 -0
  17. package/dist/commands/ephemeral/impersonate/index.js +120 -0
  18. package/dist/commands/ephemeral/list/index.d.ts +19 -0
  19. package/dist/commands/ephemeral/list/index.js +123 -0
  20. package/dist/commands/ephemeral/pull/index.d.ts +19 -0
  21. package/dist/commands/ephemeral/pull/index.js +163 -0
  22. package/dist/commands/ephemeral/push/index.d.ts +35 -0
  23. package/dist/commands/ephemeral/push/index.js +223 -0
  24. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +20 -0
  25. package/dist/commands/ephemeral/static_host/build/delete/index.js +121 -0
  26. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +18 -0
  27. package/dist/commands/ephemeral/static_host/build/get/index.js +137 -0
  28. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +18 -0
  29. package/dist/commands/ephemeral/static_host/build/list/index.js +137 -0
  30. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +53 -0
  31. package/dist/commands/ephemeral/static_host/build/pull/index.js +308 -0
  32. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +24 -0
  33. package/dist/commands/ephemeral/static_host/build/push/index.js +250 -0
  34. package/dist/commands/ephemeral/static_host/create/index.d.ts +18 -0
  35. package/dist/commands/ephemeral/static_host/create/index.js +92 -0
  36. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +19 -0
  37. package/dist/commands/ephemeral/static_host/deploy/index.js +112 -0
  38. package/dist/commands/ephemeral/static_host/edit/index.d.ts +24 -0
  39. package/dist/commands/ephemeral/static_host/edit/index.js +158 -0
  40. package/dist/commands/ephemeral/static_host/get/index.d.ts +19 -0
  41. package/dist/commands/ephemeral/static_host/get/index.js +101 -0
  42. package/dist/commands/ephemeral/static_host/list/index.d.ts +17 -0
  43. package/dist/commands/ephemeral/static_host/list/index.js +132 -0
  44. package/dist/commands/flatten/index.d.ts +34 -0
  45. package/dist/commands/flatten/index.js +140 -0
  46. package/dist/commands/function/create/index.js +1 -1
  47. package/dist/commands/function/edit/index.js +26 -32
  48. package/dist/commands/function/get/index.js +2 -3
  49. package/dist/commands/function/run/index.d.ts +1 -0
  50. package/dist/commands/function/run/index.js +19 -6
  51. package/dist/commands/profile/create/index.d.ts +1 -1
  52. package/dist/commands/profile/create/index.js +7 -7
  53. package/dist/commands/profile/me/index.js +1 -1
  54. package/dist/commands/profile/use/index.js +0 -4
  55. package/dist/commands/profile/wizard/index.d.ts +1 -1
  56. package/dist/commands/profile/wizard/index.js +12 -10
  57. package/dist/commands/profile/workspace/set/index.js +1 -1
  58. package/dist/commands/release/edit/index.js +2 -2
  59. package/dist/commands/release/export/index.js +1 -2
  60. package/dist/commands/release/import/index.js +1 -1
  61. package/dist/commands/release/pull/index.d.ts +0 -6
  62. package/dist/commands/release/pull/index.js +13 -99
  63. package/dist/commands/release/push/index.js +2 -2
  64. package/dist/commands/sandbox/env/get_all/index.js +1 -1
  65. package/dist/commands/sandbox/env/set_all/index.js +1 -1
  66. package/dist/commands/sandbox/get/index.js +1 -1
  67. package/dist/commands/sandbox/license/get/index.js +1 -1
  68. package/dist/commands/sandbox/license/set/index.js +1 -1
  69. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  70. package/dist/commands/sandbox/pull/index.js +13 -81
  71. package/dist/commands/sandbox/push/index.d.ts +1 -1
  72. package/dist/commands/sandbox/push/index.js +5 -5
  73. package/dist/commands/static_host/build/create/index.d.ts +1 -1
  74. package/dist/commands/static_host/build/create/index.js +2 -2
  75. package/dist/commands/static_host/build/pull/index.js +1 -1
  76. package/dist/commands/static_host/build/push/index.js +1 -1
  77. package/dist/commands/static_host/edit/index.js +1 -1
  78. package/dist/commands/tenant/backup/export/index.js +1 -2
  79. package/dist/commands/tenant/backup/import/index.js +1 -1
  80. package/dist/commands/tenant/cluster/create/index.js +1 -1
  81. package/dist/commands/tenant/cluster/license/get/index.js +1 -1
  82. package/dist/commands/tenant/cluster/license/set/index.js +1 -1
  83. package/dist/commands/tenant/create/index.d.ts +1 -1
  84. package/dist/commands/tenant/create/index.js +6 -6
  85. package/dist/commands/tenant/deploy_platform/index.js +1 -1
  86. package/dist/commands/tenant/edit/index.js +7 -7
  87. package/dist/commands/tenant/env/get_all/index.js +1 -1
  88. package/dist/commands/tenant/env/set_all/index.js +1 -1
  89. package/dist/commands/tenant/impersonate/index.d.ts +1 -1
  90. package/dist/commands/tenant/impersonate/index.js +14 -14
  91. package/dist/commands/tenant/license/get/index.js +1 -1
  92. package/dist/commands/tenant/license/set/index.js +1 -1
  93. package/dist/commands/tenant/pull/index.d.ts +0 -6
  94. package/dist/commands/tenant/pull/index.js +14 -100
  95. package/dist/commands/workspace/git/pull/index.d.ts +4 -4
  96. package/dist/commands/workspace/git/pull/index.js +158 -97
  97. package/dist/commands/workspace/pull/index.d.ts +1 -7
  98. package/dist/commands/workspace/pull/index.js +17 -103
  99. package/dist/help.js +4 -2
  100. package/dist/update-check.d.ts +1 -1
  101. package/dist/update-check.js +1 -1
  102. package/dist/utils/document-parser.d.ts +116 -0
  103. package/dist/utils/document-parser.js +311 -4
  104. package/dist/utils/flatten.d.ts +36 -0
  105. package/dist/utils/flatten.js +68 -0
  106. package/dist/utils/knowledge-sync.d.ts +1 -0
  107. package/dist/utils/knowledge-sync.js +11 -6
  108. package/dist/utils/microservice-wait.d.ts +53 -0
  109. package/dist/utils/microservice-wait.js +84 -0
  110. package/dist/utils/multidoc-push.d.ts +88 -0
  111. package/dist/utils/multidoc-push.js +378 -15
  112. package/dist/utils/paging.d.ts +2 -1
  113. package/dist/utils/paging.js +0 -1
  114. package/dist/utils/reference-checker.js +14 -3
  115. package/oclif.manifest.json +5264 -3170
  116. package/package.json +10 -1
@@ -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 * as path from 'node:path';
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
- // Helper method to get the default profile from credentials file
197
- getDefaultProfile() {
198
- try {
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
- loadCredentialsFile() {
229
- const credentialsPath = this.getCredentialsPath();
230
- if (!fs.existsSync(credentialsPath)) {
231
- return null;
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
- const fileContent = fs.readFileSync(credentialsPath, 'utf8');
234
- const parsed = yaml.load(fileContent);
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
- const fetchOptions = {
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
- const response = await fetch(url, fetchOptions);
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
- const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host/${staticHost}/build/${buildId}`;
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
+ }