@xano/cli 1.3.4 → 1.3.5-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +69 -2
  2. package/dist/base-command.d.ts +12 -1
  3. package/dist/base-command.js +36 -22
  4. package/dist/commands/branch/set_live/index.d.ts +14 -0
  5. package/dist/commands/branch/set_live/index.js +38 -0
  6. package/dist/commands/ephemeral/push/index.js +6 -1
  7. package/dist/commands/function/create/index.d.ts +15 -0
  8. package/dist/commands/function/create/index.js +39 -0
  9. package/dist/commands/function/edit/index.d.ts +15 -0
  10. package/dist/commands/function/edit/index.js +39 -0
  11. package/dist/commands/function/run/index.js +5 -11
  12. package/dist/commands/policy/catalogue/index.d.ts +16 -0
  13. package/dist/commands/policy/catalogue/index.js +60 -0
  14. package/dist/commands/policy/coverage/index.d.ts +17 -0
  15. package/dist/commands/policy/coverage/index.js +33 -0
  16. package/dist/commands/policy/create/index.d.ts +18 -0
  17. package/dist/commands/policy/create/index.js +44 -0
  18. package/dist/commands/policy/delete/index.d.ts +19 -0
  19. package/dist/commands/policy/delete/index.js +61 -0
  20. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  21. package/dist/commands/policy/evaluate/index.js +71 -0
  22. package/dist/commands/policy/list/index.d.ts +14 -0
  23. package/dist/commands/policy/list/index.js +21 -0
  24. package/dist/commands/policy/parse/index.d.ts +19 -0
  25. package/dist/commands/policy/parse/index.js +18 -0
  26. package/dist/commands/policy/publish/index.d.ts +20 -0
  27. package/dist/commands/policy/publish/index.js +38 -0
  28. package/dist/commands/policy/runs/index.d.ts +54 -0
  29. package/dist/commands/policy/runs/index.js +273 -0
  30. package/dist/commands/policy/status/index.d.ts +21 -0
  31. package/dist/commands/policy/status/index.js +54 -0
  32. package/dist/commands/release/create/index.js +4 -0
  33. package/dist/commands/release/deploy/index.d.ts +19 -0
  34. package/dist/commands/release/deploy/index.js +68 -6
  35. package/dist/commands/release/import/index.js +5 -1
  36. package/dist/commands/release/list/index.d.ts +7 -0
  37. package/dist/commands/release/list/index.js +46 -9
  38. package/dist/commands/release/push/index.js +19 -1
  39. package/dist/commands/sandbox/push/index.js +6 -1
  40. package/dist/commands/skills/pull/index.d.ts +30 -0
  41. package/dist/commands/skills/pull/index.js +118 -0
  42. package/dist/commands/tenant/create/index.d.ts +3 -0
  43. package/dist/commands/tenant/create/index.js +21 -0
  44. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  45. package/dist/commands/tenant/deploy_release/index.js +182 -4
  46. package/dist/commands/tenant/edit/index.d.ts +2 -0
  47. package/dist/commands/tenant/edit/index.js +39 -4
  48. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  49. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  50. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  51. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  52. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  53. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  54. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  55. package/dist/commands/tenant_deploy_request/get/index.js +113 -0
  56. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  57. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  58. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  59. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  60. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  61. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  62. package/dist/commands/workspace/pull/index.d.ts +10 -0
  63. package/dist/commands/workspace/pull/index.js +70 -7
  64. package/dist/commands/workspace/push/index.d.ts +19 -0
  65. package/dist/commands/workspace/push/index.js +135 -6
  66. package/dist/policy-command.d.ts +91 -0
  67. package/dist/policy-command.js +95 -0
  68. package/dist/utils/document-parser.d.ts +10 -0
  69. package/dist/utils/document-parser.js +21 -0
  70. package/dist/utils/multidoc-push.d.ts +57 -2
  71. package/dist/utils/multidoc-push.js +102 -33
  72. package/dist/utils/policy/catalogue.d.ts +85 -0
  73. package/dist/utils/policy/catalogue.js +196 -0
  74. package/dist/utils/policy/coverage.d.ts +19 -0
  75. package/dist/utils/policy/coverage.js +93 -0
  76. package/dist/utils/policy/errors.d.ts +20 -0
  77. package/dist/utils/policy/errors.js +93 -0
  78. package/dist/utils/policy/feedback.d.ts +90 -0
  79. package/dist/utils/policy/feedback.js +210 -0
  80. package/dist/utils/policy/findings.d.ts +29 -0
  81. package/dist/utils/policy/findings.js +82 -0
  82. package/dist/utils/policy/gate.d.ts +138 -0
  83. package/dist/utils/policy/gate.js +193 -0
  84. package/dist/utils/policy/permission.d.ts +7 -0
  85. package/dist/utils/policy/permission.js +55 -0
  86. package/dist/utils/policy/push-gate.d.ts +5 -0
  87. package/dist/utils/policy/push-gate.js +43 -0
  88. package/dist/utils/policy/release.d.ts +85 -0
  89. package/dist/utils/policy/release.js +117 -0
  90. package/dist/utils/policy/request.d.ts +55 -0
  91. package/dist/utils/policy/request.js +119 -0
  92. package/dist/utils/policy/runs.d.ts +30 -0
  93. package/dist/utils/policy/runs.js +101 -0
  94. package/dist/utils/policy/status.d.ts +55 -0
  95. package/dist/utils/policy/status.js +127 -0
  96. package/dist/utils/policy/template.d.ts +66 -0
  97. package/dist/utils/policy/template.js +152 -0
  98. package/dist/utils/policy/types.d.ts +295 -0
  99. package/dist/utils/policy/types.js +1 -0
  100. package/oclif.manifest.json +5782 -2573
  101. package/package.json +9 -1
