@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.
Files changed (55) hide show
  1. package/README.md +95 -4
  2. package/dist/commands/branch/list/index.js +5 -1
  3. package/dist/commands/ephemeral/list/index.js +2 -1
  4. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
  5. package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
  6. package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
  7. package/dist/commands/ephemeral/static_host/list/index.js +20 -40
  8. package/dist/commands/function/list/index.d.ts +4 -4
  9. package/dist/commands/function/list/index.js +30 -38
  10. package/dist/commands/knowledge/list/index.js +2 -1
  11. package/dist/commands/platform/list/index.js +13 -14
  12. package/dist/commands/release/list/index.js +13 -14
  13. package/dist/commands/sandbox/env/list/index.js +4 -0
  14. package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
  15. package/dist/commands/sandbox/unit_test/list/index.js +9 -14
  16. package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
  17. package/dist/commands/sandbox/unit_test/run_all/index.js +67 -37
  18. package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
  19. package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
  20. package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
  21. package/dist/commands/sandbox/workflow_test/run_all/index.js +65 -35
  22. package/dist/commands/static_host/build/list/index.d.ts +2 -3
  23. package/dist/commands/static_host/build/list/index.js +19 -40
  24. package/dist/commands/static_host/build/pull/index.js +12 -9
  25. package/dist/commands/static_host/list/index.d.ts +2 -3
  26. package/dist/commands/static_host/list/index.js +20 -40
  27. package/dist/commands/static_host/migrate/index.js +14 -10
  28. package/dist/commands/tenant/backup/list/index.js +13 -14
  29. package/dist/commands/tenant/cluster/list/index.js +9 -10
  30. package/dist/commands/tenant/env/list/index.js +4 -0
  31. package/dist/commands/tenant/list/index.js +19 -20
  32. package/dist/commands/tenant/snapshot/list/index.js +5 -1
  33. package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
  34. package/dist/commands/tenant/unit_test/list/index.js +9 -14
  35. package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
  36. package/dist/commands/tenant/unit_test/run_all/index.js +67 -37
  37. package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
  38. package/dist/commands/tenant/workflow_test/list/index.js +9 -14
  39. package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
  40. package/dist/commands/tenant/workflow_test/run_all/index.js +65 -35
  41. package/dist/commands/unit_test/list/index.d.ts +3 -1
  42. package/dist/commands/unit_test/list/index.js +9 -14
  43. package/dist/commands/unit_test/run_all/index.d.ts +1 -0
  44. package/dist/commands/unit_test/run_all/index.js +68 -40
  45. package/dist/commands/workflow_test/list/index.d.ts +3 -1
  46. package/dist/commands/workflow_test/list/index.js +10 -13
  47. package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
  48. package/dist/commands/workflow_test/run_all/index.js +67 -36
  49. package/dist/commands/workspace/list/index.js +7 -13
  50. package/dist/utils/concurrency.d.ts +29 -0
  51. package/dist/utils/concurrency.js +54 -0
  52. package/dist/utils/paging.d.ts +141 -0
  53. package/dist/utils/paging.js +194 -0
  54. package/oclif.manifest.json +3017 -2891
  55. 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
- const listParams = new URLSearchParams({ include_xanoscript: 'false' });
49
- if (flags.branch) {
50
- listParams.set('branch', flags.branch);
51
- }
52
- const listResponse = await this.verboseFetch(`${baseUrl}?${listParams}`, {
53
- headers: {
54
- accept: 'application/json',
55
- Authorization: `Bearer ${profile.access_token}`,
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
- method: 'GET',
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
- for (const test of tests) {
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
- this.log(`FAIL ${test.name} (0.000s)`);
106
- this.log(` Error: API error ${runResponse.status}`);
123
+ log(`FAIL ${test.name} (0.000s)`);
124
+ log(` Error: API error ${runResponse.status}`);
107
125
  }
108
- continue;
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
- this.log(`PASS ${test.name} ${timing}`);
141
+ log(`PASS ${test.name} ${timing}`);
124
142
  }
125
143
  else {
126
- this.log(`FAIL ${test.name} ${timing}`);
144
+ log(`FAIL ${test.name} ${timing}`);
127
145
  if (runResult.message) {
128
- this.log(` Error: ${runResult.message}`);
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
- this.log(`FAIL ${test.name} (0.000s)`);
143
- this.log(` Error: ${message}`);
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, failed, total_timing: totalTiming, results }, null, 2));
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 data = (await response.json());
75
- // Handle different response formats
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(workspaces, null, 2));
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
+ }