@xano/cli 1.2.0 → 1.3.1-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 (192) hide show
  1. package/README.md +381 -4
  2. package/dist/base-command.d.ts +54 -36
  3. package/dist/base-command.js +246 -169
  4. package/dist/commands/auth/index.d.ts +1 -1
  5. package/dist/commands/auth/index.js +7 -7
  6. package/dist/commands/branch/create/index.d.ts +1 -1
  7. package/dist/commands/branch/create/index.js +1 -1
  8. package/dist/commands/branch/set_live/index.d.ts +22 -0
  9. package/dist/commands/branch/set_live/index.js +60 -1
  10. package/dist/commands/ephemeral/create/index.d.ts +18 -0
  11. package/dist/commands/ephemeral/create/index.js +103 -0
  12. package/dist/commands/ephemeral/delete/index.d.ts +18 -0
  13. package/dist/commands/ephemeral/delete/index.js +99 -0
  14. package/dist/commands/ephemeral/edit/index.d.ts +18 -0
  15. package/dist/commands/ephemeral/edit/index.js +109 -0
  16. package/dist/commands/ephemeral/get/index.d.ts +21 -0
  17. package/dist/commands/ephemeral/get/index.js +125 -0
  18. package/dist/commands/ephemeral/impersonate/index.d.ts +20 -0
  19. package/dist/commands/ephemeral/impersonate/index.js +120 -0
  20. package/dist/commands/ephemeral/list/index.d.ts +19 -0
  21. package/dist/commands/ephemeral/list/index.js +123 -0
  22. package/dist/commands/ephemeral/pull/index.d.ts +19 -0
  23. package/dist/commands/ephemeral/pull/index.js +163 -0
  24. package/dist/commands/ephemeral/push/index.d.ts +35 -0
  25. package/dist/commands/ephemeral/push/index.js +228 -0
  26. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +20 -0
  27. package/dist/commands/ephemeral/static_host/build/delete/index.js +121 -0
  28. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +18 -0
  29. package/dist/commands/ephemeral/static_host/build/get/index.js +137 -0
  30. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +18 -0
  31. package/dist/commands/ephemeral/static_host/build/list/index.js +137 -0
  32. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +53 -0
  33. package/dist/commands/ephemeral/static_host/build/pull/index.js +308 -0
  34. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +24 -0
  35. package/dist/commands/ephemeral/static_host/build/push/index.js +250 -0
  36. package/dist/commands/ephemeral/static_host/create/index.d.ts +18 -0
  37. package/dist/commands/ephemeral/static_host/create/index.js +92 -0
  38. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +19 -0
  39. package/dist/commands/ephemeral/static_host/deploy/index.js +112 -0
  40. package/dist/commands/ephemeral/static_host/edit/index.d.ts +24 -0
  41. package/dist/commands/ephemeral/static_host/edit/index.js +158 -0
  42. package/dist/commands/ephemeral/static_host/get/index.d.ts +19 -0
  43. package/dist/commands/ephemeral/static_host/get/index.js +101 -0
  44. package/dist/commands/ephemeral/static_host/list/index.d.ts +17 -0
  45. package/dist/commands/ephemeral/static_host/list/index.js +132 -0
  46. package/dist/commands/flatten/index.d.ts +34 -0
  47. package/dist/commands/flatten/index.js +140 -0
  48. package/dist/commands/function/create/index.d.ts +15 -0
  49. package/dist/commands/function/create/index.js +41 -1
  50. package/dist/commands/function/edit/index.d.ts +15 -0
  51. package/dist/commands/function/edit/index.js +66 -32
  52. package/dist/commands/function/get/index.js +2 -3
  53. package/dist/commands/function/run/index.d.ts +1 -0
  54. package/dist/commands/function/run/index.js +19 -6
  55. package/dist/commands/policy/catalogue/index.d.ts +15 -0
  56. package/dist/commands/policy/catalogue/index.js +29 -0
  57. package/dist/commands/policy/create/index.d.ts +18 -0
  58. package/dist/commands/policy/create/index.js +39 -0
  59. package/dist/commands/policy/delete/index.d.ts +19 -0
  60. package/dist/commands/policy/delete/index.js +61 -0
  61. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  62. package/dist/commands/policy/evaluate/index.js +71 -0
  63. package/dist/commands/policy/list/index.d.ts +14 -0
  64. package/dist/commands/policy/list/index.js +21 -0
  65. package/dist/commands/policy/parse/index.d.ts +19 -0
  66. package/dist/commands/policy/parse/index.js +18 -0
  67. package/dist/commands/policy/publish/index.d.ts +20 -0
  68. package/dist/commands/policy/publish/index.js +38 -0
  69. package/dist/commands/policy/runs/index.d.ts +54 -0
  70. package/dist/commands/policy/runs/index.js +273 -0
  71. package/dist/commands/policy/status/index.d.ts +21 -0
  72. package/dist/commands/policy/status/index.js +54 -0
  73. package/dist/commands/profile/create/index.d.ts +1 -1
  74. package/dist/commands/profile/create/index.js +7 -7
  75. package/dist/commands/profile/me/index.js +1 -1
  76. package/dist/commands/profile/use/index.js +0 -4
  77. package/dist/commands/profile/wizard/index.d.ts +1 -1
  78. package/dist/commands/profile/wizard/index.js +12 -10
  79. package/dist/commands/profile/workspace/set/index.js +1 -1
  80. package/dist/commands/release/create/index.js +7 -0
  81. package/dist/commands/release/deploy/index.d.ts +28 -0
  82. package/dist/commands/release/deploy/index.js +90 -6
  83. package/dist/commands/release/edit/index.js +2 -2
  84. package/dist/commands/release/export/index.js +1 -2
  85. package/dist/commands/release/import/index.js +6 -2
  86. package/dist/commands/release/list/index.d.ts +7 -0
  87. package/dist/commands/release/list/index.js +54 -10
  88. package/dist/commands/release/pull/index.d.ts +0 -6
  89. package/dist/commands/release/pull/index.js +13 -99
  90. package/dist/commands/release/push/index.js +21 -3
  91. package/dist/commands/sandbox/env/get_all/index.js +1 -1
  92. package/dist/commands/sandbox/env/set_all/index.js +1 -1
  93. package/dist/commands/sandbox/get/index.js +1 -1
  94. package/dist/commands/sandbox/license/get/index.js +1 -1
  95. package/dist/commands/sandbox/license/set/index.js +1 -1
  96. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  97. package/dist/commands/sandbox/pull/index.js +13 -81
  98. package/dist/commands/sandbox/push/index.d.ts +1 -1
  99. package/dist/commands/sandbox/push/index.js +11 -6
  100. package/dist/commands/skills/pull/index.d.ts +30 -0
  101. package/dist/commands/skills/pull/index.js +118 -0
  102. package/dist/commands/static_host/build/create/index.d.ts +1 -1
  103. package/dist/commands/static_host/build/create/index.js +2 -2
  104. package/dist/commands/static_host/build/pull/index.js +1 -1
  105. package/dist/commands/static_host/build/push/index.js +1 -1
  106. package/dist/commands/static_host/edit/index.js +1 -1
  107. package/dist/commands/tenant/backup/export/index.js +1 -2
  108. package/dist/commands/tenant/backup/import/index.js +1 -1
  109. package/dist/commands/tenant/cluster/create/index.js +1 -1
  110. package/dist/commands/tenant/cluster/license/get/index.js +1 -1
  111. package/dist/commands/tenant/cluster/license/set/index.js +1 -1
  112. package/dist/commands/tenant/create/index.d.ts +4 -1
  113. package/dist/commands/tenant/create/index.js +26 -5
  114. package/dist/commands/tenant/deploy_platform/index.js +1 -1
  115. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  116. package/dist/commands/tenant/deploy_release/index.js +186 -4
  117. package/dist/commands/tenant/edit/index.d.ts +2 -0
  118. package/dist/commands/tenant/edit/index.js +46 -11
  119. package/dist/commands/tenant/env/get_all/index.js +1 -1
  120. package/dist/commands/tenant/env/set_all/index.js +1 -1
  121. package/dist/commands/tenant/impersonate/index.d.ts +1 -1
  122. package/dist/commands/tenant/impersonate/index.js +14 -14
  123. package/dist/commands/tenant/license/get/index.js +1 -1
  124. package/dist/commands/tenant/license/set/index.js +1 -1
  125. package/dist/commands/tenant/pull/index.d.ts +0 -6
  126. package/dist/commands/tenant/pull/index.js +14 -100
  127. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  128. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  129. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  130. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  131. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  132. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  133. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  134. package/dist/commands/tenant_deploy_request/get/index.js +116 -0
  135. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  136. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  137. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  138. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  139. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  140. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  141. package/dist/commands/workspace/git/pull/index.d.ts +4 -4
  142. package/dist/commands/workspace/git/pull/index.js +158 -97
  143. package/dist/commands/workspace/pull/index.d.ts +9 -5
  144. package/dist/commands/workspace/pull/index.js +83 -106
  145. package/dist/commands/workspace/push/index.d.ts +19 -0
  146. package/dist/commands/workspace/push/index.js +124 -5
  147. package/dist/help.js +4 -2
  148. package/dist/policy-command.d.ts +90 -0
  149. package/dist/policy-command.js +94 -0
  150. package/dist/update-check.d.ts +1 -1
  151. package/dist/update-check.js +1 -1
  152. package/dist/utils/document-parser.d.ts +126 -0
  153. package/dist/utils/document-parser.js +332 -4
  154. package/dist/utils/flatten.d.ts +36 -0
  155. package/dist/utils/flatten.js +68 -0
  156. package/dist/utils/knowledge-sync.d.ts +1 -0
  157. package/dist/utils/knowledge-sync.js +11 -6
  158. package/dist/utils/microservice-wait.d.ts +53 -0
  159. package/dist/utils/microservice-wait.js +84 -0
  160. package/dist/utils/multidoc-push.d.ts +143 -2
  161. package/dist/utils/multidoc-push.js +479 -40
  162. package/dist/utils/paging.d.ts +2 -1
  163. package/dist/utils/paging.js +0 -1
  164. package/dist/utils/policy/catalogue.d.ts +7 -0
  165. package/dist/utils/policy/catalogue.js +72 -0
  166. package/dist/utils/policy/errors.d.ts +12 -0
  167. package/dist/utils/policy/errors.js +61 -0
  168. package/dist/utils/policy/feedback.d.ts +68 -0
  169. package/dist/utils/policy/feedback.js +165 -0
  170. package/dist/utils/policy/findings.d.ts +23 -0
  171. package/dist/utils/policy/findings.js +65 -0
  172. package/dist/utils/policy/gate.d.ts +161 -0
  173. package/dist/utils/policy/gate.js +201 -0
  174. package/dist/utils/policy/goal.d.ts +46 -0
  175. package/dist/utils/policy/goal.js +95 -0
  176. package/dist/utils/policy/permission.d.ts +23 -0
  177. package/dist/utils/policy/permission.js +84 -0
  178. package/dist/utils/policy/push-gate.d.ts +5 -0
  179. package/dist/utils/policy/push-gate.js +29 -0
  180. package/dist/utils/policy/release.d.ts +85 -0
  181. package/dist/utils/policy/release.js +115 -0
  182. package/dist/utils/policy/request.d.ts +55 -0
  183. package/dist/utils/policy/request.js +115 -0
  184. package/dist/utils/policy/runs.d.ts +30 -0
  185. package/dist/utils/policy/runs.js +101 -0
  186. package/dist/utils/policy/status.d.ts +47 -0
  187. package/dist/utils/policy/status.js +117 -0
  188. package/dist/utils/policy/types.d.ts +185 -0
  189. package/dist/utils/policy/types.js +1 -0
  190. package/dist/utils/reference-checker.js +14 -3
  191. package/oclif.manifest.json +8538 -3445
  192. package/package.json +16 -1
