@xano/cli 1.3.4 → 1.3.5-beta.1

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 (105) hide show
  1. package/README.md +70 -3
  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/{tenant/snapshot/create → policy/coverage}/index.d.ts +4 -4
  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/{tenant/snapshot → policy}/delete/index.d.ts +5 -5
  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/{tenant/snapshot → policy}/list/index.d.ts +3 -5
  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/snapshot/swap → tenant_deploy_request/revision}/index.d.ts +8 -6
  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 +6829 -4006
  101. package/package.json +9 -1
  102. package/dist/commands/tenant/snapshot/create/index.js +0 -78
  103. package/dist/commands/tenant/snapshot/delete/index.js +0 -102
  104. package/dist/commands/tenant/snapshot/list/index.js +0 -100
  105. package/dist/commands/tenant/snapshot/swap/index.js +0 -103
package/README.md CHANGED
@@ -47,7 +47,7 @@ The Metadata API pages unevenly, so `list` commands differ in what they expose.
47
47
  |---|---|---|
48
48
  | `function list`, `unit_test list`, `workflow_test list` (plus `sandbox` / `tenant` variants) | `--page`, `--per_page` | `Page 2 · 50 shown · next: --page 3` |
49
49
  | `static_host list`, `static_host build list` (plus `ephemeral` variants) | `--page` only | `Page 2 · 100 shown · 340 total` |
50
- | `branch list`, `workspace list`, `tenant snapshot list`, `sandbox env list`, `tenant env list` | none | `12 branches` |
50
+ | `branch list`, `workspace list`, `sandbox env list`, `tenant env list` | none | `12 branches` |
51
51
  | `tenant list`, `release list`, `platform list`, `tenant cluster list`, `ephemeral list` | none | none |
52
52
  | `tenant backup list` | `--page` only (pre-existing) | none |
53
53
 
@@ -197,6 +197,7 @@ xano workspace delete -w <workspace_id> --force
197
197
  xano workspace pull
198
198
  xano workspace pull -d ./my-workspace # Specify output directory
199
199
  xano workspace pull -b dev # Specific branch
200
+ xano workspace pull -b '' # The live branch, whatever the profile's branch
200
201
  xano workspace pull --env --records # Include env vars and table records
201
202
  xano workspace pull --draft # Include draft changes
202
203
  xano workspace pull --sync-state # Write .xano/sync_state.json; later pulls keep it current
@@ -205,6 +206,7 @@ xano workspace pull --sync-state # Write .xano/sync_stat
205
206
  xano workspace push
206
207
  xano workspace push -d ./my-workspace # Push from a specific directory
207
208
  xano workspace push -b dev
209
+ xano workspace push -b '' # The live branch, whatever the profile's branch
208
210
  xano workspace push --sync # Full push — send all files, not just changed ones
209
211
  xano workspace push --sync --delete # Full push + delete remote objects not included
210
212
  xano workspace push --dry-run # Preview changes without pushing
@@ -222,6 +224,7 @@ xano workspace push --force # Skip preview and conf
222
224
  xano workspace push -i "function/*" # Push only matching files
223
225
  xano workspace push -e "table/*" # Push all files except tables
224
226
  xano workspace push -i "function/*" -e "**/test*" # Include functions, exclude tests
227
+ xano workspace push -o json # One JSON document: the preview (with --dry-run) or the import result
225
228
 
226
229
  # Pull from a git repository to local files (defaults to current directory)
227
230
  xano workspace git pull -r https://github.com/owner/repo
@@ -431,8 +434,8 @@ Input flexibility for `function run` (assembled into one JSON `input` object):
431
434
  to run against `live`; an unknown label is rejected by the server with
432
435
  `Invalid data source.`
433
436
 
