@xano/cli 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -312,8 +312,8 @@ xano tenant push ./my-tenant -t <tenant_name> --truncate
312
312
  # Deploy a platform version
313
313
  xano tenant deploy_platform <tenant_name> --platform_id 5
314
314
 
315
- # Deploy a release
316
- xano tenant deploy_release <tenant_name> --release_id 10
315
+ # Deploy a release by name
316
+ xano tenant deploy_release <tenant_name> --release v1.0
317
317
  ```
318
318
 
319
319
  #### Tenant License
@@ -62,8 +62,8 @@ User Information:
62
62
  try {
63
63
  const response = await this.verboseFetch(apiUrl, {
64
64
  headers: {
65
- 'accept': 'application/json',
66
- 'Authorization': `Bearer ${profile.access_token}`,
65
+ accept: 'application/json',
66
+ Authorization: `Bearer ${profile.access_token}`,
67
67
  },
68
68
  method: 'GET',
69
69
  }, flags.verbose, profile.access_token);
@@ -71,7 +71,7 @@ User Information:
71
71
  const errorText = await response.text();
72
72
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
73
73
  }
74
- const data = await response.json();
74
+ const data = (await response.json());
75
75
  // Output results
76
76
  if (flags.output === 'json') {
77
77
  this.log(JSON.stringify(data, null, 2));
@@ -92,17 +92,37 @@ User Information:
92
92
  const date = new Date(data.created_at * 1000);
93
93
  this.log(` Created: ${date.toISOString()}`);
94
94
  }
95
- // Display any other fields that aren't already shown
95
+ // Display extra fields from the API response
96
96
  const knownFields = new Set(['created_at', 'email', 'id', 'name']);
97
- for (const [key, value] of Object.entries(data)) {
98
- if (!knownFields.has(key) && value !== null && value !== undefined) {
99
- if (typeof value === 'object') {
100
- this.log(` ${this.formatKey(key)}: ${JSON.stringify(value)}`);
101
- }
102
- else {
103
- this.log(` ${this.formatKey(key)}: ${value}`);
97
+ // In default mode, show condensed instance info from extras
98
+ // In verbose mode, show all extra fields with full detail
99
+ const extras = data.extras;
100
+ if (extras?.instance && typeof extras.instance === 'object') {
101
+ const inst = extras.instance;
102
+ this.log('');
103
+ this.log('Instance Information:');
104
+ if (inst.id)
105
+ this.log(` ID: ${inst.id}`);
106
+ if (inst.name)
107
+ this.log(` Name: ${inst.name}`);
108
+ if (inst.display)
109
+ this.log(` Display: ${inst.display}`);
110
+ }
111
+ if (flags.verbose) {
112
+ knownFields.add('extras');
113
+ for (const [key, value] of Object.entries(data)) {
114
+ if (!knownFields.has(key) && value !== null && value !== undefined) {
115
+ if (typeof value === 'object') {
116
+ this.log(` ${this.formatKey(key)}: ${JSON.stringify(value, null, 2)}`);
117
+ }
118
+ else {
119
+ this.log(` ${this.formatKey(key)}: ${value}`);
120
+ }
104
121
  }
105
122
  }
123
+ if (extras) {
124
+ this.log(` Extras: ${JSON.stringify(extras, null, 2)}`);
125
+ }
106
126
  }
107
127
  }
108
128
  }
@@ -119,7 +139,7 @@ User Information:
119
139
  // Convert snake_case to Title Case
120
140
  return key
121
141
  .split('_')
122
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
142
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
123
143
  .join(' ');
124
144
  }
125
145
  loadCredentials() {
@@ -127,8 +147,7 @@ User Information:
127
147
  const credentialsPath = path.join(configDir, 'credentials.yaml');
128
148
  // Check if credentials file exists
129
149
  if (!fs.existsSync(credentialsPath)) {
130
- this.error(`Credentials file not found at ${credentialsPath}\n` +
131
- `Create a profile using 'xano profile:create'`);
150
+ this.error(`Credentials file not found at ${credentialsPath}\n` + `Create a profile using 'xano profile:create'`);
132
151
  }
133
152
  // Read credentials file
134
153
  try {
@@ -7,7 +7,7 @@ export default class TenantDeployRelease extends BaseCommand {
7
7
  static examples: string[];
8
8
  static flags: {
9
9
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
- release_id: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ release: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
11
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
13
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -13,10 +13,10 @@ export default class TenantDeployRelease extends BaseCommand {
13
13
  };
14
14
  static description = 'Deploy a release to a tenant';
15
15
  static examples = [
16
- `$ xano tenant deploy_release t1234-abcd-xyz1 --release_id 10
17
- Deployed release 10 to tenant: My Tenant (my-tenant)
16
+ `$ xano tenant deploy_release t1234-abcd-xyz1 --release v1.0
17
+ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
18
18
  `,
