@xano/cli 0.0.15 → 0.0.17

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 CHANGED
@@ -35,7 +35,7 @@ npm install -g @xano/cli
35
35
  Profiles store your Xano credentials and default workspace/project settings.
36
36
 
37
37
  ```bash
38
- # Create a profile interactively
38
+ # Create a profile interactively (auto-fetches run projects)
39
39
  xano profile:wizard
40
40
 
41
41
  # Create a profile manually
@@ -52,13 +52,17 @@ xano profile:list --details
52
52
  xano profile:set-default myprofile
53
53
 
54
54
  # Edit a profile
55
- xano profile:edit myprofile -w 123 # Set default workspace
56
- xano profile:edit myprofile -j my-project # Set default project
55
+ xano profile:edit myprofile -w 123 # Set default workspace
56
+ xano profile:edit myprofile -j my-project # Set default project
57
+ xano profile:edit myprofile --run-project <id> # Set run project for xano run commands
58
+ xano profile:edit myprofile --remove-run-project # Remove run project
57
59
 
58
60
  # Delete a profile
59
61
  xano profile:delete myprofile
60
62
  ```
61
63
 
64
+ The `profile:wizard` command automatically fetches your run projects and sets the first one as the default for `xano run` commands.
65
+
62
66
  ### Workspaces
63
67
 