@@ -1,5 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { deployedGateLine, fetchPolicyGate, gateExitCode, gateLines, gateOverrideHint, gateRefusal, gateRefusalOutput, quoted, } from '../../../utils/policy/gate.js';
4
+ import { policyPermissionGuidance } from '../../../utils/policy/permission.js';
3
5
  export default class TenantDeployRelease extends BaseCommand {
4
6
  static args = {
5
7
  tenant_name: Args.string({
@@ -7,15 +9,26 @@ export default class TenantDeployRelease extends BaseCommand {
7
9
  required: true,
8
10
  }),
9
11
  };
10
- static description = '[CRITICAL] STOP and confirm with the user before deploying a release to a tenant; this mutates the live tenant. Deploys a release to a tenant.';
12
+ static description = "[CRITICAL] STOP and confirm with the user before deploying a release to a tenant; this mutates the live tenant. Deploys a release to a tenant. A deploy is gated on the release's own policy check: any blocking finding in the release refuses it (exit 2) unless --override-reason is given, whatever the tenant runs today, so the release gets the same verdict on every tenant. --check previews that verdict without deploying.";
11
13
  static examples = [
12
14
  `$ xano tenant deploy_release t1234-abcd-xyz1 --release v1.0
13
15
  Deployed release "v1.0" to tenant: My Tenant (my-tenant)
14
16
  `,
15
17
  `$ xano tenant deploy_release t1234-abcd-xyz1 --release v1.0 -o json`,
18
+ `$ xano tenant deploy_release prod --release v1.2 --check
19
+ Checked release "v1.2" for tenant "prod"; nothing was deployed.
20
+ Policy gate: blocked
21
+ Deploy blocked: this release has 1 blocking policy finding.
22
+ `,
23
+ `$ xano tenant deploy_release prod --release v1.2 --override-reason "Hotfix; AUTH-001 finding tracked in JIRA-12"`,
16
24
  ];
17
25
  static flags = {
18
26
  ...BaseCommand.baseFlags,
27
+ check: Flags.boolean({
28
+ default: false,
29
+ description: "Preview the release's policy gate on this tenant without deploying; exits 2 when the deploy would be refused",
30
+ exclusive: ['override-reason'],
31
+ }),
19
32
  output: Flags.string({
20
33
  char: 'o',
21
34
  default: 'summary',
@@ -23,6 +36,10 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
23
36
  options: ['summary', 'json'],
24
37
  required: false,
25
38
  }),
39
+ 'override-reason': Flags.string({
40
+ description: 'Deploy past a blocking policy gate, recording this reason (needs the workspace:policy update permission; audited)',
41
+ exclusive: ['check'],
42
+ }),
26
43
  release: Flags.string({
27
44
  char: 'r',
28
45
  description: 'Release name to deploy',
@@ -34,6 +51,14 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
34
51
  required: false,
35
52
  }),
36
53
  };
54
+ /** Set once the policy gate refuses the deploy, the one failure that exits 2. */
55
+ refusedByPolicyGate = false;
56
+ /** Exit 2 is the policy gate's refusal; every other failure, flag errors included, exits 1. */
57
+ async catch(error) {
58
+ if (this.refusedByPolicyGate)
59
+ return super.catch(error);
60
+ return this.catchAsOperational(error);
61
+ }
37
62
  async run() {
38
63
  const { args, flags } = await this.parse(TenantDeployRelease);
39
64
  const { profile } = this.resolveProfile(flags);
@@ -43,12 +68,46 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
43
68
  }
44
69
  const releaseName = flags.release;
45
70
  const tenantName = args.tenant_name;
71
+ if (flags.check) {
72
+ await this.checkPolicyGate({ flags, profile, releaseName, tenantName, workspaceId });
73
+ return;
74
+ }
75
+ const overrideReason = flags['override-reason']?.trim();
76
+ if (flags['override-reason'] !== undefined && !overrideReason) {
77
+ this.error('--override-reason needs a reason: say why this deploy may proceed past the policy gate.');
78
+ }
79
+ // Pre-flight: the "approval required" error must always be shown before any
80
+ // permission/RBAC error. The backend's own deploy route checks RBAC first
81
+ // and the approval gate second (inside mvp:tenant_deploy_release), so a
82
+ // caller who lacks deploy permission would otherwise never learn a request
83
+ // is what's actually needed. Checking here first, with an endpoint that
84
+ // only needs read scope, guarantees the right error is shown regardless of
85
+ // whether the caller can deploy at all.
86
+ const requiresApproval = await this.tenantRequiresApproval(profile, workspaceId, tenantName, flags.verbose);
87
+ if (requiresApproval) {
88
+ const hasApprovedRequest = await this.hasApprovedDeployRequest({
89
+ profile,
90
+ releaseName,
91
+ tenantName,
92
+ verbose: flags.verbose,
93
+ workspaceId,
94
+ });
95
+ if (!hasApprovedRequest) {
96
+ this.error(`Tenant "${tenantName}" requires an approved deploy request before "${releaseName}" can be deployed.\n` +
97
+ `Run: xano tenant_deploy_request create "Deploy ${releaseName} to ${tenantName}" --tenant ${tenantName} --release ${releaseName} --reviewers <id,id,...>`);
98
+ }
99
+ }
46
100
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/deploy`;
47
101
  this.warn('This may take a few minutes. Please be patient.');
48
102
  const startTime = Date.now();
103
+ // A refusal by the policy gate exits 2 with its verdict; the catch that rewords other failures keeps it.
104
+ let refused = null;
49
105
  try {
50
106
  const response = await this.verboseFetch(apiUrl, {
51
- body: JSON.stringify({ release_name: releaseName }),
107
+ body: JSON.stringify({
108
+ release_name: releaseName,
109
+ ...(overrideReason ? { override_policy: true, override_reason: overrideReason } : {}),
110
+ }),
52
111
  headers: {
53
112
  accept: 'application/json',
54
113
  Authorization: `Bearer ${profile.access_token}`,
@@ -57,8 +116,22 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
57
116
  method: 'POST',
58
117
  }, flags.verbose, profile.access_token);
59
118
  if (!response.ok) {
60
- const errorText = await response.text();
61
- this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
119
+ refused = await gateRefusal(response);
120
+ if (refused) {
121
+ const override = `xano tenant deploy_release ${quoted(tenantName)} --release ${quoted(releaseName)} --override-reason "<why>"`;
122
+ this.refuseByPolicyGate(refused, flags, override);
123
+ }
124
+ let message = await this.parseApiError(response, 'API request failed');
125
+ // Only reached once the pre-flight above has already confirmed the
126
+ // approval gate is satisfied or not applicable — so a permission error
127
+ // here is genuinely about RBAC, not about a missing approval. If the
128
+ // tenant supports deploy requests, mention that path as an alternative
129
+ // to direct deploy, since opening a request doesn't require deploy
130
+ // permission.
131
+ if ((response.status === 401 || response.status === 403) && requiresApproval) {
132
+ message += `\n\nYou don't have permission to deploy to this tenant directly, but it supports deploy requests: xano tenant_deploy_request create "Deploy ${releaseName} to ${tenantName}" --tenant ${tenantName} --release ${releaseName} --reviewers <id,id,...>`;
133
+ }
134
+ this.error(message);
62
135
  }
