@xano/cli 1.0.4-beta.2 → 1.0.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.
Files changed (42) hide show
  1. package/README.md +4 -51
  2. package/dist/base-command.d.ts +0 -27
  3. package/dist/base-command.js +1 -124
  4. package/dist/commands/auth/index.d.ts +0 -7
  5. package/dist/commands/auth/index.js +9 -85
  6. package/dist/commands/{tenant/snapshot/list → knowledge/pull}/index.d.ts +4 -6
  7. package/dist/commands/knowledge/pull/index.js +86 -0
  8. package/dist/commands/knowledge/push/index.d.ts +20 -0
  9. package/dist/commands/knowledge/push/index.js +126 -0
  10. package/dist/commands/static_host/build/create/index.d.ts +1 -9
  11. package/dist/commands/static_host/build/create/index.js +4 -54
  12. package/dist/commands/static_host/build/get/index.d.ts +1 -1
  13. package/dist/commands/static_host/build/get/index.js +10 -16
  14. package/dist/utils/knowledge-sync.d.ts +108 -0
  15. package/dist/utils/knowledge-sync.js +380 -0
  16. package/dist/utils/multidoc-push.js +17 -21
  17. package/dist/utils/reference-checker.js +2 -2
  18. package/oclif.manifest.json +2565 -3683
  19. package/package.json +1 -3
  20. package/dist/commands/static_host/build/delete/index.d.ts +0 -19
  21. package/dist/commands/static_host/build/delete/index.js +0 -114
  22. package/dist/commands/static_host/build/pull/index.d.ts +0 -52
  23. package/dist/commands/static_host/build/pull/index.js +0 -300
  24. package/dist/commands/static_host/build/push/index.d.ts +0 -23
  25. package/dist/commands/static_host/build/push/index.js +0 -225
  26. package/dist/commands/static_host/create/index.d.ts +0 -17
  27. package/dist/commands/static_host/create/index.js +0 -86
  28. package/dist/commands/static_host/deploy/index.d.ts +0 -18
  29. package/dist/commands/static_host/deploy/index.js +0 -105
  30. package/dist/commands/static_host/edit/index.d.ts +0 -23
  31. package/dist/commands/static_host/edit/index.js +0 -151
  32. package/dist/commands/static_host/get/index.d.ts +0 -18
  33. package/dist/commands/static_host/get/index.js +0 -94
  34. package/dist/commands/static_host/migrate/index.d.ts +0 -44
  35. package/dist/commands/static_host/migrate/index.js +0 -205
  36. package/dist/commands/tenant/snapshot/create/index.d.ts +0 -17
  37. package/dist/commands/tenant/snapshot/create/index.js +0 -78
  38. package/dist/commands/tenant/snapshot/delete/index.d.ts +0 -19
  39. package/dist/commands/tenant/snapshot/delete/index.js +0 -102
  40. package/dist/commands/tenant/snapshot/list/index.js +0 -96
  41. package/dist/commands/tenant/snapshot/swap/index.d.ts +0 -19
  42. package/dist/commands/tenant/snapshot/swap/index.js +0 -103
