@xano/cli 1.0.6-beta.3 → 1.0.6-beta.5
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/dist/commands/ephemeral/pull/index.d.ts +0 -6
- package/dist/commands/ephemeral/pull/index.js +10 -95
- package/dist/commands/release/pull/index.d.ts +0 -6
- package/dist/commands/release/pull/index.js +10 -95
- package/dist/commands/sandbox/pull/index.d.ts +0 -1
- package/dist/commands/sandbox/pull/index.js +10 -77
- package/dist/commands/tenant/pull/index.d.ts +0 -6
- package/dist/commands/tenant/pull/index.js +7 -142
- package/dist/commands/workspace/pull/index.d.ts +0 -6
- package/dist/commands/workspace/pull/index.js +7 -144
- package/dist/utils/document-parser.d.ts +39 -0
- package/dist/utils/document-parser.js +139 -0
- package/oclif.manifest.json +2493 -2493
- package/package.json +1 -1
|
@@ -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, buildChannelServerResolver,
|
|
6
|
+
import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } 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';
|
|
@@ -141,141 +141,12 @@ Pulled 58 documents
|
|
|
141
141
|
const filenameCounters = new Map();
|
|
142
142
|
let writtenCount = 0;
|
|
143
143
|
for (const doc of documents) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
else if (doc.type === 'workspace_trigger') {
|
|
152
|
-
// workspace_trigger → workspace/trigger/{name}.xs
|
|
153
|
-
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
154
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
155
|
-
}
|
|
156
|
-
else if (doc.type === 'error_trigger') {
|
|
157
|
-
// error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
|
|
158
|
-
typeDir = path.join(outputDir, 'workspace', 'trigger');
|
|
159
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
160
|
-
}
|
|
161
|
-
else if (doc.type === 'agent') {
|
|
162
|
-
// agent → ai/agent/{name}.xs
|
|
163
|
-
typeDir = path.join(outputDir, 'ai', 'agent');
|
|
164
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
165
|
-
}
|
|
166
|
-
else if (doc.type === 'mcp_server') {
|
|
167
|
-
// mcp_server → ai/mcp_server/{name}.xs
|
|
168
|
-
typeDir = path.join(outputDir, 'ai', 'mcp_server');
|
|
169
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
170
|
-
}
|
|
171
|
-
else if (doc.type === 'tool') {
|
|
172
|
-
// tool → ai/tool/{name}.xs
|
|
173
|
-
typeDir = path.join(outputDir, 'ai', 'tool');
|
|
174
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
175
|
-
}
|
|
176
|
-
else if (doc.type === 'agent_trigger') {
|
|
177
|
-
// agent_trigger → ai/agent/trigger/{name}.xs
|
|
178
|
-
typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
|
|
179
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
180
|
-
}
|
|
181
|
-
else if (doc.type === 'mcp_server_trigger') {
|
|
182
|
-
// mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
|
|
183
|
-
typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
|
|
184
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
185
|
-
}
|
|
186
|
-
else if (doc.type === 'table_trigger') {
|
|
187
|
-
// table_trigger → table/trigger/{name}.xs
|
|
188
|
-
typeDir = path.join(outputDir, 'table', 'trigger');
|
|
189
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
190
|
-
}
|
|
191
|
-
else if (doc.type === 'realtime_channel') {
|
|
192
|
-
// realtime_channel → realtime/channel/{name}.xs
|
|
193
|
-
typeDir = path.join(outputDir, 'realtime', 'channel');
|
|
194
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
195
|
-
}
|
|
196
|
-
else if (doc.type === 'realtime_trigger') {
|
|
197
|
-
// realtime_trigger → realtime/trigger/{name}.xs
|
|
198
|
-
typeDir = path.join(outputDir, 'realtime', 'trigger');
|
|
199
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
200
|
-
}
|
|
201
|
-
else if (doc.type === 'realtime_server') {
|
|
202
|
-
// Realtime v2. The realtime_server is the top-level container that owns
|
|
203
|
-
// channels (which own messages). Its own document is named after itself
|
|
204
|
-
// inside its name directory, mirroring api_group (api/<group>/<group>.xs)
|
|
205
|
-
// — the server name is a simple segment, so no fixed leaf is needed.
|
|
206
|
-
// realtime_server "chat" → realtime/server/chat/chat.xs
|
|
207
|
-
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
|
|
208
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
209
|
-
}
|
|
210
|
-
else if (doc.type === 'channel') {
|
|
211
|
-
// Realtime v2. The channel owns a directory named after itself (the full
|
|
212
|
-
// channel name, snake_cased into a single flat segment — like every other
|
|
213
|
-
// object), and its messages live in a message/ subfolder inside it. It
|
|
214
|
-
// nests under its owning realtime_server (from `server = "..."`):
|
|
215
|
-
// channel "rooms/{room_id}" (server "chat")
|
|
216
|
-
// → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
|
|
217
|
-
//
|
|
218
|
-
// The channel name is snake_cased as a whole (rooms/{room_id} →
|
|
219
|
-
// rooms_room_id), so there is no path nesting and no bracket escaping —
|
|
220
|
-
// the on-disk name is a display form; the .xs content keeps the real
|
|
221
|
-
// name. A channel with no resolvable server (e.g. a pre-server export)
|
|
222
|
-
// falls back to the legacy flat channel/<path>/_channel.xs layout.
|
|
223
|
-
if (doc.server) {
|
|
224
|
-
typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
|
|
225
|
-
baseName = snakeCase(doc.name);
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
|
|
229
|
-
baseName = '_channel';
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
else if (doc.type === 'message' && doc.channel) {
|
|
233
|
-
// Realtime v2 message → nests in a message/ subfolder under its channel,
|
|
234
|
-
// under that channel's server. The message names only its channel; the
|
|
235
|
-
// server is resolved by looking that channel up in the same multidoc
|
|
236
|
-
// (two-pass resolve).
|
|
237
|
-
// message "post" on channel "rooms/{room_id}" (server "chat")
|
|
238
|
-
// → realtime/server/chat/channel/rooms_room_id/message/post.xs
|
|
239
|
-
//
|
|
240
|
-
// Nesting matters beyond tidiness: message names are unique only WITHIN
|
|
241
|
-
// a channel, so a flat message/ directory would collide when two
|
|
242
|
-
// channels both define e.g. "say". A channel whose server can't be
|
|
243
|
-
// resolved falls back to the legacy flat channel/ layout.
|
|
244
|
-
const messageServer = getChannelServer(doc.channel);
|
|
245
|
-
typeDir = messageServer
|
|
246
|
-
? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
|
|
247
|
-
: path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
|
|
248
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
249
|
-
}
|
|
250
|
-
else if (doc.type === 'api_group') {
|
|
251
|
-
// api_group "test" → api/{resolved_folder}/{name}.xs
|
|
252
|
-
const groupFolder = getApiGroupFolder(doc.name);
|
|
253
|
-
typeDir = path.join(outputDir, 'api', groupFolder);
|
|
254
|
-
baseName = this.sanitizeFilename(doc.name);
|
|
255
|
-
}
|
|
256
|
-
else if (doc.type === 'query' && doc.apiGroup) {
|
|
257
|
-
// query in group "test" → api/{resolved_folder}/{query_name}.xs
|
|
258
|
-
const groupFolder = getApiGroupFolder(doc.apiGroup);
|
|
259
|
-
const nameParts = doc.name.split('/');
|
|
260
|
-
const leafName = nameParts.pop();
|
|
261
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
262
|
-
typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
|
|
263
|
-
baseName = this.sanitizeFilename(leafName);
|
|
264
|
-
if (doc.verb) {
|
|
265
|
-
baseName = `${baseName}_${doc.verb}`;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
else {
|
|
269
|
-
// Default: split folder path from name
|
|
270
|
-
const nameParts = doc.name.split('/');
|
|
271
|
-
const leafName = nameParts.pop();
|
|
272
|
-
const folderParts = nameParts.map((part) => snakeCase(part));
|
|
273
|
-
typeDir = path.join(outputDir, doc.type, ...folderParts);
|
|
274
|
-
baseName = this.sanitizeFilename(leafName);
|
|
275
|
-
if (doc.verb) {
|
|
276
|
-
baseName = `${baseName}_${doc.verb}`;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
144
|
+
const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
|
|
145
|
+
getApiGroupFolder,
|
|
146
|
+
getChannelServer,
|
|
147
|
+
join: path.join,
|
|
148
|
+
snakeCase,
|
|
149
|
+
});
|
|
279
150
|
fs.mkdirSync(typeDir, { recursive: true });
|
|
280
151
|
// Track duplicates per directory
|
|
281
152
|
const dirKey = path.relative(outputDir, typeDir);
|
|
@@ -304,12 +175,4 @@ Pulled 58 documents
|
|
|
304
175
|
parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
|
|
305
176
|
this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
|
|
306
177
|
}
|
|
307
|
-
/**
|
|
308
|
-
* Sanitize a document name for use as a filename.
|
|
309
|
-
* Strips quotes, replaces spaces with underscores, and removes
|
|
310
|
-
* characters that are unsafe in filenames.
|
|
311
|
-
*/
|
|
312
|
-
sanitizeFilename(name) {
|
|
313
|
-
return snakeCase(name.replaceAll('"', ''));
|
|
314
|
-
}
|
|
315
178
|
}
|
|
@@ -58,6 +58,45 @@ export declare function channelPathSegments(name: string, snakeCaseFn: (s: strin
|
|
|
58
58
|
* callers fall back accordingly.
|
|
59
59
|
*/
|
|
60
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;
|
|
61
100
|
/**
|
|
62
101
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
63
102
|
* Used to match server GUID map entries back to local files.
|
|
@@ -133,6 +133,145 @@ export function buildChannelServerResolver(documents) {
|
|
|
133
133
|
}
|
|
134
134
|
return (channelName) => channelToServer.get(channelName);
|
|
135
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
|
+
};
|
|
274
|
+
}
|
|
136
275
|
/**
|
|
137
276
|
* Build a unique key for a document based on its type, name, verb, and api_group.
|
|
138
277
|
* Used to match server GUID map entries back to local files.
|