@xano/cli 1.0.3 → 1.0.4-beta.10
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 +100 -3
- package/dist/base-command.d.ts +27 -0
- package/dist/base-command.js +124 -1
- package/dist/commands/auth/index.d.ts +48 -1
- package/dist/commands/auth/index.js +259 -27
- package/dist/commands/function/run/index.d.ts +40 -0
- package/dist/commands/function/run/index.js +244 -0
- package/dist/commands/sandbox/pull/index.js +12 -2
- package/dist/commands/sandbox/push/index.d.ts +1 -1
- package/dist/commands/sandbox/push/index.js +17 -13
- package/dist/commands/static_host/build/create/index.d.ts +9 -1
- package/dist/commands/static_host/build/create/index.js +54 -4
- package/dist/commands/static_host/build/delete/index.d.ts +19 -0
- package/dist/commands/static_host/build/delete/index.js +114 -0
- package/dist/commands/static_host/build/get/index.d.ts +1 -1
- package/dist/commands/static_host/build/get/index.js +16 -10
- package/dist/commands/static_host/build/pull/index.d.ts +52 -0
- package/dist/commands/static_host/build/pull/index.js +300 -0
- package/dist/commands/static_host/build/push/index.d.ts +23 -0
- package/dist/commands/static_host/build/push/index.js +228 -0
- package/dist/commands/static_host/create/index.d.ts +17 -0
- package/dist/commands/static_host/create/index.js +86 -0
- package/dist/commands/static_host/deploy/index.d.ts +18 -0
- package/dist/commands/static_host/deploy/index.js +105 -0
- package/dist/commands/static_host/edit/index.d.ts +23 -0
- package/dist/commands/static_host/edit/index.js +151 -0
- package/dist/commands/static_host/get/index.d.ts +18 -0
- package/dist/commands/static_host/get/index.js +94 -0
- package/dist/commands/static_host/migrate/index.d.ts +44 -0
- package/dist/commands/static_host/migrate/index.js +205 -0
- package/dist/commands/tenant/snapshot/create/index.d.ts +17 -0
- package/dist/commands/tenant/snapshot/create/index.js +78 -0
- package/dist/commands/tenant/snapshot/delete/index.d.ts +19 -0
- package/dist/commands/tenant/snapshot/delete/index.js +102 -0
- package/dist/commands/tenant/snapshot/list/index.d.ts +16 -0
- package/dist/commands/tenant/snapshot/list/index.js +96 -0
- package/dist/commands/tenant/snapshot/swap/index.d.ts +19 -0
- package/dist/commands/tenant/snapshot/swap/index.js +103 -0
- package/dist/commands/tenant/unit_test/run/index.d.ts +1 -0
- package/dist/commands/tenant/unit_test/run/index.js +7 -0
- package/dist/commands/tenant/unit_test/run_all/index.js +7 -3
- package/dist/commands/unit_test/run/index.d.ts +1 -0
- package/dist/commands/unit_test/run/index.js +8 -0
- package/dist/commands/unit_test/run_all/index.js +7 -3
- package/dist/commands/workspace/pull/index.js +13 -2
- package/dist/commands/workspace/push/index.d.ts +1 -1
- package/dist/commands/workspace/push/index.js +15 -5
- package/dist/utils/function-input.d.ts +77 -0
- package/dist/utils/function-input.js +148 -0
- package/dist/utils/knowledge-sync.d.ts +113 -0
- package/dist/utils/knowledge-sync.js +404 -0
- package/dist/utils/multidoc-push.d.ts +22 -0
- package/dist/utils/multidoc-push.js +262 -107
- package/dist/utils/reference-checker.js +2 -2
- package/dist/utils/static-host-files.d.ts +21 -0
- package/dist/utils/static-host-files.js +41 -0
- package/oclif.manifest.json +4179 -2636
- package/package.json +4 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ExitPromptError } from '@inquirer/core';
|
|
2
1
|
import { Command, Flags } from '@oclif/core';
|
|
3
2
|
import inquirer from 'inquirer';
|
|
4
3
|
import * as yaml from 'js-yaml';
|
|
@@ -7,9 +6,37 @@ import * as http from 'node:http';
|
|
|
7
6
|
import { dirname } from 'node:path';
|
|
8
7
|
import open from 'open';
|
|
9
8
|
import { buildUserAgent, resolveCredentialsPath } from '../../base-command.js';
|
|
9
|
+
function originHostname(value) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(value.includes('://') ? value : `https://${value}`).hostname.toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Match a user-supplied --instance value against the instance list:
|
|
19
|
+
* numeric values match by ID, URL/hostname values match by the instance
|
|
20
|
+
* origin's hostname, anything else matches by name. Exported for tests.
|
|
21
|
+
*/
|
|
22
|
+
export function matchInstance(instances, query) {
|
|
23
|
+
const q = query.trim();
|
|
24
|
+
if (/^\d+$/.test(q)) {
|
|
25
|
+
return instances.find((inst) => inst.id === q);
|
|
26
|
+
}
|
|
27
|
+
if (q.includes('://') || q.includes('.')) {
|
|
28
|
+
const queryHost = originHostname(q);
|
|
29
|
+
const match = queryHost ? instances.find((inst) => originHostname(inst.origin) === queryHost) : undefined;
|
|
30
|
+
if (match) {
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return instances.find((inst) => inst.name === q);
|
|
35
|
+
}
|
|
10
36
|
const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
11
37
|
export default class Auth extends Command {
|
|
12
38
|
static description = 'Authenticate with Xano via browser login';
|
|
39
|
+
static enableJsonFlag = true;
|
|
13
40
|
static examples = [
|
|
14
41
|
`$ xano auth
|
|
15
42
|
Opening browser for Xano login...
|
|
@@ -20,8 +47,28 @@ Authenticated as John Doe (john@example.com)
|
|
|
20
47
|
Profile 'default' created successfully!`,
|
|
21
48
|
`$ xano auth --origin https://custom.xano.com
|
|
22
49
|
Opening browser for Xano login at https://custom.xano.com...`,
|
|
50
|
+
`$ xano auth --no-browser
|
|
51
|
+
To authenticate, open the following URL in any browser:
|
|
52
|
+
https://app.xano.com/login?dest=cli&display=code
|
|
53
|
+
? Paste the code shown in your browser: ****`,
|
|
54
|
+
`$ xano auth --code "$CODE" --instance https://my-instance.xano.io --workspace 5
|
|
55
|
+
(fully non-interactive: no browser, no prompts; missing --branch/--profile fall back to defaults)`,
|
|
56
|
+
`$ echo "$CODE" | xano auth --no-browser --instance my-instance --workspace 5 --branch dev --profile staging
|
|
57
|
+
(fully scripted: the code is read from piped stdin, no prompt at all)`,
|
|
58
|
+
`$ xano auth --code "$CODE" --instance 42 --workspace 5 --json
|
|
59
|
+
(machine-readable: prints the created profile as JSON)`,
|
|
23
60
|
];
|
|
24
61
|
static flags = {
|
|
62
|
+
branch: Flags.string({
|
|
63
|
+
char: 'b',
|
|
64
|
+
description: 'Pre-select a branch by label (skips the branch picker); pass "" to skip and use the live branch',
|
|
65
|
+
required: false,
|
|
66
|
+
}),
|
|
67
|
+
code: Flags.string({
|
|
68
|
+
description: 'Login code copied from the browser (implies --no-browser and runs fully non-interactively). Get the code at <origin>/login?dest=cli&display=code',
|
|
69
|
+
env: 'XANO_AUTH_CODE',
|
|
70
|
+
required: false,
|
|
71
|
+
}),
|
|
25
72
|
config: Flags.string({
|
|
26
73
|
char: 'c',
|
|
27
74
|
description: 'Path to credentials file (default: ~/.xano/credentials.yaml)',
|
|
@@ -33,11 +80,30 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
33
80
|
default: false,
|
|
34
81
|
description: 'Skip TLS certificate verification (for self-signed certificates)',
|
|
35
82
|
}),
|
|
83
|
+
instance: Flags.string({
|
|
84
|
+
char: 'i',
|
|
85
|
+
description: 'Pre-select an instance by name, numeric ID, or instance URL/hostname (skips the instance picker)',
|
|
86
|
+
required: false,
|
|
87
|
+
}),
|
|
88
|
+
'no-browser': Flags.boolean({
|
|
89
|
+
default: false,
|
|
90
|
+
description: 'Headless login: print a URL and paste back the code shown in the browser, instead of starting a local callback server (use on remote/SSH/Docker hosts where 127.0.0.1 is not reachable from the browser)',
|
|
91
|
+
}),
|
|
36
92
|
origin: Flags.string({
|
|
37
93
|
char: 'o',
|
|
38
94
|
default: 'https://app.xano.com',
|
|
39
95
|
description: 'Xano account origin URL',
|
|
40
96
|
}),
|
|
97
|
+
profile: Flags.string({
|
|
98
|
+
char: 'p',
|
|
99
|
+
description: 'Profile name to save (skips the profile name prompt); pass "" to use the default name',
|
|
100
|
+
required: false,
|
|
101
|
+
}),
|
|
102
|
+
workspace: Flags.string({
|
|
103
|
+
char: 'w',
|
|
104
|
+
description: 'Pre-select a workspace by ID or name (skips the workspace picker); pass "" to skip workspace',
|
|
105
|
+
required: false,
|
|
106
|
+
}),
|
|
41
107
|
};
|
|
42
108
|
async run() {
|
|
43
109
|
const { flags } = await this.parse(Auth);
|
|
@@ -45,10 +111,13 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
45
111
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
46
112
|
this.warn('TLS certificate verification is disabled (insecure mode)');
|
|
47
113
|
}
|
|
114
|
+
// A supplied code (flag or env), piped stdin, or --json output means no
|
|
115
|
+
// prompt can ever open: every unanswered picker falls back to its default.
|
|
116
|
+
const nonInteractive = flags.code !== undefined || (flags['no-browser'] && !process.stdin.isTTY) || this.jsonEnabled();
|
|
48
117
|
try {
|
|
49
|
-
// Step 1: Get token via browser auth
|
|
118
|
+
// Step 1: Get token via supplied code or browser auth
|
|
50
119
|
this.log('Starting authentication flow...');
|
|
51
|
-
const token = await this.
|
|
120
|
+
const token = await this.acquireToken(flags.code, flags['no-browser'], flags.origin);
|
|
52
121
|
// Step 2: Validate token and get user info
|
|
53
122
|
this.log('');
|
|
54
123
|
this.log('Validating authentication...');
|
|
@@ -60,6 +129,9 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
60
129
|
const isSelfHosted = !/^https:\/\/app\.(.*\.)?xano\.com$/.test(flags.origin);
|
|
61
130
|
let instance;
|
|
62
131
|
if (isSelfHosted) {
|
|
132
|
+
if (flags.instance) {
|
|
133
|
+
this.warn('Ignoring --instance: the origin itself is the instance for self-hosted Xano.');
|
|
134
|
+
}
|
|
63
135
|
instance = {
|
|
64
136
|
display: flags.origin,
|
|
65
137
|
id: 'self-hosted',
|
|
@@ -74,31 +146,15 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
74
146
|
if (instances.length === 0) {
|
|
75
147
|
this.error('No instances found. Please check your account.');
|
|
76
148
|
}
|
|
77
|
-
instance = await this.
|
|
78
|
-
}
|
|
79
|
-
// Step 4: Workspace selection
|
|
80
|
-
let workspace;
|
|
81
|
-
let branch;
|
|
82
|
-
this.log('');
|
|
83
|
-
this.log('Fetching available workspaces...');
|
|
84
|
-
const workspaces = await this.fetchWorkspaces(token, instance.origin);
|
|
85
|
-
if (workspaces.length > 0) {
|
|
86
|
-
workspace = await this.selectWorkspace(workspaces);
|
|
87
|
-
if (workspace) {
|
|
88
|
-
// Step 5: Branch selection
|
|
89
|
-
this.log('');
|
|
90
|
-
this.log('Fetching available branches...');
|
|
91
|
-
const branches = await this.fetchBranches(token, instance.origin, workspace.id);
|
|
92
|
-
if (branches.length > 1) {
|
|
93
|
-
branch = await this.selectBranch(branches);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
149
|
+
instance = await this.resolveInstance(instances, flags.instance, nonInteractive);
|
|
96
150
|
}
|
|
151
|
+
// Steps 4 + 5: Workspace and branch selection
|
|
152
|
+
const { branch, workspace } = await this.resolveWorkspaceAndBranch(token, instance.origin, flags, nonInteractive);
|
|
97
153
|
// Step 6: Profile name
|
|
98
154
|
this.log('');
|
|
99
|
-
const profileName = await this.
|
|
155
|
+
const profileName = await this.resolveProfileName(flags.profile, nonInteractive);
|
|
100
156
|
// Step 7: Save profile
|
|
101
|
-
await this.saveProfile({
|
|
157
|
+
const credentialsPath = await this.saveProfile({
|
|
102
158
|
access_token: token,
|
|
103
159
|
account_origin: flags.origin,
|
|
104
160
|
branch,
|
|
@@ -109,17 +165,53 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
109
165
|
}, flags.config);
|
|
110
166
|
this.log('');
|
|
111
167
|
this.log(`Profile '${profileName}' created successfully!`);
|
|
168
|
+
const result = {
|
|
169
|
+
branch: branch ?? null,
|
|
170
|
+
credentialsPath,
|
|
171
|
+
instance: { id: instance.id, name: instance.name, origin: instance.origin },
|
|
172
|
+
profile: profileName,
|
|
173
|
+
user: { email: user.email, id: user.id, name: user.name },
|
|
174
|
+
workspace: workspace ? { id: workspace.id, name: workspace.name } : null,
|
|
175
|
+
};
|
|
176
|
+
if (this.jsonEnabled()) {
|
|
177
|
+
this.logJson(result);
|
|
178
|
+
}
|
|
112
179
|
// Ensure clean exit (the open() call can keep the event loop alive)
|
|
113
180
|
process.exit(0);
|
|
114
181
|
}
|
|
115
182
|
catch (error) {
|
|
116
|
-
|
|
183
|
+
// Ctrl+C at an inquirer prompt throws ExitPromptError. Match on the name
|
|
184
|
+
// rather than `instanceof`: inquirer bundles its own copy of
|
|
185
|
+
// @inquirer/core, so the thrown class won't match an imported one.
|
|
186
|
+
if (error?.name === 'ExitPromptError') {
|
|
117
187
|
this.log('Authentication cancelled.');
|
|
118
|
-
|
|
188
|
+
this.exit(0);
|
|
119
189
|
}
|
|
120
190
|
throw error;
|
|
121
191
|
}
|
|
122
192
|
}
|
|
193
|
+
// oclif's default toErrorJson serializes Error objects to {} (message is a
|
|
194
|
+
// non-enumerable property), leaving --json consumers with no error detail.
|
|
195
|
+
toErrorJson(err) {
|
|
196
|
+
const error = err;
|
|
197
|
+
return {
|
|
198
|
+
error: {
|
|
199
|
+
...(error.code ? { code: error.code } : {}),
|
|
200
|
+
message: error.message ?? String(err),
|
|
201
|
+
...(error.suggestions?.length ? { suggestions: error.suggestions } : {}),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
async acquireToken(code, noBrowser, origin) {
|
|
206
|
+
if (code !== undefined) {
|
|
207
|
+
const token = code.trim();
|
|
208
|
+
if (token === '') {
|
|
209
|
+
this.error(`No code provided. Copy it from ${origin}/login?dest=cli&display=code and pass it via --code or XANO_AUTH_CODE.`);
|
|
210
|
+
}
|
|
211
|
+
return token;
|
|
212
|
+
}
|
|
213
|
+
return noBrowser ? this.promptForToken(origin) : this.startAuthServer(origin);
|
|
214
|
+
}
|
|
123
215
|
getHeaders(accessToken) {
|
|
124
216
|
return {
|
|
125
217
|
'User-Agent': buildUserAgent(this.config.version),
|
|
@@ -167,7 +259,7 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
167
259
|
if (Array.isArray(data)) {
|
|
168
260
|
return data.map((inst) => ({
|
|
169
261
|
display: inst.display,
|
|
170
|
-
id: inst.id
|
|
262
|
+
id: String(inst.id ?? inst.name),
|
|
171
263
|
name: inst.name,
|
|
172
264
|
origin: new URL(inst.meta_api).origin,
|
|
173
265
|
}));
|
|
@@ -196,6 +288,45 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
196
288
|
return [];
|
|
197
289
|
}
|
|
198
290
|
}
|
|
291
|
+
async promptForToken(origin) {
|
|
292
|
+
// Headless flow: no local callback server. The login page, when opened
|
|
293
|
+
// without a `callback` param, renders the access token on screen for the
|
|
294
|
+
// user to copy. We point the browser there (best-effort) and prompt for
|
|
295
|
+
// the pasted code.
|
|
296
|
+
const authUrl = `${origin}/login?dest=cli&display=code`;
|
|
297
|
+
this.log('To authenticate, open the following URL in any browser:');
|
|
298
|
+
this.log('');
|
|
299
|
+
this.log(` ${authUrl}`);
|
|
300
|
+
this.log('');
|
|
301
|
+
// Piped (non-TTY) stdin: read the code directly instead of prompting, so
|
|
302
|
+
// scripts and agents can do `echo $CODE | xano auth --no-browser ...`.
|
|
303
|
+
// The masked inquirer prompt below requires an interactive terminal.
|
|
304
|
+
if (!process.stdin.isTTY) {
|
|
305
|
+
const piped = await this.readTokenFromStdin();
|
|
306
|
+
if (!piped) {
|
|
307
|
+
this.error('No code received on stdin. Pipe the code shown in the browser, e.g. `echo "$CODE" | xano auth --no-browser ...`');
|
|
308
|
+
}
|
|
309
|
+
this.log('Read code from stdin.');
|
|
310
|
+
return piped;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
await open(authUrl);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Best-effort only; the URL is already printed above for manual use.
|
|
317
|
+
}
|
|
318
|
+
const { token } = await inquirer.prompt([
|
|
319
|
+
{
|
|
320
|
+
message: 'Paste the code shown in your browser',
|
|
321
|
+
name: 'token',
|
|
322
|
+
type: 'password',
|
|
323
|
+
validate(input) {
|
|
324
|
+
return input.trim() === '' ? 'A code is required' : true;
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
]);
|
|
328
|
+
return token.trim();
|
|
329
|
+
}
|
|
199
330
|
async promptProfileName() {
|
|
200
331
|
const { profileName } = await inquirer.prompt([
|
|
201
332
|
{
|
|
@@ -214,6 +345,106 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
214
345
|
]);
|
|
215
346
|
return profileName.trim() || 'default';
|
|
216
347
|
}
|
|
348
|
+
readTokenFromStdin() {
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
let data = '';
|
|
351
|
+
process.stdin.setEncoding('utf8');
|
|
352
|
+
process.stdin.on('data', (chunk) => {
|
|
353
|
+
data += chunk;
|
|
354
|
+
});
|
|
355
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
356
|
+
process.stdin.on('error', () => resolve(data.trim()));
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async resolveBranch(branches, flagValue, nonInteractive) {
|
|
360
|
+
if (flagValue !== undefined) {
|
|
361
|
+
// An empty value means "skip and use live branch" (same as the picker's skip option)
|
|
362
|
+
if (flagValue.trim() === '') {
|
|
363
|
+
this.log('Using live branch');
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const match = branches.find((br) => br.label === flagValue || br.id === flagValue);
|
|
367
|
+
if (!match) {
|
|
368
|
+
this.error(`Branch '${flagValue}' not found. Available branches: ${branches.map((br) => br.label).join(', ')}`);
|
|
369
|
+
}
|
|
370
|
+
this.log(`Using branch: ${match.label}`);
|
|
371
|
+
return match.id;
|
|
372
|
+
}
|
|
373
|
+
if (nonInteractive) {
|
|
374
|
+
this.log('Using live branch (non-interactive; pass --branch to select one)');
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
return branches.length > 1 ? this.selectBranch(branches) : undefined;
|
|
378
|
+
}
|
|
379
|
+
async resolveInstance(instances, flagValue, nonInteractive) {
|
|
380
|
+
if (flagValue) {
|
|
381
|
+
const match = matchInstance(instances, flagValue);
|
|
382
|
+
if (!match) {
|
|
383
|
+
this.error(`Instance '${flagValue}' not found (match by name, numeric ID, or instance URL). Available instances: ${instances.map((inst) => `${inst.name} (${inst.id})`).join(', ')}`);
|
|
384
|
+
}
|
|
385
|
+
this.log(`Using instance: ${match.name} (${match.display})`);
|
|
386
|
+
return match;
|
|
387
|
+
}
|
|
388
|
+
if (nonInteractive) {
|
|
389
|
+
if (instances.length === 1) {
|
|
390
|
+
this.log(`Using instance: ${instances[0].name} (${instances[0].display})`);
|
|
391
|
+
return instances[0];
|
|
392
|
+
}
|
|
393
|
+
this.error(`Multiple instances available; pass --instance to select one. Available instances: ${instances.map((inst) => `${inst.name} (${inst.id})`).join(', ')}`);
|
|
394
|
+
}
|
|
395
|
+
return this.selectInstance(instances);
|
|
396
|
+
}
|
|
397
|
+
async resolveProfileName(flagValue, nonInteractive) {
|
|
398
|
+
// An empty --profile value means "use the default name" (same as accepting the prompt's default);
|
|
399
|
+
// non-interactive runs with no --profile fall back to the default name too.
|
|
400
|
+
if (flagValue !== undefined) {
|
|
401
|
+
return flagValue.trim() || 'default';
|
|
402
|
+
}
|
|
403
|
+
return nonInteractive ? 'default' : this.promptProfileName();
|
|
404
|
+
}
|
|
405
|
+
async resolveWorkspace(workspaces, flagValue, nonInteractive) {
|
|
406
|
+
if (flagValue !== undefined) {
|
|
407
|
+
// An empty value means "skip workspace" (same as the picker's skip option)
|
|
408
|
+
if (flagValue.trim() === '') {
|
|
409
|
+
this.log('Skipping workspace selection');
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
const match = workspaces.find((ws) => String(ws.id) === flagValue || ws.name === flagValue);
|
|
413
|
+
if (!match) {
|
|
414
|
+
this.error(`Workspace '${flagValue}' not found. Available workspaces: ${workspaces.map((ws) => `${ws.name} (${ws.id})`).join(', ')}`);
|
|
415
|
+
}
|
|
416
|
+
this.log(`Using workspace: ${match.name} (${match.id})`);
|
|
417
|
+
return match;
|
|
418
|
+
}
|
|
419
|
+
if (nonInteractive) {
|
|
420
|
+
this.log('Skipping workspace selection (non-interactive; pass --workspace to select one)');
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
return this.selectWorkspace(workspaces);
|
|
424
|
+
}
|
|
425
|
+
async resolveWorkspaceAndBranch(token, instanceOrigin, flags, nonInteractive) {
|
|
426
|
+
let workspace;
|
|
427
|
+
let branch;
|
|
428
|
+
this.log('');
|
|
429
|
+
this.log('Fetching available workspaces...');
|
|
430
|
+
const workspaces = await this.fetchWorkspaces(token, instanceOrigin);
|
|
431
|
+
if (workspaces.length > 0) {
|
|
432
|
+
workspace = await this.resolveWorkspace(workspaces, flags.workspace, nonInteractive);
|
|
433
|
+
if (workspace) {
|
|
434
|
+
this.log('');
|
|
435
|
+
this.log('Fetching available branches...');
|
|
436
|
+
const branches = await this.fetchBranches(token, instanceOrigin, workspace.id);
|
|
437
|
+
branch = await this.resolveBranch(branches, flags.branch, nonInteractive);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else if (flags.workspace) {
|
|
441
|
+
this.error(`Workspace '${flags.workspace}' not found: no workspaces are available on this instance.`);
|
|
442
|
+
}
|
|
443
|
+
if (flags.branch && !workspace) {
|
|
444
|
+
this.warn('Ignoring --branch: no workspace selected.');
|
|
445
|
+
}
|
|
446
|
+
return { branch, workspace };
|
|
447
|
+
}
|
|
217
448
|
async saveProfile(profile, configPath) {
|
|
218
449
|
const credentialsPath = resolveCredentialsPath(configPath);
|
|
219
450
|
const credDir = dirname(credentialsPath);
|
|
@@ -253,6 +484,7 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
253
484
|
noRefs: true,
|
|
254
485
|
});
|
|
255
486
|
fs.writeFileSync(credentialsPath, yamlContent, 'utf8');
|
|
487
|
+
return credentialsPath;
|
|
256
488
|
}
|
|
257
489
|
async selectBranch(branches) {
|
|
258
490
|
const { selectedBranch } = await inquirer.prompt([
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class FunctionRun extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
name: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
data: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
json: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
logs: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
'no-input-check': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
|
+
};
|
|
22
|
+
run(): Promise<void>;
|
|
23
|
+
private emitResult;
|
|
24
|
+
/**
|
|
25
|
+
* Fetch a single function record by name via the list endpoint's search,
|
|
26
|
+
* returning the exact-name match (so `calc` doesn't accidentally match
|
|
27
|
+
* `calcScore`). Best-effort: returns undefined and warns on any failure so a
|
|
28
|
+
* transient introspection problem never blocks an otherwise-valid run.
|
|
29
|
+
*/
|
|
30
|
+
private fetchFunctionByName;
|
|
31
|
+
/**
|
|
32
|
+
* Prompt for each missing required input on an interactive TTY. Values are
|
|
33
|
+
* read as raw JSON when they parse as such (so numbers/bools/objects work),
|
|
34
|
+
* otherwise treated as a string — mirroring the `key:=`/`key=` distinction.
|
|
35
|
+
* Non-TTY: fail fast with the list of missing params (never hang on a prompt).
|
|
36
|
+
*/
|
|
37
|
+
private promptForMissing;
|
|
38
|
+
private readStdinSync;
|
|
39
|
+
private resolveWorkspaceId;
|
|
40
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import BaseCommand from '../../../base-command.js';
|
|
4
|
+
import { assembleInput, loadJsonSource, parseDataPairs, validateAgainstSchema, } from '../../../utils/function-input.js';
|
|
5
|
+
export default class FunctionRun extends BaseCommand {
|
|
6
|
+
static args = {
|
|
7
|
+
name: Args.string({
|
|
8
|
+
description: 'Name of the function to run (interactive if omitted with --name unset)',
|
|
9
|
+
required: false,
|
|
10
|
+
}),
|
|
11
|
+
};
|
|
12
|
+
static description = 'Run (execute) a named function in a workspace and print its result';
|
|
13
|
+
static examples = [
|
|
14
|
+
`$ xano function:run calcScore -w 40
|
|
15
|
+
# Prompts for any declared inputs, then runs the function`,
|
|
16
|
+
`$ xano function:run calcScore --data email=jo@x.com --data age:=30 --data active:=true`,
|
|
17
|
+
`$ xano function:run calcScore --json @payload.json --data env=staging`,
|
|
18
|
+
`$ echo '{"email":"jo@x.com"}' | xano function:run calcScore --stdin -o json | jq .result`,
|
|
19
|
+
`$ xano function:run calcScore --branch dev --logs`,
|
|
20
|
+
];
|
|
21
|
+
static flags = {
|
|
22
|
+
...BaseCommand.baseFlags,
|
|
23
|
+
branch: Flags.string({
|
|
24
|
+
description: 'Branch to run from (defaults to profile branch, then main)',
|
|
25
|
+
required: false,
|
|
26
|
+
}),
|
|
27
|
+
data: Flags.string({
|
|
28
|
+
char: 'd',
|
|
29
|
+
description: 'Input field as key=value (string), key:=json (raw JSON), or key@file (file contents). Repeatable.',
|
|
30
|
+
multiple: true,
|
|
31
|
+
required: false,
|
|
32
|
+
}),
|
|
33
|
+
json: Flags.string({
|
|
34
|
+
description: "Input as a JSON object: inline, @file.json, or '-' for stdin",
|
|
35
|
+
exclusive: ['stdin'],
|
|
36
|
+
required: false,
|
|
37
|
+
}),
|
|
38
|
+
logs: Flags.boolean({
|
|
39
|
+
default: false,
|
|
40
|
+
description: 'Print the execution logs returned by the debugger',
|
|
41
|
+
required: false,
|
|
42
|
+
}),
|
|
43
|
+
'name': Flags.string({
|
|
44
|
+
char: 'n',
|
|
45
|
+
description: 'Name of the function to run (alternative to the positional argument)',
|
|
46
|
+
required: false,
|
|
47
|
+
}),
|
|
48
|
+
'no-input-check': Flags.boolean({
|
|
49
|
+
default: false,
|
|
50
|
+
description: 'Skip local schema validation and interactive prompting; send the payload as-is',
|
|
51
|
+
required: false,
|
|
52
|
+
}),
|
|
53
|
+
output: Flags.string({
|
|
54
|
+
char: 'o',
|
|
55
|
+
default: 'json',
|
|
56
|
+
description: 'Output format',
|
|
57
|
+
options: ['json', 'summary'],
|
|
58
|
+
required: false,
|
|
59
|
+
}),
|
|
60
|
+
stdin: Flags.boolean({
|
|
61
|
+
char: 's',
|
|
62
|
+
default: false,
|
|
63
|
+
description: "Read the input JSON object from stdin (same as --json -)",
|
|
64
|
+
exclusive: ['json'],
|
|
65
|
+
required: false,
|
|
66
|
+
}),
|
|
67
|
+
workspace: Flags.string({
|
|
68
|
+
char: 'w',
|
|
69
|
+
description: 'Workspace ID (optional if set in profile)',
|
|
70
|
+
required: false,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
async run() {
|
|
74
|
+
const { args, flags } = await this.parse(FunctionRun);
|
|
75
|
+
const { profile, profileName } = this.resolveProfile(flags);
|
|
76
|
+
const functionName = args.name ?? flags.name;
|
|
77
|
+
if (!functionName) {
|
|
78
|
+
this.error('A function name is required. Provide it as an argument or with --name.');
|
|
79
|
+
}
|
|
80
|
+
const workspaceId = this.resolveWorkspaceId(flags, profile, profileName);
|
|
81
|
+
const branch = flags.branch ?? profile.branch ?? '';
|
|
82
|
+
// 1) Assemble the input object from JSON base + --data overrides.
|
|
83
|
+
const jsonSource = flags.stdin ? '-' : flags.json;
|
|
84
|
+
const jsonBase = loadJsonSource(jsonSource, { stdin: () => this.readStdinSync() });
|
|
85
|
+
let input = assembleInput({ dataPairs: parseDataPairs(flags.data ?? []), jsonBase });
|
|
86
|
+
// 2) Introspect the function's declared inputs (best-effort) to validate,
|
|
87
|
+
// warn on obvious mismatches, and prompt for missing required params.
|
|
88
|
+
if (!flags['no-input-check']) {
|
|
89
|
+
const fn = await this.fetchFunctionByName({
|
|
90
|
+
branch,
|
|
91
|
+
name: functionName,
|
|
92
|
+
profile,
|
|
93
|
+
verbose: flags.verbose,
|
|
94
|
+
workspaceId,
|
|
95
|
+
});
|
|
96
|
+
const schema = fn?.input;
|
|
97
|
+
const { missingRequired, warnings } = validateAgainstSchema(input, schema);
|
|
98
|
+
for (const w of warnings)
|
|
99
|
+
this.warn(w);
|
|
100
|
+
if (missingRequired.length > 0) {
|
|
101
|
+
input = await this.promptForMissing(input, missingRequired);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 3) Execute via the meta run endpoint.
|
|
105
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/function/run`;
|
|
106
|
+
const body = { input, name: functionName };
|
|
107
|
+
if (branch)
|
|
108
|
+
body.branch = branch;
|
|
109
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
headers: {
|
|
112
|
+
'accept': 'application/json',
|
|
113
|
+
'Authorization': `Bearer ${profile.access_token}`,
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
},
|
|
116
|
+
method: 'POST',
|
|
117
|
+
}, flags.verbose, profile.access_token);
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const message = await this.parseApiError(response, 'Failed to run function');
|
|
120
|
+
this.error(message);
|
|
121
|
+
}
|
|
122
|
+
const result = (await response.json());
|
|
123
|
+
this.emitResult(result, flags.output, flags.logs);
|
|
124
|
+
// 4) Compose in scripts/CI: non-zero exit when the function errored.
|
|
125
|
+
if (result.status && result.status !== 'ok') {
|
|
126
|
+
this.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
emitResult(result, output, showLogs) {
|
|
130
|
+
if (output === 'json') {
|
|
131
|
+
// Raw result object so `| jq .result` works predictably.
|
|
132
|
+
this.log(JSON.stringify(result.result ?? null, null, 2));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const status = result.status ?? 'ok';
|
|
136
|
+
this.log(`Status: ${status}`);
|
|
137
|
+
this.log('Result:');
|
|
138
|
+
this.log(JSON.stringify(result.result ?? null, null, 2));
|
|
139
|
+
}
|
|
140
|
+
if (showLogs && Array.isArray(result.logs) && result.logs.length > 0) {
|
|
141
|
+
this.log('');
|
|
142
|
+
this.log('Logs:');
|
|
143
|
+
for (const entry of result.logs) {
|
|
144
|
+
this.log(typeof entry === 'string' ? entry : JSON.stringify(entry));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Fetch a single function record by name via the list endpoint's search,
|
|
150
|
+
* returning the exact-name match (so `calc` doesn't accidentally match
|
|
151
|
+
* `calcScore`). Best-effort: returns undefined and warns on any failure so a
|
|
152
|
+
* transient introspection problem never blocks an otherwise-valid run.
|
|
153
|
+
*/
|
|
154
|
+
async fetchFunctionByName(opts) {
|
|
155
|
+
const { branch, name, profile, verbose, workspaceId } = opts;
|
|
156
|
+
// eslint-disable-next-line camelcase -- external Metadata API query param
|
|
157
|
+
const params = new URLSearchParams({ per_page: '100', search: name });
|
|
158
|
+
if (branch)
|
|
159
|
+
params.set('branch', branch);
|
|
160
|
+
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/function?${params.toString()}`;
|
|
161
|
+
try {
|
|
162
|
+
const response = await this.verboseFetch(url, { headers: { accept: 'application/json', Authorization: `Bearer ${profile.access_token}` }, method: 'GET' }, verbose, profile.access_token);
|
|
163
|
+
if (!response.ok)
|
|
164
|
+
return undefined;
|
|
165
|
+
const page = (await response.json());
|
|
166
|
+
const items = Array.isArray(page) ? page : (page.items ?? []);
|
|
167
|
+
const exact = items.filter((f) => f.name === name);
|
|
168
|
+
if (exact.length > 1) {
|
|
169
|
+
this.warn(`Multiple functions named '${name}' found; skipping input validation.`);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return exact[0];
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Introspection is a convenience, not a requirement.
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Prompt for each missing required input on an interactive TTY. Values are
|
|
181
|
+
* read as raw JSON when they parse as such (so numbers/bools/objects work),
|
|
182
|
+
* otherwise treated as a string — mirroring the `key:=`/`key=` distinction.
|
|
183
|
+
* Non-TTY: fail fast with the list of missing params (never hang on a prompt).
|
|
184
|
+
*/
|
|
185
|
+
async promptForMissing(input, missing) {
|
|
186
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
187
|
+
const names = missing.map((p) => `${p.name}${p.type ? ` (${p.type})` : ''}`).join(', ');
|
|
188
|
+
this.error(`Missing required input: ${names}. ` +
|
|
189
|
+
`Provide via --data/--json, or run interactively in a terminal.`);
|
|
190
|
+
}
|
|
191
|
+
const readline = await import('node:readline');
|
|
192
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
193
|
+
const ask = (message) => new Promise((resolve) => {
|
|
194
|
+
rl.question(message, resolve);
|
|
195
|
+
});
|
|
196
|
+
const filled = { ...input };
|
|
197
|
+
try {
|
|
198
|
+
for (const param of missing) {
|
|
199
|
+
const hint = [param.type, param.description].filter(Boolean).join(' — ');
|
|
200
|
+
// eslint-disable-next-line no-await-in-loop
|
|
201
|
+
const raw = await ask(`${param.name}${hint ? ` (${hint})` : ''}: `);
|
|
202
|
+
filled[param.name] = coercePromptValue(raw, param);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
rl.close();
|
|
207
|
+
}
|
|
208
|
+
return filled;
|
|
209
|
+
}
|
|
210
|
+
readStdinSync() {
|
|
211
|
+
// Synchronous read of all of stdin (fd 0); used for --json - / --stdin.
|
|
212
|
+
try {
|
|
213
|
+
return readFileSync(0, 'utf8');
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
this.error(`Failed to read from stdin: ${error instanceof Error ? error.message : String(error)}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
resolveWorkspaceId(flags, profile, profileName) {
|
|
220
|
+
if (flags.workspace)
|
|
221
|
+
return flags.workspace;
|
|
222
|
+
if (profile.workspace)
|
|
223
|
+
return profile.workspace;
|
|
224
|
+
this.error(`Workspace ID is required. Either:\n` +
|
|
225
|
+
` 1. Provide it as a flag: xano function:run <name> -w <workspace_id>\n` +
|
|
226
|
+
` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Coerce a prompted string into the value the parameter likely expects. Non-text
|
|
231
|
+
* params attempt a JSON parse (so `30`, `true`, `["a"]` become typed values);
|
|
232
|
+
* text params keep the raw string. Falls back to the raw string on parse errors.
|
|
233
|
+
*/
|
|
234
|
+
function coercePromptValue(raw, param) {
|
|
235
|
+
const isTextLike = !param.type || param.type === 'text' || param.type === 'uuid';
|
|
236
|
+
if (isTextLike)
|
|
237
|
+
return raw;
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(raw);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return raw;
|
|
243
|
+
}
|
|
244
|
+
}
|