@@ -0,0 +1,273 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import PolicyCommand from '../../../policy-command.js';
3
+ import { quoted } from '../../../utils/policy/gate.js';
4
+ import { releasePolicyRunLines, releaseRunLines } from '../../../utils/policy/release.js';
5
+ import { listItems } from '../../../utils/policy/request.js';
6
+ import { policyRunSummary, policyRunTable } from '../../../utils/policy/runs.js';
7
+ /** A branch retains this many runs, and the run list serves at most this many. */
8
+ const RUNS_RETAINED = 20;
9
+ /** How many findings a run read prints unless `--limit` says otherwise, and the most a page holds. */
10
+ const FINDINGS_LIMIT = 100;
11
+ const FINDINGS_MAX = 500;
12
+ /** The findings route's list filters, each a repeatable flag of the same name. */
13
+ const LIST_FILTERS = ['policy', 'rule', 'severity', 'kind', 'object', 'tag'];
14
+ /** The flags that page or filter one run's findings, so a run list refuses them. */
15
+ const FINDING_FLAGS = ['offset', 'all', 'advisory', 'blocking', ...LIST_FILTERS, 'search'];
16
+ /** How many releases a lookup by name reads a request, and the most requests it makes. */
17
+ const RELEASES_PAGE = 100;
18
+ const RELEASE_PAGES = 50;
19
+ export default class PolicyRuns extends PolicyCommand {
20
+ static args = {
21
+ run_id: Args.string({
22
+ description: 'Run ID to read; omit to list the retained runs, newest first',
23
+ required: false,
24
+ }),
25
+ };
26
+ static description = "List the stored policy check runs, or read one of them a page of findings at a time. --release reads a release's check instead: the run stored when the release was cut, evaluated on what it ships against the policies it ships";
27
+ static examples = [
28
+ '$ xano policy runs',
29
+ '$ xano policy runs --limit 5',
30
+ '$ xano policy runs 1674 --run-detail',
31
+ '$ xano policy runs 1674 --offset 100 --limit 100',
32
+ '$ xano policy runs 1674 --all -o json',
33
+ '$ xano policy runs 1674 --blocking --policy AUTH-001 --severity critical --severity high',
34
+ '$ xano policy runs 1674 -o json',
35
+ '$ xano policy runs --release v1.2',
36
+ '$ xano policy runs --release v1.2 --blocking --all -o json',
37
+ '$ xano policy runs --release v1.2 --recheck',
38
+ '$ xano policy runs --release-id 12',
39
+ ];
40
+ static flags = {
41
+ ...PolicyCommand.policyFlags,
42
+ advisory: Flags.boolean({
43
+ default: false,
44
+ description: 'With a run ID, only the advisory findings',
45
+ exclusive: ['blocking'],
46
+ }),
47
+ all: Flags.boolean({
48
+ default: false,
49
+ description: 'With a run ID, read every page and print every finding that matches, instead of one page',
50
+ exclusive: ['offset', 'limit'],
51
+ }),
52
+ blocking: Flags.boolean({
53
+ default: false,
54
+ description: "With a run ID, only the blocking findings (an active blocking policy's)",
55
+ exclusive: ['advisory'],
56
+ }),
57
+ kind: Flags.string({
58
+ description: 'With a run ID, only findings on this object kind (query, function, table, ...); repeatable',
59
+ multiple: true,
60
+ }),
61
+ limit: Flags.integer({
62
+ description: `How many runs to list, newest first (1-${RUNS_RETAINED}, default ${RUNS_RETAINED}: a branch retains at most ${RUNS_RETAINED}); with a run ID, how many findings to print (1-${FINDINGS_MAX}, default ${FINDINGS_LIMIT})`,
63
+ min: 1,
64
+ }),
65
+ object: Flags.string({
66
+ description: 'With a run ID, only findings on this object, written type:id as a finding names it (query:18); repeatable',
67
+ multiple: true,
68
+ }),
69
+ offset: Flags.integer({
70
+ description: "With a run ID, how many findings to skip, in the platform's order (blocking first)",
71
+ min: 0,
72
+ }),
73
+ policy: Flags.string({
74
+ description: 'With a run ID, only findings of this policy key; repeatable',
75
+ multiple: true,
76
+ }),
77
+ recheck: Flags.boolean({
78
+ default: false,
79
+ description: "With --release, check the release again from its archive and store the new run, then read it (a read-only credential is refused)",
80
+ }),
81
+ release: Flags.string({
82
+ description: "Read this release's policy check (by release name) instead of a branch's run; the finding flags page and filter it as they do a run's",
83
+ exclusive: ['branch', 'release-id'],
84
+ }),
85
+ 'release-id': Flags.integer({
86
+ description: 'Read a release policy check by release ID, without looking up its name',
87
+ exclusive: ['branch', 'release'],
88
+ min: 1,
89
+ }),
90
+ rule: Flags.string({
91
+ description: 'With a run ID, only findings of this rule ID (AUTH-001.R1); repeatable',
92
+ multiple: true,
93
+ }),
94
+ 'run-detail': Flags.boolean({
95
+ default: false,
96
+ description: 'With a run ID, also print the policy descriptions and rule settings that run recorded',
97
+ }),
98
+ search: Flags.string({
99
+ description: 'With a run ID, only findings whose policy key or title, rule title, object name or message contains this text',
100
+ }),
101
+ severity: Flags.string({
102
+ description: 'With a run ID, only findings of this severity (critical, high, medium, low); repeatable',
103
+ multiple: true,
104
+ }),
105
+ tag: Flags.string({
106
+ description: 'With a run ID, only findings whose policy or object carries this tag; repeatable',
107
+ multiple: true,
108
+ }),
109
+ };
110
+ async run() {
111
+ const { args, flags } = await this.parse(PolicyRuns);
112
+ if (flags.release !== undefined || flags['release-id'] !== undefined)
113
+ return this.readRelease(flags, args.run_id);
114
+ if (flags.recheck)
115
+ this.error('--recheck checks a release again: `xano policy runs --release <name> --recheck`.');
116
+ const { request } = this.policyTarget(flags);
117
+ const wanted = args.run_id?.trim() ?? '';
118
+ if (wanted === '')
119
+ return this.listRuns(request, flags);
120
+ // Runs older than the one `policy status` reads are reachable here by id.
121
+ if (!/^\d+$/.test(wanted))
122
+ this.error(`"${wanted}" is not a run ID. Run \`xano policy runs\` for the retained runs.`);
123
+ const limit = findingsLimit(this, flags);
124
+ // The run's summary and its findings a page at a time: neither reads the whole run, which can hold tens of thousands.
125
+ const head = (await request(`/run/${wanted}/summary`));
126
+ await this.reportRun({ flags, head, limit, path: `/run/${wanted}/findings`, reading: `run ${wanted}`, request });
127
+ }
128
+ /**
129
+ * `--all`: every finding that matches, read a page of 500 at a time in the platform's order, as
130
+ * one page. A run of many pages says on stderr what it is reading.
131
+ */
132
+ async everyFinding(request, path, reading, query) {
133
+ const items = [];
134
+ let total = Number.POSITIVE_INFINITY;
135
+ while (items.length < total) {
136
+ // eslint-disable-next-line no-await-in-loop -- one page at a time, each starting where the last ended
137
+ const page = (await request(path, 'GET', undefined, { ...query, limit: String(FINDINGS_MAX), offset: String(items.length) }));
138
+ if (items.length === 0 && page.total > FINDINGS_MAX)
139
+ this.logToStderr(`Reading ${page.total} findings of ${reading}, ${FINDINGS_MAX} a request (${Math.ceil(page.total / FINDINGS_MAX)} requests)...`);
140
+ total = page.total ?? 0;
141
+ if ((page.items ?? []).length === 0)
142
+ break;
143
+ items.push(...page.items);
144
+ }
145
+ return { items, limit: items.length, offset: 0, total: Number.isFinite(total) ? total : items.length };
146
+ }
147
+ /** The retained runs, newest first; the flags that read one run's findings are refused. */
148
+ async listRuns(request, flags) {
149
+ if (flags['run-detail'])
150
+ this.error('--run-detail reads one run: `xano policy runs <id> --run-detail`.');
151
+ const paging = FINDING_FLAGS.find(name => flags[name] !== undefined && flags[name] !== false);
152
+ if (paging)
153
+ this.error(`--${paging} pages one run's findings: \`xano policy runs <id> --${paging} ...\`.`);
154
+ const limit = flags.limit ?? RUNS_RETAINED;
155
+ if (limit > RUNS_RETAINED)
156
+ this.error(`--limit lists at most ${RUNS_RETAINED} runs: a branch retains only its newest ${RUNS_RETAINED}.`);
157
+ const result = await request('/run', 'GET', undefined, { limit: String(limit) });
158
+ if (flags.output === 'json') {
159
+ this.log(JSON.stringify(result, null, 2));
160
+ return;
161
+ }
162
+ const runs = listItems(result);
163
+ if (runs.length === 0) {
164
+ this.log('No policy runs retained on this branch.');
165
+ return;
166
+ }
167
+ for (const line of policyRunTable(runs))
168
+ this.log(line);
169
+ this.log('Only the most recent runs are retained per branch. Read one with `xano policy runs <id> --run-detail`.');
170
+ }
171
+ /**
172
+ * `--release <name>`: the release's stored check (`GET release/{id}/policy_run`) and a page of its
173
+ * findings, as a run is read. `--recheck` checks the release again from its archive first. A
174
+ * release with no check says so; under `-o json` it is `{run: null}`.
175
+ */
176
+ async readRelease(flags, runId) {
177
+ if (runId?.trim())
178
+ this.error('Read one run by its ID, or a release\'s check with --release, not both.');
179
+ const name = flags.release?.trim() ?? String(flags['release-id'] ?? '');
180
+ if (!name)
181
+ this.error('--release needs a release name.');
182
+ const limit = findingsLimit(this, flags);
183
+ const { request } = this.policyTarget(flags, { label: 'Release policy check', path: '' });
184
+ const releaseId = flags['release-id'] ?? await this.releaseId(request, name);
185
+ const route = `/release/${releaseId}/policy_run`;
186
+ const answer = (flags.recheck ? await request(route, 'POST', {}) : await request(route));
187
+ const check = flags.recheck ? { check: answer.check ?? null } : {};
188
+ // The recheck's outcome in a line: recorded with its verdict, or why nothing was recorded.
189
+ const rechecked = flags.recheck ? releasePolicyRunLines(answer.check, name).slice(0, 1).map(line => `Rechecked. ${line.trim()}`) : [];
190
+ const head = answer.run ?? null;
191
+ if (!head) {
192
+ if (flags.output === 'json') {
193
+ this.log(JSON.stringify({ ...check, run: null }, null, 2));
194
+ return;
195
+ }
196
+ for (const line of rechecked)
197
+ this.log(line);
198
+ this.log(`Release ${name} has no policy check.${flags.recheck ? '' : ' `--recheck` checks it now from its archive.'}`);
199
+ return;
200
+ }
201
+ await this.reportRun({
202
+ check,
203
+ flags,
204
+ head,
205
+ limit,
206
+ next: flags['release-id'] ? `xano policy runs --release-id ${releaseId}` : `xano policy runs --release ${quoted(name)}`,
207
+ path: `${route}/findings`,
208
+ preface: [...rechecked, ...releaseRunLines(name, releaseId, head)],
209
+ reading: `release ${name}`,
210
+ request,
211
+ });
212
+ }
213
+ /** The release's ID from its name, reading the workspace's releases a page at a time until it is found. */
214
+ async releaseId(request, name) {
215
+ for (let page = 1; page <= RELEASE_PAGES; page++) {
216
+ // eslint-disable-next-line no-await-in-loop -- pages in order, stopping at the release
217
+ const data = await request('/release', 'GET', undefined, { page: String(page), per_page: String(RELEASES_PAGE) });
218
+ const releases = Array.isArray(data) ? data : listItems(data);
219
+ const match = releases.find(release => release.name === name);
220
+ if (match)
221
+ return match.id;
222
+ if (releases.length < RELEASES_PAGE)
223
+ break;
224
+ }
225
+ return this.error(`Release '${name}' not found. \`xano release list\` lists the workspace's releases.`);
226
+ }
227
+ /**
228
+ * One run's summary and a page of its findings (`path`), or under `-o json` the summary with
229
+ * `findings` and `findings_page`, plus what `extra` adds to it.
230
+ */
231
+ async reportRun(opts) {
232
+ const { flags, head, limit, path, request } = opts;
233
+ const query = findingsQuery(flags, limit);
234
+ const page = flags.all
235
+ ? await this.everyFinding(request, path, opts.reading, query)
236
+ : (await request(path, 'GET', undefined, query));
237
+ const items = page.items ?? [];
238
+ const offset = page.offset ?? 0;
239
+ const total = page.total ?? items.length;
240
+ if (flags.output === 'json') {
241
+ const next = offset + items.length < total && items.length > 0 ? offset + items.length : null;
242
+ const findingsPage = { limit: page.limit ?? limit, next_offset: next, offset, total };
243
+ this.log(JSON.stringify({ ...head, ...opts.check, findings: items, findings_page: findingsPage }, null, 2));
244
+ return;
245
+ }
246
+ for (const line of opts.preface ?? [])
247
+ this.log(line);
248
+ const filtered = Object.keys(query).some(name => !['limit', 'offset'].includes(name));
249
+ for (const line of policyRunSummary({ ...head, findings: items }, { filtered, next: opts.next, offset, total }))
250
+ this.log(line);
251
+ if (flags['run-detail'])
252
+ this.logRunDetail(head);
253
+ }
254
+ }
255
+ /** How many findings a page prints: `--limit`, at most 500. */
256
+ function findingsLimit(command, flags) {
257
+ const limit = flags.limit ?? FINDINGS_LIMIT;
258
+ if (limit > FINDINGS_MAX)
259
+ command.error(`--limit prints at most ${FINDINGS_MAX} findings a page; page on with --offset.`);
260
+ return limit;
261
+ }
262
+ /** The findings route's query for one page: `blocking` true or false, and each list filter given. */
263
+ function findingsQuery(flags, limit) {
264
+ const query = { limit: String(limit), offset: String(flags.offset ?? 0) };
265
+ if (flags.blocking || flags.advisory)
266
+ query.blocking = String(flags.blocking);
267
+ for (const name of LIST_FILTERS)
268
+ if (flags[name]?.length)
269
+ query[name] = flags[name];
270
+ if (flags.search?.trim())
271
+ query.q = flags.search;
272
+ return query;
273
+ }
@@ -0,0 +1,21 @@
1
+ import PolicyCommand from '../../../policy-command.js';
2
+ export default class PolicyStatus extends PolicyCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'fail-on-findings': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ 'run-detail': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ };
15
+ run(): Promise<void>;
16
+ /**
17
+ * The run every policy's `latest_run` answer was decided against, or none when the branch has no
18
+ * run: its summary, which counts each policy's findings without listing them.
19
+ */
20
+ private latestRun;
21
+ }
@@ -0,0 +1,54 @@
1
+ import { Flags } from '@oclif/core';
2
+ import PolicyCommand from '../../../policy-command.js';
3
+ import { policyResultSummary } from '../../../utils/policy/findings.js';
4
+ import { listAllPolicies } from '../../../utils/policy/request.js';
5
+ import { computeStatusRows, enforcementColumn, findingsLabel, statusExitCode, statusExitReason, statusLabel, } from '../../../utils/policy/status.js';
6
+ export default class PolicyStatus extends PolicyCommand {
7
+ static description = 'Show the latest policy check results without evaluating';
8
+ static examples = ['$ xano policy status -o json', '$ xano policy status --fail-on-findings -o json', '$ xano policy status --run-detail'];
9
+ static flags = {
10
+ ...PolicyCommand.policyFlags,
11
+ 'fail-on-findings': Flags.boolean({
12
+ default: false,
13
+ description: 'Exit 2 for current blocking findings, whatever else is true; otherwise exit 1 for stale, missing or errored evaluation evidence',
14
+ }),
15
+ 'run-detail': Flags.boolean({
16
+ default: false,
17
+ description: 'Also print what the latest run recorded: each policy description and the settings each rule ran with',
18
+ }),
19
+ };
20
+ async run() {
21
+ const { flags } = await this.parse(PolicyStatus);
22
+ const { request } = this.policyTarget(flags);
23
+ const policies = await listAllPolicies(request);
24
+ const run = await this.latestRun(request, policies);
25
+ const rows = computeStatusRows(policies, run);
26
+ const failOnFindings = flags['fail-on-findings'] ? { exit: statusExitCode(rows), reason: statusExitReason(rows) } : undefined;
27
+ if (flags.output === 'json') {
28
+ this.log(JSON.stringify({ policies, run: run ?? null, status: rows, ...(failOnFindings ? { fail_on_findings: failOnFindings } : {}) }, null, 2));
29
+ }
30
+ else if (rows.length === 0)
31
+ this.log('No policies found.');
32
+ else {
33
+ for (const row of rows)
34
+ this.log(`${row.key} ${statusLabel(row)} ${enforcementColumn(row)} ${findingsLabel(row)} ${row.title ?? ''}`);
35
+ const counted = new Set(rows.filter((row) => row.counted).map((row) => row.key));
36
+ for (const line of policyResultSummary(run?.results?.filter((result) => counted.has(result.policy_key ?? ''))))
37
+ this.log(line);
38
+ if (flags['run-detail'] && run)
39
+ this.logRunDetail(run);
40
+ if (failOnFindings?.reason)
41
+ this.log(failOnFindings.reason);
42
+ }
43
+ if (failOnFindings?.exit)
44
+ process.exitCode = failOnFindings.exit;
45
+ }
46
+ /**
47
+ * The run every policy's `latest_run` answer was decided against, or none when the branch has no
48
+ * run: its summary, which counts each policy's findings without listing them.
49
+ */
50
+ async latestRun(request, policies) {
51
+ const runId = policies.map((policy) => policy.latest_run?.run_id).find(Boolean);
52
+ return runId ? (await request(`/run/${runId}/summary`)) : undefined;
53
+ }
54
+ }
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { releasePolicyRunLines } from '../../../utils/policy/release.js';
3
4
  export default class ReleaseCreate extends BaseCommand {
4
5
  static args = {
5
6
  name: Args.string({
@@ -11,6 +12,7 @@ export default class ReleaseCreate extends BaseCommand {
11
12
  static examples = [
12
13
  `$ xano release create "v1.0" --branch main
13
14
  Created release: v1.0 - ID: 10
15
+ Branch: main
14
16
  `,
15
17
  `$ xano release create "v1.1-hotfix" --branch main --hotfix --description "Critical fix" -o json`,
16
18
  ];
@@ -90,6 +92,8 @@ Created release: v1.0 - ID: 10
90
92
  this.log(` Branch: ${release.branch}`);
91
93
  if (release.hotfix)
92
94
  this.log(` Hotfix: true`);
95
+ for (const line of releasePolicyRunLines(release.policy_run, release.name ?? args.name))
96
+ this.log(line);
93
97
  }
94
98
  }
95
99
  catch (error) {
@@ -9,12 +9,31 @@ export default class ReleaseDeploy extends BaseCommand {
9
9
  branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
10
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  set_live: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
16
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
17
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  };
19
+ /** Set once the set-live policy gate refuses, the one failure that exits 2. */
20
+ private refusedByPolicyGate;
21
+ /** Exit 2 is the set-live gate's refusal; every other failure, flag errors included, exits 1. */
22
+ protected catch(error: Error & {
23
+ oclif?: {
24
+ exit?: number;
25
+ };
26
+ }): Promise<void>;
18
27
  run(): Promise<void>;
19
28
  private confirm;
29
+ /** The set-live verdict a successful deploy answered with --set_live (`pass`, `overridden`, ...). */
30
+ private logPolicyOutcome;
31
+ /** Stops the command when the set-live gate refused after the branch was created (exit 2). */
32
+ private refuseIfSetLiveRefused;
33
+ /**
34
+ * The set-live gate refused after the branch was created: the verdict, the refusal, and how to
35
+ * proceed, exiting 2. Under `-o json` the refusal is `{branch_created, set_live, branch, message,
36
+ * policy_gate}`.
37
+ */
38
+ private refuseSetLive;
20
39
  }
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { BLANK_POLICY_OVERRIDE, blankPolicyOverride, gateOverrideHint, gateRefusal, liveGateLines, quoted, } from '../../../utils/policy/gate.js';
3
4
  export default class ReleaseDeploy extends BaseCommand {
4
5
  static args = {
5
6
  release_name: Args.string({
@@ -7,7 +8,7 @@ export default class ReleaseDeploy extends BaseCommand {
7
8
  required: true,
8
9
  }),
9
10
  };
10
- static description = '[IMPORTANT] ALWAYS confirm with the user before deploying a release. Deploys a release to its workspace as a new branch.';
11
+ static description = "[IMPORTANT] ALWAYS confirm with the user before deploying a release. Deploys a release to its workspace as a new branch.";
11
12
  static examples = [
12
13
  `$ xano release deploy "v1.0"
13
14
  Are you sure you want to deploy release "v1.0"? (y/N) y
@@ -39,6 +40,7 @@ Deployed release "v1.0" to workspace 40 (branch: v1.0)
39
40
  options: ['summary', 'json'],
40
41
  required: false,
41
42
  }),
43
+ 'policy-override': Flags.string({ description: 'With --set_live, set the branch live past a blocking set-live policy gate with an audited reason (requires workspace:policy update)', hidden: true }),
42
44
  set_live: Flags.boolean({
43
45
  default: false,
44
46
  description: '[CRITICAL] STOP and confirm with the user before setting the deployed branch as live.',
@@ -50,8 +52,18 @@ Deployed release "v1.0" to workspace 40 (branch: v1.0)
50
52
  required: false,
51
53
  }),
52
54
  };
55
+ /** Set once the set-live policy gate refuses, the one failure that exits 2. */
56
+ refusedByPolicyGate = false;
57
+ /** Exit 2 is the set-live gate's refusal; every other failure, flag errors included, exits 1. */
58
+ async catch(error) {
59
+ if (this.refusedByPolicyGate)
60
+ return super.catch(error);
61
+ return this.catchAsOperational(error);
62
+ }
53
63
  async run() {
54
64
  const { args, flags } = await this.parse(ReleaseDeploy);
65
+ if (blankPolicyOverride(flags['policy-override']))
66
+ this.error(BLANK_POLICY_OVERRIDE);
55
67
  const { profile } = this.resolveProfile(flags);
56
68
  const workspaceId = flags.workspace || profile.workspace;
57
69
  if (!workspaceId) {
@@ -59,11 +71,10 @@ Deployed release "v1.0" to workspace 40 (branch: v1.0)
59
71
  }
60
72
  const releaseName = encodeURIComponent(args.release_name);
61
73
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/release/${releaseName}/deploy`;
62
- const body = {
63
- set_live: flags.set_live,
64
- };
65
- if (flags.branch)
66
- body.branch = flags.branch;
74
+ const body = deployBody(flags);
75
+ if (body.override_reason && !flags.set_live) {
76
+ this.error('--policy-override only applies with --set_live: a branch that is not set live is not gated.');
77
+ }
67
78
  if (!flags.force) {
68
79
  const confirmed = await this.confirm(`Are you sure you want to deploy release "${args.release_name}" to workspace ${workspaceId}?`);
69
80
  if (!confirmed) {
@@ -84,6 +95,7 @@ Deployed release "v1.0" to workspace 40 (branch: v1.0)
84
95
  method: 'POST',
85
96
  }, flags.verbose, profile.access_token);
86
97
  if (!response.ok) {
98
+ await this.refuseIfSetLiveRefused(response, flags, args.release_name);
87
99
  const errorText = await response.text();
88
100
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
89
101
  }
@@ -98,10 +110,17 @@ Deployed release "v1.0" to workspace 40 (branch: v1.0)
98
110
  this.log(`Deployed release "${release.name}" to workspace ${workspaceId} (branch: ${branchLabel}${liveStatus})`);
99
111
  if (release.description)
100
112
  this.log(` Description: ${release.description}`);
113
+ this.logPolicyOutcome(release);
101
114
  this.log(` Time: ${elapsed}s`);
102
115
  }
116
+ // A policy of the release the new branch could not take is never dropped silently.
117
+ const skipped = release.policies_skipped?.message;
118
+ if (skipped)
119
+ this.warn(skipped);
103
120
  }
104
121
  catch (error) {
122
+ if (this.refusedByPolicyGate)
123
+ throw error;
105
124
  if (error instanceof Error) {
106
125
  this.error(`Failed to deploy release: ${error.message}`);
107
126
  }
@@ -123,4 +142,47 @@ Deployed release "v1.0" to workspace 40 (branch: v1.0)
123
142
  });
124
143
  });
125
144
  }