64
68
  ```bash
@@ -211,7 +215,19 @@ All commands support these options:
211
215
 
212
216
  ## Configuration
213
217
 
214
- Profiles are stored in `~/.xano/credentials.yaml`.
218
+ Profiles are stored in `~/.xano/credentials.yaml`:
219
+
220
+ ```yaml
221
+ profiles:
222
+ default:
223
+ account_origin: https://app.xano.com
224
+ instance_origin: https://instance.xano.com
225
+ access_token: <token>
226
+ workspace: <workspace_id>
227
+ branch: <branch_id>
228
+ run_project: <run_project_id> # Used by xano run commands
229
+ default: default
230
+ ```
215
231
 
216
232
  ## Help
217
233
 
@@ -40,7 +40,7 @@ export default class ProfileEdit extends BaseCommand {
40
40
  }),
41
41
  project: Flags.string({
42
42
  char: 'j',
43
- description: 'Update project name',
43
+ description: 'Update project ID',
44
44
  required: false,
45
45
  }),
46
46
  'remove-workspace': Flags.boolean({
@@ -11,6 +11,7 @@ export default class ProfileWizard extends Command {
11
11
  private fetchWorkspaces;
12
12
  private fetchBranches;
13
13
  private fetchProjects;
14
+ private fetchRunProjects;
14
15
  private getDefaultProfileName;
15
16
  private saveProfile;
16
17
  }
@@ -4,6 +4,7 @@ import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import * as yaml from 'js-yaml';
6
6
  import inquirer from 'inquirer';
7
+ import { DEFAULT_RUN_BASE_URL } from '../../../lib/run-http-client.js';
7
8
  export default class ProfileWizard extends Command {
8
9
  static flags = {
9
10
  name: Flags.string({
@@ -191,6 +192,25 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
191
192
  }
192
193
  }
193
194
  }
195
+ // Step 7: Fetch run projects and auto-select the first one if no project was selected
196
+ this.log('');
197
+ this.log('Fetching available run projects...');
198
+ try {
199
+ const runProjects = await this.fetchRunProjects(accessToken);
200
+ if (runProjects.length > 0) {
201
+ // Use run project if no metadata project was selected
202
+ if (!project) {
203
+ project = runProjects[0].id;
204
+ }
205
+ this.log(`✓ Found ${runProjects.length} run project(s). Using "${runProjects[0].name}" as default.`);
206
+ }
207
+ else {
208
+ this.log('No run projects found. You can create one later with "xano run projects create".');
209
+ }
210
+ }
211
+ catch {
212
+ // Silently ignore - project will remain undefined
213
+ }
194
214
  // Save profile
195
215
  await this.saveProfile({
196
216
  name: profileName,
@@ -357,6 +377,24 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
357
377
  }
358
378
  return [];
359
379
  }
380
+ async fetchRunProjects(accessToken, runBaseUrl = DEFAULT_RUN_BASE_URL) {
381
+ const baseUrl = runBaseUrl.endsWith('/') ? runBaseUrl.slice(0, -1) : runBaseUrl;
382
+ const response = await fetch(`${baseUrl}/api:run/project`, {
383
+ method: 'GET',
384
+ headers: {
385
+ 'Content-Type': 'application/json',
386
+ Authorization: `Bearer ${accessToken}`,
387
+ },
388
+ });
389
+ if (!response.ok) {
390
+ if (response.status === 401) {
391
+ throw new Error('Unauthorized. Please check your access token.');
392
+ }
393
+ throw new Error(`API request failed with status ${response.status}`);
394
+ }
395
+ const data = (await response.json());
396
+ return Array.isArray(data) ? data : [];
397
+ }
360
398
  getDefaultProfileName() {
361
399
  try {
362
400
  const configDir = path.join(os.homedir(), '.xano');
@@ -0,0 +1,28 @@
1
+ import BaseCommand from '../../../base-command.js';
2
+ export default class Pull extends BaseCommand {
3
+ static args: {
4
+ directory: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static flags: {
7
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ static description: string;
13
+ static examples: string[];
14
+ run(): Promise<void>;
15
+ /**
16
+ * Parse a single document to extract its type, name, and optional verb.
17
+ * Skips leading comment lines (starting with //) to find the first
18
+ * meaningful line containing the type keyword and name.
19
+ */
20
+ private parseDocument;
21
+ /**
22
+ * Sanitize a document name for use as a filename.
23
+ * Strips quotes, replaces spaces with underscores, and removes
24
+ * characters that are unsafe in filenames.
25
+ */
26
+ private sanitizeFilename;
27
+ private loadCredentials;
28
+ }
@@ -0,0 +1,238 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import * as yaml from 'js-yaml';
6
+ import BaseCommand from '../../../base-command.js';
7
+ export default class Pull extends BaseCommand {
8
+ static args = {
9
+ directory: Args.string({
10
+ description: 'Output directory for pulled documents',
11
+ required: true,
12
+ }),
13
+ };
14
+ static flags = {
15
+ ...BaseCommand.baseFlags,
16
+ workspace: Flags.string({
17
+ char: 'w',
18
+ description: 'Workspace ID (optional if set in profile)',
19
+ required: false,
20
+ }),
21
+ env: Flags.boolean({
22
+ description: 'Include environment variables',
23
+ required: false,
24
+ default: false,
25
+ }),
26
+ records: Flags.boolean({
27
+ description: 'Include records',
28
+ required: false,
29
+ default: false,
30
+ }),
31
+ };
32
+ static description = 'Pull a workspace multidoc from the Xano Metadata API and split into individual files';
33
+ static examples = [
34
+ `$ xano workspace pull ./my-workspace
35
+ Pulled 42 documents to ./my-workspace
36
+ `,
37
+ `$ xano workspace pull ./output -w 40
38
+ Pulled 15 documents to ./output
39
+ `,
40
+ `$ xano workspace pull ./backup --profile production --env --records
41
+ Pulled 58 documents to ./backup
42
+ `,
43
+ ];
44
+ async run() {
45
+ const { args, flags } = await this.parse(Pull);
46
+ // Get profile name (default or from flag/env)
47
+ const profileName = flags.profile || this.getDefaultProfile();
48
+ // Load credentials
49
+ const credentials = this.loadCredentials();
50
+ // Get the profile configuration
51
+ if (!(profileName in credentials.profiles)) {
52
+ this.error(`Profile '${profileName}' not found. Available profiles: ${Object.keys(credentials.profiles).join(', ')}\n` +
53
+ `Create a profile using 'xano profile:create'`);
54
+ }
55
+ const profile = credentials.profiles[profileName];
56
+ // Validate required fields
57
+ if (!profile.instance_origin) {
58
+ this.error(`Profile '${profileName}' is missing instance_origin`);
59
+ }
60
+ if (!profile.access_token) {
61
+ this.error(`Profile '${profileName}' is missing access_token`);
62
+ }
63
+ // Determine workspace_id from flag or profile
64
+ let workspaceId;
65
+ if (flags.workspace) {
66
+ workspaceId = flags.workspace;
67
+ }
68
+ else if (profile.workspace) {
69
+ workspaceId = profile.workspace;
70
+ }
71
+ else {
72
+ this.error(`Workspace ID is required. Either:\n` +
73
+ ` 1. Provide it as a flag: xano workspace pull <directory> -w <workspace_id>\n` +
74
+ ` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
75
+ }
76
+ // Build query parameters
77
+ const queryParams = new URLSearchParams({
78
+ env: flags.env.toString(),
79
+ records: flags.records.toString(),
80
+ });
81
+ // Construct the API URL
82
+ const apiUrl = `${profile.instance_origin}/api:meta/beta/workspace/${workspaceId}/multidoc?${queryParams.toString()}`;
83
+ // Fetch multidoc from the API
84
+ let responseText;
85
+ try {
86
+ const response = await fetch(apiUrl, {
87
+ method: 'GET',
88
+ headers: {
89
+ 'accept': 'application/json',
90
+ 'Authorization': `Bearer ${profile.access_token}`,
91
+ },
92
+ });
93
+ if (!response.ok) {
94
+ const errorText = await response.text();
95
+ this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
96
+ }
97
+ responseText = await response.text();
98
+ }
99
+ catch (error) {
100
+ if (error instanceof Error) {
101
+ this.error(`Failed to fetch multidoc: ${error.message}`);
102
+ }
103
+ else {
104
+ this.error(`Failed to fetch multidoc: ${String(error)}`);
105
+ }
106
+ }
107
+ // Split the response into individual documents
108
+ const rawDocuments = responseText.split('\n---\n');
109
+ // Parse each document
110
+ const documents = [];
111
+ for (const raw of rawDocuments) {
112
+ const trimmed = raw.trim();
113
+ if (!trimmed) {
114
+ continue;
115
+ }
116
+ const parsed = this.parseDocument(trimmed);
117
+ if (parsed) {
118
+ documents.push(parsed);
119
+ }
120
+ }
121
+ if (documents.length === 0) {
122
+ this.log('No documents found in response');
123
+ return;
124
+ }
125
+ // Resolve the output directory
126
+ const outputDir = path.resolve(args.directory);
127
+ // Create the output directory if it doesn't exist
128
+ fs.mkdirSync(outputDir, { recursive: true });
129
+ // Track filenames per type to handle duplicates
130
+ const filenameCounters = new Map();
131
+ let writtenCount = 0;
132
+ for (const doc of documents) {
133
+ // Create the type subdirectory
134
+ const typeDir = path.join(outputDir, doc.type);
135
+ fs.mkdirSync(typeDir, { recursive: true });
136
+ // Build the base filename
137
+ let baseName = this.sanitizeFilename(doc.name);
138
+ if (doc.verb) {
139
+ baseName = `${baseName}_${doc.verb}`;
140
+ }
141
+ // Track duplicates per type directory
142
+ if (!filenameCounters.has(doc.type)) {
143
+ filenameCounters.set(doc.type, new Map());
144
+ }
145
+ const typeCounters = filenameCounters.get(doc.type);
146
+ const count = typeCounters.get(baseName) || 0;
147
+ typeCounters.set(baseName, count + 1);
148
+ // Append numeric suffix for duplicates
149
+ let filename;
150
+ if (count === 0) {
151
+ filename = `${baseName}.xs`;
152
+ }
153
+ else {
154
+ filename = `${baseName}_${count + 1}.xs`;
155
+ }
156
+ const filePath = path.join(typeDir, filename);
157
+ fs.writeFileSync(filePath, doc.content, 'utf8');
158
+ writtenCount++;
159
+ }
160
+ this.log(`Pulled ${writtenCount} documents to ${args.directory}`);
161
+ }
162
+ /**
163
+ * Parse a single document to extract its type, name, and optional verb.
164
+ * Skips leading comment lines (starting with //) to find the first
165
+ * meaningful line containing the type keyword and name.
166
+ */
167
+ parseDocument(content) {
168
+ const lines = content.split('\n');
169
+ // Find the first non-comment line
170
+ let firstLine = null;
171
+ for (const line of lines) {
172
+ const trimmedLine = line.trim();
173
+ if (trimmedLine && !trimmedLine.startsWith('//')) {
174
+ firstLine = trimmedLine;
175
+ break;
176
+ }
177
+ }
178
+ if (!firstLine) {
179
+ return null;
180
+ }
181
+ // Parse the type keyword and name from the first meaningful line
182
+ // Expected formats:
183
+ // type name {
184
+ // type name verb=GET {
185
+ // type "name with spaces" {
186
+ // type "name with spaces" verb=PATCH {
187
+ const match = firstLine.match(/^(\w+)\s+("(?:[^"\\]|\\.)*"|\S+)(?:\s+(.*))?/);
188
+ if (!match) {
189
+ return null;
190
+ }
191
+ const type = match[1];
192
+ let name = match[2];
193
+ const rest = match[3] || '';
194
+ // Strip surrounding quotes from the name
195
+ if (name.startsWith('"') && name.endsWith('"')) {
196
+ name = name.slice(1, -1);
197
+ }
198
+ // Extract verb if present (e.g., verb=GET)
199
+ let verb;
200
+ const verbMatch = rest.match(/verb=(\S+)/);
201
+ if (verbMatch) {
202
+ verb = verbMatch[1];
203
+ }
204
+ return { type, name, verb, content };
205
+ }
206
+ /**
207
+ * Sanitize a document name for use as a filename.
208
+ * Strips quotes, replaces spaces with underscores, and removes
209
+ * characters that are unsafe in filenames.
210
+ */
211
+ sanitizeFilename(name) {
212
+ return name
213
+ .replace(/"/g, '')
214
+ .replace(/\s+/g, '_')
215
+ .replace(/[<>:"/\\|?*]/g, '_');
216
+ }
217
+ loadCredentials() {
218
+ const configDir = path.join(os.homedir(), '.xano');
219
+ const credentialsPath = path.join(configDir, 'credentials.yaml');
220
+ // Check if credentials file exists
221
+ if (!fs.existsSync(credentialsPath)) {
222
+ this.error(`Credentials file not found at ${credentialsPath}\n` +
223
+ `Create a profile using 'xano profile:create'`);
224
+ }
225
+ // Read credentials file
226
+ try {
227
+ const fileContent = fs.readFileSync(credentialsPath, 'utf8');
228
+ const parsed = yaml.load(fileContent);
229
+ if (!parsed || typeof parsed !== 'object' || !('profiles' in parsed)) {
230
+ this.error('Credentials file has invalid format.');
231
+ }
232
+ return parsed;
233
+ }
234
+ catch (error) {
235
+ this.error(`Failed to parse credentials file: ${error}`);
236
+ }
237
+ }
238
+ }
@@ -0,0 +1,19 @@
1
+ import BaseCommand from '../../../base-command.js';
2
+ export default class Push extends BaseCommand {
3
+ static args: {
4
+ directory: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ run(): Promise<void>;
13
+ /**
14
+ * Recursively collect all .xs files from a directory, sorted by
15
+ * type subdirectory name then filename for deterministic ordering.
16
+ */
17
+ private collectFiles;
18
+ private loadCredentials;
19
+ }
@@ -0,0 +1,163 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import * as yaml from 'js-yaml';
6
+ import BaseCommand from '../../../base-command.js';
7
+ export default class Push extends BaseCommand {
8
+ static args = {
9
+ directory: Args.string({
10
+ description: 'Directory containing documents to push (as produced by workspace pull)',
11
+ required: true,
12
+ }),
13
+ };
14
+ static description = 'Push local documents to a workspace via the Xano Metadata API multidoc endpoint';
15
+ static examples = [
16
+ `$ xano workspace push ./my-workspace
17
+ Pushed 42 documents from ./my-workspace
18
+ `,
19
+ `$ xano workspace push ./output -w 40
20
+ Pushed 15 documents from ./output
21
+ `,
22
+ `$ xano workspace push ./backup --profile production
23
+ Pushed 58 documents from ./backup
24
+ `,
25
+ ];
26
+ static flags = {
27
+ ...BaseCommand.baseFlags,
28
+ workspace: Flags.string({
29
+ char: 'w',
30
+ description: 'Workspace ID (optional if set in profile)',
31
+ required: false,
32
+ }),
33
+ };
34
+ async run() {
35
+ const { args, flags } = await this.parse(Push);
36
+ // Get profile name (default or from flag/env)
37
+ const profileName = flags.profile || this.getDefaultProfile();
38
+ // Load credentials
39
+ const credentials = this.loadCredentials();
40
+ // Get the profile configuration
41
+ if (!(profileName in credentials.profiles)) {
42
+ this.error(`Profile '${profileName}' not found. Available profiles: ${Object.keys(credentials.profiles).join(', ')}\n` +
43
+ `Create a profile using 'xano profile:create'`);
44
+ }
45
+ const profile = credentials.profiles[profileName];
46
+ // Validate required fields
47
+ if (!profile.instance_origin) {
48
+ this.error(`Profile '${profileName}' is missing instance_origin`);
49
+ }
50
+ if (!profile.access_token) {
51
+ this.error(`Profile '${profileName}' is missing access_token`);
52
+ }
53
+ // Determine workspace_id from flag or profile
54
+ let workspaceId;
55
+ if (flags.workspace) {
56
+ workspaceId = flags.workspace;
57
+ }
58
+ else if (profile.workspace) {
59
+ workspaceId = profile.workspace;
60
+ }
61
+ else {
62
+ this.error(`Workspace ID is required. Either:\n` +
63
+ ` 1. Provide it as a flag: xano workspace push <directory> -w <workspace_id>\n` +
64
+ ` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
65
+ }
66
+ // Resolve the input directory
67
+ const inputDir = path.resolve(args.directory);
68
+ if (!fs.existsSync(inputDir)) {
69
+ this.error(`Directory not found: ${inputDir}`);
70
+ }
71
+ if (!fs.statSync(inputDir).isDirectory()) {
72
+ this.error(`Not a directory: ${inputDir}`);
73
+ }
74
+ // Collect all .xs files from the directory tree
75
+ const files = this.collectFiles(inputDir);
76
+ if (files.length === 0) {
77
+ this.error(`No .xs files found in ${args.directory}`);
78
+ }
79
+ // Read each file and join with --- separator
80
+ const documents = [];
81
+ for (const filePath of files) {
82
+ const content = fs.readFileSync(filePath, 'utf8').trim();
83
+ if (content) {
84
+ documents.push(content);
85
+ }
86
+ }
87
+ if (documents.length === 0) {
88
+ this.error(`All .xs files in ${args.directory} are empty`);
89
+ }
90
+ const multidoc = documents.join('\n---\n');
91
+ // Construct the API URL
92
+ const apiUrl = `${profile.instance_origin}/api:meta/beta/workspace/${workspaceId}/multidoc`;
93
+ // POST the multidoc to the API
94
+ try {
95
+ const response = await fetch(apiUrl, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'accept': 'application/json',
99
+ 'Authorization': `Bearer ${profile.access_token}`,
100
+ 'Content-Type': 'text/x-xanoscript',
101
+ },
102
+ body: multidoc,
103
+ });
104
+ if (!response.ok) {
105
+ const errorText = await response.text();
106
+ this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
107
+ }
108
+ // Log the response if any
109
+ const responseText = await response.text();
110
+ if (responseText) {
111
+ this.log(responseText);
112
+ }
113
+ }
114
+ catch (error) {
115
+ if (error instanceof Error) {
116
+ this.error(`Failed to push multidoc: ${error.message}`);
117
+ }
118
+ else {
119
+ this.error(`Failed to push multidoc: ${String(error)}`);
120
+ }
121
+ }
122
+ this.log(`Pushed ${documents.length} documents from ${args.directory}`);
123
+ }
124
+ /**
125
+ * Recursively collect all .xs files from a directory, sorted by
126
+ * type subdirectory name then filename for deterministic ordering.
127
+ */
128
+ collectFiles(dir) {
129
+ const files = [];
130
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
131
+ for (const entry of entries) {
132
+ const fullPath = path.join(dir, entry.name);
133
+ if (entry.isDirectory()) {
134
+ files.push(...this.collectFiles(fullPath));
135
+ }
136
+ else if (entry.isFile() && entry.name.endsWith('.xs')) {
137
+ files.push(fullPath);
138
+ }
139
+ }
140
+ return files.sort();
141
+ }
142
+ loadCredentials() {
143
+ const configDir = path.join(os.homedir(), '.xano');
144
+ const credentialsPath = path.join(configDir, 'credentials.yaml');
145
+ // Check if credentials file exists
146
+ if (!fs.existsSync(credentialsPath)) {
147
+ this.error(`Credentials file not found at ${credentialsPath}\n` +
148
+ `Create a profile using 'xano profile:create'`);
149
+ }
150
+ // Read credentials file
151
+ try {
152
+ const fileContent = fs.readFileSync(credentialsPath, 'utf8');
153
+ const parsed = yaml.load(fileContent);
154
+ if (!parsed || typeof parsed !== 'object' || !('profiles' in parsed)) {
155
+ this.error('Credentials file has invalid format.');
156
+ }
157
+ return parsed;
158
+ }
159
+ catch (error) {
160
+ this.error(`Failed to parse credentials file: ${error}`);
161
+ }
162
+ }
163
+ }
@@ -39,7 +39,7 @@ export default class BaseRunCommand extends BaseCommand {
39
39
  await this.initRunCommand(profileFlag);
40
40
  if (!this.profile.project) {
41
41
  this.error(`Profile '${this.profileName}' is missing project. ` +
42
- `Update your profile with 'xano profile:edit --project <project-id>'`);
42
+ `Run 'xano profile:wizard' to set up your profile or use 'xano profile:edit --project <project-id>'`);
43
43
  }
44
44
  }
45
45
  /**