19
- `$ xano tenant deploy_release t1234-abcd-xyz1 --release_id 10 -o json`,
19
+ `$ xano tenant deploy_release t1234-abcd-xyz1 --release v1.0 -o json`,
20
20
  ];
21
21
  static flags = {
22
22
  ...BaseCommand.baseFlags,
@@ -27,8 +27,9 @@ Deployed release 10 to tenant: My Tenant (my-tenant)
27
27
  options: ['summary', 'json'],
28
28
  required: false,
29
29
  }),
30
- release_id: Flags.integer({
31
- description: 'Release ID to deploy',
30
+ release: Flags.string({
31
+ char: 'r',
32
+ description: 'Release name to deploy',
32
33
  required: true,
33
34
  }),
34
35
  workspace: Flags.string({
@@ -56,12 +57,12 @@ Deployed release 10 to tenant: My Tenant (my-tenant)
56
57
  if (!workspaceId) {
57
58
  this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
58
59
  }
60
+ const releaseName = flags.release;
59
61
  const tenantName = args.tenant_name;
60
- const releaseId = flags.release_id;
61
62
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/deploy`;
62
63
  try {
63
64
  const response = await this.verboseFetch(apiUrl, {
64
- body: JSON.stringify({ release_id: releaseId }),
65
+ body: JSON.stringify({ release_name: releaseName }),
65
66
  headers: {
66
67
  accept: 'application/json',
67
68
  Authorization: `Bearer ${profile.access_token}`,
@@ -78,7 +79,7 @@ Deployed release 10 to tenant: My Tenant (my-tenant)
78
79
  this.log(JSON.stringify(tenant, null, 2));
79
80
  }
80
81
  else {
81
- this.log(`Deployed release ${releaseId} to tenant: ${tenant.display || tenant.name} (${tenant.name})`);
82
+ this.log(`Deployed release "${releaseName}" to tenant: ${tenant.display || tenant.name} (${tenant.name})`);
82
83
  if (tenant.state)
83
84
  this.log(` State: ${tenant.state}`);
84
85
  if (tenant.release?.name)
@@ -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 { parseDocument } from '../../../utils/document-parser.js';
8
9
  export default class Pull extends BaseCommand {
9
10
  static args = {
10
11
  directory: Args.string({
@@ -104,8 +105,8 @@ Pulled 42 documents to ./my-workspace
104
105
  // Fetch multidoc from the API
105
106
  let responseText;
106
107
  const requestHeaders = {
107
- 'accept': 'application/json',
108
- 'Authorization': `Bearer ${profile.access_token}`,
108
+ accept: 'application/json',
109
+ Authorization: `Bearer ${profile.access_token}`,
109
110
  };
110
111
  try {
111
112
  const response = await this.verboseFetch(apiUrl, {
@@ -135,7 +136,7 @@ Pulled 42 documents to ./my-workspace
135
136
  if (!trimmed) {
136
137
  continue;
137
138
  }
138
- const parsed = this.parseDocument(trimmed);
139
+ const parsed = parseDocument(trimmed);
139
140
  if (parsed) {
140
141
  documents.push(parsed);
141
142
  }
@@ -256,8 +257,7 @@ Pulled 42 documents to ./my-workspace
256
257
  const credentialsPath = path.join(configDir, 'credentials.yaml');
257
258
  // Check if credentials file exists
258
259
  if (!fs.existsSync(credentialsPath)) {
259
- this.error(`Credentials file not found at ${credentialsPath}\n` +
260
- `Create a profile using 'xano profile:create'`);
260
+ this.error(`Credentials file not found at ${credentialsPath}\n` + `Create a profile using 'xano profile:create'`);
261
261
  }
262
262
  // Read credentials file
263
263
  try {
@@ -272,56 +272,6 @@ Pulled 42 documents to ./my-workspace
272
272
  this.error(`Failed to parse credentials file: ${error}`);
273
273
  }
274
274
  }
275
- /**
276
- * Parse a single document to extract its type, name, and optional verb.
277
- * Skips leading comment lines (starting with //) to find the first
278
- * meaningful line containing the type keyword and name.
279
- */
280
- parseDocument(content) {
281
- const lines = content.split('\n');
282
- // Find the first non-comment line
283
- let firstLine = null;
284
- for (const line of lines) {
285
- const trimmedLine = line.trim();
286
- if (trimmedLine && !trimmedLine.startsWith('//')) {
287
- firstLine = trimmedLine;
288
- break;
289
- }
290
- }
291
- if (!firstLine) {
292
- return null;
293
- }
294
- // Parse the type keyword and name from the first meaningful line
295
- // Expected formats:
296
- // type name {
297
- // type name verb=GET {
298
- // type "name with spaces" {
299
- // type "name with spaces" verb=PATCH {
300
- const match = firstLine.match(/^(\w+)\s+("(?:[^"\\]|\\.)*"|\S+)(?:\s+(.*))?/);
301
- if (!match) {
302
- return null;
303
- }
304
- const type = match[1];
305
- let name = match[2];
306
- const rest = match[3] || '';
307
- // Strip surrounding quotes from the name
308
- if (name.startsWith('"') && name.endsWith('"')) {
309
- name = name.slice(1, -1);
310
- }
311
- // Extract verb if present (e.g., verb=GET)
312
- let verb;
313
- const verbMatch = rest.match(/verb=(\S+)/);
314
- if (verbMatch) {
315
- verb = verbMatch[1];
316
- }
317
- // Extract api_group if present (e.g., api_group = "test")
318
- let apiGroup;
319
- const apiGroupMatch = content.match(/api_group\s*=\s*"([^"]*)"/);
320
- if (apiGroupMatch) {
321
- apiGroup = apiGroupMatch[1];
322
- }
323
- return { apiGroup, content, name, type, verb };
324
- }
325
275
  /**
326
276
  * Sanitize a document name for use as a filename.
327
277
  * Strips quotes, replaces spaces with underscores, and removes
@@ -9,6 +9,7 @@ export default class Push extends BaseCommand {
9
9
  branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
10
  env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ 'sync-guids': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  truncate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -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 { buildDocumentKey, parseDocument } from '../../../utils/document-parser.js';
7
8
  export default class Push extends BaseCommand {
8
9
  static args = {
9
10
  directory: Args.string({
@@ -60,6 +61,12 @@ Push schema only, skip records and environment variables
60
61
  description: 'Include records in import (default: true, use --no-records to exclude)',
61
62
  required: false,
62
63
  }),
64
+ 'sync-guids': Flags.boolean({
65
+ allowNo: true,
66
+ default: true,
67
+ description: 'Write server-assigned GUIDs back to local files (use --no-sync-guids to skip)',
68
+ required: false,
69
+ }),
63
70
  truncate: Flags.boolean({
64
71
  default: false,
65
72
  description: 'Truncate all table records before importing',
@@ -116,18 +123,27 @@ Push schema only, skip records and environment variables
116
123
  if (files.length === 0) {
117
124
  this.error(`No .xs files found in ${args.directory}`);
118
125
  }
119
- // Read each file and join with --- separator
120
- const documents = [];
126
+ // Read each file and track file path alongside content
127
+ const documentEntries = [];
121
128
  for (const filePath of files) {
122
129
  const content = fs.readFileSync(filePath, 'utf8').trim();
123
130
  if (content) {
124
- documents.push(content);
131
+ documentEntries.push({ content, filePath });
125
132
  }
126
133
  }
127
- if (documents.length === 0) {
134
+ if (documentEntries.length === 0) {
128
135
  this.error(`All .xs files in ${args.directory} are empty`);
129
136
  }
130
- const multidoc = documents.join('\n---\n');
137
+ const multidoc = documentEntries.map((d) => d.content).join('\n---\n');
138
+ // Build lookup map from document key to file path (for GUID writeback)
139
+ const documentFileMap = new Map();
140
+ for (const entry of documentEntries) {
141
+ const parsed = parseDocument(entry.content);
142
+ if (parsed) {
143
+ const key = buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup);
144
+ documentFileMap.set(key, entry.filePath);
145
+ }
146
+ }
131
147
  // Determine branch from flag or profile
132
148
  const branch = flags.branch || profile.branch || '';
133
149
  // Construct the API URL
@@ -165,10 +181,49 @@ Push schema only, skip records and environment variables
165
181
  }
166
182
  this.error(errorMessage);
167
183
  }
168
- // Log the response if any
184
+ // Parse the response for GUID map
169
185
  const responseText = await response.text();
186
+ let guidMap = [];
170
187
  if (responseText && responseText !== 'null') {
171
- this.log(responseText);
188
+ try {
189
+ const responseJson = JSON.parse(responseText);
190
+ if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
191
+ guidMap = responseJson.guid_map;
192
+ }
193
+ }
194
+ catch {
195
+ // Response is not JSON (e.g., older server version)
196
+ if (flags.verbose) {
197
+ this.log('Server response is not JSON; skipping GUID sync');
198
+ }
199
+ }
200
+ }
201
+ // Write GUIDs back to local files
202
+ if (flags['sync-guids'] && guidMap.length > 0) {
203
+ let updatedCount = 0;
204
+ for (const entry of guidMap) {
205
+ if (!entry.guid)
206
+ continue;
207
+ const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
208
+ const filePath = documentFileMap.get(key);
209
+ if (!filePath) {
210
+ if (flags.verbose) {
211
+ this.log(` No local file found for ${entry.type} "${entry.name}", skipping GUID sync`);
212
+ }
213
+ continue;
214
+ }
215
+ try {
216
+ const updated = syncGuidToFile(filePath, entry.guid);
217
+ if (updated)
218
+ updatedCount++;
219
+ }
220
+ catch (error) {
221
+ this.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
222
+ }
223
+ }
224
+ if (updatedCount > 0) {
225
+ this.log(`Synced ${updatedCount} GUIDs to local files`);
226
+ }
172
227
  }
173
228
  }
174
229
  catch (error) {
@@ -179,7 +234,7 @@ Push schema only, skip records and environment variables
179
234
  this.error(`Failed to push multidoc: ${String(error)}`);
180
235
  }
181
236
  }
182
- this.log(`Pushed ${documents.length} documents from ${args.directory}`);
237
+ this.log(`Pushed ${documentEntries.length} documents from ${args.directory}`);
183
238
  }
184
239
  /**
185
240
  * Recursively collect all .xs files from a directory, sorted by
@@ -220,3 +275,52 @@ Push schema only, skip records and environment variables
220
275
  }
221
276
  }
222
277
  }
278
+ const GUID_REGEX = /guid\s*=\s*(["'])([^"']*)\1/;
279
+ /**
280
+ * Sync a GUID into a local .xs file. Returns true if the file was modified.
281
+ *
282
+ * - If the file already has a matching GUID, returns false (no change).
283
+ * - If the file has a different GUID, updates it.
284
+ * - If the file has no GUID, inserts one before the final closing brace.
285
+ */
286
+ function syncGuidToFile(filePath, guid) {
287
+ const content = fs.readFileSync(filePath, 'utf8');
288
+ const existingMatch = content.match(GUID_REGEX);
289
+ if (existingMatch) {
290
+ // Already has a GUID
291
+ if (existingMatch[2] === guid) {
292
+ return false; // Already matches
293
+ }
294
+ // Update existing GUID
295
+ const updated = content.replace(GUID_REGEX, `guid = "${guid}"`);
296
+ fs.writeFileSync(filePath, updated, 'utf8');
297
+ return true;
298
+ }
299
+ // No GUID line exists — insert before the final closing brace of the top-level block
300
+ const lines = content.split('\n');
301
+ let insertIndex = -1;
302
+ // Find the last closing brace (top-level block end)
303
+ for (let i = lines.length - 1; i >= 0; i--) {
304
+ if (lines[i].trim() === '}') {
305
+ insertIndex = i;
306
+ break;
307
+ }
308
+ }
309
+ if (insertIndex === -1) {
310
+ return false; // Could not find insertion point
311
+ }
312
+ // Determine indentation from the line above the closing brace
313
+ let indent = ' ';
314
+ for (let i = insertIndex - 1; i >= 0; i--) {
315
+ if (lines[i].trim()) {
316
+ const indentMatch = lines[i].match(/^(\s+)/);
317
+ if (indentMatch) {
318
+ indent = indentMatch[1];
319
+ }
320
+ break;
321
+ }
322
+ }
323
+ lines.splice(insertIndex, 0, `${indent}guid = "${guid}"`);
324
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
325
+ return true;
326
+ }
@@ -0,0 +1,17 @@
1
+ export interface ParsedDocument {
2
+ apiGroup?: string;
3
+ content: string;
4
+ name: string;
5
+ type: string;
6
+ verb?: string;
7
+ }
8
+ /**
9
+ * Parse a single XanoScript document to extract its type, name, and optional verb/api_group.
10
+ * Skips leading comment lines (starting with //) to find the first meaningful line.
11
+ */
12
+ export declare function parseDocument(content: string): null | ParsedDocument;
13
+ /**
14
+ * Build a unique key for a document based on its type, name, verb, and api_group.
15
+ * Used to match server GUID map entries back to local files.
16
+ */
17
+ export declare function buildDocumentKey(type: string, name: string, verb?: string, apiGroup?: string): string;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Parse a single XanoScript document to extract its type, name, and optional verb/api_group.
3
+ * Skips leading comment lines (starting with //) to find the first meaningful line.
4
+ */
5
+ export function parseDocument(content) {
6
+ const lines = content.split('\n');
7
+ // Find the first non-comment line
8
+ let firstLine = null;
9
+ for (const line of lines) {
10
+ const trimmedLine = line.trim();
11
+ if (trimmedLine && !trimmedLine.startsWith('//')) {
12
+ firstLine = trimmedLine;
13
+ break;
14
+ }
15
+ }
16
+ if (!firstLine) {
17
+ return null;
18
+ }
19
+ // Parse the type keyword and name from the first meaningful line
20
+ // Expected formats:
21
+ // type name {
22
+ // type name verb=GET {
23
+ // type "name with spaces" {
24
+ // type "name with spaces" verb=PATCH {
25
+ const match = firstLine.match(/^(\w+)\s+("(?:[^"\\]|\\.)*"|\S+)(?:\s+(.*))?/);
26
+ if (!match) {
27
+ return null;
28
+ }
29
+ const type = match[1];
30
+ let name = match[2];
31
+ const rest = match[3] || '';
32
+ // Strip surrounding quotes from the name
33
+ if (name.startsWith('"') && name.endsWith('"')) {
34
+ name = name.slice(1, -1);
35
+ }
36
+ // Extract verb if present (e.g., verb=GET)
37
+ let verb;
38
+ const verbMatch = rest.match(/verb=(\S+)/);
39
+ if (verbMatch) {
40
+ verb = verbMatch[1];
41
+ }
42
+ // Extract api_group if present (e.g., api_group = "test")
43
+ let apiGroup;
44
+ const apiGroupMatch = content.match(/api_group\s*=\s*"([^"]*)"/);
45
+ if (apiGroupMatch) {
46
+ apiGroup = apiGroupMatch[1];
47
+ }
48
+ return { apiGroup, content, name, type, verb };
49
+ }
50
+ /**
51
+ * Build a unique key for a document based on its type, name, verb, and api_group.
52
+ * Used to match server GUID map entries back to local files.
53
+ */
54
+ export function buildDocumentKey(type, name, verb, apiGroup) {
55
+ const parts = [type, name];
56
+ if (verb)
57
+ parts.push(verb);
58
+ if (apiGroup)
59
+ parts.push(apiGroup);
60
+ return parts.join(':');
61
+ }