145
+ /** The set-live verdict a successful deploy answered with --set_live (`pass`, `overridden`, ...). */
146
+ logPolicyOutcome(release) {
147
+ const status = release.policy_gate?.status;
148
+ if (status && status !== 'disabled')
149
+ this.log(` Policy gate: ${status}`);
150
+ }
151
+ /** Stops the command when the set-live gate refused after the branch was created (exit 2). */
152
+ async refuseIfSetLiveRefused(response, flags, releaseName) {
153
+ const refused = await gateRefusal(response);
154
+ if (refused)
155
+ this.refuseSetLive(refused, flags, releaseName);
156
+ }
157
+ /**
158
+ * The set-live gate refused after the branch was created: the verdict, the refusal, and how to
159
+ * proceed, exiting 2. Under `-o json` the refusal is `{branch_created, set_live, branch, message,
160
+ * policy_gate}`.
161
+ */
162
+ refuseSetLive(refused, flags, releaseName) {
163
+ const answer = refused.answer;
164
+ const label = answer.branch?.label || flags.branch || 'the release branch';
165
+ if (flags.output === 'json') {
166
+ this.log(JSON.stringify({ branch: answer.branch ?? null, branch_created: true, message: refused.message, policy_gate: answer, set_live: false }, null, 2));
167
+ }
168
+ else {
169
+ for (const line of liveGateLines(answer, 'release'))
170
+ this.log(line);
171
+ }
172
+ const workspace = flags.workspace ? ` -w ${quoted(flags.workspace)}` : '';
173
+ const rerun = `xano branch delete ${quoted(label)}${workspace} && xano release deploy ${quoted(releaseName)}${workspace}${flags.branch ? ` --branch ${quoted(flags.branch)}` : ''} --set_live --policy-override "<why>"`;
174
+ const hint = gateOverrideHint(answer, rerun);
175
+ this.refusedByPolicyGate = true;
176
+ this.error(`The branch was created; set live was refused. ${refused.message}${hint}${answer.can_override && !answer.override_denied ? `\nOr set "${label}" live from Studio's Branches panel, which asks for the reason.` : ''}`, { exit: 2 });
177
+ }
178
+ }
179
+ /** The deploy request's body: the new branch's label, set_live, and the override reason when one is given. */
180
+ function deployBody(flags) {
181
+ const body = { set_live: flags.set_live };
182
+ if (flags.branch)
183
+ body.branch = flags.branch;
184
+ const overrideReason = flags['policy-override']?.trim();
185
+ if (overrideReason)
186
+ body.override_reason = overrideReason;
187
+ return body;
126
188
  }