63
136
  const tenant = (await response.json());
64
137
  if (flags.output === 'json') {
@@ -71,10 +144,15 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
71
144
  this.log(` State: ${tenant.state}`);
72
145
  if (tenant.release?.name)
73
146
  this.log(` Release: ${tenant.release.name}`);
147
+ if (tenant.policy_gate?.status)
148
+ this.log(` ${deployedGateLine(tenant.policy_gate)}`);
74
149
  this.log(` Time: ${elapsed}s`);
75
150
  }
151
+ this.warnPoliciesSkipped(tenant);
76
152
  }
77
153
  catch (error) {
154
+ if (refused)
155
+ throw error;
78
156
  if (error instanceof Error) {
79
157
  this.error(`Failed to deploy to tenant: ${error.message}`);
80
158
  }
@@ -83,4 +161,108 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
83
161
  }
84
162
  }
85
163
  }
164
+ /**
165
+ * `--check`: what the policy gate would decide for this release on this tenant, without deploying.
166
+ * Exits 2 when the deploy would be refused; a preview the platform cannot answer exits 1.
167
+ */
168
+ async checkPolicyGate(opts) {
169
+ const { flags, profile, releaseName, tenantName, workspaceId } = opts;
170
+ const result = await fetchPolicyGate({ verboseFetch: (...args) => this.verboseFetch(...args) }, { profile, release: releaseName, tenant: tenantName, verbose: flags.verbose, workspace: workspaceId });
171
+ if ('unavailable' in result) {
172
+ const { message, payload, status } = result.unavailable;
173
+ this.error(`Policy gate preview failed (${status}): ${message}${policyPermissionGuidance(status, payload)}`);
174
+ }
175
+ const { answer } = result;
176
+ const exit = gateExitCode(answer);
177
+ if (flags.output === 'json') {
178
+ this.log(JSON.stringify(answer, null, 2));
179
+ }
180
+ else {
181
+ this.log(`Checked release "${releaseName}" for tenant "${tenantName}"; nothing was deployed.`);
182
+ for (const line of gateLines(answer))
183
+ this.log(line);
184
+ if (exit) {
185
+ this.log(answer.can_override
186
+ ? `To deploy past the gate: xano tenant deploy_release ${quoted(tenantName)} --release ${quoted(releaseName)} --override-reason "<why>"`
187
+ : 'A deploy is refused unless someone with the workspace:policy update permission overrides the gate.');
188
+ }
189
+ }
190
+ if (exit)
191
+ process.exitCode = exit;
192
+ }
193
+ /**
194
+ * Does an approved tenant_deploy_request already exist for this (tenant, release)
195
+ * pair? The list endpoint has no release filter, so this fetches approved
196
+ * requests for the tenant and filters client-side by the resolved release name.
197
+ */
198
+ async hasApprovedDeployRequest(opts) {
199
+ const { profile, releaseName, tenantName, verbose, workspaceId } = opts;
200
+ const params = new URLSearchParams({ per_page: '100', status: 'approved', tenant_name: tenantName });
201
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request?${params}`;
202
+ const response = await this.verboseFetch(apiUrl, {
203
+ headers: {
204
+ accept: 'application/json',
205
+ Authorization: `Bearer ${profile.access_token}`,
206
+ },
207
+ method: 'GET',
208
+ }, verbose, profile.access_token);
209
+ if (!response.ok) {
210
+ // A read-scope failure here means something is more broadly wrong (e.g.
211
+ // no workspace access at all) — surface it rather than silently treating
212
+ // it as "no approval found", which would misreport as approval-required.
213
+ const message = await this.parseApiError(response, 'Failed to check for an approved deploy request');
214
+ this.error(message);
215
+ }
216
+ const data = (await response.json());
217
+ const items = Array.isArray(data) ? data : (data.items ?? []);
218
+ return items.some((item) => item.status === 'approved' && item._release?.name === releaseName);
219
+ }
220
+ /** A deploy the policy gate refused: its verdict, then the refusal and how to proceed, exiting 2. */
221
+ refuseByPolicyGate(refused, flags, override) {
222
+ for (const line of gateRefusalOutput(refused, flags.output === 'json'))
223
+ this.log(line);
224
+ this.refusedByPolicyGate = true;
225
+ this.error(`${refused.message}${gateOverrideHint(refused.answer, override)}`, { exit: 2 });
226
+ }
227
+ /**
228
+ * Does this tenant have the deploy approval gate enabled?
229
+ *
230
+ * Returns false if the tenant has `allow_quick_deploy` set — that flag lets a
231
+ * tenant skip the approval gate entirely for quick deploys, regardless of
232
+ * `required_reviewers`.
233
+ *
234
+ * Deliberately uses `tenant list` rather than `tenant get`: the single-tenant
235
+ * GET route's output whitelist (tenant-detail.yaml) omits `deploy_settings`
236
+ * (which holds `required_reviewers`, `allow_deploy_bypass`, and
237
+ * `allow_quick_deploy`) even though the list route's output includes it
238
+ * — confirmed live against a running instance. Filed as a backend bug; this
239
+ * is the workaround until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
240
+ */
241
+ async tenantRequiresApproval(profile, workspaceId, tenantName, verbose) {
242
+ const params = new URLSearchParams({ per_page: '100' });
243
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant?${params}`;
244
+ const response = await this.verboseFetch(apiUrl, {
245
+ headers: {
246
+ accept: 'application/json',
247
+ Authorization: `Bearer ${profile.access_token}`,
248
+ },
249
+ method: 'GET',
250
+ }, verbose, profile.access_token);
251
+ if (!response.ok) {
252
+ const message = await this.parseApiError(response, 'Failed to look up tenant');
253
+ this.error(message);
254
+ }
255
+ const data = (await response.json());
256
+ const items = Array.isArray(data) ? data : (data.items ?? []);
257
+ const tenant = items.find((item) => item.name === tenantName);
258
+ if (tenant?.deploy_settings?.allow_quick_deploy)
259
+ return false;
260
+ return (tenant?.deploy_settings?.required_reviewers ?? 0) > 0;
261
+ }
262
+ /** A release policy the tenant's import could not land (its key is held by another policy) is never dropped silently. */
263
+ warnPoliciesSkipped(tenant) {
264
+ const message = tenant.policies_skipped?.message;
265
+ if (message)
266
+ this.warn(message);
267
+ }
86
268
  }
