@xano/cli 1.0.6-beta.6 → 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 +9 -67
- 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 +17 -14
- package/dist/utils/paging.js +19 -14
- package/dist/utils/reference-checker.js +0 -13
- package/oclif.manifest.json +3578 -5586
- 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
|
@@ -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
|
import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
|
|
8
8
|
export default class Pull extends BaseCommand {
|
|
9
9
|
static description = 'Pull a workspace multidoc from the Xano Metadata API and split into individual files';
|
|
@@ -134,19 +134,96 @@ Pulled 58 documents
|
|
|
134
134
|
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
135
135
|
// where different names produce the same snakeCase (e.g., "Authentication" vs "authentication")
|
|
136
136
|
const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
|
|
137
|
-
// Resolve a realtime v2 channel path -> owning realtime_server name, so
|
|
138
|
-
// messages can nest under their channel's server (see resolver docs).
|
|
139
|
-
const getChannelServer = buildChannelServerResolver(documents);
|
|
140
137
|
// Track filenames per type to handle duplicates
|
|
141
138
|
const filenameCounters = new Map();
|
|
142
139
|
let writtenCount = 0;
|
|
143
140
|
for (const doc of documents) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
141
|
+
let typeDir;
|
|
142
|
+
let baseName;
|
|
143
|
+
if (doc.type === 'workspace') {
|
|
144
|
+
// workspace → workspace/{name}.xs
|
|
145
|
+
typeDir = path.join(outputDir, 'workspace');
|
|
146
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
147
|
+
}
|
|
148
|
+
else if (doc.type === 'workspace_trigger') {
|
|
149
|
+
// workspace_trigger → workspace/trigger/{name}.xs
|
|
150
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
151
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
152
|
+
}
|
|
153
|
+
else if (doc.type === 'error_trigger') {
|
|
154
|
+
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
155
|
+
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
156
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
157
|
+
}
|
|
158
|
+
else if (doc.type === 'agent') {
|
|
159
|
+
// agent → ai/agent/{name}.xs
|
|
160
|
+
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
161
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
162
|
+
}
|
|
163
|
+
else if (doc.type === 'mcp_server') {
|
|
164
|
+
// mcp_server → ai/mcp_server/{name}.xs
|
|
165
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
166
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
167
|
+
}
|
|
168
|
+
else if (doc.type === 'tool') {
|
|
169
|
+
// tool → ai/tool/{name}.xs
|
|
170
|
+
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
171
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
172
|
+
}
|
|
173
|
+
else if (doc.type === 'agent_trigger') {
|
|
174
|
+
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
175
|
+
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
176
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
177
|
+
}
|
|
178
|
+
else if (doc.type === 'mcp_server_trigger') {
|
|
179
|
+
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
180
|
+
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
181
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
182
|
+
}
|
|
183
|
+
else if (doc.type === 'table_trigger') {
|
|
184
|
+
// table_trigger → table/trigger/{name}.xs
|
|
185
|
+
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
186
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
187
|
+
}
|
|
188
|
+
else if (doc.type === 'realtime_channel') {
|
|
189
|
+
// realtime_channel → realtime/channel/{name}.xs
|
|
190
|
+
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
191
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
192
|
+
}
|
|
193
|
+
else if (doc.type === 'realtime_trigger') {
|
|
194
|
+
// realtime_trigger → realtime/trigger/{name}.xs
|
|
195
|
+
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
196
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
197
|
+
}
|
|
198
|
+
else if (doc.type === 'api_group') {
|
|
199
|
+
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
200
|
+
const groupFolder = getApiGroupFolder(doc.name);
|
|
201
|
+
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
202
|
+
baseName = this.sanitizeFilename(doc.name);
|
|
203
|
+
}
|
|
204
|
+
else if (doc.type === 'query' && doc.apiGroup) {
|
|
205
|
+
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
206
|
+
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
207
|
+
const nameParts = doc.name.split('/');
|
|
208
|
+
const leafName = nameParts.pop();
|
|
209
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
210
|
+
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
211
|
+
baseName = this.sanitizeFilename(leafName);
|
|
212
|
+
if (doc.verb) {
|
|
213
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// Default: split folder path from name
|
|
218
|
+
const nameParts = doc.name.split('/');
|
|
219
|
+
const leafName = nameParts.pop();
|
|
220
|
+
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
221
|
+
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
222
|
+
baseName = this.sanitizeFilename(leafName);
|
|
223
|
+
if (doc.verb) {
|
|
224
|
+
baseName = `${baseName}_${doc.verb}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
150
227
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
151
228
|
// Track duplicates per directory
|
|
152
229
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -175,4 +252,12 @@ Pulled 58 documents
|
|
|
175
252
|
parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
|
|
176
253
|
this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
|
|
177
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Sanitize a document name for use as a filename.
|
|
257
|
+
* Strips quotes, replaces spaces with underscores, and removes
|
|
258
|
+
* characters that are unsafe in filenames.
|
|
259
|
+
*/
|
|
260
|
+
sanitizeFilename(name) {
|
|
261
|
+
return snakeCase(name.replaceAll('"', ''));
|
|
262
|
+
}
|
|
178
263
|
}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
export interface ParsedDocument {
|
|
2
2
|
apiGroup?: string;
|
|
3
3
|
canonical?: string;
|
|
4
|
-
/** Owning channel path for realtime v2 `message` documents. */
|
|
5
|
-
channel?: string;
|
|
6
4
|
content: string;
|
|
7
5
|
guid?: string;
|
|
8
6
|
name: string;
|
|
9
|
-
/** Owning realtime_server name for realtime v2 `channel` documents. */
|
|
10
|
-
server?: string;
|
|
11
7
|
type: string;
|
|
12
8
|
verb?: string;
|
|
13
9
|
}
|
|
@@ -16,87 +12,6 @@ export interface ParsedDocument {
|
|
|
16
12
|
* Skips leading comment lines (starting with //) to find the first meaningful line.
|
|
17
13
|
*/
|
|
18
14
|
export declare function parseDocument(content: string): null | ParsedDocument;
|
|
19
|
-
/**
|
|
20
|
-
* Map a realtime v2 channel path onto directory segments.
|
|
21
|
-
*
|
|
22
|
-
* A channel name IS a path template — "rooms/{room_id}" — so the directory tree
|
|
23
|
-
* mirrors it rather than flattening it. That is the whole reason messages nest
|
|
24
|
-
* under channels: the tree should show the structure, and
|
|
25
|
-
* "channel/rooms/[room_id]/post.xs" says immediately that `post` lives on a
|
|
26
|
-
* parameterized rooms channel.
|
|
27
|
-
*
|
|
28
|
-
* "rooms" -> ["rooms"]
|
|
29
|
-
* "rooms/{room_id}" -> ["rooms", "[room_id]"]
|
|
30
|
-
* "org/{org_id}/room/{id}" -> ["org", "[org_id]", "room", "[id]"]
|
|
31
|
-
*
|
|
32
|
-
* Braces become brackets because "{" and "}" are awkward in shells, while "["
|
|
33
|
-
* and "]" are legal on every filesystem. They are still glob character classes,
|
|
34
|
-
* so scripting against a checkout needs the path quoted.
|
|
35
|
-
*
|
|
36
|
-
* Escaping the braces rather than dropping them is what keeps a parameterized
|
|
37
|
-
* channel distinct from a literal one: without it, "rooms/{room_id}" and a
|
|
38
|
-
* literal channel named "rooms/room_id" collide on the same directory.
|
|
39
|
-
*
|
|
40
|
-
* Mirrors Vfs::channelSegment() on the server.
|
|
41
|
-
*/
|
|
42
|
-
export declare function channelPathSegments(name: string, snakeCaseFn: (s: string) => string): string[];
|
|
43
|
-
/**
|
|
44
|
-
* Build a map of realtime v2 channel path -> owning realtime_server name.
|
|
45
|
-
*
|
|
46
|
-
* This is the cross-document lookup the on-disk nesting needs: a `message`
|
|
47
|
-
* document names its channel (`channel = "..."`), while a `channel` document
|
|
48
|
-
* names its server (`realtime_server = "..."`). To place a message under
|
|
49
|
-
* `realtime/server/<server>/channel/<path>/...` we resolve the message's channel
|
|
50
|
-
* to that channel's server — which the channel document carries. (Messages now
|
|
51
|
-
* also carry `realtime_server` directly, but resolving via the channel keeps a
|
|
52
|
-
* single source of truth for the channel→server mapping.)
|
|
53
|
-
*
|
|
54
|
-
* Because every pull path parses the WHOLE multidoc into memory before writing,
|
|
55
|
-
* we can build this map from the `channel` docs in the same batch (a two-pass
|
|
56
|
-
* resolve), keyed by the channel's own name (its path). A channel with no
|
|
57
|
-
* `realtime_server` (e.g. a pre-server export) simply isn't in the map, and
|
|
58
|
-
* callers fall back accordingly.
|
|
59
|
-
*/
|
|
60
|
-
export declare function buildChannelServerResolver(documents: ParsedDocument[]): (channelName: string) => string | undefined;
|
|
61
|
-
/**
|
|
62
|
-
* Sanitize a document name for use as a single on-disk filename segment.
|
|
63
|
-
* Strips quotes and snake_cases the rest. Mirrors the per-command
|
|
64
|
-
* `sanitizeFilename` the pull commands used to each define privately.
|
|
65
|
-
*/
|
|
66
|
-
export declare function sanitizeDocumentName(name: string, snakeCaseFn: (s: string) => string): string;
|
|
67
|
-
/**
|
|
68
|
-
* The directory + base filename (no extension, no duplicate suffix) a document
|
|
69
|
-
* is written to on disk. `resolveDocumentPath` returns this; callers append the
|
|
70
|
-
* `.xs` extension and any `_N` duplicate suffix.
|
|
71
|
-
*/
|
|
72
|
-
export interface DocumentPlacement {
|
|
73
|
-
baseName: string;
|
|
74
|
-
typeDir: string;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Resolve where a parsed document lands on disk — the SINGLE source of truth for
|
|
78
|
-
* the pull layout, shared by every pull command (workspace, tenant, release,
|
|
79
|
-
* sandbox, ephemeral). Previously each command inlined a ~130-line copy of this
|
|
80
|
-
* decision; the copies drifted (the realtime v2 realtime_server/channel/message
|
|
81
|
-
* branches existed only in workspace/tenant), so an ephemeral pull dumped v2
|
|
82
|
-
* objects into flat top-level channel/, message/ and realtime_server/ folders —
|
|
83
|
-
* detaching messages from their channels and risking name collisions (message
|
|
84
|
-
* names are unique only WITHIN a channel).
|
|
85
|
-
*
|
|
86
|
-
* @param doc - the parsed document
|
|
87
|
-
* @param outputDir - the resolved pull output root
|
|
88
|
-
* @param deps - injected dependencies
|
|
89
|
-
* @param deps.getApiGroupFolder - resolves an api_group name to its unique folder
|
|
90
|
-
* @param deps.getChannelServer - resolves a v2 channel path to its owning realtime_server
|
|
91
|
-
* @param deps.join - the path.join implementation (injectable for testing)
|
|
92
|
-
* @param deps.snakeCase - the snakeCase implementation the caller uses
|
|
93
|
-
*/
|
|
94
|
-
export declare function resolveDocumentPath(doc: ParsedDocument, outputDir: string, deps: {
|
|
95
|
-
getApiGroupFolder: (groupName: string) => string;
|
|
96
|
-
getChannelServer: (channelName: string) => string | undefined;
|
|
97
|
-
join: (...parts: string[]) => string;
|
|
98
|
-
snakeCase: (s: string) => string;
|
|
99
|
-
}): DocumentPlacement;
|
|
100
15
|
/**
|
|
101
16
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
102
17
|
* Used to match server GUID map entries back to local files.
|
|
@@ -45,25 +45,6 @@ export function parseDocument(content) {
|
|
|
45
45
|
if (apiGroupMatch) {
|
|
46
46
|
apiGroup = apiGroupMatch[1];
|
|
47
47
|
}
|
|
48
|
-
// Extract channel if present (e.g., channel = "rooms/{room_id}").
|
|
49
|
-
// Realtime v2 messages reference their owning channel by PATH, which is what
|
|
50
|
-
// nests them under it on disk.
|
|
51
|
-
let channel;
|
|
52
|
-
const channelMatch = content.match(/^\s*channel\s*=\s*"([^"]*)"/m);
|
|
53
|
-
if (channelMatch) {
|
|
54
|
-
channel = channelMatch[1];
|
|
55
|
-
}
|
|
56
|
-
// Extract realtime_server if present (e.g., realtime_server = "chat").
|
|
57
|
-
// Realtime v2 channels/messages reference their owning realtime_server by NAME
|
|
58
|
-
// via a top-level `realtime_server = "..."`, which is what nests them under it
|
|
59
|
-
// on disk (mirrors how a message names its channel via `channel = "..."`).
|
|
60
|
-
// The keyword is `realtime_server` (renamed from a bare `server` to match the
|
|
61
|
-
// api_group/mcp_server convention — reference the container by its full type).
|
|
62
|
-
let server;
|
|
63
|
-
const serverMatch = content.match(/^\s*realtime_server\s*=\s*"([^"]*)"/m);
|
|
64
|
-
if (serverMatch) {
|
|
65
|
-
server = serverMatch[1];
|
|
66
|
-
}
|
|
67
48
|
// Extract canonical if present (e.g., canonical = "abc123")
|
|
68
49
|
let canonical;
|
|
69
50
|
const canonicalMatch = content.match(/canonical\s*=\s*"([^"]*)"/);
|
|
@@ -76,201 +57,7 @@ export function parseDocument(content) {
|
|
|
76
57
|
if (guidMatch) {
|
|
77
58
|
guid = guidMatch[1];
|
|
78
59
|
}
|
|
79
|
-
return { apiGroup, canonical,
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Map a realtime v2 channel path onto directory segments.
|
|
83
|
-
*
|
|
84
|
-
* A channel name IS a path template — "rooms/{room_id}" — so the directory tree
|
|
85
|
-
* mirrors it rather than flattening it. That is the whole reason messages nest
|
|
86
|
-
* under channels: the tree should show the structure, and
|
|
87
|
-
* "channel/rooms/[room_id]/post.xs" says immediately that `post` lives on a
|
|
88
|
-
* parameterized rooms channel.
|
|
89
|
-
*
|
|
90
|
-
* "rooms" -> ["rooms"]
|
|
91
|
-
* "rooms/{room_id}" -> ["rooms", "[room_id]"]
|
|
92
|
-
* "org/{org_id}/room/{id}" -> ["org", "[org_id]", "room", "[id]"]
|
|
93
|
-
*
|
|
94
|
-
* Braces become brackets because "{" and "}" are awkward in shells, while "["
|
|
95
|
-
* and "]" are legal on every filesystem. They are still glob character classes,
|
|
96
|
-
* so scripting against a checkout needs the path quoted.
|
|
97
|
-
*
|
|
98
|
-
* Escaping the braces rather than dropping them is what keeps a parameterized
|
|
99
|
-
* channel distinct from a literal one: without it, "rooms/{room_id}" and a
|
|
100
|
-
* literal channel named "rooms/room_id" collide on the same directory.
|
|
101
|
-
*
|
|
102
|
-
* Mirrors Vfs::channelSegment() on the server.
|
|
103
|
-
*/
|
|
104
|
-
export function channelPathSegments(name, snakeCaseFn) {
|
|
105
|
-
// A parameter keeps the author's name verbatim so it matches the `input`
|
|
106
|
-
// block; only the delimiters are swapped.
|
|
107
|
-
const isParam = /^\{.*\}$/;
|
|
108
|
-
return name.split('/').map((segment) => (isParam.test(segment) ? `[${segment.slice(1, -1)}]` : snakeCaseFn(segment)));
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Build a map of realtime v2 channel path -> owning realtime_server name.
|
|
112
|
-
*
|
|
113
|
-
* This is the cross-document lookup the on-disk nesting needs: a `message`
|
|
114
|
-
* document names its channel (`channel = "..."`), while a `channel` document
|
|
115
|
-
* names its server (`realtime_server = "..."`). To place a message under
|
|
116
|
-
* `realtime/server/<server>/channel/<path>/...` we resolve the message's channel
|
|
117
|
-
* to that channel's server — which the channel document carries. (Messages now
|
|
118
|
-
* also carry `realtime_server` directly, but resolving via the channel keeps a
|
|
119
|
-
* single source of truth for the channel→server mapping.)
|
|
120
|
-
*
|
|
121
|
-
* Because every pull path parses the WHOLE multidoc into memory before writing,
|
|
122
|
-
* we can build this map from the `channel` docs in the same batch (a two-pass
|
|
123
|
-
* resolve), keyed by the channel's own name (its path). A channel with no
|
|
124
|
-
* `realtime_server` (e.g. a pre-server export) simply isn't in the map, and
|
|
125
|
-
* callers fall back accordingly.
|
|
126
|
-
*/
|
|
127
|
-
export function buildChannelServerResolver(documents) {
|
|
128
|
-
const channelToServer = new Map();
|
|
129
|
-
for (const doc of documents) {
|
|
130
|
-
if (doc.type === 'channel' && doc.server) {
|
|
131
|
-
channelToServer.set(doc.name, doc.server);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return (channelName) => channelToServer.get(channelName);
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Sanitize a document name for use as a single on-disk filename segment.
|
|
138
|
-
* Strips quotes and snake_cases the rest. Mirrors the per-command
|
|
139
|
-
* `sanitizeFilename` the pull commands used to each define privately.
|
|
140
|
-
*/
|
|
141
|
-
export function sanitizeDocumentName(name, snakeCaseFn) {
|
|
142
|
-
return snakeCaseFn(name.replaceAll('"', ''));
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Resolve where a parsed document lands on disk — the SINGLE source of truth for
|
|
146
|
-
* the pull layout, shared by every pull command (workspace, tenant, release,
|
|
147
|
-
* sandbox, ephemeral). Previously each command inlined a ~130-line copy of this
|
|
148
|
-
* decision; the copies drifted (the realtime v2 realtime_server/channel/message
|
|
149
|
-
* branches existed only in workspace/tenant), so an ephemeral pull dumped v2
|
|
150
|
-
* objects into flat top-level channel/, message/ and realtime_server/ folders —
|
|
151
|
-
* detaching messages from their channels and risking name collisions (message
|
|
152
|
-
* names are unique only WITHIN a channel).
|
|
153
|
-
*
|
|
154
|
-
* @param doc - the parsed document
|
|
155
|
-
* @param outputDir - the resolved pull output root
|
|
156
|
-
* @param deps - injected dependencies
|
|
157
|
-
* @param deps.getApiGroupFolder - resolves an api_group name to its unique folder
|
|
158
|
-
* @param deps.getChannelServer - resolves a v2 channel path to its owning realtime_server
|
|
159
|
-
* @param deps.join - the path.join implementation (injectable for testing)
|
|
160
|
-
* @param deps.snakeCase - the snakeCase implementation the caller uses
|
|
161
|
-
*/
|
|
162
|
-
export function resolveDocumentPath(doc, outputDir, deps) {
|
|
163
|
-
const { getApiGroupFolder, getChannelServer, join, snakeCase } = deps;
|
|
164
|
-
const sanitize = (name) => sanitizeDocumentName(name, snakeCase);
|
|
165
|
-
if (doc.type === 'workspace') {
|
|
166
|
-
// workspace → workspace/{name}.xs
|
|
167
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'workspace') };
|
|
168
|
-
}
|
|
169
|
-
if (doc.type === 'workspace_trigger' || doc.type === 'error_trigger') {
|
|
170
|
-
// workspace_trigger / error_trigger → workspace/trigger/{name}.xs
|
|
171
|
-
// (error_trigger is a singleton, colocated with workspace triggers)
|
|
172
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'workspace', 'trigger') };
|
|
173
|
-
}
|
|
174
|
-
if (doc.type === 'agent') {
|
|
175
|
-
// agent → ai/agent/{name}.xs
|
|
176
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'agent') };
|
|
177
|
-
}
|
|
178
|
-
if (doc.type === 'mcp_server') {
|
|
179
|
-
// mcp_server → ai/mcp_server/{name}.xs
|
|
180
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'mcp_server') };
|
|
181
|
-
}
|
|
182
|
-
if (doc.type === 'tool') {
|
|
183
|
-
// tool → ai/tool/{name}.xs
|
|
184
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'tool') };
|
|
185
|
-
}
|
|
186
|
-
if (doc.type === 'agent_trigger') {
|
|
187
|
-
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
188
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'agent', 'trigger') };
|
|
189
|
-
}
|
|
190
|
-
if (doc.type === 'mcp_server_trigger') {
|
|
191
|
-
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
192
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'ai', 'mcp_server', 'trigger') };
|
|
193
|
-
}
|
|
194
|
-
if (doc.type === 'table_trigger') {
|
|
195
|
-
// table_trigger → table/trigger/{name}.xs
|
|
196
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'table', 'trigger') };
|
|
197
|
-
}
|
|
198
|
-
if (doc.type === 'realtime_channel') {
|
|
199
|
-
// Realtime v1: realtime_channel → realtime/channel/{name}.xs
|
|
200
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'channel') };
|
|
201
|
-
}
|
|
202
|
-
if (doc.type === 'realtime_trigger') {
|
|
203
|
-
// Realtime v1: realtime_trigger → realtime/trigger/{name}.xs
|
|
204
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'trigger') };
|
|
205
|
-
}
|
|
206
|
-
if (doc.type === 'realtime_server') {
|
|
207
|
-
// Realtime v2. The realtime_server is the top-level container that owns
|
|
208
|
-
// channels (which own messages). Its own document is named after itself
|
|
209
|
-
// inside its name directory, mirroring api_group (api/<group>/<group>.xs).
|
|
210
|
-
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
211
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.name)) };
|
|
212
|
-
}
|
|
213
|
-
if (doc.type === 'channel') {
|
|
214
|
-
// Realtime v2. The channel owns a directory named after itself (the full
|
|
215
|
-
// channel name, snake_cased into a single flat segment), and its messages
|
|
216
|
-
// live in a message/ subfolder inside it. It nests under its owning
|
|
217
|
-
// realtime_server (from `realtime_server = "..."`):
|
|
218
|
-
// channel "rooms/{room_id}" (server "chat")
|
|
219
|
-
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
220
|
-
//
|
|
221
|
-
// A channel with no resolvable server (e.g. a pre-server export) falls back
|
|
222
|
-
// to the legacy flat channel/<path>/_channel.xs layout.
|
|
223
|
-
if (doc.server) {
|
|
224
|
-
return {
|
|
225
|
-
baseName: snakeCase(doc.name),
|
|
226
|
-
typeDir: join(outputDir, 'realtime', 'server', sanitize(doc.server), 'channel', snakeCase(doc.name)),
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
return { baseName: '_channel', typeDir: join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase)) };
|
|
230
|
-
}
|
|
231
|
-
if (doc.type === 'message' && doc.channel) {
|
|
232
|
-
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
233
|
-
// under that channel's server (resolved via the two-pass channel→server map).
|
|
234
|
-
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
235
|
-
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
236
|
-
//
|
|
237
|
-
// Nesting matters beyond tidiness: message names are unique only WITHIN a
|
|
238
|
-
// channel, so a flat message/ directory would collide when two channels
|
|
239
|
-
// both define e.g. "say". A channel whose server can't be resolved falls
|
|
240
|
-
// back to the legacy flat channel/ layout.
|
|
241
|
-
const messageServer = getChannelServer(doc.channel);
|
|
242
|
-
return {
|
|
243
|
-
baseName: sanitize(doc.name),
|
|
244
|
-
typeDir: messageServer
|
|
245
|
-
? join(outputDir, 'realtime', 'server', sanitize(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
246
|
-
: join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase)),
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
if (doc.type === 'api_group') {
|
|
250
|
-
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
251
|
-
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'api', getApiGroupFolder(doc.name)) };
|
|
252
|
-
}
|
|
253
|
-
if (doc.type === 'query' && doc.apiGroup) {
|
|
254
|
-
// query in group "test" → api/{resolved_folder}/{folders}/{query_name}[_verb].xs
|
|
255
|
-
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
256
|
-
const nameParts = doc.name.split('/');
|
|
257
|
-
const leafName = nameParts.pop();
|
|
258
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
259
|
-
const baseName = sanitize(leafName);
|
|
260
|
-
return {
|
|
261
|
-
baseName: doc.verb ? `${baseName}_${doc.verb}` : baseName,
|
|
262
|
-
typeDir: join(outputDir, 'api', groupFolder, ...folderParts),
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
// Default: split folder path from name
|
|
266
|
-
const nameParts = doc.name.split('/');
|
|
267
|
-
const leafName = nameParts.pop();
|
|
268
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
269
|
-
const baseName = sanitize(leafName);
|
|
270
|
-
return {
|
|
271
|
-
baseName: doc.verb ? `${baseName}_${doc.verb}` : baseName,
|
|
272
|
-
typeDir: join(outputDir, doc.type, ...folderParts),
|
|
273
|
-
};
|
|
60
|
+
return { apiGroup, canonical, content, guid, name, type, verb };
|
|
274
61
|
}
|
|
275
62
|
/**
|
|
276
63
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
@@ -17,7 +17,6 @@ export interface KnowledgeObject {
|
|
|
17
17
|
mode?: string;
|
|
18
18
|
name: string;
|
|
19
19
|
scope?: string;
|
|
20
|
-
tag?: string[];
|
|
21
20
|
}
|
|
22
21
|
/** A locally-collected object that also remembers its primary file on disk (for GUID writeback). */
|
|
23
22
|
export interface LocalKnowledgeObject extends KnowledgeObject {
|
|
@@ -12,7 +12,7 @@ import { dirname, join, relative, sep } from 'node:path';
|
|
|
12
12
|
// let the server dictate file paths.
|
|
13
13
|
export const KNOWLEDGE_DIR = 'knowledge';
|
|
14
14
|
// ── Frontmatter ──────────────────────────────────────────────────────────────
|
|
15
|
-
const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'inclusion', '
|
|
15
|
+
const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'scope', 'inclusion', 'enabled', 'guid'];
|
|
16
16
|
/**
|
|
17
17
|
* Build a primary `.md` file body: YAML frontmatter (built from the object's
|
|
18
18
|
* structured fields) followed by the markdown content.
|
|
@@ -23,10 +23,12 @@ export function buildPrimaryContent(obj) {
|
|
|
23
23
|
if (obj.description !== undefined)
|
|
24
24
|
meta.description = obj.description;
|
|
25
25
|
meta.knowledge_type = obj.knowledge_type;
|
|
26
|
+
if (obj.scope !== undefined)
|
|
27
|
+
meta.scope = obj.scope;
|
|
26
28
|
if (obj.mode !== undefined)
|
|
27
29
|
meta.inclusion = modeToInclusion(obj.mode);
|
|
28
|
-
if (obj.
|
|
29
|
-
meta.
|
|
30
|
+
if (obj.enabled !== undefined)
|
|
31
|
+
meta.enabled = obj.enabled;
|
|
30
32
|
if (obj.guid !== undefined)
|
|
31
33
|
meta.guid = obj.guid;
|
|
32
34
|
// Emit keys in a stable, human-friendly order.
|
|
@@ -105,10 +107,6 @@ const MODE_TO_INCLUSION = {
|
|
|
105
107
|
function modeToInclusion(mode) {
|
|
106
108
|
return MODE_TO_INCLUSION[mode] ?? mode;
|
|
107
109
|
}
|
|
108
|
-
/** Parse the on-disk `tags:` frontmatter value into a string array, or `undefined` if absent/malformed. */
|
|
109
|
-
function parseTags(tags) {
|
|
110
|
-
return Array.isArray(tags) && tags.every((t) => typeof t === 'string') ? tags : undefined;
|
|
111
|
-
}
|
|
112
110
|
// ── Path mapping (CLI-owned layout) ────────────────────────────────────────────
|
|
113
111
|
/**
|
|
114
112
|
* Allocate a unique snake_case folder/file segment per call, suffixing
|
|
@@ -248,9 +246,6 @@ export function collectKnowledgeObjects(inputDir, include, exclude) {
|
|
|
248
246
|
};
|
|
249
247
|
if (typeof meta.guid === 'string')
|
|
250
248
|
obj.guid = meta.guid;
|
|
251
|
-
const tags = parseTags(meta.tags);
|
|
252
|
-
if (tags)
|
|
253
|
-
obj.tag = tags;
|
|
254
249
|
if (knowledgeType === 'skill') {
|
|
255
250
|
const skillFolder = posixPath.slice('skills/'.length, posixPath.indexOf('/SKILL.md'));
|
|
256
251
|
const refPrefix = `skills/${skillFolder}/references/`;
|
|
@@ -350,7 +345,6 @@ function canonical(obj) {
|
|
|
350
345
|
files,
|
|
351
346
|
mode: obj.mode ?? '',
|
|
352
347
|
scope: obj.scope ?? '',
|
|
353
|
-
tag: [...(obj.tag ?? [])].sort(),
|
|
354
348
|
});
|
|
355
349
|
}
|
|
356
350
|
/**
|
package/dist/utils/paging.d.ts
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
* so the item count IS the total.
|
|
17
17
|
*
|
|
18
18
|
* There is deliberately no tier for endpoints that page but return a bare array
|
|
19
|
-
* with no metadata (tenants, releases, platforms, clusters,
|
|
20
|
-
* backups). Those commands are left alone: the only has-more signal available
|
|
19
|
+
* with no metadata (tenants, releases, platforms, clusters, tenant backups). Those commands are left alone: the only has-more signal available
|
|
21
20
|
* would be "the page came back full", which is a guaranteed false positive when
|
|
22
21
|
* the result set is an exact multiple of the page size — sending the user to an
|
|
23
22
|
* empty page. Better to offer nothing than to offer a lie.
|
|
@@ -92,25 +91,29 @@ export declare function collectAllPages<T>(fetchPage: (page: number) => Promise<
|
|
|
92
91
|
onTruncate?: (collected: number) => void;
|
|
93
92
|
}): Promise<T[]>;
|
|
94
93
|
export interface PagingJson<T> {
|
|
95
|
-
|
|
94
|
+
curPage?: number;
|
|
96
95
|
items: T[];
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
total?: number;
|
|
96
|
+
itemsTotal?: number;
|
|
97
|
+
nextPage?: number;
|
|
98
|
+
perPage?: number;
|
|
99
|
+
prevPage?: number;
|
|
102
100
|
}
|
|
103
101
|
/**
|
|
104
102
|
* Build the `--output json` payload for a list command.
|
|
105
103
|
*
|
|
104
|
+
* Field names mirror the Metadata API envelope exactly — `curPage`, `nextPage`,
|
|
105
|
+
* `prevPage`, `itemsTotal`, `items` — so a script can move between the CLI and
|
|
106
|
+
* the raw API without a translation layer. `perPage` is the one field the CLI
|
|
107
|
+
* injects: the API never echoes it back, and it is otherwise invisible when the
|
|
108
|
+
* user leaves the flag at its default.
|
|
109
|
+
*
|
|
106
110
|
* Emits only fields the response actually proves, mirroring the footer's
|
|
107
|
-
* honesty rule: `
|
|
108
|
-
*
|
|
109
|
-
* items in this payload and is the one figure that is unconditionally true.
|
|
111
|
+
* honesty rule: `itemsTotal` appears only when the server sent it, `nextPage`
|
|
112
|
+
* only when the server computed it.
|
|
110
113
|
*
|
|
111
|
-
* Without this, a script driving `--output json` sees a bare array and
|
|
112
|
-
* available stop condition is `length <
|
|
113
|
-
* inference this module refuses to make in the footer.
|
|
114
|
+
* Without this envelope, a script driving `--output json` sees a bare array and
|
|
115
|
+
* its only available stop condition is `length < perPage` — precisely the
|
|
116
|
+
* page-fullness inference this module refuses to make in the footer.
|
|
114
117
|
*/
|
|
115
118
|
export declare function buildPagingJson<T>(list: NormalizedList<T>, options: {
|
|
116
119
|
page?: number;
|
package/dist/utils/paging.js
CHANGED
|
@@ -114,17 +114,22 @@ export async function collectAllPages(fetchPage, options = {}) {
|
|
|
114
114
|
/**
|
|
115
115
|
* Build the `--output json` payload for a list command.
|
|
116
116
|
*
|
|
117
|
+
* Field names mirror the Metadata API envelope exactly — `curPage`, `nextPage`,
|
|
118
|
+
* `prevPage`, `itemsTotal`, `items` — so a script can move between the CLI and
|
|
119
|
+
* the raw API without a translation layer. `perPage` is the one field the CLI
|
|
120
|
+
* injects: the API never echoes it back, and it is otherwise invisible when the
|
|
121
|
+
* user leaves the flag at its default.
|
|
122
|
+
*
|
|
117
123
|
* Emits only fields the response actually proves, mirroring the footer's
|
|
118
|
-
* honesty rule: `
|
|
119
|
-
*
|
|
120
|
-
* items in this payload and is the one figure that is unconditionally true.
|
|
124
|
+
* honesty rule: `itemsTotal` appears only when the server sent it, `nextPage`
|
|
125
|
+
* only when the server computed it.
|
|
121
126
|
*
|
|
122
|
-
* Without this, a script driving `--output json` sees a bare array and
|
|
123
|
-
* available stop condition is `length <
|
|
124
|
-
* inference this module refuses to make in the footer.
|
|
127
|
+
* Without this envelope, a script driving `--output json` sees a bare array and
|
|
128
|
+
* its only available stop condition is `length < perPage` — precisely the
|
|
129
|
+
* page-fullness inference this module refuses to make in the footer.
|
|
125
130
|
*/
|
|
126
131
|
export function buildPagingJson(list, options) {
|
|
127
|
-
const payload = {
|
|
132
|
+
const payload = { items: list.items };
|
|
128
133
|
if (options.tier === 'none') {
|
|
129
134
|
return payload;
|
|
130
135
|
}
|
|
@@ -136,17 +141,17 @@ export function buildPagingJson(list, options) {
|
|
|
136
141
|
if (!hasPositionEvidence) {
|
|
137
142
|
return payload;
|
|
138
143
|
}
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
payload.
|
|
144
|
+
const curPage = list.curPage ?? options.page;
|
|
145
|
+
if (curPage !== undefined)
|
|
146
|
+
payload.curPage = curPage;
|
|
142
147
|
if (options.tier === 'envelope' && options.perPage !== undefined)
|
|
143
|
-
payload.
|
|
148
|
+
payload.perPage = options.perPage;
|
|
144
149
|
if (list.nextPage !== undefined)
|
|
145
|
-
payload.
|
|
150
|
+
payload.nextPage = list.nextPage;
|
|
146
151
|
if (list.prevPage !== undefined)
|
|
147
|
-
payload.
|
|
152
|
+
payload.prevPage = list.prevPage;
|
|
148
153
|
if (list.itemsTotal !== undefined)
|
|
149
|
-
payload.
|
|
154
|
+
payload.itemsTotal = list.itemsTotal;
|
|
150
155
|
return payload;
|
|
151
156
|
}
|
|
152
157
|
const SEPARATOR = ' · ';
|
|
@@ -28,16 +28,6 @@ const REFERENCE_PATTERNS = [
|
|
|
28
28
|
},
|
|
29
29
|
// Schema foreign key references: table = "name" inside field definitions
|
|
30
30
|
{ keyword: 'table (FK)', regex: /\btable\s*=\s*"([^"]*)"/gm, targetType: 'table' },
|
|
31
|
-
// Realtime v2: a channel references its owning realtime_server by name via a
|
|
32
|
-
// top-level `realtime_server = "..."` (renamed from a bare `server` to match
|
|
33
|
-
// the api_group/mcp_server convention). The full keyword is unambiguous, so no
|
|
34
|
-
// false-match against another object type's `server` field.
|
|
35
|
-
{
|
|
36
|
-
keyword: 'channel (realtime_server)',
|
|
37
|
-
regex: /^\s*realtime_server\s*=\s*"([^"]*)"/gm,
|
|
38
|
-
sourceType: 'channel',
|
|
39
|
-
targetType: 'realtime_server',
|
|
40
|
-
},
|
|
41
31
|
];
|
|
42
32
|
/**
|
|
43
33
|
* Strip surrounding quotes from a name if present.
|
|
@@ -129,9 +119,6 @@ export function checkReferences(documents, serverOperations) {
|
|
|
129
119
|
if (!parsed)
|
|
130
120
|
continue;
|
|
131
121
|
for (const pattern of REFERENCE_PATTERNS) {
|
|
132
|
-
// Skip patterns scoped to a different source document type.
|
|
133
|
-
if (pattern.sourceType && pattern.sourceType !== parsed.type)
|
|
134
|
-
continue;
|
|
135
122
|
// Reset regex state for each document
|
|
136
123
|
pattern.regex.lastIndex = 0;
|
|
137
124
|
let match;
|