@@ -2,6 +2,7 @@ import { Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import BaseCommand from '../../../base-command.js';
5
+ import { releaseImportRefusal, releasePolicyRunLines } from '../../../utils/policy/release.js';
5
6
  export default class ReleaseImport extends BaseCommand {
6
7
  static args = {};
7
8
  static description = '[IMPORTANT] ALWAYS confirm with the user before importing a release. Imports a release file into a workspace.';
@@ -58,7 +59,8 @@ Imported release as #15
58
59
  }, flags.verbose, profile.access_token);
59
60
  if (!response.ok) {
60
61
  const errorText = await response.text();
61
- this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
62
+ this.error(releaseImportRefusal(errorText, response.status, apiUrl) ??
63
+ `API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
62
64
  }
63
65
  const result = await response.json();
64
66
  if (flags.output === 'json') {
@@ -67,6 +69,8 @@ Imported release as #15
67
69
  else {
68
70
  const sizeMb = (fileBuffer.length / 1024 / 1024).toFixed(2);
69
71
  this.log(`Imported release as #${result.id} (${sizeMb} MB)`);
72
+ for (const line of releasePolicyRunLines(result.policy_run, result.name, result.id))
73
+ this.log(line);
70
74
  }
71
75
  }
72
76
  catch (error) {
@@ -10,4 +10,11 @@ export default class ReleaseList extends BaseCommand {
10
10
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  };
12
12
  run(): Promise<void>;
13
+ /**
14
+ * The releases' stored policy checks, read once for the whole list. The list never fails or
15
+ * changes because of this read: it answers `null` when the checks cannot be read (the Policies
16
+ * feature is off, the credential lacks `workspace:policy` read, or the request failed), and the
17
+ * list then prints as it would without policies.
18
+ */
19
+ private policyChecks;
13
20
  }
