@xano/cli 1.3.3 → 1.3.4

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.
@@ -0,0 +1,353 @@
1
+ import { createHash } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { buildDocumentKey, parseDocument } from './document-parser.js';
5
+ export const SYNC_STATE_VERSION = 1;
6
+ export const SYNC_STATE_DIR = '.xano';
7
+ export const SYNC_STATE_FILENAME = 'sync_state.json';
8
+ const TEMP_PREFIX = `${SYNC_STATE_FILENAME}.tmp-`;
9
+ export function instanceHost(instanceOrigin) {
10
+ return new URL(instanceOrigin).host.toLowerCase();
11
+ }
12
+ export function toRelPath(dir, filePath) {
13
+ return relative(dir, filePath).split('\\').join('/');
14
+ }
15
+ export function hashBytes(data) {
16
+ return createHash('sha256').update(data).digest('hex');
17
+ }
18
+ export function hashFile(filePath) {
19
+ return hashBytes(fs.readFileSync(filePath));
20
+ }
21
+ export function syncStatePath(dir) {
22
+ return join(dir, SYNC_STATE_DIR, SYNC_STATE_FILENAME);
23
+ }
24
+ export function syncStateExists(dir) {
25
+ return fs.existsSync(syncStatePath(dir));
26
+ }
27
+ function isRecord(value) {
28
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
29
+ }
30
+ function parseFileEntry(value) {
31
+ if (!isRecord(value))
32
+ return null;
33
+ if (typeof value.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(value.sha256))
34
+ return null;
35
+ if (typeof value.key !== 'string' || value.key.length === 0)
36
+ return null;
37
+ const entry = { key: value.key, sha256: value.sha256.toLowerCase() };
38
+ if (typeof value.id === 'number' && Number.isInteger(value.id)) {
39
+ entry.id = value.id;
40
+ }
41
+ return entry;
42
+ }
43
+ function parseTarget(value) {
44
+ if (!isRecord(value))
45
+ return null;
46
+ if (typeof value.instance_host !== 'string' || value.instance_host.length === 0)
47
+ return null;
48
+ if (typeof value.workspace_id !== 'string')
49
+ return null;
50
+ if (typeof value.branch !== 'string' || value.branch.length === 0)
51
+ return null;
52
+ if (typeof value.branch_created_at !== 'string')
53
+ return null;
54
+ if (typeof value.workspace_created_at !== 'string')
55
+ return null;
56
+ if (!isRecord(value.files))
57
+ return null;
58
+ const files = {};
59
+ for (const [path, raw] of Object.entries(value.files)) {
60
+ const entry = parseFileEntry(raw);
61
+ if (!entry)
62
+ return null;
63
+ files[path] = entry;
64
+ }
65
+ const target = {
66
+ branch: value.branch,
67
+ branch_created_at: value.branch_created_at,
68
+ files,
69
+ instance_host: value.instance_host,
70
+ workspace_created_at: value.workspace_created_at,
71
+ workspace_id: value.workspace_id,
72
+ };
73
+ if (typeof value.sandbox_token === 'string' && value.sandbox_token.length > 0) {
74
+ target.sandbox_token = value.sandbox_token;
75
+ }
76
+ return target;
77
+ }
78
+ export function recordEntriesForPaths(dir, filePaths) {
79
+ const files = [];
80
+ for (const filePath of filePaths) {
81
+ try {
82
+ files.push({ content: fs.readFileSync(filePath, 'utf8'), filePath });
83
+ }
84
+ catch {
85
+ // Skip unreadable files.
86
+ }
87
+ }
88
+ return recordEntriesForContents(dir, files);
89
+ }
90
+ /**
91
+ * Build record entries from file bytes the caller already holds. Push uses this to record
92
+ * exactly what it sent, so an edit made while the push ran is not recorded as synced.
93
+ */
94
+ export function recordEntriesForContents(dir, files) {
95
+ const entries = {};
96
+ for (const { content, filePath } of files) {
97
+ const parsed = parseDocument(content);
98
+ if (!parsed)
99
+ continue;
100
+ entries[toRelPath(dir, filePath)] = {
101
+ key: buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup),
102
+ sha256: hashBytes(content),
103
+ };
104
+ }
105
+ return entries;
106
+ }
107
+ export function sandboxTargetIdentity(host, tenant) {
108
+ return {
109
+ branch: 'sandbox',
110
+ branch_created_at: String(tenant.id),
111
+ instance_host: host,
112
+ workspace_created_at: tenant.created_at,
113
+ workspace_id: 'sandbox',
114
+ };
115
+ }
116
+ function parseSyncStateFile(raw) {
117
+ if (!isRecord(raw))
118
+ return null;
119
+ if (raw.version !== SYNC_STATE_VERSION)
120
+ return null;
121
+ if (!Array.isArray(raw.targets))
122
+ return null;
123
+ const targets = [];
124
+ for (const item of raw.targets) {
125
+ const parsed = parseTarget(item);
126
+ if (!parsed)
127
+ return null;
128
+ targets.push(parsed);
129
+ }
130
+ return { targets, version: SYNC_STATE_VERSION };
131
+ }
132
+ export function sameTargetIdentity(a, b) {
133
+ return (a.instance_host === b.instance_host &&
134
+ a.workspace_id === b.workspace_id &&
135
+ a.branch === b.branch);
136
+ }
137
+ export function sameTargetFingerprints(a, b) {
138
+ return (sameTargetIdentity(a, b) &&
139
+ a.branch_created_at === b.branch_created_at &&
140
+ a.workspace_created_at === b.workspace_created_at);
141
+ }
142
+ export function readSyncStateFile(dir, warn) {
143
+ const filePath = syncStatePath(dir);
144
+ if (!fs.existsSync(filePath))
145
+ return null;
146
+ let text;
147
+ try {
148
+ text = fs.readFileSync(filePath, 'utf8');
149
+ }
150
+ catch {
151
+ warn?.('Could not read .xano/sync_state.json; treating it as missing');
152
+ return null;
153
+ }
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(text);
157
+ }
158
+ catch {
159
+ warn?.('.xano/sync_state.json is not valid JSON; treating it as missing');
160
+ return null;
161
+ }
162
+ const file = parseSyncStateFile(parsed);
163
+ if (!file) {
164
+ warn?.('.xano/sync_state.json is invalid or from an unknown version; treating it as missing');
165
+ return null;
166
+ }
167
+ return file;
168
+ }
169
+ export function loadSyncState(dir, target, warn) {
170
+ const file = readSyncStateFile(dir, warn);
171
+ if (!file)
172
+ return null;
173
+ return file.targets.find((candidate) => sameTargetFingerprints(candidate, target)) ?? null;
174
+ }
175
+ function itemsOf(data) {
176
+ if (Array.isArray(data))
177
+ return data;
178
+ if (isRecord(data) && Array.isArray(data.items))
179
+ return data.items;
180
+ return [];
181
+ }
182
+ export async function resolveTarget(instanceOrigin, workspaceId, branch, fetchJson) {
183
+ let host;
184
+ try {
185
+ host = instanceHost(instanceOrigin);
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ let workspace;
191
+ let branches;
192
+ try {
193
+ ;
194
+ [workspace, branches] = await Promise.all([
195
+ fetchJson(`/workspace/${workspaceId}`),
196
+ fetchJson(`/workspace/${workspaceId}/branch`),
197
+ ]);
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ if (!isRecord(workspace) || typeof workspace.created_at !== 'string') {
203
+ return null;
204
+ }
205
+ const branchItems = itemsOf(branches).filter((item) => isRecord(item));
206
+ if (branchItems.length === 0)
207
+ return null;
208
+ const requested = branch.trim();
209
+ const live = branchItems.find((item) => item.live === true);
210
+ const match = requested
211
+ ? branchItems.find((item) => item.label === requested)
212
+ : live;
213
+ if (!match ||
214
+ typeof match.label !== 'string' ||
215
+ typeof match.created_at !== 'string' ||
216
+ !live ||
217
+ typeof live.label !== 'string') {
218
+ return null;
219
+ }
220
+ return {
221
+ branch: match.label,
222
+ branch_created_at: match.created_at,
223
+ instance_host: host,
224
+ live_branch: live.label,
225
+ workspace_created_at: workspace.created_at,
226
+ workspace_id: String(workspaceId),
227
+ };
228
+ }
229
+ export function computeCandidates(files, state) {
230
+ const local = new Map(files.map((file) => [file.relPath, file]));
231
+ const candidates = [];
232
+ const unchanged = [];
233
+ for (const file of files) {
234
+ const recorded = state?.files[file.relPath];
235
+ if (!recorded || recorded.sha256 !== file.sha256) {
236
+ candidates.push(file);
237
+ }
238
+ else {
239
+ unchanged.push(file);
240
+ }
241
+ }
242
+ const deletedLocally = [];
243
+ if (state) {
244
+ for (const [relPath, entry] of Object.entries(state.files)) {
245
+ if (!local.has(relPath)) {
246
+ deletedLocally.push({ key: entry.key, relPath });
247
+ }
248
+ }
249
+ }
250
+ return { candidates, deletedLocally, unchanged };
251
+ }
252
+ export function recordFiles(state, entries) {
253
+ return {
254
+ ...state,
255
+ files: mergeFileEntries(state.files, entries),
256
+ };
257
+ }
258
+ function mergeFileEntries(existing, incoming) {
259
+ const files = { ...existing };
260
+ for (const [relPath, entry] of Object.entries(incoming)) {
261
+ const previous = files[relPath];
262
+ if (previous && previous.key === entry.key && entry.id === undefined && previous.id !== undefined) {
263
+ files[relPath] = { ...entry, id: previous.id };
264
+ }
265
+ else {
266
+ files[relPath] = entry.id === undefined ? { key: entry.key, sha256: entry.sha256 } : { ...entry };
267
+ }
268
+ }
269
+ return files;
270
+ }
271
+ function cleanupTempFiles(dir, keep) {
272
+ let names = [];
273
+ try {
274
+ names = fs.readdirSync(dir);
275
+ }
276
+ catch {
277
+ return;
278
+ }
279
+ for (const name of names) {
280
+ if (!name.startsWith(TEMP_PREFIX))
281
+ continue;
282
+ const full = join(dir, name);
283
+ if (keep && full === keep)
284
+ continue;
285
+ try {
286
+ fs.unlinkSync(full);
287
+ }
288
+ catch {
289
+ // Best-effort cleanup.
290
+ }
291
+ }
292
+ }
293
+ export function saveSyncState(dir, target, entries, options = {}) {
294
+ const stateDir = join(dir, SYNC_STATE_DIR);
295
+ fs.mkdirSync(stateDir, { recursive: true });
296
+ const existing = readSyncStateFile(dir);
297
+ const targets = existing?.targets ? [...existing.targets] : [];
298
+ const index = targets.findIndex((candidate) => sameTargetIdentity(candidate, target));
299
+ const nextFiles = index === -1 || options.replace || !sameTargetFingerprints(targets[index], target)
300
+ ? { ...entries }
301
+ : mergeFileEntries(targets[index].files, entries);
302
+ if (options.pruneMissing) {
303
+ for (const relPath of Object.keys(nextFiles)) {
304
+ if (!fs.existsSync(join(dir, relPath))) {
305
+ delete nextFiles[relPath];
306
+ }
307
+ }
308
+ }
309
+ const keepToken = index !== -1 && sameTargetFingerprints(targets[index], target) ? targets[index].sandbox_token : undefined;
310
+ const sandboxToken = options.sandboxToken ?? keepToken;
311
+ const nextTarget = {
312
+ branch: target.branch,
313
+ branch_created_at: target.branch_created_at,
314
+ files: nextFiles,
315
+ instance_host: target.instance_host,
316
+ workspace_created_at: target.workspace_created_at,
317
+ workspace_id: target.workspace_id,
318
+ };
319
+ if (sandboxToken) {
320
+ nextTarget.sandbox_token = sandboxToken;
321
+ }
322
+ if (index === -1) {
323
+ targets.push(nextTarget);
324
+ }
325
+ else {
326
+ targets[index] = nextTarget;
327
+ }
328
+ const payload = { targets, version: SYNC_STATE_VERSION };
329
+ const dest = syncStatePath(dir);
330
+ const tmp = join(stateDir, `${TEMP_PREFIX}${process.pid}`);
331
+ try {
332
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
333
+ fs.renameSync(tmp, dest);
334
+ }
335
+ catch (error) {
336
+ try {
337
+ fs.unlinkSync(tmp);
338
+ }
339
+ catch {
340
+ // Already gone.
341
+ }
342
+ throw error;
343
+ }
344
+ finally {
345
+ cleanupTempFiles(stateDir);
346
+ }
347
+ }
348
+ export function listSyncDir(dir) {
349
+ const stateDir = join(dir, SYNC_STATE_DIR);
350
+ if (!fs.existsSync(stateDir))
351
+ return [];
352
+ return fs.readdirSync(stateDir);
353
+ }