@@ -6,6 +6,7 @@ export default class TenantEdit extends BaseCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ allow_quick_deploy: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  display: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -13,6 +14,7 @@ export default class TenantEdit extends BaseCommand {
13
14
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
15
  proxy: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
16
  rbac: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ required_reviewers: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
18
  tasks: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
19
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
20
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -13,9 +13,15 @@ export default class TenantEdit extends BaseCommand {
13
13
  Updated tenant: New Name (my-tenant) - ID: 42
14
14
  `,
15
15
  `$ xano tenant edit t1234-abcd-xyz1 --no-tasks --no-ingress -o json`,
16
+ `$ xano tenant edit t1234-abcd-xyz1 --required_reviewers 1`,
16
17
  ];
17
18
  static flags = {
18
19
  ...BaseCommand.baseFlags,
20
+ allow_quick_deploy: Flags.boolean({
21
+ allowNo: true,
22
+ description: 'Allow this tenant to skip the deploy approval gate entirely for quick deploys, regardless of --required_reviewers.',
23
+ required: false,
24
+ }),
19
25
  description: Flags.string({
20
26
  char: 'd',
21
27
  description: 'New description',
@@ -50,6 +56,10 @@ Updated tenant: New Name (my-tenant) - ID: 42
50
56
  description: '[CRITICAL] NEVER disable RBAC without explicit user confirmation; this removes role-based access controls on the tenant. Enables/disables RBAC.',
51
57
  required: false,
52
58
  }),
59
+ required_reviewers: Flags.integer({
60
+ description: 'When greater than 0, that many distinct reviewers must approve a tenant_deploy_request before a release may be deployed to this tenant. 0 means not gated.',
61
+ required: false,
62
+ }),
53
63
  tasks: Flags.boolean({
54
64
  allowNo: true,
55
65
  description: '[IMPORTANT] ALWAYS confirm with the user before disabling background tasks; this stops scheduled jobs on the tenant. Enables/disables background tasks.',
@@ -76,8 +86,17 @@ Updated tenant: New Name (my-tenant) - ID: 42
76
86
  'Content-Type': 'application/json',
77
87
  };
78
88
  try {
79
- // Fetch current tenant state (PUT requires all fields)
80
- const getResponse = await this.verboseFetch(baseUrl, {
89
+ // Fetch current tenant state (PUT requires all fields). Deliberately uses
90
+ // `tenant list` rather than `tenant get`: the single-tenant GET route's
91
+ // output whitelist (tenant-detail.yaml) omits `deploy_settings` (which
92
+ // holds `required_reviewers`, `allow_deploy_bypass`, and
93
+ // `allow_quick_deploy`) even though the list route's output includes it
94
+ // — confirmed live against a running instance. Using GET here would
95
+ // silently reset those fields to their defaults on every edit that doesn't
96
+ // explicitly pass them. Filed as a backend bug; this is the workaround
97
+ // until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
98
+ const listParams = new URLSearchParams({ per_page: '100' });
99
+ const getResponse = await this.verboseFetch(`${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant?${listParams}`, {
81
100
  headers: {
82
101
  'accept': 'application/json',
83
102
  'Authorization': `Bearer ${profile.access_token}`,
@@ -88,19 +107,35 @@ Updated tenant: New Name (my-tenant) - ID: 42
88
107
  const errorText = await getResponse.text();
89
108
  this.error(`Failed to fetch tenant: ${getResponse.status} ${getResponse.statusText}\n${errorText}`);
90
109
  }
91
- const current = await getResponse.json();
92
- // Merge in user-provided values
110
+ const listData = (await getResponse.json());
111
+ const listItems = Array.isArray(listData) ? listData : (listData.items ?? []);
112
+ const current = listItems.find((item) => item.name === tenantName);
113
+ if (!current) {
114
+ this.error(`Tenant "${tenantName}" not found`);
115
+ }
116
+ // Merge in user-provided values. allow_deploy_bypass has no edit flag (it's
117
+ // fixed at `tenant create` time), but the PUT still requires the full body,
118
+ // so its current value is preserved here rather than reset.
93
119
  const body = {
94
- description: flags.description !== undefined ? flags.description : (current.description ?? ''),
95
- display: flags.display !== undefined ? flags.display : (current.display ?? current.name),
96
- domain: flags.domain !== undefined ? flags.domain : (current.domain ?? ''),
97
- ingress: flags.ingress !== undefined ? flags.ingress : (current.ingress ?? true),
98
- proxy: flags.proxy !== undefined ? flags.proxy : (current.proxy ?? ''),
120
+ deploy_settings: {
121
+ allow_deploy_bypass: current.deploy_settings?.allow_deploy_bypass ?? false,
122
+ allow_quick_deploy: flags.allow_quick_deploy === undefined
123
+ ? (current.deploy_settings?.allow_quick_deploy ?? false)
124
+ : flags.allow_quick_deploy,
125
+ required_reviewers: flags.required_reviewers === undefined
126
+ ? (current.deploy_settings?.required_reviewers ?? 0)
127
+ : flags.required_reviewers,
128
+ },
129
+ description: flags.description === undefined ? (current.description ?? '') : flags.description,
130
+ display: flags.display === undefined ? (current.display ?? current.name) : flags.display,
131
+ domain: flags.domain === undefined ? (current.domain ?? '') : flags.domain,
132
+ ingress: flags.ingress === undefined ? (current.ingress ?? true) : flags.ingress,
133
+ proxy: flags.proxy === undefined ? (current.proxy ?? '') : flags.proxy,
99
134
  rbac: {
100
- enabled: flags.rbac !== undefined ? flags.rbac : (current.rbac?.enabled ?? false),
135
+ enabled: flags.rbac === undefined ? (current.rbac?.enabled ?? false) : flags.rbac,
101
136
  },
102
137
  tag: current.tag ?? [],
103
- tasks: flags.tasks !== undefined ? flags.tasks : (current.tasks ?? true),
138
+ tasks: flags.tasks === undefined ? (current.tasks ?? true) : flags.tasks,
104
139
  };
105
140
  // Update tenant
106
141
  const putResponse = await this.verboseFetch(baseUrl, {
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as yaml from 'js-yaml';
3
3
  import * as fs from 'node:fs';
4
- import * as path from 'node:path';
4
+ import path from 'node:path';
5
5
  import BaseCommand from '../../../../base-command.js';
6
6
  export default class TenantEnvGetAll extends BaseCommand {
7
7
  static args = {
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as yaml from 'js-yaml';
3
3
  import * as fs from 'node:fs';
4
- import * as path from 'node:path';
4
+ import path from 'node:path';
5
5
  import BaseCommand from '../../../../base-command.js';
6
6
  export default class TenantEnvSetAll extends BaseCommand {
7
7
  static args = {
@@ -14,6 +14,6 @@ export default class TenantImpersonate extends BaseCommand {
14
14
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  };
16
16
  run(): Promise<void>;
17
- private getImpersonateResponse;
18
17
  private getFrontendUrl;
18
+ private getImpersonateResponse;
19
19
  }
@@ -76,13 +76,26 @@ Impersonation successful!
76
76
  }
77
77
  }
78
78
  }
79
+ getFrontendUrl(instanceOrigin) {
80
+ try {
81
+ const url = new URL(instanceOrigin);
82
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
83
+ url.port = '4200';
84
+ return url.origin;
85
+ }
86
+ }
87
+ catch {
88
+ // fall through
89
+ }
90
+ return instanceOrigin;
91
+ }
79
92
  async getImpersonateResponse(profile, workspaceId, tenantName) {
80
93
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${encodeURIComponent(tenantName)}/impersonate`;
81
94
  const { verbose } = await this.parse(TenantImpersonate).then((r) => r.flags);
82
95
  const response = await this.verboseFetch(apiUrl, {
83
96
  headers: {
84
- Authorization: `Bearer ${profile.access_token}`,
85
97
  accept: 'application/json',
98
+ Authorization: `Bearer ${profile.access_token}`,
86
99
  },
87
100
  method: 'GET',
88
101
  }, verbose, profile.access_token);
@@ -96,17 +109,4 @@ Impersonation successful!
96
109
  }
97
110
  return result;
98
111
  }
99
- getFrontendUrl(instanceOrigin) {
100
- try {
101
- const url = new URL(instanceOrigin);
102
- if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
103
- url.port = '4200';
104
- return url.origin;
105
- }
106
- }
107
- catch {
108
- // fall through
109
- }
110
- return instanceOrigin;
111
- }
112
112
  }
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
- import * as path from 'node:path';
3
+ import path from 'node:path';
4
4
  import BaseCommand from '../../../../base-command.js';