434
- `-t/--tenant <name>` runs the function on that tenant instead of the workspace by sending
435
- the `X-Tenant` header (also used when reading the function's declared inputs).
437
+ `-t/--tenant <name>` runs the function on that tenant
438
+ (`/workspace/{id}/tenant/{name}/function/run`) instead of the workspace.
436
439
 
437
440
  Merge order is JSON base first, then `--data` overrides. Missing required inputs are
438
441
  prompted for on an interactive terminal; in a non-TTY (CI) context the command fails
@@ -654,9 +657,14 @@ xano tenant get <tenant_name>
654
657
  xano tenant create "My Tenant"
655
658
  xano tenant create "My Tenant" -d "Description" --type tier2 --cluster_id 1 --platform_id 5
656
659
  xano tenant create "My Tenant" --type tier2 --cluster_id 1 --license ./license.yaml
660
+ xano tenant create "Production" --required_reviewers 1 --allow_deploy_bypass
661
+ xano tenant create "Staging" --required_reviewers 0 --allow_quick_deploy
657
662
 
658
663
  # Edit a tenant
659
664
  xano tenant edit <tenant_name> --display "New Name" -d "New description"
665
+ xano tenant edit <tenant_name> --required_reviewers 1
666
+ xano tenant edit <tenant_name> --required_reviewers 0
667
+ xano tenant edit <tenant_name> --allow_quick_deploy
660
668
 
661
669
  # Delete a tenant (confirmation required)
662
670
  xano tenant delete <tenant_name>
@@ -707,6 +715,65 @@ xano tenant deploy_release <tenant_name> --release v1.0
707
715
  xano tenant deploy_platform <tenant_name> --platform_id 5 --license ./license.yaml
708
716
  ```
709
717
 
718
+ If a tenant has `required_reviewers` set above 0, `tenant deploy_release` fails with an
719
+ error naming the `tenant_deploy_request` command to run instead — see below. A tenant
720
+ with `allow_quick_deploy` enabled skips the gate entirely, regardless of
721
+ `required_reviewers`.
722
+
723
+ #### Tenant Deploy Requests
724
+
725
+ When a tenant has `required_reviewers` set above 0 and `allow_quick_deploy` is off (see
726
+ `tenant create`/`tenant edit` above), a release may only be deployed to it through an
727
+ **approved deploy request**, requiring that many distinct reviewer approvals. An author
728
+ with no deploy permission opens a request; a named reviewer who does hold deploy
729
+ permission on the tenant votes to approve it, and once enough reviewers have approved,
730
+ it deploys as part of that action. `allow_deploy_bypass` (set at `tenant create` time
731
+ only) additionally allows an authorized user to skip a specific request's gate via
732
+ `tenant_deploy_request bypass`, audited with a required reason.
733
+
734
+ ```bash
735
+ # List deploy requests in a workspace (results show the target tenant and release)
736
+ xano tenant_deploy_request list
737
+ xano tenant_deploy_request list --tenant prod --status pending
738
+ xano tenant_deploy_request list --tenant prod --release v1.2
739
+ xano tenant_deploy_request list --to-review
740
+
741
+ # Get details of a specific deploy request
742
+ xano tenant_deploy_request get <id>
743
+
744
+ # Open a deploy request (submits for review immediately unless --draft is passed)
745
+ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --reviewers 12,45
746
+ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --draft
747
+
748
+ # Edit a deploy request's title, description or reviewers
749
+ xano tenant_deploy_request edit <id> --title "Deploy v1.2.1 to prod"
750
+ xano tenant_deploy_request edit <id> --reviewers 12,45,67
751
+
752
+ # Change status: draft, submit, approve, request_changes, close, or reopen
753
+ # (approve records one reviewer's vote; once enough votes are in it deploys the
754
+ # release as part of that same call)
755
+ xano tenant_deploy_request set_status <id> --status submit
756
+ xano tenant_deploy_request set_status <id> --status approve
757
+ xano tenant_deploy_request set_status <id> --status request_changes --reason "Needs a migration note"
758
+ xano tenant_deploy_request set_status <id> --status close --reason "Superseded by a newer request"
759
+ xano tenant_deploy_request set_status <id> --status reopen
760
+
761
+ # Link a newer release to a request after changes were requested (author only)
762
+ xano tenant_deploy_request revision <id> --release v1.2.1 --note "Added the missing migration"
763
+
764
+ # Deploy past the approval gate under the deploy-bypass permission (requires the
765
+ # tenant's allow_deploy_bypass to be enabled, and a separately-grantable RBAC
766
+ # permission). Every use is audited.
767
+ xano tenant_deploy_request bypass <id> --reason "Prod incident, reviewer unavailable"
768
+ ```
769
+
770
+ CI example (e.g. a GitHub Action step):
771
+
772
+ ```bash
773
+ xano tenant_deploy_request create "$PR_TITLE" --tenant prod --release v1.2 \
774
+ --description "$PR_BODY" --reviewers 12,45 -o json
775
+ ```
776
+
710
777
  #### Tenant License
711
778
 
712
779
  ```bash
@@ -63,6 +63,16 @@ export default abstract class BaseCommand extends Command {
63
63
  * Sets NODE_TLS_REJECT_UNAUTHORIZED=0 so all fetch() calls skip cert verification.
64
64
  */
65
65
  protected applyInsecureFromProfile(): void;
66
+ /**
67
+ * Report a failure as exit 1 for commands that reserve exit 2 for findings: operational errors,
68
+ * flag errors and oclif's own default exit 2 all become 1, and under `-o json` the failure is also
69
+ * written to stdout as `{"error": {"exit": 1, "message"}}`. A deliberate exit 0 passes through.
70
+ */
71
+ protected catchAsOperational(error: Error & {
72
+ oclif?: {
73
+ exit?: number;
74
+ };
75
+ }): Promise<void>;
66
76
  finally(_: Error | undefined): Promise<void>;
67
77
  /**
68
78
  * Get the resolved credentials file path, respecting --config flag and XANO_CONFIG env var.
@@ -78,6 +88,8 @@ export default abstract class BaseCommand extends Command {
78
88
  protected getOrCreateSandbox(profile: ProfileConfig, verbose: boolean): Promise<SandboxTenant>;
79
89
  protected getProfile(): string | undefined;
80
90
  init(): Promise<void>;
91
+ /** Raw-argv check: the parsed flags are not available yet when the banner prints. */
92
+ protected isJsonOutput(): boolean;
81
93
  protected loadCredentialsFile(): CredentialsFile | null;
82
94
  /**
83
95
  * Poll a static-host build until it reaches a terminal status (ok | error),
@@ -129,7 +141,6 @@ export default abstract class BaseCommand extends Command {
129
141
  verbose: boolean;
130
142
  workspaceId: string;
131
143
  }): Promise<string>;
132
- private isJsonOutput;
133
144
  /**
134
145
  * Find and parse the nearest project-local profile.yaml, unless an explicit
135
146
  * -p/XANO_PROFILE was given (in which case the local file is ignored).
@@ -156,6 +156,18 @@ export default class BaseCommand extends Command {
156
156
  // Don't fail the command if we can't read the profile for insecure check
157
157
  }
158
158
  }
159
+ /**
160
+ * Report a failure as exit 1 for commands that reserve exit 2 for findings: operational errors,
161
+ * flag errors and oclif's own default exit 2 all become 1, and under `-o json` the failure is also
162
+ * written to stdout as `{"error": {"exit": 1, "message"}}`. A deliberate exit 0 passes through.
163
+ */
164
+ async catchAsOperational(error) {
165
+ if (error.oclif?.exit === 0)
166
+ return super.catch(error);
167
+ if (this.isJsonOutput())
168
+ this.log(JSON.stringify({ error: { exit: 1, message: error.message } }, null, 2));
169
+ this.error(error, { exit: 1 });
170
+ }
159
171
  async finally(_) {
160
172
  if (this.updateNotice && !this.isJsonOutput()) {
161
173
  this.log(this.updateNotice);
@@ -221,6 +233,19 @@ export default class BaseCommand extends Command {
221
233
  const forceUpdateCheck = process.env.XANO_FORCE_UPDATE_CHECK === '1';
222
234
  this.updateNotice = checkForUpdate(this.config.version, forceUpdateCheck);
223
235
  }
236
+ /** Raw-argv check: the parsed flags are not available yet when the banner prints. */
237
+ isJsonOutput() {
238
+ const args = this.argv;
239
+ for (let i = 0; i < args.length; i++) {
240
+ if (args[i] === '--output' && args[i + 1] === 'json')
241
+ return true;
242
+ if (args[i] === '-o' && args[i + 1] === 'json')
243
+ return true;
244
+ if (args[i] === '--output=json' || args[i] === '-o=json')
245
+ return true;
246
+ }
247
+ return false;
248
+ }
224
249
  loadCredentialsFile() {
225
250
  const credentialsPath = this.getCredentialsPath();
226
251
  if (!fs.existsSync(credentialsPath)) {
@@ -354,18 +379,19 @@ export default class BaseCommand extends Command {
354
379
  ...(timeoutMs > 0 && !options.signal ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
355
380
  });
356
381
  const contentType = headers['Content-Type'] || 'application/json';
382
+ const logDiagnostic = this.isJsonOutput() ? this.logToStderr.bind(this) : this.log.bind(this);
357
383
  if (verbose) {
358
- this.log('');
359
- this.log('─'.repeat(60));
360
- this.log(`→ ${method} ${url}`);
361
- this.log(` Content-Type: ${contentType}`);
384
+ logDiagnostic('');
385
+ logDiagnostic('─'.repeat(60));
386
+ logDiagnostic(`→ ${method} ${url}`);
387
+ logDiagnostic(` Content-Type: ${contentType}`);
362
388
  if (authToken) {
363
- this.log(` Authorization: Bearer ${authToken.slice(0, 8)}...${authToken.slice(-4)}`);
389
+ logDiagnostic(` Authorization: Bearer ${authToken.slice(0, 8)}...${authToken.slice(-4)}`);
364
390
  }
365
391
  if (options.body) {
366
392
  const bodyStr = typeof options.body === 'string' ? options.body : String(options.body);
367
393
  const bodyPreview = bodyStr.length > 500 ? bodyStr.slice(0, 500) + '...' : bodyStr;
368
- this.log(` Body: ${bodyPreview}`);
394
+ logDiagnostic(` Body: ${bodyPreview}`);
369
395
  }
370
396
  }
371
397
  const startTime = Date.now();
@@ -381,7 +407,7 @@ export default class BaseCommand extends Command {
381
407
  if (dispatcherSupported && isInvalidDispatcherError(error)) {
382
408
  dispatcherSupported = false;
383
409
  if (verbose) {
384
- this.log(' (this Node runtime rejected undici dispatcher; retrying without it — see DEV-7773)');
410
+ logDiagnostic(' (this Node runtime rejected undici dispatcher; retrying without it — see DEV-7773)');
385
411
  }
386
412
  response = await fetch(url, buildFetchOptions(false));
387
413
  }
@@ -391,9 +417,9 @@ export default class BaseCommand extends Command {
391
417
  }
392
418
  const elapsed = Date.now() - startTime;
393
419
  if (verbose) {
394
- this.log(`← ${response.status} ${response.statusText} (${elapsed}ms)`);
395
- this.log('─'.repeat(60));
396
- this.log('');
420
+ logDiagnostic(`← ${response.status} ${response.statusText} (${elapsed}ms)`);
421
+ logDiagnostic('─'.repeat(60));
422
+ logDiagnostic('');
397
423
  }
398
424
  return response;
399
425
  }
@@ -468,18 +494,6 @@ export default class BaseCommand extends Command {
468
494
  conclude(`stopped waiting after ${Math.round(timeoutMs / 1000)}s (last status: ${stage || 'unknown'})`);
469
495
  return stage;
470
496
  }
471
- isJsonOutput() {
472
- const args = process.argv;
473
- for (let i = 0; i < args.length; i++) {
474
- if (args[i] === '--output' && args[i + 1] === 'json')
475
- return true;
476
- if (args[i] === '-o' && args[i + 1] === 'json')
477
- return true;
478
- if (args[i] === '--output=json' || args[i] === '-o=json')
479
- return true;
480
- }
481
- return false;
482
- }
483
497
  /**
484
498
  * Find and parse the nearest project-local profile.yaml, unless an explicit
485
499
  * -p/XANO_PROFILE was given (in which case the local file is ignored).
@@ -8,11 +8,25 @@ export default class BranchSetLive extends BaseCommand {
8
8
  static flags: {
9
9
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  workspace: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  };
17
+ /** Set once the set-live policy gate refuses, the one failure that exits 2. */
18
+ private refusedByPolicyGate;
19
+ /** Exit 2 is the set-live gate's refusal; every other failure, flag errors included, exits 1. */
20
+ protected catch(error: Error & {
21
+ oclif?: {
22
+ exit?: number;
23
+ };
24
+ }): Promise<void>;
16
25
  run(): Promise<void>;
17
26
  private confirm;
27
+ /**
28
+ * The set-live gate refused: the verdict on stdout (under `-o json`, `{set_live: false, message,
29
+ * policy_gate}`), then the platform's message and how to proceed, exiting 2.
30
+ */
31
+ private refuseSetLive;
18
32
  }
@@ -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 BranchSetLive extends BaseCommand {
4
5
  static args = {
5
6
  branch_label: Args.string({
@@ -40,14 +41,25 @@ Branch 'v1' is now live
40
41
  options: ['summary', 'json'],
41
42
  required: false,
42
43
  }),
44
+ 'policy-override': Flags.string({ description: 'Set the branch live past a blocking set-live policy gate with an audited reason (requires workspace:policy update)', hidden: true }),
43
45
  workspace: Flags.integer({
44
46
  char: 'w',
45
47
  description: 'Workspace ID (uses profile workspace if not provided)',
46
48
  required: false,
47
49
  }),
48
50
  };
51
+ /** Set once the set-live policy gate refuses, the one failure that exits 2. */
52
+ refusedByPolicyGate = false;
53
+ /** Exit 2 is the set-live gate's refusal; every other failure, flag errors included, exits 1. */
54
+ async catch(error) {
55
+ if (this.refusedByPolicyGate)
56
+ return super.catch(error);
57
+ return this.catchAsOperational(error);
58
+ }
49
59
  async run() {
50
60
  const { args, flags } = await this.parse(BranchSetLive);
61
+ if (blankPolicyOverride(flags['policy-override']))
62
+ this.error(BLANK_POLICY_OVERRIDE);
51
63
  const { profile } = this.resolveProfile(flags);
52
64
  // Get workspace ID from flag or profile
53
65
  const workspaceId = flags.workspace || profile.workspace;
@@ -66,16 +78,23 @@ Branch 'v1' is now live
66
78
  }
67
79
  // Construct the API URL
68
80
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/branch/${encodeURIComponent(branchLabel)}/live`;
81
+ // A reason from someone with workspace:policy update sets the branch live past a blocking gate.
82
+ const overrideReason = flags['policy-override']?.trim();
69
83
  // Set branch as live via the API
70
84
  try {
71
85
  const response = await this.verboseFetch(apiUrl, {
86
+ ...(overrideReason ? { body: JSON.stringify({ override_reason: overrideReason }) } : {}),
72
87
  headers: {
73
88
  'accept': 'application/json',
74
89
  'Authorization': `Bearer ${profile.access_token}`,
90
+ ...(overrideReason ? { 'Content-Type': 'application/json' } : {}),
75
91
  },
76
92
  method: 'POST',
77
93
  }, flags.verbose, profile.access_token);
78
94
  if (!response.ok) {
95
+ const refused = await gateRefusal(response);
96
+ if (refused)
97
+ this.refuseSetLive(refused, flags, branchLabel);
79
98
  const errorText = await response.text();
80
99
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
81
100
  }
@@ -89,6 +108,8 @@ Branch 'v1' is now live
89
108
  }
90
109
  }
91
110
  catch (error) {
111
+ if (this.refusedByPolicyGate)
112
+ throw error;
92
113
  if (error instanceof Error) {
93
114
  this.error(`Failed to set branch as live: ${error.message}`);
94
115
  }
@@ -111,4 +132,21 @@ Branch 'v1' is now live
111
132
  });
112
133
  });
113
134
  }
135
+ /**
136
+ * The set-live gate refused: the verdict on stdout (under `-o json`, `{set_live: false, message,
137
+ * policy_gate}`), then the platform's message and how to proceed, exiting 2.
138
+ */
139
+ refuseSetLive(refused, flags, branchLabel) {
140
+ if (flags.output === 'json') {
141
+ this.log(JSON.stringify({ message: refused.message, policy_gate: refused.answer, set_live: false }, null, 2));
142
+ }
143
+ else {
144
+ for (const line of liveGateLines(refused.answer, 'branch'))
145
+ this.log(line);
146
+ }
147
+ const workspace = flags.workspace ? ` -w ${flags.workspace}` : '';
148
+ const hint = gateOverrideHint(refused.answer, `xano branch set_live ${quoted(branchLabel)}${workspace} --policy-override "<why>"`);
149
+ this.refusedByPolicyGate = true;
150
+ this.error(`${refused.message}${hint}`, { exit: 2 });
151
+ }
114
152
  }
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
4
4
  import BaseCommand from '../../../base-command.js';
5
5
  import { isAwaited, waitForMicroservices, } from '../../../utils/microservice-wait.js';
6
6
  import { executePush } from '../../../utils/multidoc-push.js';
7
+ import { policiesSkippedNotice } from '../../../utils/policy/feedback.js';
7
8
  /** Status glyph for one microservice row in the `--wait` progress block. */
8
9
  const icon = (e) => {
9
10
  if (!isAwaited(e))
@@ -168,13 +169,17 @@ Skip preview and push immediately
168
169
  truncate: flags.truncate,
169
170
  verbose: flags.verbose,
170
171
  };
171
- await executePush({
172
+ const result = await executePush({
172
173
  accessToken: profile.access_token,
173
174
  branch: '',
174
175
  command: this,
175
176
  inputDir,
176
177
  verboseFetch: this.verboseFetch.bind(this),
177
178
  }, target, pushFlags);
179
+ // When the platform leaves policy files out, it says so once.
180
+ const skipped = policiesSkippedNotice(result.response ?? result.preview);
181
+ if (skipped)
182
+ this.warn(skipped);
178
183
  // --wait: after a real push, poll the tenant's live microservice status until
179
184
  // every auto-deployed microservice (tenant_deploy="auto") is ready or the
180
185
  // wait times out. Skipped on --dry-run (nothing was deployed).
@@ -7,13 +7,28 @@ export default class FunctionCreate extends BaseCommand {
7
7
  edit: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
8
  file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
9
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  };
17
+ /** Set once the publish policy gate refuses, the one failure that exits 2. */
18
+ private refusedByPolicyGate;
19
+ /** Exit 2 is the publish gate's refusal; every other failure, flag errors included, exits 1. */
20
+ protected catch(error: Error & {
21
+ oclif?: {
22
+ exit?: number;
23
+ };
24
+ }): Promise<void>;
16
25
  run(): Promise<void>;
17
26
  private editFile;
18
27
  private readStdin;
28
+ /**
29
+ * The live-branch publish gate refused the new function: the verdict on stdout (under `-o json`,
30
+ * `{created: false, message, policy_gate}`), then the platform's message and how to proceed,
31
+ * exiting 2.
32
+ */
33
+ private refusePublish;
19
34
  }
@@ -4,6 +4,7 @@ import * as fs from 'node:fs';
4
4
  import * as os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import BaseCommand from '../../../base-command.js';
7
+ import { BLANK_POLICY_OVERRIDE, blankPolicyOverride, gateRefusal, publishRefusal, quoted } from '../../../utils/policy/gate.js';
7
8
  export default class FunctionCreate extends BaseCommand {
8
9
  static args = {};
9
10
  static description = 'Create a new function in a workspace';
@@ -59,6 +60,7 @@ Name: my_function
59
60
  options: ['summary', 'json'],
60
61
  required: false,
61
62
  }),
63
+ 'policy-override': Flags.string({ description: 'Create the function past a blocking live-branch publish policy gate with an audited reason (requires workspace:policy update)', hidden: true }),
62
64
  stdin: Flags.boolean({
63
65
  char: 's',
64
66
  default: false,
@@ -72,8 +74,18 @@ Name: my_function
72
74
  required: false,
73
75
  }),
74
76
  };
77
+ /** Set once the publish policy gate refuses, the one failure that exits 2. */
78
+ refusedByPolicyGate = false;
79
+ /** Exit 2 is the publish gate's refusal; every other failure, flag errors included, exits 1. */
80
+ async catch(error) {
81
+ if (this.refusedByPolicyGate)
82
+ return super.catch(error);
83
+ return this.catchAsOperational(error);
84
+ }
75
85
  async run() {
76
86
  const { flags } = await this.parse(FunctionCreate);
87
+ if (blankPolicyOverride(flags['policy-override']))
88
+ this.error(BLANK_POLICY_OVERRIDE);
77
89
  const { profile, profileName } = this.resolveProfile(flags);
78
90
  // Determine workspace_id from flag or profile
79
91
  let workspaceId;
@@ -133,6 +145,10 @@ Name: my_function
133
145
  const queryParams = new URLSearchParams({
134
146
  include_xanoscript: 'false',
135
147
  });
148
+ // A reason from someone with workspace:policy update saves past a blocking publish gate.
149
+ const overrideReason = flags['policy-override']?.trim();
150
+ if (overrideReason)
151
+ queryParams.set('override_reason', overrideReason);
136
152
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/function?${queryParams.toString()}`;
137
153
  // Create function via API
138
154
  try {
@@ -146,6 +162,9 @@ Name: my_function
146
162
  method: 'POST',
147
163
  }, flags.verbose, profile.access_token);
148
164
  if (!response.ok) {
165
+ const refused = await gateRefusal(response);
166
+ if (refused)
167
+ this.refusePublish(refused, flags);
149
168
  const errorText = await response.text();
150
169
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
151
170
  }
@@ -166,6 +185,8 @@ Name: my_function
166
185
  }
167
186
  }
168
187
  catch (error) {
188
+ if (this.refusedByPolicyGate)
189
+ throw error;
169
190
  if (error instanceof Error) {
170
191
  this.error(`Failed to create function: ${error.message}`);
171
192
  }
@@ -239,4 +260,22 @@ Name: my_function
239
260
  process.stdin.resume();
240
261
  });
241
262
  }
263
+ /**
264
+ * The live-branch publish gate refused the new function: the verdict on stdout (under `-o json`,
265
+ * `{created: false, message, policy_gate}`), then the platform's message and how to proceed,
266
+ * exiting 2.
267
+ */
268
+ refusePublish(refused, flags) {
269
+ const workspace = flags.workspace ? ` -w ${quoted(flags.workspace)}` : '';
270
+ const source = flags.file ? ` -f ${quoted(flags.file)}` : ' --stdin';
271
+ const { lines, message } = publishRefusal(refused, {
272
+ json: flags.output === 'json',
273
+ outcome: 'created',
274
+ rerun: `xano function create${workspace}${source} --policy-override "<why>"`,
275
+ });
276
+ for (const line of lines)
277
+ this.log(line);
278
+ this.refusedByPolicyGate = true;
279
+ this.error(message, { exit: 2 });
280
+ }
242
281
  }