@@ -0,0 +1,380 @@
1
+ import { ux } from '@oclif/core';
2
+ import * as fs from 'node:fs';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
+ import { applyFilters, confirm } from './multidoc-push.js';
5
+ // ── Name & Path Safety ──────────────────────────────────────────────────────
6
+ // Mirrors the Metadata API's name filter: text|min(1)|max(200)|alphaOk|digitOk|ok("/_-{}. ")
7
+ const NAME_PATTERN = /^[\w/{}. -]+$/;
8
+ export const MAX_NAME_LENGTH = 200;
9
+ /**
10
+ * Validate a knowledge name (a relative path like "some/thing/CLAUDE.md")
11
+ * against the Metadata API's accepted character set and length.
12
+ * Returns null when valid, otherwise a human-readable reason.
13
+ */
14
+ export function validateKnowledgeName(name) {
15
+ if (!name || name.trim() === '') {
16
+ return 'name is empty';
17
+ }
18
+ if (name.length > MAX_NAME_LENGTH) {
19
+ return `name exceeds ${MAX_NAME_LENGTH} characters`;
20
+ }
21
+ if (!NAME_PATTERN.test(name)) {
22
+ return 'name contains unsupported characters (allowed: letters, digits, "/_-{}. ")';
23
+ }
24
+ return null;
25
+ }
26
+ /**
27
+ * Resolve a knowledge name to a local file path inside baseDir, rejecting
28
+ * names that would escape it (absolute paths, "..", etc.).
29
+ * Returns null when the name is unsafe.
30
+ */
31
+ export function safeKnowledgePath(baseDir, name) {
32
+ // Normalize: knowledge names always use forward slashes
33
+ if (!name || name.startsWith('/') || name.includes('\\')) {
34
+ return null;
35
+ }
36
+ const segments = name.split('/');
37
+ if (segments.some((s) => s === '' || s === '.' || s === '..')) {
38
+ return null;
39
+ }
40
+ const base = resolve(baseDir);
41
+ const target = resolve(base, ...segments);
42
+ if (target !== base && !target.startsWith(base + sep)) {
43
+ return null;
44
+ }
45
+ return target;
46
+ }
47
+ /**
48
+ * Infer the knowledge_type for a newly created knowledge item from its
49
+ * filename. AGENTS.md and SKILL.md are special-cased; everything else is a doc.
50
+ */
51
+ export function inferKnowledgeType(name) {
52
+ const basename = name.split('/').pop()?.toLowerCase() ?? '';
53
+ if (basename === 'agents.md')
54
+ return 'agents.md';
55
+ if (basename === 'skill.md')
56
+ return 'skill';
57
+ return 'doc';
58
+ }
59
+ // ── File Collection ─────────────────────────────────────────────────────────
60
+ const SKIPPED_DIRECTORIES = new Set(['node_modules']);
61
+ /**
62
+ * Recursively collect all files under a directory, skipping hidden files,
63
+ * hidden directories, and node_modules. Sorted for deterministic ordering.
64
+ */
65
+ export function collectKnowledgeFiles(dir) {
66
+ const files = [];
67
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
68
+ for (const entry of entries) {
69
+ if (entry.name.startsWith('.'))
70
+ continue;
71
+ const fullPath = join(dir, entry.name);
72
+ if (entry.isDirectory()) {
73
+ if (SKIPPED_DIRECTORIES.has(entry.name))
74
+ continue;
75
+ files.push(...collectKnowledgeFiles(fullPath));
76
+ }
77
+ else if (entry.isFile()) {
78
+ files.push(fullPath);
79
+ }
80
+ }
81
+ return files.sort();
82
+ }
83
+ /**
84
+ * Read local files into knowledge entries. The knowledge name is the path
85
+ * relative to inputDir, using forward slashes.
86
+ */
87
+ export function readKnowledgeEntries(files, inputDir) {
88
+ return files.map((filePath) => ({
89
+ content: fs.readFileSync(filePath, 'utf8'),
90
+ filePath,
91
+ name: relative(inputDir, filePath).split(sep).join('/'),
92
+ }));
93
+ }
94
+ // ── Diff Plan ───────────────────────────────────────────────────────────────
95
+ /**
96
+ * Compute the create/update/delete plan by matching local entries against
97
+ * remote knowledge items by name.
98
+ */
99
+ export function buildKnowledgePlan(localEntries, remoteItems) {
100
+ const remoteByName = new Map();
101
+ for (const item of remoteItems) {
102
+ remoteByName.set(item.name, item);
103
+ }
104
+ const plan = { creates: [], deletes: [], unchanged: [], updates: [] };
105
+ const localNames = new Set();
106
+ for (const entry of localEntries) {
107
+ localNames.add(entry.name);
108
+ const remote = remoteByName.get(entry.name);
109
+ if (!remote) {
110
+ plan.creates.push(entry);
111
+ }
112
+ else if ((remote.content ?? '') === entry.content) {
113
+ plan.unchanged.push({ entry, remote });
114
+ }
115
+ else {
116
+ plan.updates.push({ entry, remote });
117
+ }
118
+ }
119
+ for (const item of remoteItems) {
120
+ if (!localNames.has(item.name)) {
121
+ plan.deletes.push(item);
122
+ }
123
+ }
124
+ return plan;
125
+ }
126
+ // ── Preview Rendering ───────────────────────────────────────────────────────
127
+ export function renderKnowledgePreview(plan, opts, log) {
128
+ log('');
129
+ log(ux.colorize('bold', `=== Push Preview: ${opts.label} ===`));
130
+ let instanceHost = opts.instanceOrigin;
131
+ try {
132
+ instanceHost = new URL(opts.instanceOrigin).hostname;
133
+ }
134
+ catch { }
135
+ log(ux.colorize('dim', ` instance: ${instanceHost} | cli: v${opts.cliVersion}`));
136
+ log('');
137
+ const parts = [];
138
+ if (plan.creates.length > 0) {
139
+ parts.push(ux.colorize('green', `+${plan.creates.length} created`));
140
+ }
141
+ if (plan.updates.length > 0) {
142
+ parts.push(ux.colorize('yellow', `~${plan.updates.length} updated`));
143
+ }
144
+ if (opts.willDelete && plan.deletes.length > 0) {
145
+ parts.push(ux.colorize('red', `-${plan.deletes.length} deleted`));
146
+ }
147
+ if (parts.length > 0) {
148
+ log(` ${'Knowledge'.padEnd(20)} ${parts.join(' ')}`);
149
+ }
150
+ if (plan.creates.length > 0 || plan.updates.length > 0) {
151
+ log('');
152
+ log(ux.colorize('bold', '--- Changes ---'));
153
+ log('');
154
+ for (const entry of plan.creates) {
155
+ log(` ${ux.colorize('green', 'CREATE'.padEnd(16))} ${'knowledge'.padEnd(18)} ${entry.name}`);
156
+ }
157
+ for (const { entry } of plan.updates) {
158
+ log(` ${ux.colorize('yellow', 'UPDATE'.padEnd(16))} ${'knowledge'.padEnd(18)} ${entry.name}`);
159
+ }
160
+ }
161
+ if (opts.willDelete && plan.deletes.length > 0) {
162
+ log('');
163
+ log(ux.colorize('bold', '--- Destructive Operations ---'));
164
+ log('');
165
+ for (const item of plan.deletes) {
166
+ log(` ${ux.colorize('red', 'DELETE'.padEnd(16))} ${'knowledge'.padEnd(18)} ${item.name}`);
167
+ }
168
+ }
169
+ // Show remote-only items when not deleting (full sync only, to match workspace push)
170
+ if (!opts.willDelete && !opts.partial && plan.deletes.length > 0) {
171
+ log('');
172
+ log(ux.colorize('dim', '--- Remote Only (not included in push) ---'));
173
+ log('');
174
+ for (const item of plan.deletes) {
175
+ log(ux.colorize('dim', ` ${'knowledge'.padEnd(18)} ${item.name}`));
176
+ }
177
+ log('');
178
+ log(ux.colorize('dim', ` Use --delete to remove these ${plan.deletes.length} item(s) from remote.`));
179
+ }
180
+ log('');
181
+ }
182
+ // ── API Calls ───────────────────────────────────────────────────────────────
183
+ function knowledgeUrl(ctx, suffix = '') {
184
+ return `${ctx.instanceOrigin}/api:meta/workspace/${ctx.workspaceId}/knowledge${suffix}`;
185
+ }
186
+ function jsonHeaders(accessToken) {
187
+ return {
188
+ accept: 'application/json',
189
+ Authorization: `Bearer ${accessToken}`,
190
+ 'Content-Type': 'application/json',
191
+ };
192
+ }
193
+ /**
194
+ * Fetch all knowledge items (with content) for a workspace/branch.
195
+ */
196
+ export async function fetchKnowledgeList(ctx, verbose) {
197
+ // eslint-disable-next-line camelcase
198
+ const queryParams = new URLSearchParams({ include_content: 'true' });
199
+ if (ctx.branch) {
200
+ queryParams.set('branch', ctx.branch);
201
+ }
202
+ const response = await ctx.verboseFetch(`${knowledgeUrl(ctx)}?${queryParams.toString()}`, { headers: jsonHeaders(ctx.accessToken), method: 'GET' }, verbose, ctx.accessToken);
203
+ if (!response.ok) {
204
+ const errorText = await response.text();
205
+ throw new Error(`failed to list knowledge (${response.status}): ${errorText}`);
206
+ }
207
+ const data = (await response.json());
208
+ const items = Array.isArray(data) ? data : data && Array.isArray(data.items) ? data.items : null;
209
+ if (!items) {
210
+ throw new Error('unexpected response format from knowledge list endpoint');
211
+ }
212
+ // Records without a name can't be matched to a local file path, so they are
213
+ // invisible to push/pull (the API also rejects updates/deletes on them).
214
+ return items.filter((item) => typeof item.name === 'string' && item.name !== '');
215
+ }
216
+ // ── Main Push Logic ─────────────────────────────────────────────────────────
217
+ /**
218
+ * Execute a knowledge push: collect local files, diff against remote by name,
219
+ * preview, confirm, then apply creates/updates/deletes through the Metadata API.
220
+ * Mirrors the UX of `workspace push` (partial by default, --sync for full,
221
+ * --delete to remove remote-only items, --dry-run / --force).
222
+ */
223
+ export async function executeKnowledgePush(ctx, flags) {
224
+ const { command, inputDir } = ctx;
225
+ const log = command.log.bind(command);
226
+ // ── Resolve push mode ─────────────────────────────────────────────────
227
+ const isPartial = !flags.sync;
228
+ if (flags.delete && isPartial) {
229
+ command.error('Cannot use --delete without --sync');
230
+ }
231
+ const shouldDelete = isPartial ? false : flags.delete;
232
+ // ── Collect, filter, and validate local files ─────────────────────────
233
+ const entries = collectLocalKnowledge(command, inputDir, flags);
234
+ // ── Diff against remote ───────────────────────────────────────────────
235
+ let remoteItems = [];
236
+ try {
237
+ remoteItems = await fetchKnowledgeList(ctx, flags.verbose);
238
+ }
239
+ catch (error) {
240
+ command.error(`Failed to fetch remote knowledge: ${error.message}`);
241
+ }
242
+ const plan = buildKnowledgePlan(entries, remoteItems);
243
+ const label = `knowledge in workspace ${ctx.workspaceId}`;
244
+ // ── Preview & confirm ─────────────────────────────────────────────────
245
+ if (flags['dry-run'] || !flags.force) {
246
+ renderKnowledgePreview(plan, {
247
+ cliVersion: ctx.cliVersion,
248
+ instanceOrigin: ctx.instanceOrigin,
249
+ label,
250
+ partial: isPartial,
251
+ willDelete: shouldDelete,
252
+ }, log);
253
+ }
254
+ const hasChanges = plan.creates.length > 0 || plan.updates.length > 0 || (shouldDelete && plan.deletes.length > 0);
255
+ if (!hasChanges) {
256
+ log('No changes to push.');
257
+ return;
258
+ }
259
+ if (flags['dry-run']) {
260
+ return;
261
+ }
262
+ if (!flags.force && !(await confirmKnowledgePush(command, shouldDelete && plan.deletes.length > 0))) {
263
+ log('Push cancelled.');
264
+ return;
265
+ }
266
+ // ── Apply ─────────────────────────────────────────────────────────────
267
+ const startTime = Date.now();
268
+ let applied = '';
269
+ try {
270
+ applied = await applyKnowledgePlan(ctx, plan, shouldDelete, flags.verbose);
271
+ }
272
+ catch (error) {
273
+ command.error(`Push failed: ${error.message}`);
274
+ }
275
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
276
+ log(`Pushed knowledge to ${label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s (${applied}, ${plan.unchanged.length} unchanged)`);
277
+ }
278
+ /**
279
+ * Collect local knowledge files, apply include/exclude filters, and validate
280
+ * names against the API's accepted character set. Errors out on empty or
281
+ * invalid input.
282
+ */
283
+ function collectLocalKnowledge(command, inputDir, flags) {
284
+ const allFiles = collectKnowledgeFiles(inputDir);
285
+ const files = applyFilters(allFiles, inputDir, flags.include, flags.exclude, command.log.bind(command));
286
+ if (files.length === 0) {
287
+ command.error(flags.include || flags.exclude
288
+ ? `No knowledge files remain after include/exclude filters in ${inputDir}`
289
+ : `No knowledge files found in ${inputDir}`);
290
+ }
291
+ const entries = readKnowledgeEntries(files, inputDir);
292
+ const invalid = entries
293
+ .map((entry) => ({ entry, reason: validateKnowledgeName(entry.name) }))
294
+ .filter((item) => item.reason !== null);
295
+ if (invalid.length > 0) {
296
+ const lines = invalid.map(({ entry, reason }) => ` ${entry.name} — ${reason}`);
297
+ command.error(`Cannot push: ${invalid.length} file(s) have unsupported names:\n${lines.join('\n')}`);
298
+ }
299
+ return entries;
300
+ }
301
+ /**
302
+ * Prompt for confirmation before pushing. Errors out in non-interactive
303
+ * environments (matching `workspace push`), where --force must be used instead.
304
+ */
305
+ async function confirmKnowledgePush(command, hasDestructive) {
306
+ if (!process.stdin.isTTY) {
307
+ command.error('Non-interactive environment detected. Use --force to skip confirmation.');
308
+ }
309
+ const message = hasDestructive
310
+ ? 'Proceed with push? This includes DESTRUCTIVE operations listed above.'
311
+ : 'Proceed with push?';
312
+ return confirm(message);
313
+ }
314
+ /**
315
+ * Apply a knowledge plan against the Metadata API: POST creates, PUT updates
316
+ * (content only, so other metadata is preserved), and DELETE removals when
317
+ * shouldDelete is set. Returns a "2 created, 1 updated" style summary.
318
+ * Throws on the first failed request, noting what was applied so far.
319
+ */
320
+ async function applyKnowledgePlan(ctx, plan, shouldDelete, verbose) {
321
+ let created = 0;
322
+ let updated = 0;
323
+ let deleted = 0;
324
+ const describeApplied = () => {
325
+ const counts = [];
326
+ if (created > 0)
327
+ counts.push(`${created} created`);
328
+ if (updated > 0)
329
+ counts.push(`${updated} updated`);
330
+ if (deleted > 0)
331
+ counts.push(`${deleted} deleted`);
332
+ return counts.join(', ') || 'no changes applied';
333
+ };
334
+ // eslint-disable-next-line no-undef
335
+ const request = async (action, name, url, init) => {
336
+ const response = await ctx.verboseFetch(url, init, verbose, ctx.accessToken);
337
+ if (!response.ok) {
338
+ throw new Error(`failed to ${action} "${name}" (${response.status}): ${await response.text()} (after ${describeApplied()})`);
339
+ }
340
+ };
341
+ /* eslint-disable no-await-in-loop */
342
+ for (const entry of plan.creates) {
343
+ const body = {
344
+ content: entry.content,
345
+ // eslint-disable-next-line camelcase
346
+ knowledge_type: inferKnowledgeType(entry.name),
347
+ name: entry.name,
348
+ };
349
+ if (ctx.branch) {
350
+ body.branch = ctx.branch;
351
+ }
352
+ await request('create', entry.name, knowledgeUrl(ctx), {
353
+ body: JSON.stringify(body),
354
+ headers: jsonHeaders(ctx.accessToken),
355
+ method: 'POST',
356
+ });
357
+ created++;
358
+ }
359
+ for (const { entry, remote } of plan.updates) {
360
+ // name is included because the server requires it on edit; other metadata
361
+ // (description, mode, enabled, tags, ...) is left untouched.
362
+ await request('update', entry.name, knowledgeUrl(ctx, `/${remote.id}`), {
363
+ body: JSON.stringify({ content: entry.content, name: entry.name }),
364
+ headers: jsonHeaders(ctx.accessToken),
365
+ method: 'PUT',
366
+ });
367
+ updated++;
368
+ }
369
+ if (shouldDelete) {
370
+ for (const item of plan.deletes) {
371
+ await request('delete', item.name, knowledgeUrl(ctx, `/${item.id}`), {
372
+ headers: jsonHeaders(ctx.accessToken),
373
+ method: 'DELETE',
374
+ });
375
+ deleted++;
376
+ }
377
+ }
378
+ /* eslint-enable no-await-in-loop */
379
+ return describeApplied();
380
+ }
@@ -476,7 +476,6 @@ export async function executePush(ctx, target, flags) {
476
476
  }
