@xano/cli 0.0.23 → 0.0.25

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.
@@ -0,0 +1,21 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Auth extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ origin: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ };
8
+ run(): Promise<void>;
9
+ private fetchBranches;
10
+ private fetchInstances;
11
+ private fetchProjects;
12
+ private fetchWorkspaces;
13
+ private promptProfileName;
14
+ private saveProfile;
15
+ private selectBranch;
16
+ private selectInstance;
17
+ private selectProject;
18
+ private selectWorkspace;
19
+ private startAuthServer;
20
+ private validateToken;
21
+ }
@@ -0,0 +1,533 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import inquirer from 'inquirer';
3
+ import * as yaml from 'js-yaml';
4
+ import * as fs from 'node:fs';
5
+ import * as http from 'node:http';
6
+ import * as os from 'node:os';
7
+ import { join } from 'node:path';
8
+ import open from 'open';
9
+ const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
10
+ export default class Auth extends Command {
11
+ static description = 'Authenticate with Xano via browser login';
12
+ static examples = [
13
+ `$ xano auth
14
+ Opening browser for Xano login...
15
+ Waiting for authentication...
16
+ Authenticated as John Doe (john@example.com)
17
+ ? Select an instance: US-1 (Production)
18
+ ? Profile name: default
19
+ Profile 'default' created successfully!`,
20
+ `$ xano auth --origin https://custom.xano.com
21
+ Opening browser for Xano login at https://custom.xano.com...`,
22
+ ];
23
+ static flags = {
24
+ origin: Flags.string({
25
+ char: 'o',
26
+ default: 'https://app.xano.com',
27
+ description: 'Xano account origin URL',
28
+ }),
29
+ };
30
+ async run() {
31
+ const { flags } = await this.parse(Auth);
32
+ try {
33
+ // Step 1: Get token via browser auth
34
+ this.log('Starting authentication flow...');
35
+ const token = await this.startAuthServer(flags.origin);
36
+ // Step 2: Validate token and get user info
37
+ this.log('');
38
+ this.log('Validating authentication...');
39
+ const user = await this.validateToken(token, flags.origin);
40
+ this.log(`Authenticated as ${user.name} (${user.email})`);
41
+ // Step 3: Fetch and select instance
42
+ this.log('');
43
+ this.log('Fetching available instances...');
44
+ const instances = await this.fetchInstances(token, flags.origin);
45
+ if (instances.length === 0) {
46
+ this.error('No instances found. Please check your account.');
47
+ }
48
+ const instance = await this.selectInstance(instances);
49
+ // Step 4: Workspace selection
50
+ let workspace;
51
+ let branch;
52
+ let project;
53
+ this.log('');
54
+ this.log('Fetching available workspaces...');
55
+ const workspaces = await this.fetchWorkspaces(token, instance.origin);
56
+ if (workspaces.length > 0) {
57
+ workspace = await this.selectWorkspace(workspaces);
58
+ if (workspace) {
59
+ // Step 5: Branch selection
60
+ this.log('');
61
+ this.log('Fetching available branches...');
62
+ const branches = await this.fetchBranches(token, instance.origin, workspace.id);
63
+ if (branches.length > 0) {
64
+ branch = await this.selectBranch(branches);
65
+ }
66
+ // Step 6: Project selection
67
+ this.log('');
68
+ this.log('Fetching available projects...');
69
+ const projects = await this.fetchProjects(token, instance.origin, workspace.id, branch);
70
+ if (projects.length > 0) {
71
+ project = await this.selectProject(projects);
72
+ }
73
+ }
74
+ }
75
+ // Step 7: Profile name
76
+ this.log('');
77
+ const profileName = await this.promptProfileName();
78
+ // Step 8: Save profile
79
+ await this.saveProfile({
80
+ access_token: token,
81
+ account_origin: flags.origin,
82
+ branch,
83
+ instance_origin: instance.origin,
84
+ name: profileName,
85
+ project,
86
+ workspace: workspace?.id,
87
+ });
88
+ this.log('');
89
+ this.log(`Profile '${profileName}' created successfully!`);
90
+ // Ensure clean exit (the open() call can keep the event loop alive)
91
+ process.exit(0);
92
+ }
93
+ catch (error) {
94
+ if (error instanceof Error && error.message.includes('User force closed the prompt')) {
95
+ this.log('Authentication cancelled.');
96
+ return;
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+ async fetchBranches(accessToken, origin, workspaceId) {
102
+ try {
103
+ const response = await fetch(`${origin}/api:meta/workspace/${workspaceId}/branch`, {
104
+ headers: {
105
+ accept: 'application/json',
106
+ Authorization: `Bearer ${accessToken}`,
107
+ },
108
+ method: 'GET',
109
+ });
110
+ if (!response.ok) {
111
+ throw new Error(`API request failed with status ${response.status}`);
112
+ }
113
+ const data = (await response.json());
114
+ if (Array.isArray(data)) {
115
+ return data.map((br) => ({
116
+ id: br.id || br.label,
117
+ label: br.label,
118
+ }));
119
+ }
120
+ return [];
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ async fetchInstances(accessToken, origin) {
127
+ const response = await fetch(`${origin}/api:meta/instance`, {
128
+ headers: {
129
+ accept: 'application/json',
130
+ Authorization: `Bearer ${accessToken}`,
131
+ },
132
+ method: 'GET',
133
+ });
134
+ if (!response.ok) {
135
+ if (response.status === 401) {
136
+ throw new Error('Unauthorized. Please check your access token.');
137
+ }
138
+ throw new Error(`API request failed with status ${response.status}`);
139
+ }
140
+ const data = (await response.json());
141
+ if (Array.isArray(data)) {
142
+ return data.map((inst) => ({
143
+ display: inst.display,
144
+ id: inst.id || inst.name,
145
+ name: inst.name,
146
+ origin: new URL(inst.meta_api).origin,
147
+ }));
148
+ }
149
+ return [];
150
+ }
151
+ async fetchProjects(accessToken, origin, workspaceId, branchId) {
152
+ try {
153
+ const branchParam = branchId ? `?branch=${branchId}` : '';
154
+ const response = await fetch(`${origin}/api:meta/workspace/${workspaceId}/project${branchParam}`, {
155
+ headers: {
156
+ accept: 'application/json',
157
+ Authorization: `Bearer ${accessToken}`,
158
+ },
159
+ method: 'GET',
160
+ });
161
+ if (!response.ok) {
162
+ throw new Error(`API request failed with status ${response.status}`);
163
+ }
164
+ const data = (await response.json());
165
+ if (Array.isArray(data)) {
166
+ return data.map((proj) => ({
167
+ id: proj.id || proj.name,
168
+ name: proj.name,
169
+ }));
170
+ }
171
+ return [];
172
+ }
173
+ catch {
174
+ return [];
175
+ }
176
+ }
177
+ async fetchWorkspaces(accessToken, origin) {
178
+ try {
179
+ const response = await fetch(`${origin}/api:meta/workspace`, {
180
+ headers: {
181
+ accept: 'application/json',
182
+ Authorization: `Bearer ${accessToken}`,
183
+ },
184
+ method: 'GET',
185
+ });
186
+ if (!response.ok) {
187
+ throw new Error(`API request failed with status ${response.status}`);
188
+ }
189
+ const data = (await response.json());
190
+ if (Array.isArray(data)) {
191
+ return data.map((ws) => ({
192
+ id: ws.id || ws.name,
193
+ name: ws.name,
194
+ }));
195
+ }
196
+ return [];
197
+ }
198
+ catch {
199
+ return [];
200
+ }
201
+ }
202
+ async promptProfileName() {
203
+ const { profileName } = await inquirer.prompt([
204
+ {
205
+ default: 'default',
206
+ message: 'Profile name',
207
+ name: 'profileName',
208
+ type: 'input',
209
+ validate(input) {
210
+ const trimmed = input.trim();
211
+ if (trimmed === '') {
212
+ return true; // Will use default
213
+ }
214
+ return true;
215
+ },
216
+ },
217
+ ]);
218
+ return profileName.trim() || 'default';
219
+ }
220
+ async saveProfile(profile) {
221
+ const configDir = join(os.homedir(), '.xano');
222
+ const credentialsPath = join(configDir, 'credentials.yaml');
223
+ // Ensure the .xano directory exists
224
+ if (!fs.existsSync(configDir)) {
225
+ fs.mkdirSync(configDir, { recursive: true });
226
+ }
227
+ // Read existing credentials file or create new structure
228
+ let credentials = { profiles: {} };
229
+ if (fs.existsSync(credentialsPath)) {
230
+ try {
231
+ const fileContent = fs.readFileSync(credentialsPath, 'utf8');
232
+ const parsed = yaml.load(fileContent);
233
+ if (parsed && typeof parsed === 'object' && 'profiles' in parsed) {
234
+ credentials = parsed;
235
+ }
236
+ }
237
+ catch {
238
+ // Continue with empty credentials if parse fails
239
+ }
240
+ }
241
+ // Add or update the profile
242
+ credentials.profiles[profile.name] = {
243
+ access_token: profile.access_token,
244
+ account_origin: profile.account_origin,
245
+ instance_origin: profile.instance_origin,
246
+ ...(profile.workspace && { workspace: profile.workspace }),
247
+ ...(profile.branch && { branch: profile.branch }),
248
+ ...(profile.project && { project: profile.project }),
249
+ };
250
+ // Set as default profile
251
+ credentials.default = profile.name;
252
+ // Write the updated credentials back to the file
253
+ const yamlContent = yaml.dump(credentials, {
254
+ indent: 2,
255
+ lineWidth: -1,
256
+ noRefs: true,
257
+ });
258
+ fs.writeFileSync(credentialsPath, yamlContent, 'utf8');
259
+ }
260
+ async selectBranch(branches) {
261
+ const { selectedBranch } = await inquirer.prompt([
262
+ {
263
+ choices: [
264
+ { name: '(Skip and use live branch)', value: '' },
265
+ ...branches.map((br) => ({
266
+ name: br.label,
267
+ value: br.id,
268
+ })),
269
+ ],
270
+ message: 'Select a branch',
271
+ name: 'selectedBranch',
272
+ type: 'list',
273
+ },
274
+ ]);
275
+ return selectedBranch || undefined;
276
+ }
277
+ async selectInstance(instances) {
278
+ const { instanceId } = await inquirer.prompt([
279
+ {
280
+ choices: instances.map((inst) => ({
281
+ name: `${inst.name} (${inst.display})`,
282
+ value: inst.id,
283
+ })),
284
+ message: 'Select an instance',
285
+ name: 'instanceId',
286
+ type: 'list',
287
+ },
288
+ ]);
289
+ return instances.find((inst) => inst.id === instanceId);
290
+ }
291
+ async selectProject(projects) {
292
+ const { selectedProject } = await inquirer.prompt([
293
+ {
294
+ choices: [
295
+ { name: '(Skip project)', value: '' },
296
+ ...projects.map((proj) => ({
297
+ name: proj.name,
298
+ value: proj.id,
299
+ })),
300
+ ],
301
+ message: 'Select a project',
302
+ name: 'selectedProject',
303
+ type: 'list',
304
+ },
305
+ ]);
306
+ return selectedProject || undefined;
307
+ }
308
+ async selectWorkspace(workspaces) {
309
+ const { selectedWorkspace } = await inquirer.prompt([
310
+ {
311
+ choices: [
312
+ { name: '(Skip workspace)', value: '' },
313
+ ...workspaces.map((ws) => ({
314
+ name: ws.name,
315
+ value: ws.id,
316
+ })),
317
+ ],
318
+ message: 'Select a workspace',
319
+ name: 'selectedWorkspace',
320
+ type: 'list',
321
+ },
322
+ ]);
323
+ if (!selectedWorkspace) {
324
+ return undefined;
325
+ }
326
+ return workspaces.find((ws) => ws.id === selectedWorkspace);
327
+ }
328
+ startAuthServer(origin) {
329
+ return new Promise((resolve, reject) => {
330
+ const server = http.createServer((req, res) => {
331
+ const url = new URL(req.url || '', `http://${req.headers.host}`);
332
+ if (url.pathname === '/callback') {
333
+ const token = url.searchParams.get('token');
334
+ if (token) {
335
+ // Send success response to browser
336
+ res.writeHead(200, { 'Content-Type': 'text/html' });
337
+ res.end(`
338
+ <!DOCTYPE html>
339
+ <html>
340
+ <head>
341
+ <title>Xano CLI - Authentication Successful</title>
342
+ <style>
343
+ body {
344
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
345
+ display: flex;
346
+ flex-direction: column;
347
+ justify-content: center;
348
+ align-items: center;
349
+ min-height: 100vh;
350
+ margin: 0;
351
+ background: #f4f5f7;
352
+ color: #1a1a2e;
353
+ }
354
+ .container {
355
+ text-align: center;
356
+ padding: 48px;
357
+ background: white;
358
+ border-radius: 12px;
359
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
360
+ max-width: 400px;
361
+ }
362
+ .checkmark {
363
+ width: 64px;
364
+ height: 64px;
365
+ background: #1b62f8;
366
+ border-radius: 50%;
367
+ display: flex;
368
+ justify-content: center;
369
+ align-items: center;
370
+ margin: 0 auto 24px;
371
+ }
372
+ .checkmark svg {
373
+ width: 32px;
374
+ height: 32px;
375
+ fill: white;
376
+ }
377
+ h1 {
378
+ font-size: 24px;
379
+ font-weight: 600;
380
+ margin: 0 0 12px;
381
+ color: #1a1a2e;
382
+ }
383
+ p {
384
+ font-size: 14px;
385
+ color: #6b7280;
386
+ margin: 0;
387
+ }
388
+ </style>
389
+ </head>
390
+ <body>
391
+ <div class="container">
392
+ <div class="checkmark">
393
+ <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>
394
+ </div>
395
+ <h1>Authentication Successful</h1>
396
+ <p>You can now close this window and return to your terminal.</p>
397
+ </div>
398
+ </body>
399
+ </html>
400
+ `);
401
+ // Close server and resolve with token
402
+ clearTimeout(timeout);
403
+ server.close();
404
+ resolve(decodeURIComponent(token));
405
+ }
406
+ else {
407
+ res.writeHead(400, { 'Content-Type': 'text/html' });
408
+ res.end(`
409
+ <!DOCTYPE html>
410
+ <html>
411
+ <head>
412
+ <title>Xano CLI - Authentication Failed</title>
413
+ <style>
414
+ body {
415
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
416
+ display: flex;
417
+ flex-direction: column;
418
+ justify-content: center;
419
+ align-items: center;
420
+ min-height: 100vh;
421
+ margin: 0;
422
+ background: #f4f5f7;
423
+ color: #1a1a2e;
424
+ }
425
+ .container {
426
+ text-align: center;
427
+ padding: 48px;
428
+ background: white;
429
+ border-radius: 12px;
430
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
431
+ max-width: 400px;
432
+ }
433
+ .error-icon {
434
+ width: 64px;
435
+ height: 64px;
436
+ background: #ef4444;
437
+ border-radius: 50%;
438
+ display: flex;
439
+ justify-content: center;
440
+ align-items: center;
441
+ margin: 0 auto 24px;
442
+ }
443
+ .error-icon svg {
444
+ width: 32px;
445
+ height: 32px;
446
+ fill: white;
447
+ }
448
+ h1 {
449
+ font-size: 24px;
450
+ font-weight: 600;
451
+ margin: 0 0 12px;
452
+ color: #1a1a2e;
453
+ }
454
+ p {
455
+ font-size: 14px;
456
+ color: #6b7280;
457
+ margin: 0;
458
+ }
459
+ </style>
460
+ </head>
461
+ <body>
462
+ <div class="container">
463
+ <div class="error-icon">
464
+ <svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
465
+ </div>
466
+ <h1>Authentication Failed</h1>
467
+ <p>No token received. Please close this window and try again.</p>
468
+ </div>
469
+ </body>
470
+ </html>
471
+ `);
472
+ }
473
+ }
474
+ else {
475
+ res.writeHead(404);
476
+ res.end('Not found');
477
+ }
478
+ });
479
+ // Set timeout
480
+ const timeout = setTimeout(() => {
481
+ server.close();
482
+ reject(new Error('Authentication timed out. Please try again.'));
483
+ }, AUTH_TIMEOUT_MS);
484
+ // Handle server errors
485
+ server.on('error', (err) => {
486
+ clearTimeout(timeout);
487
+ reject(new Error(`Failed to start auth server: ${err.message}`));
488
+ });
489
+ // Start server on random available port
490
+ server.listen(0, '127.0.0.1', async () => {
491
+ const address = server.address();
492
+ if (!address || typeof address === 'string') {
493
+ clearTimeout(timeout);
494
+ server.close();
495
+ reject(new Error('Failed to get server address'));
496
+ return;
497
+ }
498
+ const { port } = address;
499
+ const callbackUrl = encodeURIComponent(`http://127.0.0.1:${port}/callback`);
500
+ const authUrl = `${origin}/login?dest=cli&callback=${callbackUrl}`;
501
+ this.log(`Opening browser for Xano login...`);
502
+ this.log('');
503
+ try {
504
+ await open(authUrl);
505
+ this.log('Waiting for authentication...');
506
+ this.log('(If the browser did not open, visit this URL manually:)');
507
+ this.log(authUrl);
508
+ }
509
+ catch {
510
+ this.log('Could not open browser automatically.');
511
+ this.log('Please visit this URL to authenticate:');
512
+ this.log(authUrl);
513
+ }
514
+ });
515
+ });
516
+ }
517
+ async validateToken(token, origin) {
518
+ const response = await fetch(`${origin}/api:meta/auth/me`, {
519
+ headers: {
520
+ accept: 'application/json',
521
+ Authorization: `Bearer ${token}`,
522
+ },
523
+ method: 'GET',
524
+ });
525
+ if (!response.ok) {
526
+ if (response.status === 401) {
527
+ throw new Error('Invalid or expired token. Please try again.');
528
+ }
529
+ throw new Error(`Token validation failed with status ${response.status}`);
530
+ }
531
+ return (await response.json());
532
+ }
533
+ }
@@ -79,7 +79,7 @@ Pulled 58 documents to ./backup
79
79
  records: flags.records.toString(),
80
80
  });
81
81
  // Construct the API URL
82
- const apiUrl = `${profile.instance_origin}/api:meta/beta/workspace/${workspaceId}/multidoc?${queryParams.toString()}`;
82
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/multidoc?${queryParams.toString()}`;
83
83
  // Fetch multidoc from the API
84
84
  let responseText;
85
85
  try {
@@ -89,7 +89,7 @@ Pushed 58 documents from ./backup
89
89
  }
90
90
  const multidoc = documents.join('\n---\n');
91
91
  // Construct the API URL
92
- const apiUrl = `${profile.instance_origin}/api:meta/beta/workspace/${workspaceId}/multidoc`;
92
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/multidoc`;
93
93
  // POST the multidoc to the API
94
94
  try {
95
95
  const response = await fetch(apiUrl, {