@xano/cli 1.0.6-beta.3 → 1.0.6-beta.6
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 +95 -4
- package/dist/commands/branch/list/index.js +5 -1
- package/dist/commands/ephemeral/list/index.js +2 -1
- package/dist/commands/ephemeral/pull/index.d.ts +0 -6
- package/dist/commands/ephemeral/pull/index.js +10 -95
- package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
- package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
- package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
- package/dist/commands/ephemeral/static_host/list/index.js +20 -40
- package/dist/commands/function/list/index.d.ts +4 -4
- package/dist/commands/function/list/index.js +30 -38
- package/dist/commands/knowledge/list/index.js +2 -1
- package/dist/commands/platform/list/index.js +13 -14
- package/dist/commands/release/list/index.js +13 -14
- package/dist/commands/release/pull/index.d.ts +0 -6
- package/dist/commands/release/pull/index.js +10 -95
- package/dist/commands/sandbox/env/list/index.js +4 -0
- package/dist/commands/sandbox/pull/index.d.ts +0 -1
- package/dist/commands/sandbox/pull/index.js +10 -77
- package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
- package/dist/commands/sandbox/unit_test/list/index.js +9 -14
- package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/sandbox/unit_test/run_all/index.js +67 -37
- package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
- package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
- package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/sandbox/workflow_test/run_all/index.js +65 -35
- package/dist/commands/static_host/build/list/index.d.ts +2 -3
- package/dist/commands/static_host/build/list/index.js +19 -40
- package/dist/commands/static_host/build/pull/index.js +12 -9
- package/dist/commands/static_host/list/index.d.ts +2 -3
- package/dist/commands/static_host/list/index.js +20 -40
- package/dist/commands/static_host/migrate/index.js +14 -10
- package/dist/commands/tenant/backup/list/index.js +13 -14
- package/dist/commands/tenant/cluster/list/index.js +9 -10
- package/dist/commands/tenant/env/list/index.js +4 -0
- package/dist/commands/tenant/list/index.js +19 -20
- package/dist/commands/tenant/pull/index.d.ts +0 -6
- package/dist/commands/tenant/pull/index.js +7 -142
- package/dist/commands/tenant/snapshot/list/index.js +5 -1
- package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
- package/dist/commands/tenant/unit_test/list/index.js +9 -14
- package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/tenant/unit_test/run_all/index.js +67 -37
- package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
- package/dist/commands/tenant/workflow_test/list/index.js +9 -14
- package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/tenant/workflow_test/run_all/index.js +65 -35
- package/dist/commands/unit_test/list/index.d.ts +3 -1
- package/dist/commands/unit_test/list/index.js +9 -14
- package/dist/commands/unit_test/run_all/index.d.ts +1 -0
- package/dist/commands/unit_test/run_all/index.js +68 -40
- package/dist/commands/workflow_test/list/index.d.ts +3 -1
- package/dist/commands/workflow_test/list/index.js +10 -13
- package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
- package/dist/commands/workflow_test/run_all/index.js +67 -36
- package/dist/commands/workspace/list/index.js +7 -13
- package/dist/commands/workspace/pull/index.d.ts +0 -6
- package/dist/commands/workspace/pull/index.js +7 -144
- package/dist/utils/concurrency.d.ts +29 -0
- package/dist/utils/concurrency.js +54 -0
- package/dist/utils/document-parser.d.ts +39 -0
- package/dist/utils/document-parser.js +139 -0
- package/dist/utils/paging.d.ts +141 -0
- package/dist/utils/paging.js +194 -0
- package/oclif.manifest.json +2950 -2824
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much paging information the backing Metadata API endpoint gives us.
|
|
3
|
+
*
|
|
4
|
+
* The Metadata API pages unevenly, and what the CLI can honestly tell the user
|
|
5
|
+
* depends entirely on what the response carries:
|
|
6
|
+
*
|
|
7
|
+
* - `envelope` accepts page + per_page and returns
|
|
8
|
+
* {curPage, nextPage, prevPage, items[]}. `nextPage` is
|
|
9
|
+
* server-computed and null on the last page, so it is the
|
|
10
|
+
* one place a "there is more" hint is guaranteed correct.
|
|
11
|
+
* - `page-only-envelope` accepts page only (per_page is hardcoded server-side)
|
|
12
|
+
* and returns an object carrying `itemsTotal`. A true
|
|
13
|
+
* total, so we can say where the user sits — but we offer
|
|
14
|
+
* no next-page affordance.
|
|
15
|
+
* - `none` no server-side paging at all. The full set comes back,
|
|
16
|
+
* so the item count IS the total.
|
|
17
|
+
*
|
|
18
|
+
* There is deliberately no tier for endpoints that page but return a bare array
|
|
19
|
+
* with no metadata (tenants, releases, platforms, clusters, ephemerals, tenant
|
|
20
|
+
* backups). Those commands are left alone: the only has-more signal available
|
|
21
|
+
* would be "the page came back full", which is a guaranteed false positive when
|
|
22
|
+
* the result set is an exact multiple of the page size — sending the user to an
|
|
23
|
+
* empty page. Better to offer nothing than to offer a lie.
|
|
24
|
+
*/
|
|
25
|
+
export type PagingTier = 'envelope' | 'none' | 'page-only-envelope';
|
|
26
|
+
export interface PagingFlagValues {
|
|
27
|
+
page?: number;
|
|
28
|
+
per_page?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface NormalizedList<T> {
|
|
31
|
+
curPage?: number;
|
|
32
|
+
items: T[];
|
|
33
|
+
itemsTotal?: number;
|
|
34
|
+
nextPage?: number;
|
|
35
|
+
prevPage?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface PagingFlagOptions {
|
|
38
|
+
/** Page size the endpoint hardcodes server-side; surfaced in the page flag description. */
|
|
39
|
+
fixedPerPage?: number;
|
|
40
|
+
/** Maximum per_page the endpoint accepts; surfaced in the per_page flag description. */
|
|
41
|
+
maxPerPage?: number;
|
|
42
|
+
}
|
|
43
|
+
declare function makePageFlag(description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
44
|
+
declare function makePerPageFlag(defaultValue: number, description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
45
|
+
type PageFlag = ReturnType<typeof makePageFlag>;
|
|
46
|
+
type PerPageFlag = ReturnType<typeof makePerPageFlag>;
|
|
47
|
+
/**
|
|
48
|
+
* Build the oclif flag definitions appropriate to an endpoint's paging tier.
|
|
49
|
+
* Spread the result into a command's `static flags`.
|
|
50
|
+
*
|
|
51
|
+
* A flag the server ignores is worse than no flag, so `per_page` is only
|
|
52
|
+
* produced for tiers whose endpoint actually accepts it. The overloads keep
|
|
53
|
+
* oclif's flag-type inference intact so `flags.page` stays typed at call sites.
|
|
54
|
+
*/
|
|
55
|
+
export declare function pagingFlags(tier: 'envelope', options?: PagingFlagOptions): {
|
|
56
|
+
page: PageFlag;
|
|
57
|
+
per_page: PerPageFlag;
|
|
58
|
+
};
|
|
59
|
+
export declare function pagingFlags(tier: 'page-only-envelope', options?: PagingFlagOptions): {
|
|
60
|
+
page: PageFlag;
|
|
61
|
+
};
|
|
62
|
+
export declare function pagingFlags(tier: 'none', options?: PagingFlagOptions): Record<string, never>;
|
|
63
|
+
export declare function pagingFlags(tier: PagingTier, options?: PagingFlagOptions): Record<string, unknown>;
|
|
64
|
+
/**
|
|
65
|
+
* Translate parsed paging flags into query-string entries, emitting only the
|
|
66
|
+
* params the endpoint actually declares.
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildPagingParams(flags: PagingFlagValues | Record<string, unknown>, tier: PagingTier): Array<[string, string]>;
|
|
69
|
+
/**
|
|
70
|
+
* Absorb the response shapes the Metadata API returns for list endpoints:
|
|
71
|
+
* a bare array, a paging envelope keyed on `items`, or an object keyed on the
|
|
72
|
+
* resource name (e.g. `{workspaces: [...]}`).
|
|
73
|
+
*
|
|
74
|
+
* `resourceKeys` names any resource-keyed fallbacks to check, in order.
|
|
75
|
+
*/
|
|
76
|
+
export declare function normalizeListResponse<T>(data: unknown, resourceKeys?: string[]): NormalizedList<T>;
|
|
77
|
+
/** Page size used when the CLI pages an endpoint on the user's behalf. */
|
|
78
|
+
export declare const INTERNAL_PAGE_SIZE = 100;
|
|
79
|
+
/**
|
|
80
|
+
* Walk an envelope-tier endpoint page by page and collect every item.
|
|
81
|
+
*
|
|
82
|
+
* Termination is the server's own `nextPage`, never "the page came back full" —
|
|
83
|
+
* the same rule the footer follows. An empty page also stops the walk, which
|
|
84
|
+
* covers an endpoint that omits `nextPage` entirely rather than looping forever.
|
|
85
|
+
*
|
|
86
|
+
* `maxPages` is a runaway backstop, not a limit users are expected to hit; when
|
|
87
|
+
* it trips, `onTruncate` fires so the caller can say so out loud instead of
|
|
88
|
+
* silently processing a subset.
|
|
89
|
+
*/
|
|
90
|
+
export declare function collectAllPages<T>(fetchPage: (page: number) => Promise<NormalizedList<T>>, options?: {
|
|
91
|
+
maxPages?: number;
|
|
92
|
+
onTruncate?: (collected: number) => void;
|
|
93
|
+
}): Promise<T[]>;
|
|
94
|
+
export interface PagingJson<T> {
|
|
95
|
+
count: number;
|
|
96
|
+
items: T[];
|
|
97
|
+
next_page?: number;
|
|
98
|
+
page?: number;
|
|
99
|
+
per_page?: number;
|
|
100
|
+
prev_page?: number;
|
|
101
|
+
total?: number;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build the `--output json` payload for a list command.
|
|
105
|
+
*
|
|
106
|
+
* Emits only fields the response actually proves, mirroring the footer's
|
|
107
|
+
* honesty rule: `total` appears only when the server sent `itemsTotal`,
|
|
108
|
+
* `next_page` only when the server computed it. `count` is always the number of
|
|
109
|
+
* items in this payload and is the one figure that is unconditionally true.
|
|
110
|
+
*
|
|
111
|
+
* Without this, a script driving `--output json` sees a bare array and its only
|
|
112
|
+
* available stop condition is `length < per_page` — precisely the page-fullness
|
|
113
|
+
* inference this module refuses to make in the footer.
|
|
114
|
+
*/
|
|
115
|
+
export declare function buildPagingJson<T>(list: NormalizedList<T>, options: {
|
|
116
|
+
page?: number;
|
|
117
|
+
perPage?: number;
|
|
118
|
+
tier: PagingTier;
|
|
119
|
+
}): PagingJson<T>;
|
|
120
|
+
export interface PagingFooterOptions {
|
|
121
|
+
/** Singular noun for the listed resource, used by the `none` tier. */
|
|
122
|
+
noun: string;
|
|
123
|
+
/** Plural form; defaults to `noun + 's'`. */
|
|
124
|
+
nounPlural?: string;
|
|
125
|
+
/** Page the user requested, used when the response omits `curPage`. */
|
|
126
|
+
page?: number;
|
|
127
|
+
/** Page size the user requested. Recorded for context; never used to infer a next page. */
|
|
128
|
+
perPage?: number;
|
|
129
|
+
tier: PagingTier;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Render the paging footer for a list command, reporting only facts the response
|
|
133
|
+
* actually proves. Returns null when there is nothing worth saying — an empty
|
|
134
|
+
* result set already prints its own "No X found" line.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately asymmetric across tiers: `envelope` shows a next-page hint but no
|
|
137
|
+
* total, `page-only-envelope` shows a total but no next-page hint, and `none`
|
|
138
|
+
* shows a bare count. See PagingTier for why.
|
|
139
|
+
*/
|
|
140
|
+
export declare function formatPagingFooter<T>(list: NormalizedList<T>, options: PagingFooterOptions): null | string;
|
|
141
|
+
export {};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/* eslint-disable camelcase */
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
function makePageFlag(description) {
|
|
4
|
+
return Flags.integer({ default: 1, description, required: false });
|
|
5
|
+
}
|
|
6
|
+
function makePerPageFlag(defaultValue, description) {
|
|
7
|
+
return Flags.integer({ default: defaultValue, description, required: false });
|
|
8
|
+
}
|
|
9
|
+
export function pagingFlags(tier, options = {}) {
|
|
10
|
+
if (tier === 'none') {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
if (tier === 'page-only-envelope') {
|
|
14
|
+
const fixed = options.fixedPerPage;
|
|
15
|
+
return {
|
|
16
|
+
page: makePageFlag(fixed
|
|
17
|
+
? `Page number for pagination (page size is fixed at ${fixed} by the API)`
|
|
18
|
+
: 'Page number for pagination'),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
page: makePageFlag('Page number for pagination'),
|
|
23
|
+
per_page: makePerPageFlag(50, options.maxPerPage
|
|
24
|
+
? `Number of results per page (max ${options.maxPerPage})`
|
|
25
|
+
: 'Number of results per page'),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Translate parsed paging flags into query-string entries, emitting only the
|
|
30
|
+
* params the endpoint actually declares.
|
|
31
|
+
*/
|
|
32
|
+
export function buildPagingParams(flags, tier) {
|
|
33
|
+
if (tier === 'none') {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const { page, per_page: perPage } = flags;
|
|
37
|
+
const params = [];
|
|
38
|
+
if (page !== undefined) {
|
|
39
|
+
params.push(['page', String(page)]);
|
|
40
|
+
}
|
|
41
|
+
if (tier === 'envelope' && perPage !== undefined) {
|
|
42
|
+
params.push(['per_page', String(perPage)]);
|
|
43
|
+
}
|
|
44
|
+
return params;
|
|
45
|
+
}
|
|
46
|
+
/** Coerce a possibly-null numeric paging field to a number or undefined. */
|
|
47
|
+
function optionalNumber(value) {
|
|
48
|
+
return typeof value === 'number' ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Absorb the response shapes the Metadata API returns for list endpoints:
|
|
52
|
+
* a bare array, a paging envelope keyed on `items`, or an object keyed on the
|
|
53
|
+
* resource name (e.g. `{workspaces: [...]}`).
|
|
54
|
+
*
|
|
55
|
+
* `resourceKeys` names any resource-keyed fallbacks to check, in order.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeListResponse(data, resourceKeys = []) {
|
|
58
|
+
if (Array.isArray(data)) {
|
|
59
|
+
return { items: data };
|
|
60
|
+
}
|
|
61
|
+
if (data && typeof data === 'object') {
|
|
62
|
+
const record = data;
|
|
63
|
+
if (Array.isArray(record.items)) {
|
|
64
|
+
return {
|
|
65
|
+
curPage: optionalNumber(record.curPage),
|
|
66
|
+
items: record.items,
|
|
67
|
+
itemsTotal: optionalNumber(record.itemsTotal),
|
|
68
|
+
nextPage: optionalNumber(record.nextPage),
|
|
69
|
+
prevPage: optionalNumber(record.prevPage),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
for (const key of resourceKeys) {
|
|
73
|
+
if (Array.isArray(record[key])) {
|
|
74
|
+
return {
|
|
75
|
+
curPage: optionalNumber(record.curPage),
|
|
76
|
+
items: record[key],
|
|
77
|
+
itemsTotal: optionalNumber(record.itemsTotal),
|
|
78
|
+
nextPage: optionalNumber(record.nextPage),
|
|
79
|
+
prevPage: optionalNumber(record.prevPage),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const accepted = ['an array', 'an object with an items array', ...resourceKeys.map((k) => `an object with a ${k} array`)];
|
|
85
|
+
throw new Error(`Unexpected API response format. Expected ${accepted.join(', ')}.`);
|
|
86
|
+
}
|
|
87
|
+
/** Page size used when the CLI pages an endpoint on the user's behalf. */
|
|
88
|
+
export const INTERNAL_PAGE_SIZE = 100;
|
|
89
|
+
/**
|
|
90
|
+
* Walk an envelope-tier endpoint page by page and collect every item.
|
|
91
|
+
*
|
|
92
|
+
* Termination is the server's own `nextPage`, never "the page came back full" —
|
|
93
|
+
* the same rule the footer follows. An empty page also stops the walk, which
|
|
94
|
+
* covers an endpoint that omits `nextPage` entirely rather than looping forever.
|
|
95
|
+
*
|
|
96
|
+
* `maxPages` is a runaway backstop, not a limit users are expected to hit; when
|
|
97
|
+
* it trips, `onTruncate` fires so the caller can say so out loud instead of
|
|
98
|
+
* silently processing a subset.
|
|
99
|
+
*/
|
|
100
|
+
export async function collectAllPages(fetchPage, options = {}) {
|
|
101
|
+
const maxPages = options.maxPages ?? 100;
|
|
102
|
+
const items = [];
|
|
103
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
104
|
+
// eslint-disable-next-line no-await-in-loop
|
|
105
|
+
const list = await fetchPage(page);
|
|
106
|
+
items.push(...list.items);
|
|
107
|
+
if (list.items.length === 0 || list.nextPage === undefined) {
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
options.onTruncate?.(items.length);
|
|
112
|
+
return items;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build the `--output json` payload for a list command.
|
|
116
|
+
*
|
|
117
|
+
* Emits only fields the response actually proves, mirroring the footer's
|
|
118
|
+
* honesty rule: `total` appears only when the server sent `itemsTotal`,
|
|
119
|
+
* `next_page` only when the server computed it. `count` is always the number of
|
|
120
|
+
* items in this payload and is the one figure that is unconditionally true.
|
|
121
|
+
*
|
|
122
|
+
* Without this, a script driving `--output json` sees a bare array and its only
|
|
123
|
+
* available stop condition is `length < per_page` — precisely the page-fullness
|
|
124
|
+
* inference this module refuses to make in the footer.
|
|
125
|
+
*/
|
|
126
|
+
export function buildPagingJson(list, options) {
|
|
127
|
+
const payload = { count: list.items.length, items: list.items };
|
|
128
|
+
if (options.tier === 'none') {
|
|
129
|
+
return payload;
|
|
130
|
+
}
|
|
131
|
+
const hasPositionEvidence = list.curPage !== undefined ||
|
|
132
|
+
list.nextPage !== undefined ||
|
|
133
|
+
list.prevPage !== undefined ||
|
|
134
|
+
list.itemsTotal !== undefined;
|
|
135
|
+
// Same rule as the footer: with no metadata we cannot assert a position.
|
|
136
|
+
if (!hasPositionEvidence) {
|
|
137
|
+
return payload;
|
|
138
|
+
}
|
|
139
|
+
const page = list.curPage ?? options.page;
|
|
140
|
+
if (page !== undefined)
|
|
141
|
+
payload.page = page;
|
|
142
|
+
if (options.tier === 'envelope' && options.perPage !== undefined)
|
|
143
|
+
payload.per_page = options.perPage;
|
|
144
|
+
if (list.nextPage !== undefined)
|
|
145
|
+
payload.next_page = list.nextPage;
|
|
146
|
+
if (list.prevPage !== undefined)
|
|
147
|
+
payload.prev_page = list.prevPage;
|
|
148
|
+
if (list.itemsTotal !== undefined)
|
|
149
|
+
payload.total = list.itemsTotal;
|
|
150
|
+
return payload;
|
|
151
|
+
}
|
|
152
|
+
const SEPARATOR = ' · ';
|
|
153
|
+
/**
|
|
154
|
+
* Render the paging footer for a list command, reporting only facts the response
|
|
155
|
+
* actually proves. Returns null when there is nothing worth saying — an empty
|
|
156
|
+
* result set already prints its own "No X found" line.
|
|
157
|
+
*
|
|
158
|
+
* Deliberately asymmetric across tiers: `envelope` shows a next-page hint but no
|
|
159
|
+
* total, `page-only-envelope` shows a total but no next-page hint, and `none`
|
|
160
|
+
* shows a bare count. See PagingTier for why.
|
|
161
|
+
*/
|
|
162
|
+
export function formatPagingFooter(list, options) {
|
|
163
|
+
const count = list.items.length;
|
|
164
|
+
const plural = options.nounPlural ?? `${options.noun}s`;
|
|
165
|
+
const countPhrase = `${count} ${count === 1 ? options.noun : plural}`;
|
|
166
|
+
// A paged tier whose response carried no metadata at all tells us nothing
|
|
167
|
+
// about position — the endpoint may have ignored `page` entirely. Reporting
|
|
168
|
+
// the requested page here would state the user's wish as a server fact, so
|
|
169
|
+
// fall back to the bare count.
|
|
170
|
+
const hasPositionEvidence = list.curPage !== undefined ||
|
|
171
|
+
list.nextPage !== undefined ||
|
|
172
|
+
list.prevPage !== undefined ||
|
|
173
|
+
list.itemsTotal !== undefined;
|
|
174
|
+
if (options.tier === 'none' || !hasPositionEvidence) {
|
|
175
|
+
return count === 0 ? null : countPhrase;
|
|
176
|
+
}
|
|
177
|
+
// An empty page past the end is worth reporting when we know the true total —
|
|
178
|
+
// otherwise `--page 99` on a populated workspace is indistinguishable from an
|
|
179
|
+
// empty one.
|
|
180
|
+
if (count === 0 && list.itemsTotal === undefined) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const page = list.curPage ?? options.page ?? 1;
|
|
184
|
+
const parts = [`Page ${page}`, `${count} shown`];
|
|
185
|
+
if (options.tier === 'envelope' && list.nextPage !== undefined) {
|
|
186
|
+
// Server-computed. Null/absent on the last page, so following it can never
|
|
187
|
+
// land on an empty result. Page fullness is never consulted here.
|
|
188
|
+
parts.push(`next: --page ${list.nextPage}`);
|
|
189
|
+
}
|
|
190
|
+
if (options.tier === 'page-only-envelope' && list.itemsTotal !== undefined) {
|
|
191
|
+
parts.push(`${list.itemsTotal} total`);
|
|
192
|
+
}
|
|
193
|
+
return parts.join(SEPARATOR);
|
|
194
|
+
}
|