@xano/cli 0.0.40 → 0.0.42

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
@@ -106,9 +106,12 @@ xano workspace pull ./my-workspace --draft # Include draft changes
106
106
  # Push local files to workspace
107
107
  xano workspace push ./my-workspace
108
108
  xano workspace push ./my-workspace -b dev
109
- xano workspace push ./my-workspace --no-records # Schema only
110
- xano workspace push ./my-workspace --no-env # Skip env vars
111
- xano workspace push ./my-workspace --truncate # Truncate tables before import
109
+ xano workspace push ./my-workspace --partial # No workspace block required
110
+ xano workspace push ./my-workspace --delete # Delete objects not in the push
111
+ xano workspace push ./my-workspace --no-records # Schema only
112
+ xano workspace push ./my-workspace --no-env # Skip env vars
113
+ xano workspace push ./my-workspace --truncate # Truncate tables before import
114
+ xano workspace push ./my-workspace --no-sync-guids # Skip writing GUIDs back to local files
112
115
  ```
113
116
 
114
117
  ### Branches
@@ -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 {
@@ -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
@@ -7,8 +7,11 @@ export default class Push extends BaseCommand {
7
7
  static examples: string[];
8
8
  static flags: {
9
9
  branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ delete: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ partial: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
13
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ 'sync-guids': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
15
  truncate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
16
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
17
  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({
@@ -24,6 +25,12 @@ Pushed 58 documents from ./backup
24
25
  `,
25
26
  `$ xano workspace push ./my-workspace -b dev
26
27
  Pushed 42 documents from ./my-workspace
28
+ `,
29
+ `$ xano workspace push ./my-functions --partial
30
+ Push some files without a workspace block (implies --no-delete)
31
+ `,
32
+ `$ xano workspace push ./my-workspace --no-delete
33
+ Patch files without deleting existing workspace objects
27
34
  `,