@@ -9,6 +9,7 @@ export default class FunctionEdit extends BaseCommand {
9
9
  edit: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
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
  publish: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
15
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -16,10 +17,24 @@ export default class FunctionEdit extends BaseCommand {
16
17
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
18
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
19
  };
20
+ /** Set once the publish policy gate refuses, the one failure that exits 2. */
21
+ private refusedByPolicyGate;
22
+ /** Exit 2 is the publish gate's refusal; every other failure, flag errors included, exits 1. */
23
+ protected catch(error: Error & {
24
+ oclif?: {
25
+ exit?: number;
26
+ };
27
+ }): Promise<void>;
19
28
  run(): Promise<void>;
20
29
  private editFile;
21
30
  private editFunctionContent;
22
31
  private fetchFunctionCode;
23
32
  private promptForFunctionId;
24
33
  private readStdin;
34
+ /**
35
+ * The live-branch publish gate refused the update: the verdict on stdout (under `-o json`,
36
+ * `{updated: false, message, policy_gate}`), then the platform's message and how to proceed,
37
+ * exiting 2.
38
+ */
39
+ private refusePublish;
25
40
  }
@@ -5,6 +5,7 @@ import * as fs from 'node:fs';
5
5
  import * as os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import BaseCommand, { buildUserAgent } from '../../../base-command.js';