477
477
  // Warn when the sandbox currently holds a different workspace than the one being
478
478
  // pushed and the change set is large enough that stale state is a real risk.
479
- let mismatchConfirmed = false;
480
479
  if (target.warnOnWorkspaceMismatch && preview.workspace_name) {
481
480
  const localWorkspaceName = findLocalWorkspaceName(documentEntries);
482
481
  const totalChanges = countSummaryChanges(preview.summary, shouldDelete);
@@ -495,33 +494,30 @@ export async function executePush(ctx, target, flags) {
495
494
  log('Push cancelled. Run `xano sandbox reset` then retry.');
496
495
  return;
497
496
  }
498
- mismatchConfirmed = true;
499
497
  }
500
498
  else {
501
499
  command.error('Workspace mismatch detected in non-interactive mode. Run `xano sandbox reset` first to start clean.');
502
500
  }
503
501
  }
504
502
  }
505
- // Confirm with user (skip if workspace mismatch prompt already obtained confirmation)
506
- if (!mismatchConfirmed) {
507
- const hasDestructive = preview.operations.some((op) => (shouldDelete && (op.action === 'delete' || op.action === 'cascade_delete')) ||
508
- op.action === 'truncate' ||
509
- op.action === 'drop_field' ||
510
- op.action === 'alter_field');
511
- const message = hasDestructive
512
- ? 'Proceed with push? This includes DESTRUCTIVE operations listed above.'
513
- : 'Proceed with push?';
514
- if (process.stdin.isTTY) {
515
- const confirmed = await confirm(message);
516
- if (!confirmed) {
517
- log('Push cancelled.');
518
- return;
519
- }
520
- }
521
- else {
522
- command.error('Non-interactive environment detected. Use --force to skip confirmation.');
503
+ // Confirm with user
504
+ const hasDestructive = preview.operations.some((op) => (shouldDelete && (op.action === 'delete' || op.action === 'cascade_delete')) ||
505
+ op.action === 'truncate' ||
506
+ op.action === 'drop_field' ||
507
+ op.action === 'alter_field');
508
+ const message = hasDestructive
509
+ ? 'Proceed with push? This includes DESTRUCTIVE operations listed above.'
510
+ : 'Proceed with push?';
511
+ if (process.stdin.isTTY) {
512
+ const confirmed = await confirm(message);
513
+ if (!confirmed) {
514
+ log('Push cancelled.');
515
+ return;
523
516
  }
524
517
  }
518
+ else {
519
+ command.error('Non-interactive environment detected. Use --force to skip confirmation.');
520
+ }
525
521
  }
526
522
  else {
527
523
  // Server returned unexpected response
@@ -593,7 +589,7 @@ export async function executePush(ctx, target, flags) {
593
589
  }
594
590
  catch {
595
591
  if (flags.verbose) {
596
- log(`Server response is not JSON; skipping GUID sync\n${responseText}`);
592
+ log('Server response is not JSON; skipping GUID sync');
597
593
  }
598
594
  }
599
595
  }
@@ -170,8 +170,8 @@ export function checkTableIndexes(documents) {
170
170
  return badIndexes;
171
171
  }
172
172
  function extractSchemaFields(content) {
173
- // id, created_at, and xdo are system fields not declared in the schema
174
- const fields = new Set(['id', 'created_at', 'xdo']);
173
+ // id and created_at are auto-added during import
174
+ const fields = new Set(['id', 'created_at']);
175
175
  // Find the schema block by matching braces
176
176
  const schemaStart = content.match(/\bschema\s*\{/);
177
177
  if (!schemaStart || schemaStart.index === undefined)