@@ -1,6 +1,7 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
3
  import { buildPagingJson } from '../../../utils/paging.js';
4
+ import { readReleasePolicyChecks, releasePolicyCheckTag } from '../../../utils/policy/release.js';
4
5
  export default class ReleaseList extends BaseCommand {
5
6
  static description = 'List all releases in a workspace';
6
7
  static examples = [
@@ -57,22 +58,19 @@ Releases in workspace 5:
57
58
  else {
58
59
  this.error('Unexpected API response format');
59
60
  }
61
+ const checks = await this.policyChecks(profile, String(workspaceId), releases, flags.verbose);
60
62
  if (flags.output === 'json') {
61
- this.log(JSON.stringify(buildPagingJson({ items: releases }, { tier: 'none' }), null, 2));
63
+ this.log(JSON.stringify(buildPagingJson({ items: withPolicyChecks(releases, checks) }, { tier: 'none' }), null, 2));
62
64
  }
63
65
  else if (releases.length === 0) {
64
66
  this.log('No releases found');
65
67
  }
66
68
  else {
67
69
  this.log(`Releases in workspace ${workspaceId}:`);
68
- for (const release of releases) {
69
- const branch = release.branch ? ` - ${release.branch}` : '';
70
- const hotfix = release.hotfix ? ' [hotfix]' : '';
71
- const createdAt = release.created_at
72
- ? ` (${new Date(release.created_at).toLocaleString(undefined, { timeZoneName: 'short' })})`
73
- : '';
74
- this.log(` - ${release.name} (ID: ${release.id})${branch}${hotfix}${createdAt}`);
75
- }
70
+ for (const release of releases)
71
+ this.log(releaseLine(release, checks));
72
+ if (checks)
73
+ this.log('Policy check details: xano policy runs --release <name>');
76
74
  }
77
75
  }
78
76
  catch (error) {
@@ -84,4 +82,43 @@ Releases in workspace 5:
84
82
  }
85
83
  }