8
+ import { BLANK_POLICY_OVERRIDE, blankPolicyOverride, gateRefusal, publishRefusal, quoted } from '../../../utils/policy/gate.js';
8
9
  export default class FunctionEdit extends BaseCommand {
9
10
  static args = {
10
11
  function_id: Args.string({
@@ -81,6 +82,7 @@ Name: my_function
81
82
  options: ['summary', 'json'],
82
83
  required: false,
83
84
  }),
85
+ 'policy-override': Flags.string({ description: 'Publish past a blocking live-branch publish policy gate with an audited reason (requires workspace:policy update)', hidden: true }),
84
86
  publish: Flags.boolean({
85
87
  default: true,
86
88
  description: 'Publish the function after editing',
@@ -99,8 +101,18 @@ Name: my_function
99
101
  required: false,
100
102
  }),
101
103
  };
104
+ /** Set once the publish policy gate refuses, the one failure that exits 2. */
105
+ refusedByPolicyGate = false;
106
+ /** Exit 2 is the publish gate's refusal; every other failure, flag errors included, exits 1. */
107
+ async catch(error) {
108
+ if (this.refusedByPolicyGate)
109
+ return super.catch(error);
110
+ return this.catchAsOperational(error);
111
+ }
102
112
  async run() {
103
113
  const { args, flags } = await this.parse(FunctionEdit);
114
+ if (blankPolicyOverride(flags['policy-override']))
115
+ this.error(BLANK_POLICY_OVERRIDE);
104
116
  const { profile, profileName } = this.resolveProfile(flags);
105
117
  // Validate flag combinations
106
118
  if (flags.edit && !flags.file) {
@@ -175,6 +187,10 @@ Name: my_function
175
187
  include_xanoscript: 'false',
176
188
  publish: flags.publish ? 'true' : 'false',
177
189
  });
190
+ // A reason from someone with workspace:policy update publishes past a blocking publish gate.
191
+ const overrideReason = flags['policy-override']?.trim();
192
+ if (overrideReason)
193
+ queryParams.set('override_reason', overrideReason);
178
194
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/function/${functionId}?${queryParams.toString()}`;
179
195
  // Update function via API
180
196
  try {
@@ -188,6 +204,9 @@ Name: my_function
188
204
  method: 'PUT',
189
205
  }, flags.verbose, profile.access_token);
190
206
  if (!response.ok) {
207
+ const refused = await gateRefusal(response);
208
+ if (refused)
209
+ this.refusePublish(refused, flags, functionId);
191
210
  const errorText = await response.text();
192
211
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
193
212
  }
@@ -208,6 +227,8 @@ Name: my_function
208
227
  }
209
228
  }
210
229
  catch (error) {
230
+ if (this.refusedByPolicyGate)
231
+ throw error;
211
232
  if (error instanceof Error) {
212
233
  this.error(`Failed to update function: ${error.message}`);
213
234
  }
@@ -428,4 +449,22 @@ Name: my_function
428
449
  process.stdin.resume();
429
450
  });
430
451
  }
452
+ /**
453
+ * The live-branch publish gate refused the update: the verdict on stdout (under `-o json`,
454
+ * `{updated: false, message, policy_gate}`), then the platform's message and how to proceed,
455
+ * exiting 2.
456
+ */
457
+ refusePublish(refused, flags, functionId) {
458
+ const workspace = flags.workspace ? ` -w ${quoted(flags.workspace)}` : '';
459
+ const source = flags.file ? ` -f ${quoted(flags.file)}` : flags.stdin ? ' --stdin' : '';
460
+ const { lines, message } = publishRefusal(refused, {
461
+ json: flags.output === 'json',
462
+ outcome: 'updated',
463
+ rerun: `xano function edit ${quoted(functionId)}${workspace}${source} --policy-override "<why>"`,
464
+ });
465
+ for (const line of lines)
466
+ this.log(line);
467
+ this.refusedByPolicyGate = true;
468
+ this.error(message, { exit: 2 });
469
+ }
431
470
  }