@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.
Files changed (32) hide show
  1. package/README.md +25 -0
  2. package/dist/base-command.d.ts +25 -0
  3. package/dist/base-command.js +53 -11
  4. package/dist/commands/auth/index.d.ts +2 -0
  5. package/dist/commands/auth/index.js +23 -16
  6. package/dist/commands/function/edit/index.js +17 -18
  7. package/dist/commands/function/get/index.js +11 -11
  8. package/dist/commands/profile/create/index.d.ts +1 -0
  9. package/dist/commands/profile/create/index.js +10 -0
  10. package/dist/commands/profile/edit/index.d.ts +2 -0
  11. package/dist/commands/profile/edit/index.js +23 -1
  12. package/dist/commands/profile/list/index.js +3 -0
  13. package/dist/commands/profile/wizard/index.d.ts +2 -0
  14. package/dist/commands/profile/wizard/index.js +23 -12
  15. package/dist/commands/release/export/index.js +14 -13
  16. package/dist/commands/release/pull/index.d.ts +0 -6
  17. package/dist/commands/release/pull/index.js +15 -62
  18. package/dist/commands/release/push/index.js +16 -6
  19. package/dist/commands/tenant/backup/export/index.js +4 -2
  20. package/dist/commands/tenant/create/index.js +3 -0
  21. package/dist/commands/tenant/deploy_platform/index.js +1 -0
  22. package/dist/commands/tenant/deploy_release/index.js +1 -0
  23. package/dist/commands/tenant/pull/index.d.ts +0 -6
  24. package/dist/commands/tenant/pull/index.js +9 -56
  25. package/dist/commands/tenant/push/index.js +16 -6
  26. package/dist/commands/workspace/git/pull/index.js +9 -8
  27. package/dist/commands/workspace/pull/index.js +9 -6
  28. package/dist/commands/workspace/push/index.js +10 -1
  29. package/dist/utils/document-parser.d.ts +22 -0
  30. package/dist/utils/document-parser.js +54 -1
  31. package/oclif.manifest.json +992 -952
  32. 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
- 'accept': 'application/json',
68
- 'Authorization': `Bearer ${profile.access_token}`,
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
- 'accept': 'application/json',
149
- 'Authorization': `Bearer ${profile.access_token}`,
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
- : (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items))
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 = this.parseDocument(trimmed);
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/test/api_group.xs
196
- const groupFolder = snakeCase(doc.name);
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 = 'api_group';
201
+ baseName = this.sanitizeFilename(doc.name);
199
202
  }
200
203
  else if (doc.type === 'query' && doc.apiGroup) {
201
- // query in group "test" → api/test/{query_name}.xs
202
- const groupFolder = snakeCase(doc.apiGroup);
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
- 'accept': 'application/json',
316
- 'Authorization': `Bearer ${profile.access_token}`,
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
- : (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items))
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 join with --- separator
121
- const documents = [];
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
- documents.push(content);
126
+ documentEntries.push({ content, filePath });
126
127
  }
127
128
  }
128
- if (documents.length === 0) {
129
+ if (documentEntries.length === 0) {
129
130
  this.error(`All .xs files in ${args.directory} are empty`);
130
131
  }
131
- const multidoc = documents.join('\n---\n');
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: ${documents.length}`);
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 = this.parseDocument(trimmed);
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/test/api_group.xs
204
- const groupFolder = snakeCase(doc.name);
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 = 'api_group';
209
+ baseName = this.sanitizeFilename(doc.name);
207
210
  }
208
211
  else if (doc.type === 'query' && doc.apiGroup) {
209
- // query in group "test" → api/test/{query_name}.xs
210
- const groupFolder = snakeCase(doc.apiGroup);
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 join with --- separator
146
- const documents = [];
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
- documents.push(content);
151
+ documentEntries.push({ content, filePath });
151
152
  }
152
153
  }
153
- if (documents.length === 0) {
154
+ if (documentEntries.length === 0) {
154
155
  this.error(`All .xs files in ${args.directory} are empty`);
155
156
  }
156
- const multidoc = documents.join('\n---\n');
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 ${documents.length} documents to tenant ${tenantName} from ${args.directory} in ${elapsed}s`);
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': 'xano-cli',
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 = snakeCase(doc.name);
371
+ const groupFolder = getApiGroupFolder(doc.name);
371
372
  typeDir = path.join(outputDir, 'api', groupFolder);
372
- baseName = 'api_group';
373
+ baseName = this.sanitizeFilename(doc.name);
373
374
  }
374
375
  else if (doc.type === 'query' && doc.apiGroup) {
375
- const groupFolder = snakeCase(doc.apiGroup);
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));