@xano/cli 1.0.6-beta.7 → 1.1.0
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 +3 -61
- package/dist/base-command.d.ts +0 -2
- package/dist/base-command.js +4 -10
- package/dist/commands/release/pull/index.d.ts +6 -0
- package/dist/commands/release/pull/index.js +95 -10
- package/dist/commands/sandbox/pull/index.d.ts +1 -0
- package/dist/commands/sandbox/pull/index.js +77 -10
- package/dist/commands/tenant/pull/index.d.ts +6 -0
- package/dist/commands/tenant/pull/index.js +95 -10
- package/dist/commands/workspace/git/pull/index.js +3 -53
- package/dist/commands/workspace/pull/index.d.ts +6 -0
- package/dist/commands/workspace/pull/index.js +95 -10
- package/dist/utils/document-parser.d.ts +0 -85
- package/dist/utils/document-parser.js +1 -214
- package/dist/utils/knowledge-sync.d.ts +0 -1
- package/dist/utils/knowledge-sync.js +5 -11
- package/dist/utils/paging.d.ts +1 -2
- package/dist/utils/reference-checker.js +0 -13
- package/oclif.manifest.json +3174 -5182
- package/package.json +1 -10
- package/dist/commands/ephemeral/create/index.d.ts +0 -18
- package/dist/commands/ephemeral/create/index.js +0 -103
- package/dist/commands/ephemeral/delete/index.d.ts +0 -18
- package/dist/commands/ephemeral/delete/index.js +0 -99
- package/dist/commands/ephemeral/edit/index.d.ts +0 -18
- package/dist/commands/ephemeral/edit/index.js +0 -109
- package/dist/commands/ephemeral/get/index.d.ts +0 -21
- package/dist/commands/ephemeral/get/index.js +0 -125
- package/dist/commands/ephemeral/impersonate/index.d.ts +0 -20
- package/dist/commands/ephemeral/impersonate/index.js +0 -120
- package/dist/commands/ephemeral/list/index.d.ts +0 -19
- package/dist/commands/ephemeral/list/index.js +0 -123
- package/dist/commands/ephemeral/pull/index.d.ts +0 -19
- package/dist/commands/ephemeral/pull/index.js +0 -164
- package/dist/commands/ephemeral/push/index.d.ts +0 -25
- package/dist/commands/ephemeral/push/index.js +0 -144
- package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +0 -20
- package/dist/commands/ephemeral/static_host/build/delete/index.js +0 -121
- package/dist/commands/ephemeral/static_host/build/get/index.d.ts +0 -18
- package/dist/commands/ephemeral/static_host/build/get/index.js +0 -137
- package/dist/commands/ephemeral/static_host/build/list/index.d.ts +0 -18
- package/dist/commands/ephemeral/static_host/build/list/index.js +0 -137
- package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +0 -53
- package/dist/commands/ephemeral/static_host/build/pull/index.js +0 -308
- package/dist/commands/ephemeral/static_host/build/push/index.d.ts +0 -24
- package/dist/commands/ephemeral/static_host/build/push/index.js +0 -250
- package/dist/commands/ephemeral/static_host/create/index.d.ts +0 -18
- package/dist/commands/ephemeral/static_host/create/index.js +0 -92
- package/dist/commands/ephemeral/static_host/deploy/index.d.ts +0 -19
- package/dist/commands/ephemeral/static_host/deploy/index.js +0 -112
- package/dist/commands/ephemeral/static_host/edit/index.d.ts +0 -24
- package/dist/commands/ephemeral/static_host/edit/index.js +0 -158
- package/dist/commands/ephemeral/static_host/get/index.d.ts +0 -19
- package/dist/commands/ephemeral/static_host/get/index.js +0 -101
- package/dist/commands/ephemeral/static_host/list/index.d.ts +0 -17
- package/dist/commands/ephemeral/static_host/list/index.js +0 -132
package/README.md
CHANGED
|
@@ -46,16 +46,16 @@ The Metadata API pages unevenly, so `list` commands differ in what they expose.
|
|
|
46
46
|
| Commands | Flags | Footer |
|
|
47
47
|
|---|---|---|
|
|
48
48
|
| `function list`, `unit_test list`, `workflow_test list` (plus `sandbox` / `tenant` variants) | `--page`, `--per_page` | `Page 2 · 50 shown · next: --page 3` |
|
|
49
|
-
| `static_host list`, `static_host build list`
|
|
49
|
+
| `static_host list`, `static_host build list` | `--page` only | `Page 2 · 100 shown · 340 total` |
|
|
50
50
|
| `branch list`, `workspace list`, `tenant snapshot list`, `sandbox env list`, `tenant env list` | none | `12 branches` |
|
|
51
|
-
| `tenant list`, `release list`, `platform list`, `tenant cluster list
|
|
51
|
+
| `tenant list`, `release list`, `platform list`, `tenant cluster list` | none | none |
|
|
52
52
|
| `tenant backup list` | `--page` only (pre-existing) | none |
|
|
53
53
|
|
|
54
54
|
Notes:
|
|
55
55
|
|
|
56
56
|
- **`--per_page` is only offered where the endpoint accepts it.** Static-host endpoints hardcode 100 items per page server-side, so those commands take `--page` alone. `xano static_host list --per_page 10` previously parsed but did nothing; it is now rejected rather than silently ignored.
|
|
57
57
|
- **All paged commands default `--per_page` to 50.** The test-list commands previously requested 10000 internally so nothing was ever cut off; now that the footer and the JSON envelope both report position, a page that stops at 50 is visible rather than silent. Raise it (up to 10000) when you want everything in one call.
|
|
58
|
-
- **The last group has no CLI paging.** Those endpoints page server-side but return a plain array with no page or total metadata, so the CLI cannot tell you where you are. Rather than show a page number with no context — or infer "more available" from a full page, which is wrong exactly when the result count is a multiple of the page size — these commands are left as-is. **They return at most 25–50 items** (50 for tenants
|
|
58
|
+
- **The last group has no CLI paging.** Those endpoints page server-side but return a plain array with no page or total metadata, so the CLI cannot tell you where you are. Rather than show a page number with no context — or infer "more available" from a full page, which is wrong exactly when the result count is a multiple of the page size — these commands are left as-is. **They return at most 25–50 items** (50 for tenants, 25 for releases, platforms, and tenant backups). If you have more than that, query the Metadata API directly until those endpoints return paging metadata.
|
|
59
59
|
- **`--output json` returns an envelope, not a bare array.** Every list command emits `{items, ...}` using the Metadata API's own field names — `curPage`, `nextPage`, `prevPage`, `itemsTotal` — each included only when the endpoint actually reports it. `perPage` is the one field the CLI adds, since the API never echoes it back and it is otherwise invisible at its default. This is a **breaking change** for scripts that parsed the previous bare array — read `.items` instead.
|
|
60
60
|
|
|
61
61
|
```jsonc
|
|
@@ -652,64 +652,6 @@ xano tenant cluster license set <cluster_id>
|
|
|
652
652
|
xano tenant cluster license set <cluster_id> --file ./kubeconfig.yaml
|
|
653
653
|
```
|
|
654
654
|
|
|
655
|
-
### Ephemeral Tenants
|
|
656
|
-
|
|
657
|
-
Manage ephemeral tenants — short-lived, auto-expiring tenants scoped to a workspace (default TTL 1h, max 72h). Unlike a sandbox, an ephemeral tenant requires a workspace.
|
|
658
|
-
|
|
659
|
-
```bash
|
|
660
|
-
# List ephemeral tenants in the current workspace
|
|
661
|
-
xano ephemeral list
|
|
662
|
-
xano ephemeral list -w 5
|
|
663
|
-
|
|
664
|
-
# List ephemeral tenants across every workspace you can access
|
|
665
|
-
xano ephemeral list --global
|
|
666
|
-
|
|
667
|
-
# Create an ephemeral tenant (workspace required)
|
|
668
|
-
xano ephemeral create "PR preview"
|
|
669
|
-
xano ephemeral create "Demo" --expires-hours 24 -w 5
|
|
670
|
-
xano ephemeral create "Load test" -d "48h soak" --expires-hours 48
|
|
671
|
-
|
|
672
|
-
# Get / edit / delete
|
|
673
|
-
xano ephemeral get <tenant_name>
|
|
674
|
-
xano ephemeral edit <tenant_name> --display "New Name" -d "New description"
|
|
675
|
-
xano ephemeral delete <tenant_name> --force
|
|
676
|
-
|
|
677
|
-
# Pull to / push from local files (multidoc)
|
|
678
|
-
xano ephemeral pull <tenant_name> -d ./my-ephemeral
|
|
679
|
-
xano ephemeral push <tenant_name> -d ./my-ephemeral --dry-run # preview first
|
|
680
|
-
xano ephemeral push <tenant_name> -d ./my-ephemeral
|
|
681
|
-
|
|
682
|
-
# Open in the browser (or print the URL)
|
|
683
|
-
xano ephemeral impersonate <tenant_name>
|
|
684
|
-
xano ephemeral impersonate <tenant_name> --url-only
|
|
685
|
-
xano ephemeral impersonate <tenant_name> --guest # read-only session (browse only)
|
|
686
|
-
```
|
|
687
|
-
|
|
688
|
-
#### Static hosting for an ephemeral tenant
|
|
689
|
-
|
|
690
|
-
An ephemeral tenant can host static sites, scoped to that tenant. These commands mirror
|
|
691
|
-
`xano static_host *` but take the tenant name as the first argument (the tenant's static
|
|
692
|
-
hosting is isolated inside the tenant's own database).
|
|
693
|
-
|
|
694
|
-
```bash
|
|
695
|
-
# List / create / inspect a tenant's static hosts
|
|
696
|
-
xano ephemeral static_host list <tenant_name>
|
|
697
|
-
xano ephemeral static_host create <tenant_name> --name marketing --description "Marketing site"
|
|
698
|
-
xano ephemeral static_host get <tenant_name> -H marketing
|
|
699
|
-
xano ephemeral static_host edit <tenant_name> -H marketing --description "Updated"
|
|
700
|
-
|
|
701
|
-
# Builds: push a directory, list, inspect, deploy to an env, pull, delete
|
|
702
|
-
xano ephemeral static_host build push <tenant_name> -H default -f ./site
|
|
703
|
-
xano ephemeral static_host build list <tenant_name> -H default
|
|
704
|
-
xano ephemeral static_host build get <tenant_name> -H default --build_id 52
|
|
705
|
-
xano ephemeral static_host deploy <tenant_name> -H default --build_id 52 --env prod
|
|
706
|
-
xano ephemeral static_host build pull <tenant_name> -H default --latest
|
|
707
|
-
xano ephemeral static_host build delete <tenant_name> -H default --build_id 52
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
> Static hosting is currently available for **local** tenants. Remote (tier2/tier3)
|
|
711
|
-
> tenants are not yet supported and will return an error.
|
|
712
|
-
|
|
713
655
|
### Sandbox
|
|
714
656
|
|
|
715
657
|
Manage your sandbox tenant. Each user has a single sandbox tenant that is auto-provisioned on first use.
|
package/dist/base-command.d.ts
CHANGED
|
@@ -117,7 +117,6 @@ export default abstract class BaseCommand extends Command {
|
|
|
117
117
|
protected logStaticHostUrls(opts: {
|
|
118
118
|
profile: ProfileConfig;
|
|
119
119
|
staticHost: string;
|
|
120
|
-
tenantName?: string;
|
|
121
120
|
verbose: boolean;
|
|
122
121
|
workspaceId: string;
|
|
123
122
|
}): Promise<void>;
|
|
@@ -127,7 +126,6 @@ export default abstract class BaseCommand extends Command {
|
|
|
127
126
|
profile: ProfileConfig;
|
|
128
127
|
quiet?: boolean;
|
|
129
128
|
staticHost: string;
|
|
130
|
-
tenantName?: string;
|
|
131
129
|
timeoutMs?: number;
|
|
132
130
|
verbose: boolean;
|
|
133
131
|
workspaceId: string;
|
package/dist/base-command.js
CHANGED
|
@@ -370,11 +370,8 @@ export default class BaseCommand extends Command {
|
|
|
370
370
|
* Returns the final status. Resolves to the last-seen status on timeout.
|
|
371
371
|
*/
|
|
372
372
|
async logStaticHostUrls(opts) {
|
|
373
|
-
const { profile, staticHost,
|
|
374
|
-
|
|
375
|
-
// workspace-level static host (default, unchanged behavior).
|
|
376
|
-
const scope = tenantName ? `/tenant/${tenantName}` : '';
|
|
377
|
-
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}${scope}/static_host/${staticHost}`;
|
|
373
|
+
const { profile, staticHost, verbose, workspaceId } = opts;
|
|
374
|
+
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host/${staticHost}`;
|
|
378
375
|
try {
|
|
379
376
|
const response = await this.verboseFetch(url, {
|
|
380
377
|
headers: { accept: 'application/json', Authorization: `Bearer ${profile.access_token}` },
|
|
@@ -397,14 +394,11 @@ export default class BaseCommand extends Command {
|
|
|
397
394
|
}
|
|
398
395
|
}
|
|
399
396
|
async waitForBuild(opts) {
|
|
400
|
-
const { buildId, profile, quiet, staticHost,
|
|
397
|
+
const { buildId, profile, quiet, staticHost, verbose, workspaceId } = opts;
|
|
401
398
|
const intervalMs = opts.intervalMs ?? 2000;
|
|
402
399
|
const timeoutMs = opts.timeoutMs ?? 600_000; // 10 min
|
|
403
400
|
const terminal = new Set(['error', 'ok']);
|
|
404
|
-
|
|
405
|
-
// workspace-level static host (default, unchanged behavior).
|
|
406
|
-
const scope = tenantName ? `/tenant/${tenantName}` : '';
|
|
407
|
-
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}${scope}/static_host/${staticHost}/build/${buildId}`;
|
|
401
|
+
const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host/${staticHost}/build/${buildId}`;
|
|
408
402
|
// Spinner only on an interactive TTY and when not emitting JSON. Verbose mode
|
|
409
403
|
// also disables it (the spinner would interleave with request/response logs).
|
|
410
404
|
const animate = Boolean(process.stdout.isTTY) && !quiet && !verbose;
|
|
@@ -14,4 +14,10 @@ export default class ReleasePull extends BaseCommand {
|
|
|
14
14
|
};
|
|
15
15
|
run(): Promise<void>;
|
|
16
16
|
private resolveReleaseName;
|
|
17
|
+
/**
|
|
18
|
+
* Sanitize a document name for use as a filename.
|
|
19
|
+
* Strips quotes, replaces spaces with underscores, and removes
|
|
20
|
+
* characters that are unsafe in filenames.
|
|
21
|
+
*/
|
|
22
|
+
private sanitizeFilename;
|
|
17
23
|
}
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import snakeCase from 'lodash.snakecase';
|
|
5
5
|
import BaseCommand from '../../../base-command.js';
|
|
6
|
-
import { buildApiGroupFolderResolver,
|
|
6
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
7
7
|
export default class ReleasePull extends BaseCommand {
|
|
8
8
|
static description = 'Pull a release multidoc from the Xano Metadata API and split into individual files';
|
|
9
9
|
static examples = [
|
|
@@ -123,19 +123,96 @@ Pulled 58 documents from release 'v1.0'
|
|
|
123
123
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
124
124
|
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
125
125
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
126
|
-
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
127
|
-
// messages can nest under their channel's server (see resolver docs).
|
|
128
|
-
const getChannelServer = buildChannelServerResolver(documents);
|
|
129
126
|
// Track filenames per type to handle duplicates
|
|
130
127
|
const filenameCounters = new Map();
|
|
131
128
|
let writtenCount = 0;
|
|
132
129
|
for (const doc of documents) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
130
|
+
let typeDir;
|
|
131
|
+
let baseName;
|
|
132
|
+
if (doc.type === 'workspace') {
|
|
133
|
+
// workspace → workspace/{name}.xs
|
|
134
|
+
typeDir = path.join(outputDir, 'workspace');
|
|
135
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
136
|
+
}
|
|
137
|
+
else if (doc.type === 'workspace_trigger') {
|
|
138
|
+
// workspace_trigger → workspace/trigger/{name}.xs
|
|
139
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
140
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
141
|
+
}
|
|
142
|
+
else if (doc.type === 'error_trigger') {
|
|
143
|
+
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
144
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
145
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
146
|
+
}
|
|
147
|
+
else if (doc.type === 'agent') {
|
|
148
|
+
// agent → ai/agent/{name}.xs
|
|
149
|
+
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
150
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
151
|
+
}
|
|
152
|
+
else if (doc.type === 'mcp_server') {
|
|
153
|
+
// mcp_server → ai/mcp_server/{name}.xs
|
|
154
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
155
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
156
|
+
}
|
|
157
|
+
else if (doc.type === 'tool') {
|
|
158
|
+
// tool → ai/tool/{name}.xs
|
|
159
|
+
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
160
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
161
|
+
}
|
|
162
|
+
else if (doc.type === 'agent_trigger') {
|
|
163
|
+
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
164
|
+
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
165
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
166
|
+
}
|
|
167
|
+
else if (doc.type === 'mcp_server_trigger') {
|
|
168
|
+
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
169
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
170
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
171
|
+
}
|
|
172
|
+
else if (doc.type === 'table_trigger') {
|
|
173
|
+
// table_trigger → table/trigger/{name}.xs
|
|
174
|
+
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
175
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
176
|
+
}
|
|
177
|
+
else if (doc.type === 'realtime_channel') {
|
|
178
|
+
// realtime_channel → realtime/channel/{name}.xs
|
|
179
|
+
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
180
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
181
|
+
}
|
|
182
|
+
else if (doc.type === 'realtime_trigger') {
|
|
183
|
+
// realtime_trigger → realtime/trigger/{name}.xs
|
|
184
|
+
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
185
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
186
|
+
}
|
|
187
|
+
else if (doc.type === 'api_group') {
|
|
188
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
189
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
190
|
+
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
191
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
192
|
+
}
|
|
193
|
+
else if (doc.type === 'query' && doc.apiGroup) {
|
|
194
|
+
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
195
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
196
|
+
const nameParts = doc.name.split('/');
|
|
197
|
+
const leafName = nameParts.pop();
|
|
198
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
199
|
+
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
200
|
+
baseName = this.sanitizeFilename(leafName);
|
|
201
|
+
if (doc.verb) {
|
|
202
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Default: split folder path from name
|
|
207
|
+
const nameParts = doc.name.split('/');
|
|
208
|
+
const leafName = nameParts.pop();
|
|
209
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
210
|
+
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
211
|
+
baseName = this.sanitizeFilename(leafName);
|
|
212
|
+
if (doc.verb) {
|
|
213
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
139
216
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
140
217
|
// Track duplicates per directory
|
|
141
218
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -180,4 +257,12 @@ Pulled 58 documents from release 'v1.0'
|
|
|
180
257
|
}
|
|
181
258
|
return match.id;
|
|
182
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Sanitize a document name for use as a filename.
|
|
262
|
+
* Strips quotes, replaces spaces with underscores, and removes
|
|
263
|
+
* characters that are unsafe in filenames.
|
|
264
|
+
*/
|
|
265
|
+
sanitizeFilename(name) {
|
|
266
|
+
return snakeCase(name.replaceAll('"', ''));
|
|
267
|
+
}
|
|
183
268
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import snakeCase from 'lodash.snakecase';
|
|
3
3
|
import BaseCommand from '../../../base-command.js';
|
|
4
|
-
import { buildApiGroupFolderResolver,
|
|
4
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
5
5
|
import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as path from 'node:path';
|
|
@@ -92,18 +92,82 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
|
|
|
92
92
|
const outputDir = path.resolve(flags.directory);
|
|
93
93
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
94
94
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
95
|
-
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
96
|
-
// messages can nest under their channel's server (see resolver docs).
|
|
97
|
-
const getChannelServer = buildChannelServerResolver(documents);
|
|
98
95
|
const filenameCounters = new Map();
|
|
99
96
|
let writtenCount = 0;
|
|
100
97
|
for (const doc of documents) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
98
|
+
let typeDir;
|
|
99
|
+
let baseName;
|
|
100
|
+
if (doc.type === 'workspace') {
|
|
101
|
+
typeDir = path.join(outputDir, 'workspace');
|
|
102
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
103
|
+
}
|
|
104
|
+
else if (doc.type === 'workspace_trigger') {
|
|
105
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
106
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
107
|
+
}
|
|
108
|
+
else if (doc.type === 'error_trigger') {
|
|
109
|
+
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
110
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
111
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
112
|
+
}
|
|
113
|
+
else if (doc.type === 'agent') {
|
|
114
|
+
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
115
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
116
|
+
}
|
|
117
|
+
else if (doc.type === 'mcp_server') {
|
|
118
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
119
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
120
|
+
}
|
|
121
|
+
else if (doc.type === 'tool') {
|
|
122
|
+
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
123
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
124
|
+
}
|
|
125
|
+
else if (doc.type === 'agent_trigger') {
|
|
126
|
+
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
127
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
128
|
+
}
|
|
129
|
+
else if (doc.type === 'mcp_server_trigger') {
|
|
130
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
131
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
132
|
+
}
|
|
133
|
+
else if (doc.type === 'table_trigger') {
|
|
134
|
+
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
135
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
136
|
+
}
|
|
137
|
+
else if (doc.type === 'realtime_channel') {
|
|
138
|
+
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
139
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
140
|
+
}
|
|
141
|
+
else if (doc.type === 'realtime_trigger') {
|
|
142
|
+
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
143
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
144
|
+
}
|
|
145
|
+
else if (doc.type === 'api_group') {
|
|
146
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
147
|
+
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
148
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
149
|
+
}
|
|
150
|
+
else if (doc.type === 'query' && doc.apiGroup) {
|
|
151
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
152
|
+
const nameParts = doc.name.split('/');
|
|
153
|
+
const leafName = nameParts.pop();
|
|
154
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
155
|
+
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
156
|
+
baseName = this.sanitizeFilename(leafName);
|
|
157
|
+
if (doc.verb) {
|
|
158
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const nameParts = doc.name.split('/');
|
|
163
|
+
const leafName = nameParts.pop();
|
|
164
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
165
|
+
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
166
|
+
baseName = this.sanitizeFilename(leafName);
|
|
167
|
+
if (doc.verb) {
|
|
168
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
107
171
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
108
172
|
const dirKey = path.relative(outputDir, typeDir);
|
|
109
173
|
if (!filenameCounters.has(dirKey)) {
|
|
@@ -130,4 +194,7 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
|
|
|
130
194
|
parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
|
|
131
195
|
this.log(`Pulled ${parts.join(' + ')} from sandbox environment to ${flags.directory}`);
|
|
132
196
|
}
|
|
197
|
+
sanitizeFilename(name) {
|
|
198
|
+
return snakeCase(name.replaceAll('"', ''));
|
|
199
|
+
}
|
|
133
200
|
}
|
|
@@ -14,4 +14,10 @@ export default class Pull extends BaseCommand {
|
|
|
14
14
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
};
|
|
16
16
|
run(): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Sanitize a document name for use as a filename.
|
|
19
|
+
* Strips quotes, replaces spaces with underscores, and removes
|
|
20
|
+
* characters that are unsafe in filenames.
|
|
21
|
+
*/
|
|
22
|
+
private sanitizeFilename;
|
|
17
23
|
}
|
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import snakeCase from 'lodash.snakecase';
|
|
5
5
|
import BaseCommand from '../../../base-command.js';
|
|
6
|
-
import { buildApiGroupFolderResolver,
|
|
6
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
|
|
7
7
|
export default class Pull extends BaseCommand {
|
|
8
8
|
static description = 'Pull a tenant multidoc from the Xano Metadata API and split into individual files';
|
|
9
9
|
static examples = [
|
|
@@ -129,19 +129,96 @@ Pulled 58 documents from tenant my-tenant
|
|
|
129
129
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
130
130
|
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
131
131
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
132
|
-
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
133
|
-
// messages can nest under their channel's server (see resolver docs).
|
|
134
|
-
const getChannelServer = buildChannelServerResolver(documents);
|
|
135
132
|
// Track filenames per type to handle duplicates
|
|
136
133
|
const filenameCounters = new Map();
|
|
137
134
|
let writtenCount = 0;
|
|
138
135
|
for (const doc of documents) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
136
|
+
let typeDir;
|
|
137
|
+
let baseName;
|
|
138
|
+
if (doc.type === 'workspace') {
|
|
139
|
+
// workspace → workspace/{name}.xs
|
|
140
|
+
typeDir = path.join(outputDir, 'workspace');
|
|
141
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
142
|
+
}
|
|
143
|
+
else if (doc.type === 'workspace_trigger') {
|
|
144
|
+
// workspace_trigger → workspace/trigger/{name}.xs
|
|
145
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
146
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
147
|
+
}
|
|
148
|
+
else if (doc.type === 'error_trigger') {
|
|
149
|
+
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
150
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
151
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
152
|
+
}
|
|
153
|
+
else if (doc.type === 'agent') {
|
|
154
|
+
// agent → ai/agent/{name}.xs
|
|
155
|
+
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
156
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
157
|
+
}
|
|
158
|
+
else if (doc.type === 'mcp_server') {
|
|
159
|
+
// mcp_server → ai/mcp_server/{name}.xs
|
|
160
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
161
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
162
|
+
}
|
|
163
|
+
else if (doc.type === 'tool') {
|
|
164
|
+
// tool → ai/tool/{name}.xs
|
|
165
|
+
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
166
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
167
|
+
}
|
|
168
|
+
else if (doc.type === 'agent_trigger') {
|
|
169
|
+
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
170
|
+
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
171
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
172
|
+
}
|
|
173
|
+
else if (doc.type === 'mcp_server_trigger') {
|
|
174
|
+
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
175
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
176
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
177
|
+
}
|
|
178
|
+
else if (doc.type === 'table_trigger') {
|
|
179
|
+
// table_trigger → table/trigger/{name}.xs
|
|
180
|
+
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
181
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
182
|
+
}
|
|
183
|
+
else if (doc.type === 'realtime_channel') {
|
|
184
|
+
// realtime_channel → realtime/channel/{name}.xs
|
|
185
|
+
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
186
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
187
|
+
}
|
|
188
|
+
else if (doc.type === 'realtime_trigger') {
|
|
189
|
+
// realtime_trigger → realtime/trigger/{name}.xs
|
|
190
|
+
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
191
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
192
|
+
}
|
|
193
|
+
else if (doc.type === 'api_group') {
|
|
194
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
195
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
196
|
+
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
197
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
198
|
+
}
|
|
199
|
+
else if (doc.type === 'query' && doc.apiGroup) {
|
|
200
|
+
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
201
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
202
|
+
const nameParts = doc.name.split('/');
|
|
203
|
+
const leafName = nameParts.pop();
|
|
204
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
205
|
+
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
206
|
+
baseName = this.sanitizeFilename(leafName);
|
|
207
|
+
if (doc.verb) {
|
|
208
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Default: split folder path from name
|
|
213
|
+
const nameParts = doc.name.split('/');
|
|
214
|
+
const leafName = nameParts.pop();
|
|
215
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
216
|
+
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
217
|
+
baseName = this.sanitizeFilename(leafName);
|
|
218
|
+
if (doc.verb) {
|
|
219
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
145
222
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
146
223
|
// Track duplicates per directory
|
|
147
224
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -160,4 +237,12 @@ Pulled 58 documents from tenant my-tenant
|
|
|
160
237
|
}
|
|
161
238
|
this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
|
|
162
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Sanitize a document name for use as a filename.
|
|
242
|
+
* Strips quotes, replaces spaces with underscores, and removes
|
|
243
|
+
* characters that are unsafe in filenames.
|
|
244
|
+
*/
|
|
245
|
+
sanitizeFilename(name) {
|
|
246
|
+
return snakeCase(name.replaceAll('"', ''));
|
|
247
|
+
}
|
|
163
248
|
}
|
|
@@ -5,7 +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, { buildUserAgent } from '../../../../base-command.js';
|
|
8
|
-
import { buildApiGroupFolderResolver,
|
|
8
|
+
import { buildApiGroupFolderResolver, parseDocument } from '../../../../utils/document-parser.js';
|
|
9
9
|
export default class GitPull extends BaseCommand {
|
|
10
10
|
static description = 'Pull XanoScript files from a git repository into a local directory';
|
|
11
11
|
static examples = [
|
|
@@ -97,13 +97,10 @@ export default class GitPull extends BaseCommand {
|
|
|
97
97
|
// Write documents to output directory using the same file tree logic as workspace pull
|
|
98
98
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
99
99
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
100
|
-
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
101
|
-
// messages can nest under their channel's server (see resolver docs).
|
|
102
|
-
const getChannelServer = buildChannelServerResolver(documents);
|
|
103
100
|
const filenameCounters = new Map();
|
|
104
101
|
let writtenCount = 0;
|
|
105
102
|
for (const doc of documents) {
|
|
106
|
-
const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder
|
|
103
|
+
const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder);
|
|
107
104
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
108
105
|
// Track duplicates per directory
|
|
109
106
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -328,7 +325,7 @@ export default class GitPull extends BaseCommand {
|
|
|
328
325
|
* Resolve the output directory and base filename for a parsed document.
|
|
329
326
|
* Uses the same type-to-directory mapping as workspace pull.
|
|
330
327
|
*/
|
|
331
|
-
resolveOutputPath(outputDir, doc, getApiGroupFolder
|
|
328
|
+
resolveOutputPath(outputDir, doc, getApiGroupFolder) {
|
|
332
329
|
let typeDir;
|
|
333
330
|
let baseName;
|
|
334
331
|
if (doc.type === 'workspace') {
|
|
@@ -376,53 +373,6 @@ export default class GitPull extends BaseCommand {
|
|
|
376
373
|
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
377
374
|
baseName = this.sanitizeFilename(doc.name);
|
|
378
375
|
}
|
|
379
|
-
else if (doc.type === 'realtime_server') {
|
|
380
|
-
// Realtime v2 — see workspace/pull for the full rationale. Its own
|
|
381
|
-
// document is named after itself, mirroring api_group.
|
|
382
|
-
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
383
|
-
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
|
|
384
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
385
|
-
}
|
|
386
|
-
else if (doc.type === 'channel') {
|
|
387
|
-
// Realtime v2. The channel owns a directory named after itself (the full
|
|
388
|
-
// channel name, snake_cased into a single flat segment — like every other
|
|
389
|
-
// object), and its messages live in a message/ subfolder inside it. It
|
|
390
|
-
// nests under its owning realtime_server (from `server = "..."`):
|
|
391
|
-
// channel "rooms/{room_id}" (server "chat")
|
|
392
|
-
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
393
|
-
//
|
|
394
|
-
// The channel name is snake_cased as a whole (rooms/{room_id} →
|
|
395
|
-
// rooms_room_id), so there is no path nesting and no bracket escaping —
|
|
396
|
-
// the on-disk name is a display form; the .xs content keeps the real
|
|
397
|
-
// name. A channel with no resolvable server (e.g. a pre-server export)
|
|
398
|
-
// falls back to the legacy flat channel/<path>/_channel.xs layout.
|
|
399
|
-
if (doc.server) {
|
|
400
|
-
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
|
|
401
|
-
baseName = snakeCase(doc.name);
|
|
402
|
-
}
|
|
403
|
-
else {
|
|
404
|
-
typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
|
|
405
|
-
baseName = '_channel';
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
else if (doc.type === 'message' && doc.channel) {
|
|
409
|
-
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
410
|
-
// under that channel's server. The message names only its channel; the
|
|
411
|
-
// server is resolved by looking that channel up in the same multidoc
|
|
412
|
-
// (two-pass resolve).
|
|
413
|
-
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
414
|
-
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
415
|
-
//
|
|
416
|
-
// Nesting matters beyond tidiness: message names are unique only WITHIN
|
|
417
|
-
// a channel, so a flat message/ directory would collide when two
|
|
418
|
-
// channels both define e.g. "say". A channel whose server can't be
|
|
419
|
-
// resolved falls back to the legacy flat channel/ layout.
|
|
420
|
-
const messageServer = getChannelServer(doc.channel);
|
|
421
|
-
typeDir = messageServer
|
|
422
|
-
? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
423
|
-
: path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
|
|
424
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
425
|
-
}
|
|
426
376
|
else if (doc.type === 'api_group') {
|
|
427
377
|
const groupFolder = getApiGroupFolder(doc.name);
|
|
428
378
|
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
@@ -14,4 +14,10 @@ export default class Pull extends BaseCommand {
|
|
|
14
14
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
};
|
|
16
16
|
run(): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Sanitize a document name for use as a filename.
|
|
19
|
+
* Strips quotes, replaces spaces with underscores, and removes
|
|
20
|
+
* characters that are unsafe in filenames.
|
|
21
|
+
*/
|
|
22
|
+
private sanitizeFilename;
|
|
17
23
|
}
|