@xano/cli 0.0.15 → 0.0.16
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 +20 -4
- package/dist/commands/profile/edit/index.d.ts +2 -0
- package/dist/commands/profile/edit/index.js +15 -2
- package/dist/commands/profile/wizard/index.d.ts +1 -0
- package/dist/commands/profile/wizard/index.js +38 -0
- package/dist/commands/workspace/pull/index.d.ts +28 -0
- package/dist/commands/workspace/pull/index.js +238 -0
- package/dist/commands/workspace/push/index.d.ts +19 -0
- package/dist/commands/workspace/push/index.js +163 -0
- package/dist/lib/base-run-command.d.ts +1 -0
- package/dist/lib/base-run-command.js +6 -4
- package/oclif.manifest.json +445 -310
- package/package.json +1 -1
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
|
|
56
|
-
xano profile:edit myprofile -j my-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
|
|
|
@@ -10,9 +10,11 @@ export default class ProfileEdit extends BaseCommand {
|
|
|
10
10
|
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
11
|
branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
12
|
project: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
'run-project': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
14
|
'remove-workspace': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
15
|
'remove-branch': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
16
|
'remove-project': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
'remove-run-project': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
18
|
run_base_url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
19
|
'remove-run-base-url': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
20
|
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -43,6 +43,10 @@ export default class ProfileEdit extends BaseCommand {
|
|
|
43
43
|
description: 'Update project name',
|
|
44
44
|
required: false,
|
|
45
45
|
}),
|
|
46
|
+
'run-project': Flags.string({
|
|
47
|
+
description: 'Update run project ID (for xano run commands)',
|
|
48
|
+
required: false,
|
|
49
|
+
}),
|
|
46
50
|
'remove-workspace': Flags.boolean({
|
|
47
51
|
description: 'Remove workspace from profile',
|
|
48
52
|
required: false,
|
|
@@ -58,6 +62,11 @@ export default class ProfileEdit extends BaseCommand {
|
|
|
58
62
|
required: false,
|
|
59
63
|
default: false,
|
|
60
64
|
}),
|
|
65
|
+
'remove-run-project': Flags.boolean({
|
|
66
|
+
description: 'Remove run project from profile',
|
|
67
|
+
required: false,
|
|
68
|
+
default: false,
|
|
69
|
+
}),
|
|
61
70
|
run_base_url: Flags.string({
|
|
62
71
|
char: 'r',
|
|
63
72
|
description: 'Update Xano Run API base URL',
|
|
@@ -124,9 +133,9 @@ Profile 'default' updated successfully at ~/.xano/credentials.yaml
|
|
|
124
133
|
const existingProfile = credentials.profiles[profileName];
|
|
125
134
|
// Check if any flags were provided
|
|
126
135
|
const hasFlags = flags.account_origin || flags.instance_origin || flags.access_token ||
|
|
127
|
-
flags.workspace || flags.branch || flags.project || flags.run_base_url ||
|
|
136
|
+
flags.workspace || flags.branch || flags.project || flags['run-project'] || flags.run_base_url ||
|
|
128
137
|
flags['remove-workspace'] || flags['remove-branch'] || flags['remove-project'] ||
|
|
129
|
-
flags['remove-run-base-url'];
|
|
138
|
+
flags['remove-run-project'] || flags['remove-run-base-url'];
|
|
130
139
|
if (!hasFlags) {
|
|
131
140
|
this.error('No fields specified to update. Use at least one flag to edit the profile.');
|
|
132
141
|
}
|
|
@@ -139,6 +148,7 @@ Profile 'default' updated successfully at ~/.xano/credentials.yaml
|
|
|
139
148
|
...(flags.workspace !== undefined && { workspace: flags.workspace }),
|
|
140
149
|
...(flags.branch !== undefined && { branch: flags.branch }),
|
|
141
150
|
...(flags.project !== undefined && { project: flags.project }),
|
|
151
|
+
...(flags['run-project'] !== undefined && { run_project: flags['run-project'] }),
|
|
142
152
|
...(flags.run_base_url !== undefined && { run_base_url: flags.run_base_url }),
|
|
143
153
|
};
|
|
144
154
|
// Handle removal flags
|
|
@@ -151,6 +161,9 @@ Profile 'default' updated successfully at ~/.xano/credentials.yaml
|
|
|
151
161
|
if (flags['remove-project']) {
|
|
152
162
|
delete updatedProfile.project;
|
|
153
163
|
}
|
|
164
|
+
if (flags['remove-run-project']) {
|
|
165
|
+
delete updatedProfile.run_project;
|
|
166
|
+
}
|
|
154
167
|
if (flags['remove-run-base-url']) {
|
|
155
168
|
delete updatedProfile.run_base_url;
|
|
156
169
|
}
|
|
@@ -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,23 @@ 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
|
|
196
|
+
let runProject;
|
|
197
|
+
this.log('');
|
|
198
|
+
this.log('Fetching available run projects...');
|
|
199
|
+
try {
|
|
200
|
+
const runProjects = await this.fetchRunProjects(accessToken);
|
|
201
|
+
if (runProjects.length > 0) {
|
|
202
|
+
runProject = runProjects[0].id;
|
|
203
|
+
this.log(`✓ Found ${runProjects.length} run project(s). Using "${runProjects[0].name}" as default.`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
this.log('No run projects found. You can create one later with "xano run projects create".');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Silently ignore - run_project will remain undefined
|
|
211
|
+
}
|
|
194
212
|
// Save profile
|
|
195
213
|
await this.saveProfile({
|
|
196
214
|
name: profileName,
|
|
@@ -200,6 +218,7 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
200
218
|
workspace,
|
|
201
219
|
branch,
|
|
202
220
|
project,
|
|
221
|
+
run_project: runProject,
|
|
203
222
|
}, true);
|
|
204
223
|
this.log('');
|
|
205
224
|
this.log(`✓ Profile '${profileName}' created successfully!`);
|
|
@@ -357,6 +376,24 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
357
376
|
}
|
|
358
377
|
return [];
|
|
359
378
|
}
|
|
379
|
+
async fetchRunProjects(accessToken, runBaseUrl = DEFAULT_RUN_BASE_URL) {
|
|
380
|
+
const baseUrl = runBaseUrl.endsWith('/') ? runBaseUrl.slice(0, -1) : runBaseUrl;
|
|
381
|
+
const response = await fetch(`${baseUrl}/api:run/project`, {
|
|
382
|
+
method: 'GET',
|
|
383
|
+
headers: {
|
|
384
|
+
'Content-Type': 'application/json',
|
|
385
|
+
Authorization: `Bearer ${accessToken}`,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
if (!response.ok) {
|
|
389
|
+
if (response.status === 401) {
|
|
390
|
+
throw new Error('Unauthorized. Please check your access token.');
|
|
391
|
+
}
|
|
392
|
+
throw new Error(`API request failed with status ${response.status}`);
|
|
393
|
+
}
|
|
394
|
+
const data = (await response.json());
|
|
395
|
+
return Array.isArray(data) ? data : [];
|
|
396
|
+
}
|
|
360
397
|
getDefaultProfileName() {
|
|
361
398
|
try {
|
|
362
399
|
const configDir = path.join(os.homedir(), '.xano');
|
|
@@ -404,6 +441,7 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
404
441
|
...(profile.workspace && { workspace: profile.workspace }),
|
|
405
442
|
...(profile.branch && { branch: profile.branch }),
|
|
406
443
|
...(profile.project && { project: profile.project }),
|
|
444
|
+
...(profile.run_project && { run_project: profile.run_project }),
|
|
407
445
|
};
|
|
408
446
|
// Set as default if requested
|
|
409
447
|
if (setAsDefault) {
|
|
@@ -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
|
+
}
|