@xano/cli 0.0.64 → 0.0.66
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 +25 -0
- package/dist/base-command.d.ts +25 -0
- package/dist/base-command.js +53 -11
- package/dist/commands/auth/index.d.ts +2 -0
- package/dist/commands/auth/index.js +23 -16
- package/dist/commands/function/edit/index.js +17 -18
- package/dist/commands/function/get/index.js +11 -11
- package/dist/commands/profile/create/index.d.ts +1 -0
- package/dist/commands/profile/create/index.js +10 -0
- package/dist/commands/profile/edit/index.d.ts +2 -0
- package/dist/commands/profile/edit/index.js +23 -1
- package/dist/commands/profile/list/index.js +3 -0
- package/dist/commands/profile/wizard/index.d.ts +2 -0
- package/dist/commands/profile/wizard/index.js +23 -12
- package/dist/commands/release/export/index.js +14 -13
- package/dist/commands/release/pull/index.d.ts +0 -6
- package/dist/commands/release/pull/index.js +15 -62
- package/dist/commands/release/push/index.js +16 -6
- package/dist/commands/tenant/backup/export/index.js +4 -2
- package/dist/commands/tenant/create/index.js +3 -0
- package/dist/commands/tenant/deploy_platform/index.js +1 -0
- package/dist/commands/tenant/deploy_release/index.js +1 -0
- package/dist/commands/tenant/pull/index.d.ts +0 -6
- package/dist/commands/tenant/pull/index.js +9 -56
- package/dist/commands/tenant/push/index.js +16 -6
- package/dist/commands/workspace/git/pull/index.js +9 -8
- package/dist/commands/workspace/pull/index.js +9 -6
- package/dist/commands/workspace/push/index.js +10 -1
- package/dist/utils/document-parser.d.ts +22 -0
- package/dist/utils/document-parser.js +54 -1
- package/oclif.manifest.json +992 -952
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import * as yaml from 'js-yaml';
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import * as os from 'node:os';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
|
+
import { buildUserAgent } from '../../../base-command.js';
|
|
8
9
|
export default class ProfileWizard extends Command {
|
|
9
10
|
static description = 'Create a new profile configuration using an interactive wizard';
|
|
10
11
|
static examples = [
|
|
@@ -19,6 +20,12 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
19
20
|
`,
|
|
20
21
|
];
|
|
21
22
|
static flags = {
|
|
23
|
+
insecure: Flags.boolean({
|
|
24
|
+
char: 'k',
|
|
25
|
+
default: false,
|
|
26
|
+
description: 'Skip TLS certificate verification (for self-signed certificates)',
|
|
27
|
+
required: false,
|
|
28
|
+
}),
|
|
22
29
|
name: Flags.string({
|
|
23
30
|
char: 'n',
|
|
24
31
|
description: 'Profile name (skip prompt if provided)',
|
|
@@ -33,6 +40,10 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
33
40
|
};
|
|
34
41
|
async run() {
|
|
35
42
|
const { flags } = await this.parse(ProfileWizard);
|
|
43
|
+
if (flags.insecure) {
|
|
44
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
45
|
+
this.warn('TLS certificate verification is disabled (insecure mode)');
|
|
46
|
+
}
|
|
36
47
|
this.log('Welcome to the Xano Profile Wizard!');
|
|
37
48
|
this.log('');
|
|
38
49
|
try {
|
|
@@ -163,6 +174,7 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
163
174
|
access_token: accessToken,
|
|
164
175
|
account_origin: flags.origin,
|
|
165
176
|
branch,
|
|
177
|
+
...(flags.insecure && { insecure: true }),
|
|
166
178
|
instance_origin: selectedInstance.origin,
|
|
167
179
|
name: profileName,
|
|
168
180
|
workspace,
|
|
@@ -178,12 +190,16 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
178
190
|
throw error;
|
|
179
191
|
}
|
|
180
192
|
}
|
|
193
|
+
getHeaders(accessToken) {
|
|
194
|
+
return {
|
|
195
|
+
'User-Agent': buildUserAgent(this.config.version),
|
|
196
|
+
accept: 'application/json',
|
|
197
|
+
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
181
200
|
async fetchBranches(accessToken, origin, workspaceId) {
|
|
182
201
|
const response = await fetch(`${origin}/api:meta/workspace/${workspaceId}/branch`, {
|
|
183
|
-
headers:
|
|
184
|
-
accept: 'application/json',
|
|
185
|
-
Authorization: `Bearer ${accessToken}`,
|
|
186
|
-
},
|
|
202
|
+
headers: this.getHeaders(accessToken),
|
|
187
203
|
method: 'GET',
|
|
188
204
|
});
|
|
189
205
|
if (!response.ok) {
|
|
@@ -215,10 +231,7 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
215
231
|
}
|
|
216
232
|
async fetchInstances(accessToken, origin) {
|
|
217
233
|
const response = await fetch(`${origin}/api:meta/instance`, {
|
|
218
|
-
headers:
|
|
219
|
-
accept: 'application/json',
|
|
220
|
-
Authorization: `Bearer ${accessToken}`,
|
|
221
|
-
},
|
|
234
|
+
headers: this.getHeaders(accessToken),
|
|
222
235
|
method: 'GET',
|
|
223
236
|
});
|
|
224
237
|
if (!response.ok) {
|
|
@@ -254,10 +267,7 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
254
267
|
}
|
|
255
268
|
async fetchWorkspaces(accessToken, origin) {
|
|
256
269
|
const response = await fetch(`${origin}/api:meta/workspace`, {
|
|
257
|
-
headers:
|
|
258
|
-
accept: 'application/json',
|
|
259
|
-
Authorization: `Bearer ${accessToken}`,
|
|
260
|
-
},
|
|
270
|
+
headers: this.getHeaders(accessToken),
|
|
261
271
|
method: 'GET',
|
|
262
272
|
});
|
|
263
273
|
if (!response.ok) {
|
|
@@ -333,6 +343,7 @@ Profile 'production' created successfully at ~/.xano/credentials.yaml
|
|
|
333
343
|
instance_origin: profile.instance_origin,
|
|
334
344
|
...(profile.workspace && { workspace: profile.workspace }),
|
|
335
345
|
...(profile.branch && { branch: profile.branch }),
|
|
346
|
+
...(profile.insecure && { insecure: true }),
|
|
336
347
|
};
|
|
337
348
|
// Set as default if requested
|
|
338
349
|
if (setAsDefault) {
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as yaml from 'js-yaml';
|
|
6
|
-
import BaseCommand from '../../../base-command.js';
|
|
6
|
+
import BaseCommand, { buildUserAgent } from '../../../base-command.js';
|
|
7
7
|
export default class ReleaseExport extends BaseCommand {
|
|
8
8
|
static args = {
|
|
9
9
|
release_name: Args.string({
|
|
@@ -64,8 +64,8 @@ Downloaded release 'v1.0' to ./release-v1.0.tar.gz
|
|
|
64
64
|
try {
|
|
65
65
|
const response = await this.verboseFetch(exportUrl, {
|
|
66
66
|
headers: {
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
accept: 'application/json',
|
|
68
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
69
69
|
},
|
|
70
70
|
method: 'GET',
|
|
71
71
|
}, flags.verbose, profile.access_token);
|
|
@@ -73,7 +73,7 @@ Downloaded release 'v1.0' to ./release-v1.0.tar.gz
|
|
|
73
73
|
const errorText = await response.text();
|
|
74
74
|
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
75
75
|
}
|
|
76
|
-
const exportLink = await response.json();
|
|
76
|
+
const exportLink = (await response.json());
|
|
77
77
|
if (!exportLink.src) {
|
|
78
78
|
this.error('API did not return a download URL');
|
|
79
79
|
}
|
|
@@ -81,7 +81,9 @@ Downloaded release 'v1.0' to ./release-v1.0.tar.gz
|
|
|
81
81
|
const safeFilename = releaseName.replaceAll(/[^\w.-]/g, '_');
|
|
82
82
|
const outputPath = flags.output || `release-${safeFilename}.tar.gz`;
|
|
83
83
|
const resolvedPath = path.resolve(outputPath);
|
|
84
|
-
const downloadResponse = await fetch(exportLink.src
|
|
84
|
+
const downloadResponse = await fetch(exportLink.src, {
|
|
85
|
+
headers: { 'User-Agent': buildUserAgent(this.config.version) },
|
|
86
|
+
});
|
|
85
87
|
if (!downloadResponse.ok) {
|
|
86
88
|
this.error(`Failed to download release: ${downloadResponse.status} ${downloadResponse.statusText}`);
|
|
87
89
|
}
|
|
@@ -126,8 +128,7 @@ Downloaded release 'v1.0' to ./release-v1.0.tar.gz
|
|
|
126
128
|
const configDir = path.join(os.homedir(), '.xano');
|
|
127
129
|
const credentialsPath = path.join(configDir, 'credentials.yaml');
|
|
128
130
|
if (!fs.existsSync(credentialsPath)) {
|
|
129
|
-
this.error(`Credentials file not found at ${credentialsPath}\n` +
|
|
130
|
-
`Create a profile using 'xano profile create'`);
|
|
131
|
+
this.error(`Credentials file not found at ${credentialsPath}\n` + `Create a profile using 'xano profile create'`);
|
|
131
132
|
}
|
|
132
133
|
try {
|
|
133
134
|
const fileContent = fs.readFileSync(credentialsPath, 'utf8');
|
|
@@ -145,8 +146,8 @@ Downloaded release 'v1.0' to ./release-v1.0.tar.gz
|
|
|
145
146
|
const listUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/release`;
|
|
146
147
|
const response = await this.verboseFetch(listUrl, {
|
|
147
148
|
headers: {
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
accept: 'application/json',
|
|
150
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
150
151
|
},
|
|
151
152
|
method: 'GET',
|
|
152
153
|
}, verbose, profile.access_token);
|
|
@@ -154,15 +155,15 @@ Downloaded release 'v1.0' to ./release-v1.0.tar.gz
|
|
|
154
155
|
const errorText = await response.text();
|
|
155
156
|
this.error(`Failed to list releases: ${response.status} ${response.statusText}\n${errorText}`);
|
|
156
157
|
}
|
|
157
|
-
const data = await response.json();
|
|
158
|
+
const data = (await response.json());
|
|
158
159
|
const releases = Array.isArray(data)
|
|
159
160
|
? data
|
|
160
|
-
:
|
|
161
|
+
: data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)
|
|
161
162
|
? data.items
|
|
162
163
|
: [];
|
|
163
|
-
const match = releases.find(r => r.name === releaseName);
|
|
164
|
+
const match = releases.find((r) => r.name === releaseName);
|
|
164
165
|
if (!match) {
|
|
165
|
-
const available = releases.map(r => r.name).join(', ');
|
|
166
|
+
const available = releases.map((r) => r.name).join(', ');
|
|
166
167
|
this.error(`Release '${releaseName}' not found.${available ? ` Available releases: ${available}` : ''}`);
|
|
167
168
|
}
|
|
168
169
|
return match.id;
|
|
@@ -15,12 +15,6 @@ export default class ReleasePull extends BaseCommand {
|
|
|
15
15
|
};
|
|
16
16
|
run(): Promise<void>;
|
|
17
17
|
private loadCredentials;
|
|
18
|
-
/**
|
|
19
|
-
* Parse a single document to extract its type, name, and optional verb.
|
|
20
|
-
* Skips leading comment lines (starting with //) to find the first
|
|
21
|
-
* meaningful line containing the type keyword and name.
|
|
22
|
-
*/
|
|
23
|
-
private parseDocument;
|
|
24
18
|
private resolveReleaseName;
|
|
25
19
|
/**
|
|
26
20
|
* Sanitize a document name for use as a filename.
|
|
@@ -5,6 +5,7 @@ import * as os from 'node:os';
|
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import snakeCase from 'lodash.snakecase';
|
|
7
7
|
import BaseCommand from '../../../base-command.js';
|
|
8
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
8
9
|
export default class ReleasePull extends BaseCommand {
|
|
9
10
|
static args = {
|
|
10
11
|
directory: Args.string({
|
|
@@ -122,7 +123,7 @@ Pulled 58 documents from release 'v1.0' to ./backup
|
|
|
122
123
|
if (!trimmed) {
|
|
123
124
|
continue;
|
|
124
125
|
}
|
|
125
|
-
const parsed =
|
|
126
|
+
const parsed = parseDocument(trimmed);
|
|
126
127
|
if (parsed) {
|
|
127
128
|
documents.push(parsed);
|
|
128
129
|
}
|
|
@@ -135,6 +136,8 @@ Pulled 58 documents from release 'v1.0' to ./backup
|
|
|
135
136
|
const outputDir = path.resolve(args.directory);
|
|
136
137
|
// Create the output directory if it doesn't exist
|
|
137
138
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
139
|
+
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
140
|
+
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
138
141
|
// Track filenames per type to handle duplicates
|
|
139
142
|
const filenameCounters = new Map();
|
|
140
143
|
let writtenCount = 0;
|
|
@@ -192,14 +195,14 @@ Pulled 58 documents from release 'v1.0' to ./backup
|
|
|
192
195
|
baseName = this.sanitizeFilename(doc.name);
|
|
193
196
|
}
|
|
194
197
|
else if (doc.type === 'api_group') {
|
|
195
|
-
// api_group "test" → api/
|
|
196
|
-
const groupFolder =
|
|
198
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
199
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
197
200
|
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
198
|
-
baseName =
|
|
201
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
199
202
|
}
|
|
200
203
|
else if (doc.type === 'query' && doc.apiGroup) {
|
|
201
|
-
// query in group "test" → api/
|
|
202
|
-
const groupFolder =
|
|
204
|
+
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
205
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
203
206
|
const nameParts = doc.name.split('/');
|
|
204
207
|
const leafName = nameParts.pop();
|
|
205
208
|
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
@@ -258,62 +261,12 @@ Pulled 58 documents from release 'v1.0' to ./backup
|
|
|
258
261
|
this.error(`Failed to parse credentials file: ${error}`);
|
|
259
262
|
}
|
|
260
263
|
}
|
|
261
|
-
/**
|
|
262
|
-
* Parse a single document to extract its type, name, and optional verb.
|
|
263
|
-
* Skips leading comment lines (starting with //) to find the first
|
|
264
|
-
* meaningful line containing the type keyword and name.
|
|
265
|
-
*/
|
|
266
|
-
parseDocument(content) {
|
|
267
|
-
const lines = content.split('\n');
|
|
268
|
-
// Find the first non-comment line
|
|
269
|
-
let firstLine = null;
|
|
270
|
-
for (const line of lines) {
|
|
271
|
-
const trimmedLine = line.trim();
|
|
272
|
-
if (trimmedLine && !trimmedLine.startsWith('//')) {
|
|
273
|
-
firstLine = trimmedLine;
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (!firstLine) {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
// Parse the type keyword and name from the first meaningful line
|
|
281
|
-
// Expected formats:
|
|
282
|
-
// type name {
|
|
283
|
-
// type name verb=GET {
|
|
284
|
-
// type "name with spaces" {
|
|
285
|
-
// type "name with spaces" verb=PATCH {
|
|
286
|
-
const match = firstLine.match(/^(\w+)\s+("(?:[^"\\]|\\.)*"|\S+)(?:\s+(.*))?/);
|
|
287
|
-
if (!match) {
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
const type = match[1];
|
|
291
|
-
let name = match[2];
|
|
292
|
-
const rest = match[3] || '';
|
|
293
|
-
// Strip surrounding quotes from the name
|
|
294
|
-
if (name.startsWith('"') && name.endsWith('"')) {
|
|
295
|
-
name = name.slice(1, -1);
|
|
296
|
-
}
|
|
297
|
-
// Extract verb if present (e.g., verb=GET)
|
|
298
|
-
let verb;
|
|
299
|
-
const verbMatch = rest.match(/verb=(\S+)/);
|
|
300
|
-
if (verbMatch) {
|
|
301
|
-
verb = verbMatch[1];
|
|
302
|
-
}
|
|
303
|
-
// Extract api_group if present (e.g., api_group = "test")
|
|
304
|
-
let apiGroup;
|
|
305
|
-
const apiGroupMatch = content.match(/api_group\s*=\s*"([^"]*)"/);
|
|
306
|
-
if (apiGroupMatch) {
|
|
307
|
-
apiGroup = apiGroupMatch[1];
|
|
308
|
-
}
|
|
309
|
-
return { apiGroup, content, name, type, verb };
|
|
310
|
-
}
|
|
311
264
|
async resolveReleaseName(profile, workspaceId, releaseName, verbose) {
|
|
312
265
|
const listUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/release`;
|
|
313
266
|
const response = await this.verboseFetch(listUrl, {
|
|
314
267
|
headers: {
|
|
315
|
-
|
|
316
|
-
|
|
268
|
+
accept: 'application/json',
|
|
269
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
317
270
|
},
|
|
318
271
|
method: 'GET',
|
|
319
272
|
}, verbose, profile.access_token);
|
|
@@ -321,15 +274,15 @@ Pulled 58 documents from release 'v1.0' to ./backup
|
|
|
321
274
|
const errorText = await response.text();
|
|
322
275
|
this.error(`Failed to list releases: ${response.status} ${response.statusText}\n${errorText}`);
|
|
323
276
|
}
|
|
324
|
-
const data = await response.json();
|
|
277
|
+
const data = (await response.json());
|
|
325
278
|
const releases = Array.isArray(data)
|
|
326
279
|
? data
|
|
327
|
-
:
|
|
280
|
+
: data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)
|
|
328
281
|
? data.items
|
|
329
282
|
: [];
|
|
330
|
-
const match = releases.find(r => r.name === releaseName);
|
|
283
|
+
const match = releases.find((r) => r.name === releaseName);
|
|
331
284
|
if (!match) {
|
|
332
|
-
const available = releases.map(r => r.name).join(', ');
|
|
285
|
+
const available = releases.map((r) => r.name).join(', ');
|
|
333
286
|
this.error(`Release '${releaseName}' not found.${available ? ` Available releases: ${available}` : ''}`);
|
|
334
287
|
}
|
|
335
288
|
return match.id;
|
|
@@ -4,6 +4,7 @@ import * as fs from 'node:fs';
|
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import BaseCommand from '../../../base-command.js';
|
|
7
|
+
import { findFilesWithGuid } from '../../../utils/document-parser.js';
|
|
7
8
|
export default class ReleasePush extends BaseCommand {
|
|
8
9
|
static args = {
|
|
9
10
|
directory: Args.string({
|
|
@@ -117,18 +118,18 @@ Output release details as JSON
|
|
|
117
118
|
if (files.length === 0) {
|
|
118
119
|
this.error(`No .xs files found in ${args.directory}`);
|
|
119
120
|
}
|
|
120
|
-
// Read each file and
|
|
121
|
-
const
|
|
121
|
+
// Read each file and track file path alongside content
|
|
122
|
+
const documentEntries = [];
|
|
122
123
|
for (const filePath of files) {
|
|
123
124
|
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
124
125
|
if (content) {
|
|
125
|
-
|
|
126
|
+
documentEntries.push({ content, filePath });
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
|
-
if (
|
|
129
|
+
if (documentEntries.length === 0) {
|
|
129
130
|
this.error(`All .xs files in ${args.directory} are empty`);
|
|
130
131
|
}
|
|
131
|
-
const multidoc =
|
|
132
|
+
const multidoc = documentEntries.map((d) => d.content).join('\n---\n');
|
|
132
133
|
// Construct the API URL with query params
|
|
133
134
|
const queryParams = new URLSearchParams({
|
|
134
135
|
description: flags.description,
|
|
@@ -164,6 +165,15 @@ Output release details as JSON
|
|
|
164
165
|
catch {
|
|
165
166
|
errorMessage += `\n${errorText}`;
|
|
166
167
|
}
|
|
168
|
+
// Surface local files involved in duplicate GUID errors
|
|
169
|
+
const guidMatch = errorMessage.match(/Duplicate \w+ guid: (\S+)/);
|
|
170
|
+
if (guidMatch) {
|
|
171
|
+
const dupeFiles = findFilesWithGuid(documentEntries, guidMatch[1]);
|
|
172
|
+
if (dupeFiles.length > 0) {
|
|
173
|
+
const relPaths = dupeFiles.map((f) => path.relative(inputDir, f));
|
|
174
|
+
errorMessage += `\n Local files with this GUID:\n${relPaths.map((f) => ` ${f}`).join('\n')}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
167
177
|
this.error(errorMessage);
|
|
168
178
|
}
|
|
169
179
|
const release = (await response.json());
|
|
@@ -179,7 +189,7 @@ Output release details as JSON
|
|
|
179
189
|
if (release.description)
|
|
180
190
|
this.log(` Description: ${release.description}`);
|
|
181
191
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
182
|
-
this.log(` Documents: ${
|
|
192
|
+
this.log(` Documents: ${documentEntries.length}`);
|
|
183
193
|
this.log(` Time: ${elapsed}s`);
|
|
184
194
|
}
|
|
185
195
|
}
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as yaml from 'js-yaml';
|
|
6
|
-
import BaseCommand from '../../../../base-command.js';
|
|
6
|
+
import BaseCommand, { buildUserAgent } from '../../../../base-command.js';
|
|
7
7
|
export default class TenantBackupExport extends BaseCommand {
|
|
8
8
|
static args = {
|
|
9
9
|
tenant_name: Args.string({
|
|
@@ -84,7 +84,9 @@ Downloaded backup #10 to ./tenant-t1234-abcd-xyz1-backup-10.tar.gz
|
|
|
84
84
|
// Step 2: Download the file
|
|
85
85
|
const outputPath = flags.output || `tenant-${tenantName}-backup-${backupId}.tar.gz`;
|
|
86
86
|
const resolvedPath = path.resolve(outputPath);
|
|
87
|
-
const downloadResponse = await fetch(exportLink.src
|
|
87
|
+
const downloadResponse = await fetch(exportLink.src, {
|
|
88
|
+
headers: { 'User-Agent': buildUserAgent(this.config.version) },
|
|
89
|
+
});
|
|
88
90
|
if (!downloadResponse.ok) {
|
|
89
91
|
this.error(`Failed to download backup: ${downloadResponse.status} ${downloadResponse.statusText}`);
|
|
90
92
|
}
|
|
@@ -107,6 +107,9 @@ Created tenant: Production (production) - ID: 42
|
|
|
107
107
|
body.platform_id = flags.platform_id;
|
|
108
108
|
if (flags.domain)
|
|
109
109
|
body.domain = flags.domain;
|
|
110
|
+
if (flags.license === 'tier2' || flags.license === 'tier3' || flags.cluster_id) {
|
|
111
|
+
this.warn('This may take a few minutes. Please be patient.');
|
|
112
|
+
}
|
|
110
113
|
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant`;
|
|
111
114
|
try {
|
|
112
115
|
const response = await this.verboseFetch(apiUrl, {
|
|
@@ -59,6 +59,7 @@ Deployed platform 5 to tenant: My Tenant (my-tenant)
|
|
|
59
59
|
const tenantName = args.tenant_name;
|
|
60
60
|
const platformId = flags.platform_id;
|
|
61
61
|
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/platform/deploy`;
|
|
62
|
+
this.warn('This may take a few minutes. Please be patient.');
|
|
62
63
|
const startTime = Date.now();
|
|
63
64
|
try {
|
|
64
65
|
const response = await this.verboseFetch(apiUrl, {
|
|
@@ -60,6 +60,7 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
60
60
|
const releaseName = flags.release;
|
|
61
61
|
const tenantName = args.tenant_name;
|
|
62
62
|
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/deploy`;
|
|
63
|
+
this.warn('This may take a few minutes. Please be patient.');
|
|
63
64
|
const startTime = Date.now();
|
|
64
65
|
try {
|
|
65
66
|
const response = await this.verboseFetch(apiUrl, {
|
|
@@ -16,12 +16,6 @@ export default class Pull extends BaseCommand {
|
|
|
16
16
|
};
|
|
17
17
|
run(): Promise<void>;
|
|
18
18
|
private loadCredentials;
|
|
19
|
-
/**
|
|
20
|
-
* Parse a single document to extract its type, name, and optional verb.
|
|
21
|
-
* Skips leading comment lines (starting with //) to find the first
|
|
22
|
-
* meaningful line containing the type keyword and name.
|
|
23
|
-
*/
|
|
24
|
-
private parseDocument;
|
|
25
19
|
/**
|
|
26
20
|
* Sanitize a document name for use as a filename.
|
|
27
21
|
* Strips quotes, replaces spaces with underscores, and removes
|
|
@@ -5,6 +5,7 @@ import * as os from 'node:os';
|
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import snakeCase from 'lodash.snakecase';
|
|
7
7
|
import BaseCommand from '../../../base-command.js';
|
|
8
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
8
9
|
export default class Pull extends BaseCommand {
|
|
9
10
|
static args = {
|
|
10
11
|
directory: Args.string({
|
|
@@ -130,7 +131,7 @@ Pulled 42 documents from tenant my-tenant to ./my-tenant
|
|
|
130
131
|
if (!trimmed) {
|
|
131
132
|
continue;
|
|
132
133
|
}
|
|
133
|
-
const parsed =
|
|
134
|
+
const parsed = parseDocument(trimmed);
|
|
134
135
|
if (parsed) {
|
|
135
136
|
documents.push(parsed);
|
|
136
137
|
}
|
|
@@ -143,6 +144,8 @@ Pulled 42 documents from tenant my-tenant to ./my-tenant
|
|
|
143
144
|
const outputDir = path.resolve(args.directory);
|
|
144
145
|
// Create the output directory if it doesn't exist
|
|
145
146
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
147
|
+
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
148
|
+
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
146
149
|
// Track filenames per type to handle duplicates
|
|
147
150
|
const filenameCounters = new Map();
|
|
148
151
|
let writtenCount = 0;
|
|
@@ -200,14 +203,14 @@ Pulled 42 documents from tenant my-tenant to ./my-tenant
|
|
|
200
203
|
baseName = this.sanitizeFilename(doc.name);
|
|
201
204
|
}
|
|
202
205
|
else if (doc.type === 'api_group') {
|
|
203
|
-
// api_group "test" → api/
|
|
204
|
-
const groupFolder =
|
|
206
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
207
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
205
208
|
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
206
|
-
baseName =
|
|
209
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
207
210
|
}
|
|
208
211
|
else if (doc.type === 'query' && doc.apiGroup) {
|
|
209
|
-
// query in group "test" → api/
|
|
210
|
-
const groupFolder =
|
|
212
|
+
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
213
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
211
214
|
const nameParts = doc.name.split('/');
|
|
212
215
|
const leafName = nameParts.pop();
|
|
213
216
|
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
@@ -266,56 +269,6 @@ Pulled 42 documents from tenant my-tenant to ./my-tenant
|
|
|
266
269
|
this.error(`Failed to parse credentials file: ${error}`);
|
|
267
270
|
}
|
|
268
271
|
}
|
|
269
|
-
/**
|
|
270
|
-
* Parse a single document to extract its type, name, and optional verb.
|
|
271
|
-
* Skips leading comment lines (starting with //) to find the first
|
|
272
|
-
* meaningful line containing the type keyword and name.
|
|
273
|
-
*/
|
|
274
|
-
parseDocument(content) {
|
|
275
|
-
const lines = content.split('\n');
|
|
276
|
-
// Find the first non-comment line
|
|
277
|
-
let firstLine = null;
|
|
278
|
-
for (const line of lines) {
|
|
279
|
-
const trimmedLine = line.trim();
|
|
280
|
-
if (trimmedLine && !trimmedLine.startsWith('//')) {
|
|
281
|
-
firstLine = trimmedLine;
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
if (!firstLine) {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
// Parse the type keyword and name from the first meaningful line
|
|
289
|
-
// Expected formats:
|
|
290
|
-
// type name {
|
|
291
|
-
// type name verb=GET {
|
|
292
|
-
// type "name with spaces" {
|
|
293
|
-
// type "name with spaces" verb=PATCH {
|
|
294
|
-
const match = firstLine.match(/^(\w+)\s+("(?:[^"\\]|\\.)*"|\S+)(?:\s+(.*))?/);
|
|
295
|
-
if (!match) {
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
const type = match[1];
|
|
299
|
-
let name = match[2];
|
|
300
|
-
const rest = match[3] || '';
|
|
301
|
-
// Strip surrounding quotes from the name
|
|
302
|
-
if (name.startsWith('"') && name.endsWith('"')) {
|
|
303
|
-
name = name.slice(1, -1);
|
|
304
|
-
}
|
|
305
|
-
// Extract verb if present (e.g., verb=GET)
|
|
306
|
-
let verb;
|
|
307
|
-
const verbMatch = rest.match(/verb=(\S+)/);
|
|
308
|
-
if (verbMatch) {
|
|
309
|
-
verb = verbMatch[1];
|
|
310
|
-
}
|
|
311
|
-
// Extract api_group if present (e.g., api_group = "test")
|
|
312
|
-
let apiGroup;
|
|
313
|
-
const apiGroupMatch = content.match(/api_group\s*=\s*"([^"]*)"/);
|
|
314
|
-
if (apiGroupMatch) {
|
|
315
|
-
apiGroup = apiGroupMatch[1];
|
|
316
|
-
}
|
|
317
|
-
return { apiGroup, content, name, type, verb };
|
|
318
|
-
}
|
|
319
272
|
/**
|
|
320
273
|
* Sanitize a document name for use as a filename.
|
|
321
274
|
* Strips quotes, replaces spaces with underscores, and removes
|
|
@@ -4,6 +4,7 @@ import * as fs from 'node:fs';
|
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import BaseCommand from '../../../base-command.js';
|
|
7
|
+
import { findFilesWithGuid } from '../../../utils/document-parser.js';
|
|
7
8
|
export default class Push extends BaseCommand {
|
|
8
9
|
static args = {
|
|
9
10
|
directory: Args.string({
|
|
@@ -142,18 +143,18 @@ Truncate all table records before importing
|
|
|
142
143
|
if (files.length === 0) {
|
|
143
144
|
this.error(`No .xs files found in ${args.directory}`);
|
|
144
145
|
}
|
|
145
|
-
// Read each file and
|
|
146
|
-
const
|
|
146
|
+
// Read each file and track file path alongside content
|
|
147
|
+
const documentEntries = [];
|
|
147
148
|
for (const filePath of files) {
|
|
148
149
|
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
149
150
|
if (content) {
|
|
150
|
-
|
|
151
|
+
documentEntries.push({ content, filePath });
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
|
-
if (
|
|
154
|
+
if (documentEntries.length === 0) {
|
|
154
155
|
this.error(`All .xs files in ${args.directory} are empty`);
|
|
155
156
|
}
|
|
156
|
-
const multidoc =
|
|
157
|
+
const multidoc = documentEntries.map((d) => d.content).join('\n---\n');
|
|
157
158
|
// Construct the API URL
|
|
158
159
|
const queryParams = new URLSearchParams({
|
|
159
160
|
env: flags.env.toString(),
|
|
@@ -187,6 +188,15 @@ Truncate all table records before importing
|
|
|
187
188
|
catch {
|
|
188
189
|
errorMessage += `\n${errorText}`;
|
|
189
190
|
}
|
|
191
|
+
// Surface local files involved in duplicate GUID errors
|
|
192
|
+
const guidMatch = errorMessage.match(/Duplicate \w+ guid: (\S+)/);
|
|
193
|
+
if (guidMatch) {
|
|
194
|
+
const dupeFiles = findFilesWithGuid(documentEntries, guidMatch[1]);
|
|
195
|
+
if (dupeFiles.length > 0) {
|
|
196
|
+
const relPaths = dupeFiles.map((f) => path.relative(inputDir, f));
|
|
197
|
+
errorMessage += `\n Local files with this GUID:\n${relPaths.map((f) => ` ${f}`).join('\n')}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
190
200
|
this.error(errorMessage);
|
|
191
201
|
}
|
|
192
202
|
// Parse the response (suppress raw output; only show in verbose mode)
|
|
@@ -204,7 +214,7 @@ Truncate all table records before importing
|
|
|
204
214
|
}
|
|
205
215
|
}
|
|
206
216
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
207
|
-
this.log(`Pushed ${
|
|
217
|
+
this.log(`Pushed ${documentEntries.length} documents to tenant ${tenantName} from ${args.directory} in ${elapsed}s`);
|
|
208
218
|
}
|
|
209
219
|
/**
|
|
210
220
|
* Recursively collect all .xs files from a directory, sorted by
|
|
@@ -4,8 +4,8 @@ import * as fs from 'node:fs';
|
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import snakeCase from 'lodash.snakecase';
|
|
7
|
-
import BaseCommand from '../../../../base-command.js';
|
|
8
|
-
import { parseDocument } from '../../../../utils/document-parser.js';
|
|
7
|
+
import BaseCommand, { buildUserAgent } from '../../../../base-command.js';
|
|
8
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../../utils/document-parser.js';
|
|
9
9
|
export default class GitPull extends BaseCommand {
|
|
10
10
|
static args = {
|
|
11
11
|
directory: Args.string({
|
|
@@ -95,10 +95,11 @@ export default class GitPull extends BaseCommand {
|
|
|
95
95
|
}
|
|
96
96
|
// Write documents to output directory using the same file tree logic as workspace pull
|
|
97
97
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
98
|
+
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
98
99
|
const filenameCounters = new Map();
|
|
99
100
|
let writtenCount = 0;
|
|
100
101
|
for (const doc of documents) {
|
|
101
|
-
const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc);
|
|
102
|
+
const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder);
|
|
102
103
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
103
104
|
// Track duplicates per directory
|
|
104
105
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -174,7 +175,7 @@ export default class GitPull extends BaseCommand {
|
|
|
174
175
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/${tarballRef}`;
|
|
175
176
|
const headers = {
|
|
176
177
|
Accept: 'application/vnd.github+json',
|
|
177
|
-
'User-Agent':
|
|
178
|
+
'User-Agent': buildUserAgent(this.config.version),
|
|
178
179
|
};
|
|
179
180
|
if (token) {
|
|
180
181
|
headers.Authorization = `Bearer ${token}`;
|
|
@@ -323,7 +324,7 @@ export default class GitPull extends BaseCommand {
|
|
|
323
324
|
* Resolve the output directory and base filename for a parsed document.
|
|
324
325
|
* Uses the same type-to-directory mapping as workspace pull.
|
|
325
326
|
*/
|
|
326
|
-
resolveOutputPath(outputDir, doc) {
|
|
327
|
+
resolveOutputPath(outputDir, doc, getApiGroupFolder) {
|
|
327
328
|
let typeDir;
|
|
328
329
|
let baseName;
|
|
329
330
|
if (doc.type === 'workspace') {
|
|
@@ -367,12 +368,12 @@ export default class GitPull extends BaseCommand {
|
|
|
367
368
|
baseName = this.sanitizeFilename(doc.name);
|
|
368
369
|
}
|
|
369
370
|
else if (doc.type === 'api_group') {
|
|
370
|
-
const groupFolder =
|
|
371
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
371
372
|
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
372
|
-
baseName =
|
|
373
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
373
374
|
}
|
|
374
375
|
else if (doc.type === 'query' && doc.apiGroup) {
|
|
375
|
-
const groupFolder =
|
|
376
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
376
377
|
const nameParts = doc.name.split('/');
|
|
377
378
|
const leafName = nameParts.pop();
|
|
378
379
|
const folderParts = nameParts.map((part) => snakeCase(part));
|