@xano/cli 1.0.6-beta.5 → 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/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/sandbox/env/list/index.js +4 -0
- 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/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/utils/concurrency.d.ts +29 -0
- package/dist/utils/concurrency.js +54 -0
- package/dist/utils/paging.d.ts +141 -0
- package/dist/utils/paging.js +194 -0
- package/oclif.manifest.json +3017 -2891
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { createOrderedEmitter, mapWithConcurrency } from '../../../utils/concurrency.js';
|
|
4
|
+
import { collectAllPages, INTERNAL_PAGE_SIZE, normalizeListResponse } from '../../../utils/paging.js';
|
|
3
5
|
export default class WorkflowTestRunAll extends BaseCommand {
|
|
4
6
|
static description = 'Run all workflow tests in a workspace';
|
|
5
7
|
static examples = [
|
|
@@ -22,6 +24,11 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
22
24
|
description: 'Filter by branch name',
|
|
23
25
|
required: false,
|
|
24
26
|
}),
|
|
27
|
+
concurrency: Flags.integer({
|
|
28
|
+
default: 1,
|
|
29
|
+
description: 'Run this many tests in parallel. Tests share the workspace database, so raise this only when your tests do not depend on shared state.',
|
|
30
|
+
required: false,
|
|
31
|
+
}),
|
|
25
32
|
output: Flags.string({
|
|
26
33
|
char: 'o',
|
|
27
34
|
default: 'summary',
|
|
@@ -45,32 +52,35 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
45
52
|
const baseUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/workflow_test`;
|
|
46
53
|
try {
|
|
47
54
|
// Step 1: List all workflow tests
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Page the list rather than asking for everything at once: 100 per
|
|
56
|
+
// request is plenty for typical suites and does not make the instance
|
|
57
|
+
// build one enormous response. Termination is the server's own nextPage,
|
|
58
|
+
// never "the page came back full".
|
|
59
|
+
const branchValue = flags.branch;
|
|
60
|
+
const tests = await collectAllPages(async (page) => {
|
|
61
|
+
const listParams = new URLSearchParams();
|
|
62
|
+
listParams.set('page', String(page));
|
|
63
|
+
listParams.set('per_page', String(INTERNAL_PAGE_SIZE));
|
|
64
|
+
if (branchValue)
|
|
65
|
+
listParams.set('branch', branchValue);
|
|
66
|
+
listParams.set('include_xanoscript', 'false');
|
|
67
|
+
const listResponse = await this.verboseFetch(`${baseUrl}?${listParams}`, {
|
|
68
|
+
headers: {
|
|
69
|
+
accept: 'application/json',
|
|
70
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
71
|
+
},
|
|
72
|
+
method: 'GET',
|
|
73
|
+
}, flags.verbose, profile.access_token);
|
|
74
|
+
if (!listResponse.ok) {
|
|
75
|
+
const errorText = await listResponse.text();
|
|
76
|
+
this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
|
|
77
|
+
}
|
|
78
|
+
return normalizeListResponse(await listResponse.json());
|
|
79
|
+
}, {
|
|
80
|
+
onTruncate: (collected) => {
|
|
81
|
+
this.warn(`Stopped after ${collected} workflow tests — the list did not terminate. Some tests may not have run.`);
|
|
56
82
|
},
|
|
57
|
-
|
|
58
|
-
}, flags.verbose, profile.access_token);
|
|
59
|
-
if (!listResponse.ok) {
|
|
60
|
-
const errorText = await listResponse.text();
|
|
61
|
-
this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
|
|
62
|
-
}
|
|
63
|
-
const data = (await listResponse.json());
|
|
64
|
-
let tests;
|
|
65
|
-
if (Array.isArray(data)) {
|
|
66
|
-
tests = data;
|
|
67
|
-
}
|
|
68
|
-
else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
|
|
69
|
-
tests = data.items;
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
this.error('Unexpected API response format');
|
|
73
|
-
}
|
|
83
|
+
});
|
|
74
84
|
if (tests.length === 0) {
|
|
75
85
|
this.log('No workflow tests found');
|
|
76
86
|
return;
|
|
@@ -81,7 +91,15 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
81
91
|
// Step 2: Run each test
|
|
82
92
|
const results = [];
|
|
83
93
|
let totalTiming = 0;
|
|
84
|
-
|
|
94
|
+
const runOne = async (test) => {
|
|
95
|
+
// Shadowed accumulators: the body below is the original sequential
|
|
96
|
+
// logic, but it records output instead of printing it so a concurrent
|
|
97
|
+
// run can still emit in input order.
|
|
98
|
+
const results = [];
|
|
99
|
+
const lines = [];
|
|
100
|
+
const log = (message) => {
|
|
101
|
+
lines.push(message);
|
|
102
|
+
};
|
|
85
103
|
const runUrl = `${baseUrl}/${test.id}/run`;
|
|
86
104
|
try {
|
|
87
105
|
const runResponse = await this.verboseFetch(runUrl, {
|
|
@@ -102,10 +120,10 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
102
120
|
};
|
|
103
121
|
results.push(result);
|
|
104
122
|
if (flags.output === 'summary') {
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
log(`FAIL ${test.name} (0.000s)`);
|
|
124
|
+
log(` Error: API error ${runResponse.status}`);
|
|
107
125
|
}
|
|
108
|
-
|
|
126
|
+
return { lines, results };
|
|
109
127
|
}
|
|
110
128
|
const runResult = (await runResponse.json());
|
|
111
129
|
const passed = runResult.status === 'ok';
|
|
@@ -120,12 +138,12 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
120
138
|
if (flags.output === 'summary') {
|
|
121
139
|
const timing = `(${runResult.timing.toFixed(3)}s)`;
|
|
122
140
|
if (passed) {
|
|
123
|
-
|
|
141
|
+
log(`PASS ${test.name} ${timing}`);
|
|
124
142
|
}
|
|
125
143
|
else {
|
|
126
|
-
|
|
144
|
+
log(`FAIL ${test.name} ${timing}`);
|
|
127
145
|
if (runResult.message) {
|
|
128
|
-
|
|
146
|
+
log(` Error: ${runResult.message}`);
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
}
|
|
@@ -139,16 +157,29 @@ Results: 2 passed, 1 failed (2.691s total)
|
|
|
139
157
|
timing: 0,
|
|
140
158
|
});
|
|
141
159
|
if (flags.output === 'summary') {
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
log(`FAIL ${test.name} (0.000s)`);
|
|
161
|
+
log(` Error: ${message}`);
|
|
144
162
|
}
|
|
145
163
|
}
|
|
146
|
-
|
|
164
|
+
return { lines, results };
|
|
165
|
+
};
|
|
166
|
+
// Print in input order as each test settles, so concurrent output stays
|
|
167
|
+
// diffable against a sequential run.
|
|
168
|
+
const emitter = createOrderedEmitter((settled) => {
|
|
169
|
+
for (const line of settled.lines)
|
|
170
|
+
this.log(line);
|
|
171
|
+
});
|
|
172
|
+
const perTest = await mapWithConcurrency(tests, flags.concurrency, async (test, index) => {
|
|
173
|
+
const settled = await runOne(test);
|
|
174
|
+
emitter.settle(index, settled);
|
|
175
|
+
return settled;
|
|
176
|
+
});
|
|
177
|
+
results.push(...perTest.flatMap((r) => r.results));
|
|
147
178
|
// Step 3: Summary
|
|
148
179
|
const passed = results.filter((r) => r.status === 'pass').length;
|
|
149
180
|
const failed = results.filter((r) => r.status === 'fail').length;
|
|
150
181
|
if (flags.output === 'json') {
|
|
151
|
-
this.log(JSON.stringify({ passed,
|
|
182
|
+
this.log(JSON.stringify({ failed, passed, results, total_timing: totalTiming }, null, 2));
|
|
152
183
|
}
|
|
153
184
|
else {
|
|
154
185
|
this.log(`\nResults: ${passed} passed, ${failed} failed (${totalTiming.toFixed(3)}s total)`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { buildPagingJson, formatPagingFooter, normalizeListResponse } from '../../../utils/paging.js';
|
|
3
4
|
export default class WorkspaceList extends BaseCommand {
|
|
4
5
|
static description = 'List all workspaces from the Xano Metadata API';
|
|
5
6
|
static examples = [
|
|
@@ -71,24 +72,14 @@ Available workspaces:
|
|
|
71
72
|
const errorText = await response.text();
|
|
72
73
|
this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
|
|
73
74
|
}
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
let workspaces;
|
|
77
|
-
if (Array.isArray(data)) {
|
|
78
|
-
workspaces = data;
|
|
79
|
-
}
|
|
80
|
-
else if (data && typeof data === 'object' && 'workspaces' in data && Array.isArray(data.workspaces)) {
|
|
81
|
-
workspaces = data.workspaces;
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
this.error('Unexpected API response format');
|
|
85
|
-
}
|
|
75
|
+
const list = normalizeListResponse(await response.json(), ['workspaces']);
|
|
76
|
+
const workspaces = list.items;
|
|
86
77
|
if (flags.latest) {
|
|
87
78
|
workspaces.sort((a, b) => b.id - a.id);
|
|
88
79
|
}
|
|
89
80
|
// Output results
|
|
90
81
|
if (flags.output === 'json') {
|
|
91
|
-
this.log(JSON.stringify(
|
|
82
|
+
this.log(JSON.stringify(buildPagingJson(list, { tier: 'none' }), null, 2));
|
|
92
83
|
}
|
|
93
84
|
else {
|
|
94
85
|
// summary format
|
|
@@ -107,6 +98,9 @@ Available workspaces:
|
|
|
107
98
|
}
|
|
108
99
|
}
|
|
109
100
|
}
|
|
101
|
+
const footer = formatPagingFooter({ items: workspaces }, { noun: 'workspace', tier: 'none' });
|
|
102
|
+
if (footer)
|
|
103
|
+
this.log(footer);
|
|
110
104
|
}
|
|
111
105
|
}
|
|
112
106
|
catch (error) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `limit` in flight at once.
|
|
3
|
+
*
|
|
4
|
+
* Results come back in **input order**, not completion order. That matters for
|
|
5
|
+
* test runners: a concurrent run must still produce output a human can diff
|
|
6
|
+
* against a sequential one, and a JSON results array whose order does not shift
|
|
7
|
+
* between runs.
|
|
8
|
+
*
|
|
9
|
+
* `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
|
|
10
|
+
* execution and callers do not need a separate code path for it.
|
|
11
|
+
*
|
|
12
|
+
* The worker is expected to capture its own failures — one rejected worker
|
|
13
|
+
* rejects the whole call. Callers that want per-item error reporting (as the
|
|
14
|
+
* test runners do) should resolve a failure result instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Emit results in input order as soon as each becomes available, without
|
|
19
|
+
* waiting for the whole batch.
|
|
20
|
+
*
|
|
21
|
+
* Progress lines are the reason this exists: with concurrency the third test may
|
|
22
|
+
* finish before the first, but printing them out of order makes the output hard
|
|
23
|
+
* to follow and non-deterministic between runs. This buffers out-of-order
|
|
24
|
+
* completions and flushes the moment the next expected index arrives, so the
|
|
25
|
+
* user sees an ordered stream that still starts before the run ends.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createOrderedEmitter<R>(onReady: (result: R, index: number) => void): {
|
|
28
|
+
settle(index: number, result: R): void;
|
|
29
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `limit` in flight at once.
|
|
3
|
+
*
|
|
4
|
+
* Results come back in **input order**, not completion order. That matters for
|
|
5
|
+
* test runners: a concurrent run must still produce output a human can diff
|
|
6
|
+
* against a sequential one, and a JSON results array whose order does not shift
|
|
7
|
+
* between runs.
|
|
8
|
+
*
|
|
9
|
+
* `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
|
|
10
|
+
* execution and callers do not need a separate code path for it.
|
|
11
|
+
*
|
|
12
|
+
* The worker is expected to capture its own failures — one rejected worker
|
|
13
|
+
* rejects the whole call. Callers that want per-item error reporting (as the
|
|
14
|
+
* test runners do) should resolve a failure result instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export async function mapWithConcurrency(items, limit, worker) {
|
|
17
|
+
const effectiveLimit = Math.max(1, Math.floor(limit));
|
|
18
|
+
const results = Array.from({ length: items.length });
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
const runners = Array.from({ length: Math.min(effectiveLimit, items.length) }, async () => {
|
|
21
|
+
while (cursor < items.length) {
|
|
22
|
+
const index = cursor;
|
|
23
|
+
cursor++;
|
|
24
|
+
// eslint-disable-next-line no-await-in-loop
|
|
25
|
+
results[index] = await worker(items[index], index);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
await Promise.all(runners);
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Emit results in input order as soon as each becomes available, without
|
|
33
|
+
* waiting for the whole batch.
|
|
34
|
+
*
|
|
35
|
+
* Progress lines are the reason this exists: with concurrency the third test may
|
|
36
|
+
* finish before the first, but printing them out of order makes the output hard
|
|
37
|
+
* to follow and non-deterministic between runs. This buffers out-of-order
|
|
38
|
+
* completions and flushes the moment the next expected index arrives, so the
|
|
39
|
+
* user sees an ordered stream that still starts before the run ends.
|
|
40
|
+
*/
|
|
41
|
+
export function createOrderedEmitter(onReady) {
|
|
42
|
+
const pending = new Map();
|
|
43
|
+
let next = 0;
|
|
44
|
+
return {
|
|
45
|
+
settle(index, result) {
|
|
46
|
+
pending.set(index, result);
|
|
47
|
+
while (pending.has(next)) {
|
|
48
|
+
onReady(pending.get(next), next);
|
|
49
|
+
pending.delete(next);
|
|
50
|
+
next++;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -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
|
+
}
|