5
5
  export default class TenantLicenseGet extends BaseCommand {
6
6
  static args = {
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
- import * as path from 'node:path';
3
+ import path from 'node:path';
4
4
  import BaseCommand from '../../../../base-command.js';
5
5
  export default class TenantLicenseSet extends BaseCommand {
6
6
  static args = {
@@ -14,10 +14,4 @@ export default class Pull extends BaseCommand {
14
14
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  };
16
16
  run(): Promise<void>;
17
- /**
18
- * Sanitize a document name for use as a filename.
19
- * Strips quotes, replaces spaces with underscores, and removes
20
- * characters that are unsafe in filenames.
21
- */
22
- private sanitizeFilename;
23
17
  }
@@ -1,9 +1,9 @@
1
1
  import { Flags } from '@oclif/core';
2
- import * as fs from 'node:fs';
3
- import * as path from 'node:path';
4
2
  import snakeCase from 'lodash.snakecase';
3
+ import * as fs from 'node:fs';
4
+ import path from 'node:path';
5
5
  import BaseCommand from '../../../base-command.js';
6
- import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
7
7
  export default class Pull extends BaseCommand {
8
8
  static description = 'Pull a tenant multidoc from the Xano Metadata API and split into individual files';
9
9
  static examples = [
@@ -57,7 +57,7 @@ Pulled 58 documents from tenant my-tenant
57
57
  };
58
58
  async run() {
59
59
  const { flags } = await this.parse(Pull);
60
- const { profileName, profile } = this.resolveProfile(flags);
60
+ const { profile, profileName } = this.resolveProfile(flags);
61
61
  // Determine workspace_id from flag or profile
62
62
  let workspaceId;
63
63
  if (flags.workspace) {
@@ -129,96 +129,19 @@ Pulled 58 documents from tenant my-tenant
129
129
  fs.mkdirSync(outputDir, { recursive: true });
130
130
  // Resolve api_group names to unique folder names, disambiguating collisions
131
131
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
132
+ // Resolve a realtime v2 channel path -> owning realtime_server name, so
133
+ // messages can nest under their channel's server (see resolver docs).
134
+ const getChannelServer = buildChannelServerResolver(documents);
132
135
  // Track filenames per type to handle duplicates
133
136
  const filenameCounters = new Map();
134
137
  let writtenCount = 0;
135
138
  for (const doc of documents) {
136
- let typeDir;
137
- let baseName;
138
- if (doc.type === 'workspace') {
139
- // workspace → workspace/{name}.xs
140
- typeDir = path.join(outputDir, 'workspace');
141
- baseName = this.sanitizeFilename(doc.name);
142
- }
143
- else if (doc.type === 'workspace_trigger') {
144
- // workspace_trigger → workspace/trigger/{name}.xs
145
- typeDir = path.join(outputDir, 'workspace', 'trigger');
146
- baseName = this.sanitizeFilename(doc.name);
147
- }
148
- else if (doc.type === 'error_trigger') {
149
- // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
150
- typeDir = path.join(outputDir, 'workspace', 'trigger');
151
- baseName = this.sanitizeFilename(doc.name);
152
- }
153
- else if (doc.type === 'agent') {
154
- // agent → ai/agent/{name}.xs
155
- typeDir = path.join(outputDir, 'ai', 'agent');
156
- baseName = this.sanitizeFilename(doc.name);
157
- }
158
- else if (doc.type === 'mcp_server') {
159
- // mcp_server → ai/mcp_server/{name}.xs
160
- typeDir = path.join(outputDir, 'ai', 'mcp_server');
161
- baseName = this.sanitizeFilename(doc.name);
162
- }
163
- else if (doc.type === 'tool') {
164
- // tool → ai/tool/{name}.xs
165
- typeDir = path.join(outputDir, 'ai', 'tool');
166
- baseName = this.sanitizeFilename(doc.name);
167
- }
168
- else if (doc.type === 'agent_trigger') {
169
- // agent_trigger → ai/agent/trigger/{name}.xs
170
- typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
171
- baseName = this.sanitizeFilename(doc.name);
172
- }
173
- else if (doc.type === 'mcp_server_trigger') {
174
- // mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
175
- typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
176
- baseName = this.sanitizeFilename(doc.name);
177
- }
178
- else if (doc.type === 'table_trigger') {
179
- // table_trigger → table/trigger/{name}.xs
180
- typeDir = path.join(outputDir, 'table', 'trigger');
181
- baseName = this.sanitizeFilename(doc.name);
182
- }
183
- else if (doc.type === 'realtime_channel') {
184
- // realtime_channel → realtime/channel/{name}.xs
185
- typeDir = path.join(outputDir, 'realtime', 'channel');
186
- baseName = this.sanitizeFilename(doc.name);
187
- }
188
- else if (doc.type === 'realtime_trigger') {
189
- // realtime_trigger → realtime/trigger/{name}.xs
190
- typeDir = path.join(outputDir, 'realtime', 'trigger');
191
- baseName = this.sanitizeFilename(doc.name);
192
- }
193
- else if (doc.type === 'api_group') {
194
- // api_group "test" → api/{resolved_folder}/{name}.xs
195
- const groupFolder = getApiGroupFolder(doc.name);
196
- typeDir = path.join(outputDir, 'api', groupFolder);
197
- baseName = this.sanitizeFilename(doc.name);
198
- }
199
- else if (doc.type === 'query' && doc.apiGroup) {
200
- // query in group "test" → api/{resolved_folder}/{query_name}.xs
201
- const groupFolder = getApiGroupFolder(doc.apiGroup);
202
- const nameParts = doc.name.split('/');
203
- const leafName = nameParts.pop();
204
- const folderParts = nameParts.map((part) => snakeCase(part));
205
- typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
206
- baseName = this.sanitizeFilename(leafName);
207
- if (doc.verb) {
208
- baseName = `${baseName}_${doc.verb}`;
209
- }
210
- }
211
- else {
212
- // Default: split folder path from name
213
- const nameParts = doc.name.split('/');
214
- const leafName = nameParts.pop();
215
- const folderParts = nameParts.map((part) => snakeCase(part));
216
- typeDir = path.join(outputDir, doc.type, ...folderParts);
217
- baseName = this.sanitizeFilename(leafName);
218
- if (doc.verb) {
219
- baseName = `${baseName}_${doc.verb}`;
220
- }
221
- }
139
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
140
+ getApiGroupFolder,
141
+ getChannelServer,
142
+ join: path.join,
143
+ snakeCase,
144
+ });
222
145
  fs.mkdirSync(typeDir, { recursive: true });
223
146
  // Track duplicates per directory
224
147
  const dirKey = path.relative(outputDir, typeDir);
@@ -229,20 +152,11 @@ Pulled 58 documents from tenant my-tenant
229
152
  const count = typeCounters.get(baseName) || 0;
230
153
  typeCounters.set(baseName, count + 1);
231
154
  // Append numeric suffix for duplicates
232
- let filename;
233
- filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
155
+ const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
234
156
  const filePath = path.join(typeDir, filename);
235
157
  fs.writeFileSync(filePath, doc.content, 'utf8');
236
158
  writtenCount++;
237
159
  }
238
160
  this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
239
161
  }
240
- /**
241
- * Sanitize a document name for use as a filename.
242
- * Strips quotes, replaces spaces with underscores, and removes
243
- * characters that are unsafe in filenames.
244
- */
245
- sanitizeFilename(name) {
246
- return snakeCase(name.replaceAll('"', ''));
247
- }
248
162
  }
@@ -0,0 +1,31 @@
1
+ import BaseCommand from '../../../base-command.js';
2
+ export default class TenantDeployRequestBypass extends BaseCommand {
3
+ static args: {
4
+ id: import("@oclif/core/interfaces").Arg<number, {
5
+ max?: number;
6
+ min?: number;
7
+ }>;
8
+ };
9
+ static description: string;
10
+ static examples: string[];
11
+ static flags: {
12
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'override-policy': import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ reason: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ };
20
+ /** Set once the policy gate refuses the bypass deploy, the one failure that exits 2. */
21
+ private refusedByPolicyGate;
22
+ /** Exit 2 is the policy 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>;
28
+ run(): Promise<void>;
29
+ /** The bypass deploy was refused by the policy gate: its verdict, then the refusal, exiting 2. */
30
+ private refuseByPolicyGate;
31
+ }