28
35
  `$ xano workspace push ./my-workspace --no-records
29
36
  Push schema only, skip importing table records
@@ -48,18 +55,35 @@ Push schema only, skip records and environment variables
48
55
  description: 'Branch name (optional if set in profile, defaults to live)',
49
56
  required: false,
50
57
  }),
58
+ delete: Flags.boolean({
59
+ allowNo: true,
60
+ default: false,
61
+ description: 'Delete workspace objects not included in the push (default: false)',
62
+ required: false,
63
+ }),
51
64
  env: Flags.boolean({
52
65
  allowNo: true,
53
66
  default: true,
54
67
  description: 'Include environment variables in import (default: true, use --no-env to exclude)',
55
68
  required: false,
56
69
  }),
70
+ partial: Flags.boolean({
71
+ default: false,
72
+ description: 'Partial push — workspace block is not required, existing objects are kept (implies --no-delete)',
73
+ required: false,
74
+ }),
57
75
  records: Flags.boolean({
58
76
  allowNo: true,
59
77
  default: true,
60
78
  description: 'Include records in import (default: true, use --no-records to exclude)',
61
79
  required: false,
62
80
  }),
81
+ 'sync-guids': Flags.boolean({
82
+ allowNo: true,
83
+ default: true,
84
+ description: 'Write server-assigned GUIDs back to local files (use --no-sync-guids to skip)',
85
+ required: false,
86
+ }),
63
87
  truncate: Flags.boolean({
64
88
  default: false,
65
89
  description: 'Truncate all table records before importing',
@@ -116,24 +140,37 @@ Push schema only, skip records and environment variables
116
140
  if (files.length === 0) {
117
141
  this.error(`No .xs files found in ${args.directory}`);
118
142
  }
119
- // Read each file and join with --- separator
120
- const documents = [];
143
+ // Read each file and track file path alongside content
144
+ const documentEntries = [];
121
145
  for (const filePath of files) {
122
146
  const content = fs.readFileSync(filePath, 'utf8').trim();
123
147
  if (content) {
124
- documents.push(content);
148
+ documentEntries.push({ content, filePath });
125
149
  }
126
150
  }
127
- if (documents.length === 0) {
151
+ if (documentEntries.length === 0) {
128
152
  this.error(`All .xs files in ${args.directory} are empty`);
129
153
  }
130
- const multidoc = documents.join('\n---\n');
154
+ const multidoc = documentEntries.map((d) => d.content).join('\n---\n');
155
+ // Build lookup map from document key to file path (for GUID writeback)
156
+ const documentFileMap = new Map();
157
+ for (const entry of documentEntries) {
158
+ const parsed = parseDocument(entry.content);
159
+ if (parsed) {
160
+ const key = buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup);
161
+ documentFileMap.set(key, entry.filePath);
162
+ }
163
+ }
131
164
  // Determine branch from flag or profile
132
165
  const branch = flags.branch || profile.branch || '';
166
+ // --partial implies --no-delete
167
+ const shouldDelete = flags.partial ? false : flags.delete;
133
168
  // Construct the API URL
134
169
  const queryParams = new URLSearchParams({
135
170
  branch,
171
+ delete: shouldDelete.toString(),
136
172
  env: flags.env.toString(),
173
+ partial: flags.partial.toString(),
137
174
  records: flags.records.toString(),
138
175
  truncate: flags.truncate.toString(),
139
176
  });
@@ -165,10 +202,70 @@ Push schema only, skip records and environment variables
165
202
  }
166
203
  this.error(errorMessage);
167
204
  }
168
- // Log the response if any
205
+ // Parse the response for GUID map
169
206
  const responseText = await response.text();
207
+ let guidMap = [];
170
208
  if (responseText && responseText !== 'null') {
171
- this.log(responseText);
209
+ try {
210
+ const responseJson = JSON.parse(responseText);
211
+ if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
212
+ guidMap = responseJson.guid_map;
213
+ }
214
+ }
215
+ catch {
216
+ // Response is not JSON (e.g., older server version)
217
+ if (flags.verbose) {
218
+ this.log('Server response is not JSON; skipping GUID sync');
219
+ }
220
+ }
221
+ }
222
+ // Write GUIDs back to local files
223
+ if (flags['sync-guids'] && guidMap.length > 0) {
224
+ // Build a secondary lookup by type:name only (without verb/api_group)
225
+ // for cases where the server omits those fields
226
+ const baseKeyMap = new Map();
227
+ for (const [key, fp] of documentFileMap) {
228
+ const baseKey = key.split(':').slice(0, 2).join(':');
229
+ // Only use base key if there's no ambiguity (single entry per base key)
230
+ if (baseKeyMap.has(baseKey)) {
231
+ baseKeyMap.set(baseKey, ''); // Mark as ambiguous
232
+ }
233
+ else {
234
+ baseKeyMap.set(baseKey, fp);
235
+ }
236
+ }
237
+ let updatedCount = 0;
238
+ for (const entry of guidMap) {
239
+ if (!entry.guid)
240
+ continue;
241
+ const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
242
+ let filePath = documentFileMap.get(key);
243
+ // Fallback: try type:name only if full key didn't match
244
+ if (!filePath) {
245
+ const baseKey = `${entry.type}:${entry.name}`;
246
+ const basePath = baseKeyMap.get(baseKey);
247
+ if (basePath) {
248
+ filePath = basePath;
249
+ }
250
+ }
251
+ if (!filePath) {
252
+ if (flags.verbose) {
253
+ this.log(` No local file found for ${entry.type} "${entry.name}", skipping GUID sync`);
254
+ }
255
+ continue;
256
+ }
257
+ try {
258
+ const updated = syncGuidToFile(filePath, entry.guid);
259
+ if (updated)
260
+ updatedCount++;
261
+ }
262
+ catch (error) {
263
+ this.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
264
+ }
265
+ }
266
+ if (updatedCount > 0) {
267
+ this.log(`Synced ${updatedCount} GUIDs to local files`);
268
+ }
172
269
  }
173
270
  }
174
271
  catch (error) {
@@ -179,7 +276,7 @@ Push schema only, skip records and environment variables
179
276
  this.error(`Failed to push multidoc: ${String(error)}`);
180
277
  }
181
278
  }
182
- this.log(`Pushed ${documents.length} documents from ${args.directory}`);
279
+ this.log(`Pushed ${documentEntries.length} documents from ${args.directory}`);
183
280
  }
184
281
  /**
185
282
  * Recursively collect all .xs files from a directory, sorted by
@@ -220,3 +317,52 @@ Push schema only, skip records and environment variables
220
317
  }
221
318
  }
222
319
  }
320
+ const GUID_REGEX = /guid\s*=\s*(["'])([^"']*)\1/;
321
+ /**
322
+ * Sync a GUID into a local .xs file. Returns true if the file was modified.
323
+ *
324
+ * - If the file already has a matching GUID, returns false (no change).
325
+ * - If the file has a different GUID, updates it.
326
+ * - If the file has no GUID, inserts one before the final closing brace.
327
+ */
328
+ function syncGuidToFile(filePath, guid) {
329
+ const content = fs.readFileSync(filePath, 'utf8');
330
+ const existingMatch = content.match(GUID_REGEX);
331
+ if (existingMatch) {
332
+ // Already has a GUID
333
+ if (existingMatch[2] === guid) {
334
+ return false; // Already matches
335
+ }
336
+ // Update existing GUID
337
+ const updated = content.replace(GUID_REGEX, `guid = "${guid}"`);
338
+ fs.writeFileSync(filePath, updated, 'utf8');
339
+ return true;
340
+ }
341
+ // No GUID line exists — insert before the final closing brace of the top-level block
342
+ const lines = content.split('\n');
343
+ let insertIndex = -1;
344
+ // Find the last closing brace (top-level block end)
345
+ for (let i = lines.length - 1; i >= 0; i--) {
346
+ if (lines[i].trim() === '}') {
347
+ insertIndex = i;
348
+ break;
349
+ }
350
+ }
351
+ if (insertIndex === -1) {
352
+ return false; // Could not find insertion point
353
+ }
354
+ // Determine indentation from the line above the closing brace
355
+ let indent = ' ';
356
+ for (let i = insertIndex - 1; i >= 0; i--) {
357
+ if (lines[i].trim()) {
358
+ const indentMatch = lines[i].match(/^(\s+)/);
359
+ if (indentMatch) {
360
+ indent = indentMatch[1];
361
+ }
362
+ break;
363
+ }
364
+ }
365
+ lines.splice(insertIndex, 0, `${indent}guid = "${guid}"`);
366
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
367
+ return true;
368
+ }
@@ -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
+ }