86
84
  }
85
+ /**
86
+ * The releases' stored policy checks, read once for the whole list. The list never fails or
87
+ * changes because of this read: it answers `null` when the checks cannot be read (the Policies
88
+ * feature is off, the credential lacks `workspace:policy` read, or the request failed), and the
89
+ * list then prints as it would without policies.
90
+ */
91
+ async policyChecks(profile, workspace, releases, verbose) {
92
+ if (releases.length === 0)
93
+ return null;
94
+ return readReleasePolicyChecks({ logToStderr: (message) => this.logToStderr(message), verboseFetch: (...args) => this.verboseFetch(...args) }, { branch: '', profile, verbose, workspace }, releases.map(release => release.id));
95
+ }
96
+ }
97
+ /** A release's summary line, tagged with its stored check when the checks were read. */
98
+ function releaseLine(release, checks) {
99
+ const branch = release.branch ? ` - ${release.branch}` : '';
100
+ const hotfix = release.hotfix ? ' [hotfix]' : '';
101
+ const createdAt = release.created_at
102
+ ? ` (${new Date(release.created_at).toLocaleString(undefined, { timeZoneName: 'short' })})`
103
+ : '';
104
+ const policies = checks ? ` ${releasePolicyCheckTag(checks.get(release.id))}` : '';
105
+ return ` - ${release.name} (ID: ${release.id})${branch}${hotfix}${createdAt}${policies}`;
106
+ }
107
+ /**
108
+ * The releases for `-o json`: each gains `policy_check` (its stored check as the platform lists
109
+ * it, without the release id it is keyed by, or `null` for a release with no stored check) when
110
+ * the checks were read, and stays as served when they could not be, so a reader can tell an
111
+ * unchecked release (`null`) from checks the CLI could not read (absent).
112
+ */
113
+ function withPolicyChecks(releases, checks) {
114
+ if (!checks)
115
+ return releases;
116
+ return releases.map(release => {
117
+ const check = checks.get(release.id);
118
+ if (!check)
119
+ return { ...release, policy_check: null };
120
+ const stored = { ...check };
121
+ delete stored.release_id;
122
+ return { ...release, policy_check: stored };
123
+ });
87
124
  }