@xano/cli 1.0.6-beta.2 → 1.0.6-beta.20

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 (112) hide show
  1. package/README.md +217 -6
  2. package/dist/base-command.d.ts +5 -0
  3. package/dist/base-command.js +61 -4
  4. package/dist/commands/branch/list/index.js +5 -1
  5. package/dist/commands/ephemeral/create/index.js +3 -3
  6. package/dist/commands/ephemeral/list/index.js +2 -1
  7. package/dist/commands/ephemeral/pull/index.d.ts +0 -6
  8. package/dist/commands/ephemeral/pull/index.js +10 -95
  9. package/dist/commands/ephemeral/push/index.d.ts +10 -0
  10. package/dist/commands/ephemeral/push/index.js +77 -0
  11. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
  12. package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
  13. package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
  14. package/dist/commands/ephemeral/static_host/list/index.js +20 -40
  15. package/dist/commands/flatten/index.d.ts +34 -0
  16. package/dist/commands/flatten/index.js +140 -0
  17. package/dist/commands/function/list/index.d.ts +4 -4
  18. package/dist/commands/function/list/index.js +30 -38
  19. package/dist/commands/function/run/index.d.ts +1 -0
  20. package/dist/commands/function/run/index.js +19 -5
  21. package/dist/commands/knowledge/list/index.js +2 -1
  22. package/dist/commands/platform/list/index.js +13 -14
  23. package/dist/commands/release/list/index.js +13 -14
  24. package/dist/commands/release/pull/index.d.ts +0 -6
  25. package/dist/commands/release/pull/index.js +10 -95
  26. package/dist/commands/sandbox/env/list/index.js +4 -0
  27. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  28. package/dist/commands/sandbox/pull/index.js +10 -77
  29. package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
  30. package/dist/commands/sandbox/unit_test/list/index.js +9 -14
  31. package/dist/commands/sandbox/unit_test/run/index.js +17 -11
  32. package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
  33. package/dist/commands/sandbox/unit_test/run_all/index.js +76 -38
  34. package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
  35. package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
  36. package/dist/commands/sandbox/workflow_test/run/index.js +12 -9
  37. package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
  38. package/dist/commands/sandbox/workflow_test/run_all/index.js +74 -36
  39. package/dist/commands/static_host/build/list/index.d.ts +2 -3
  40. package/dist/commands/static_host/build/list/index.js +19 -40
  41. package/dist/commands/static_host/build/pull/index.js +12 -9
  42. package/dist/commands/static_host/list/index.d.ts +2 -3
  43. package/dist/commands/static_host/list/index.js +20 -40
  44. package/dist/commands/static_host/migrate/index.js +14 -10
  45. package/dist/commands/tenant/backup/list/index.js +13 -14
  46. package/dist/commands/tenant/cluster/list/index.js +9 -10
  47. package/dist/commands/tenant/create/index.d.ts +3 -0
  48. package/dist/commands/tenant/create/index.js +21 -0
  49. package/dist/commands/tenant/deploy_release/index.d.ts +21 -0
  50. package/dist/commands/tenant/deploy_release/index.js +94 -2
  51. package/dist/commands/tenant/edit/index.d.ts +2 -0
  52. package/dist/commands/tenant/edit/index.js +39 -4
  53. package/dist/commands/tenant/env/list/index.js +4 -0
  54. package/dist/commands/tenant/list/index.js +19 -20
  55. package/dist/commands/tenant/pull/index.d.ts +0 -6
  56. package/dist/commands/tenant/pull/index.js +10 -106
  57. package/dist/commands/tenant/snapshot/list/index.js +5 -1
  58. package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
  59. package/dist/commands/tenant/unit_test/list/index.js +9 -14
  60. package/dist/commands/tenant/unit_test/run/index.js +19 -11
  61. package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
  62. package/dist/commands/tenant/unit_test/run_all/index.js +80 -38
  63. package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
  64. package/dist/commands/tenant/workflow_test/list/index.js +9 -14
  65. package/dist/commands/tenant/workflow_test/run/index.js +14 -9
  66. package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
  67. package/dist/commands/tenant/workflow_test/run_all/index.js +78 -36
  68. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +20 -0
  69. package/dist/commands/tenant_deploy_request/bypass/index.js +79 -0
  70. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  71. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  72. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  73. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  74. package/dist/commands/tenant_deploy_request/get/index.d.ts +19 -0
  75. package/dist/commands/tenant_deploy_request/get/index.js +75 -0
  76. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  77. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  78. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  79. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  80. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +21 -0
  81. package/dist/commands/tenant_deploy_request/set_status/index.js +112 -0
  82. package/dist/commands/unit_test/list/index.d.ts +3 -1
  83. package/dist/commands/unit_test/list/index.js +9 -14
  84. package/dist/commands/unit_test/run/index.js +19 -11
  85. package/dist/commands/unit_test/run_all/index.d.ts +1 -0
  86. package/dist/commands/unit_test/run_all/index.js +81 -41
  87. package/dist/commands/workflow_test/list/index.d.ts +3 -1
  88. package/dist/commands/workflow_test/list/index.js +10 -13
  89. package/dist/commands/workflow_test/run/index.js +15 -10
  90. package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
  91. package/dist/commands/workflow_test/run_all/index.js +82 -39
  92. package/dist/commands/workspace/git/pull/index.js +48 -7
  93. package/dist/commands/workspace/list/index.js +7 -13
  94. package/dist/commands/workspace/pull/index.d.ts +0 -6
  95. package/dist/commands/workspace/pull/index.js +10 -115
  96. package/dist/utils/concurrency.d.ts +29 -0
  97. package/dist/utils/concurrency.js +54 -0
  98. package/dist/utils/document-parser.d.ts +90 -0
  99. package/dist/utils/document-parser.js +273 -1
  100. package/dist/utils/flatten.d.ts +36 -0
  101. package/dist/utils/flatten.js +68 -0
  102. package/dist/utils/knowledge-sync.d.ts +1 -0
  103. package/dist/utils/knowledge-sync.js +11 -5
  104. package/dist/utils/microservice-wait.d.ts +53 -0
  105. package/dist/utils/microservice-wait.js +83 -0
  106. package/dist/utils/multidoc-push.d.ts +88 -0
  107. package/dist/utils/multidoc-push.js +377 -8
  108. package/dist/utils/paging.d.ts +145 -0
  109. package/dist/utils/paging.js +199 -0
  110. package/dist/utils/reference-checker.js +13 -0
  111. package/oclif.manifest.json +4999 -3973
  112. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { buildPagingJson, buildPagingParams, formatPagingFooter, normalizeListResponse, pagingFlags } from '../../../utils/paging.js';
3
4
  export default class StaticHostList extends BaseCommand {
4
5
  static args = {};
5
6
  static description = 'List all static hosts in a workspace from the Xano Metadata API';
@@ -15,13 +16,17 @@ Available static hosts:
15
16
  - another-host (ID: 2)
16
17
  `,
17
18
  `$ xano static_host:list -w 40 --output json
18
- [
19
- {
20
- "id": 1,
21
- "name": "my-static-host",
22
- "domain": "example.com"
23
- }
24
- ]
19
+ {
20
+ "count": 1,
21
+ "page": 1,
22
+ "total": 3,
23
+ "items": [
24
+ {
25
+ "id": 1,
26
+ "name": "my-static-host"
27
+ }
28
+ ]
29
+ }
25
30
  `,
26
31
  `$ xano static_host:list -p staging -o json --page 2
27
32
  [
@@ -41,16 +46,7 @@ Available static hosts:
41
46
  options: ['summary', 'json'],
42
47
  required: false,
43
48
  }),
44
- page: Flags.integer({
45
- default: 1,
46
- description: 'Page number for pagination',
47
- required: false,
48
- }),
49
- per_page: Flags.integer({
50
- default: 50,
51
- description: 'Number of results per page',
52
- required: false,
53
- }),
49
+ ...pagingFlags('page-only-envelope', { fixedPerPage: 100 }),
54
50
  workspace: Flags.string({
55
51
  char: 'w',
56
52
  description: 'Workspace ID (optional if set in profile)',
@@ -74,13 +70,7 @@ Available static hosts:
74
70
  ` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
75
71
  }
76
72
  // Build query parameters
77
- const queryParams = new URLSearchParams({
78
- page: flags.page.toString(),
79
- });
80
- // Only add per_page if it's not the default value
81
- if (flags.per_page !== 50) {
82
- queryParams.append('per_page', flags.per_page.toString());
83
- }
73
+ const queryParams = new URLSearchParams(buildPagingParams(flags, 'page-only-envelope'));
84
74
  // Construct the API URL
85
75
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host?${queryParams.toString()}`;
86
76
  // Fetch static hosts from the API
@@ -96,24 +86,11 @@ Available static hosts:
96
86
  const errorText = await response.text();
97
87
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
98
88
  }
99
- const data = await response.json();
100
- // Handle different response formats
101
- let staticHosts;
102
- if (Array.isArray(data)) {
103
- staticHosts = data;
104
- }
105
- else if (data && typeof data === 'object' && 'static_hosts' in data && Array.isArray(data.static_hosts)) {
106
- staticHosts = data.static_hosts;
107
- }
108
- else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
109
- staticHosts = data.items;
110
- }
111
- else {
112
- this.error('Unexpected API response format');
113
- }
89
+ const list = normalizeListResponse(await response.json(), ['static_hosts']);
90
+ const staticHosts = list.items;
114
91
  // Output results
115
92
  if (flags.output === 'json') {
116
- this.log(JSON.stringify(staticHosts, null, 2));
93
+ this.log(JSON.stringify(buildPagingJson(list, { page: flags.page, tier: 'page-only-envelope' }), null, 2));
117
94
  }
118
95
  else {
119
96
  // summary format
@@ -132,6 +109,9 @@ Available static hosts:
132
109
  }
133
110
  }
134
111
  }
112
+ const footer = formatPagingFooter(list, { noun: 'static host', page: flags.page, tier: 'page-only-envelope' });
113
+ if (footer)
114
+ this.log(footer);
135
115
  }
136
116
  }
137
117
  catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { normalizeListResponse } from '../../../utils/paging.js';
3
4
  export default class StaticHostMigrate extends BaseCommand {
4
5
  static args = {
5
6
  static_host: Args.string({
@@ -85,9 +86,12 @@ Would migrate 3 v1 host(s): newsite, marketing, legacy
85
86
  /** List all static hosts in the workspace (paginates until exhausted). */
86
87
  async listHosts(profile, workspaceId, verbose) {
87
88
  const hosts = [];
88
- const perPage = 100;
89
89
  for (let page = 1;; page++) {
90
- const listUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host?page=${page}&per_page=${perPage}`;
90
+ // per_page is not an input on this endpoint — the server fixes the page
91
+ // size — so it is deliberately not sent. Termination uses the response's
92
+ // own itemsTotal rather than "the page came back full", which would skip
93
+ // hosts if the server page size ever differed from a hardcoded guess.
94
+ const listUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host?page=${page}`;
91
95
  // eslint-disable-next-line no-await-in-loop
92
96
  const response = await this.verboseFetch(listUrl, {
93
97
  headers: {
@@ -100,14 +104,14 @@ Would migrate 3 v1 host(s): newsite, marketing, legacy
100
104
  const errorText = await response.text(); // eslint-disable-line no-await-in-loop
101
105
  this.error(`Failed to list static hosts: ${response.status} ${response.statusText}\n${errorText}`);
102
106
  }
103
- const data = (await response.json()); // eslint-disable-line no-await-in-loop
104
- const pageHosts = Array.isArray(data)
105
- ? data
106
- : data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)
107
- ? data.items
108
- : [];
109
- hosts.push(...pageHosts);
110
- if (pageHosts.length < perPage)
107
+ const list = normalizeListResponse(await response.json(), ['static_hosts']); // eslint-disable-line no-await-in-loop
108
+ hosts.push(...list.items);
109
+ // Stop on the server's own total when it gives us one. Fall back to an
110
+ // empty page otherwise — one extra request, but it cannot under-collect
111
+ // and silently migrate a subset.
112
+ if (list.items.length === 0)
113
+ break;
114
+ if (list.itemsTotal !== undefined && hosts.length >= list.itemsTotal)
111
115
  break;
112
116
  }
113
117
  return hosts;
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../../base-command.js';
3
+ import { buildPagingJson } from '../../../../utils/paging.js';
3
4
  export default class TenantBackupList extends BaseCommand {
4
5
  static args = {
5
6
  tenant_name: Args.string({
@@ -70,23 +71,21 @@ Backups for tenant t1234-abcd-xyz1:
70
71
  this.error('Unexpected API response format');
71
72
  }
72
73
  if (flags.output === 'json') {
73
- this.log(JSON.stringify(backups, null, 2));
74
+ this.log(JSON.stringify(buildPagingJson({ items: backups }, { tier: 'none' }), null, 2));
75
+ }
76
+ else if (backups.length === 0) {
77
+ this.log(`No backups found for tenant ${tenantName}`);
74
78
  }
75
79
  else {
76
- if (backups.length === 0) {
77
- this.log(`No backups found for tenant ${tenantName}`);
78
- }
79
- else {
80
- this.log(`Backups for tenant ${tenantName}:`);
81
- for (const backup of backups) {
82
- let date = 'unknown';
83
- if (backup.created_at) {
84
- const d = new Date(backup.created_at);
85
- date = Number.isNaN(d.getTime()) ? backup.created_at : d.toISOString().split('T')[0];
86
- }
87
- const desc = backup.description || 'No description';
88
- this.log(` - #${backup.id} - ${desc} (${date})`);
80
+ this.log(`Backups for tenant ${tenantName}:`);
81
+ for (const backup of backups) {
82
+ let date = 'unknown';
83
+ if (backup.created_at) {
84
+ const d = new Date(backup.created_at);
85
+ date = Number.isNaN(d.getTime()) ? backup.created_at : d.toISOString().split('T')[0];
89
86
  }
87
+ const desc = backup.description || 'No description';
88
+ this.log(` - #${backup.id} - ${desc} (${date})`);
90
89
  }
91
90
  }
92
91
  }
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../../base-command.js';
3
+ import { buildPagingJson } from '../../../../utils/paging.js';
3
4
  export default class TenantClusterList extends BaseCommand {
4
5
  static description = 'List all tenant clusters';
5
6
  static examples = [
@@ -48,18 +49,16 @@ Tenant clusters:
48
49
  this.error('Unexpected API response format');
49
50
  }
50
51
  if (flags.output === 'json') {
51
- this.log(JSON.stringify(clusters, null, 2));
52
+ this.log(JSON.stringify(buildPagingJson({ items: clusters }, { tier: 'none' }), null, 2));
53
+ }
54
+ else if (clusters.length === 0) {
55
+ this.log('No tenant clusters found');
52
56
  }
53
57
  else {
54
- if (clusters.length === 0) {
55
- this.log('No tenant clusters found');
56
- }
57
- else {
58
- this.log('Tenant clusters:');
59
- for (const cluster of clusters) {
60
- const type = cluster.type ? ` (${cluster.type})` : '';
61
- this.log(` - ${cluster.name}${type} [ID: ${cluster.id}]`);
62
- }
58
+ this.log('Tenant clusters:');
59
+ for (const cluster of clusters) {
60
+ const type = cluster.type ? ` (${cluster.type})` : '';
61
+ this.log(` - ${cluster.name}${type} [ID: ${cluster.id}]`);
63
62
  }
64
63
  }
65
64
  }
@@ -6,6 +6,8 @@ export default class TenantCreate extends BaseCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ allow_deploy_bypass: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ allow_quick_deploy: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
11
  cluster_id: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
12
  description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
13
  domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -13,6 +15,7 @@ export default class TenantCreate extends BaseCommand {
13
15
  license: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
16
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
17
  platform_id: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ required_reviewers: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
16
19
  type: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
17
20
  tasks: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
21
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -17,9 +17,20 @@ Created tenant: Production (production) - ID: 42
17
17
  `,
18
18
  `$ xano tenant create "Staging" --description "Staging env" --cluster_id 1 --platform_id 1 --type tier2 -o json`,
19
19
  `$ xano tenant create "Staging" --type tier2 --cluster_id 1 --license ./license.yaml`,
20
+ `$ xano tenant create "Production" --required_reviewers 1 --allow_deploy_bypass`,
20
21
  ];
21
22
  static flags = {
22
23
  ...BaseCommand.baseFlags,
24
+ allow_deploy_bypass: Flags.boolean({
25
+ default: false,
26
+ description: 'Allow authorized users to bypass the deploy approval gate on this tenant (via tenant_deploy_request bypass). Has no effect unless --required_reviewers is also set above 0.',
27
+ required: false,
28
+ }),
29
+ allow_quick_deploy: Flags.boolean({
30
+ default: false,
31
+ description: 'Allow this tenant to skip the deploy approval gate entirely for quick deploys, regardless of --required_reviewers.',
32
+ required: false,
33
+ }),
23
34
  cluster_id: Flags.integer({
24
35
  description: 'Cluster ID to deploy to (required for tier2/tier3)',
25
36
  required: false,
@@ -54,6 +65,11 @@ Created tenant: Production (production) - ID: 42
54
65
  description: 'Platform ID to use',
55
66
  required: false,
56
67
  }),
68
+ required_reviewers: Flags.integer({
69
+ default: 0,
70
+ 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 (default) means not gated.',
71
+ required: false,
72
+ }),
57
73
  type: Flags.string({
58
74
  default: 'tier1',
59
75
  description: 'Tenant type',
@@ -79,6 +95,11 @@ Created tenant: Production (production) - ID: 42
79
95
  this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
80
96
  }
81
97
  const body = {
98
+ deploy_settings: {
99
+ allow_deploy_bypass: flags.allow_deploy_bypass,
100
+ allow_quick_deploy: flags.allow_quick_deploy,
101
+ required_reviewers: flags.required_reviewers,
102
+ },
82
103
  display: args.display,
83
104
  ingress: flags.ingress,
84
105
  license: flags.type,
@@ -14,4 +14,25 @@ export default class TenantDeployRelease extends BaseCommand {
14
14
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  };
16
16
  run(): Promise<void>;
17
+ /**
18
+ * Does an approved tenant_deploy_request already exist for this (tenant, release)
19
+ * pair? The list endpoint has no release filter, so this fetches approved
20
+ * requests for the tenant and filters client-side by the resolved release name.
21
+ */
22
+ private hasApprovedDeployRequest;
23
+ /**
24
+ * Does this tenant have the deploy approval gate enabled?
25
+ *
26
+ * Returns false if the tenant has `allow_quick_deploy` set — that flag lets a
27
+ * tenant skip the approval gate entirely for quick deploys, regardless of
28
+ * `required_reviewers`.
29
+ *
30
+ * Deliberately uses `tenant list` rather than `tenant get`: the single-tenant
31
+ * GET route's output whitelist (tenant-detail.yaml) omits `deploy_settings`
32
+ * (which holds `required_reviewers`, `allow_deploy_bypass`, and
33
+ * `allow_quick_deploy`) even though the list route's output includes it
34
+ * — confirmed live against a running instance. Filed as a backend bug; this
35
+ * is the workaround until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
36
+ */
37
+ private tenantRequiresApproval;
17
38
  }
@@ -43,6 +43,27 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
43
43
  }
44
44
  const releaseName = flags.release;
45
45
  const tenantName = args.tenant_name;
46
+ // Pre-flight: the "approval required" error must always be shown before any
47
+ // permission/RBAC error. The backend's own deploy route checks RBAC first
48
+ // and the approval gate second (inside mvp:tenant_deploy_release), so a
49
+ // caller who lacks deploy permission would otherwise never learn a request
50
+ // is what's actually needed. Checking here first, with an endpoint that
51
+ // only needs read scope, guarantees the right error is shown regardless of
52
+ // whether the caller can deploy at all.
53
+ const requiresApproval = await this.tenantRequiresApproval(profile, workspaceId, tenantName, flags.verbose);
54
+ if (requiresApproval) {
55
+ const hasApprovedRequest = await this.hasApprovedDeployRequest({
56
+ profile,
57
+ releaseName,
58
+ tenantName,
59
+ verbose: flags.verbose,
60
+ workspaceId,
61
+ });
62
+ if (!hasApprovedRequest) {
63
+ this.error(`Tenant "${tenantName}" requires an approved deploy request before "${releaseName}" can be deployed.\n` +
64
+ `Run: xano tenant_deploy_request create "Deploy ${releaseName} to ${tenantName}" --tenant ${tenantName} --release ${releaseName} --reviewers <id,id,...>`);
65
+ }
66
+ }
46
67
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/deploy`;
47
68
  this.warn('This may take a few minutes. Please be patient.');
48
69
  const startTime = Date.now();
@@ -57,8 +78,17 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
57
78
  method: 'POST',
58
79
  }, flags.verbose, profile.access_token);
59
80
  if (!response.ok) {
60
- const errorText = await response.text();
61
- this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
81
+ let message = await this.parseApiError(response, 'API request failed');
82
+ // Only reached once the pre-flight above has already confirmed the
83
+ // approval gate is satisfied or not applicable — so a permission error
84
+ // here is genuinely about RBAC, not about a missing approval. If the
85
+ // tenant supports deploy requests, mention that path as an alternative
86
+ // to direct deploy, since opening a request doesn't require deploy
87
+ // permission.
88
+ if ((response.status === 401 || response.status === 403) && requiresApproval) {
89
+ 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,...>`;
90
+ }
91
+ this.error(message);
62
92
  }
63
93
  const tenant = (await response.json());
64
94
  if (flags.output === 'json') {
@@ -83,4 +113,66 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
83
113
  }
84
114
  }
85
115
  }
116
+ /**
117
+ * Does an approved tenant_deploy_request already exist for this (tenant, release)
118
+ * pair? The list endpoint has no release filter, so this fetches approved
119
+ * requests for the tenant and filters client-side by the resolved release name.
120
+ */
121
+ async hasApprovedDeployRequest(opts) {
122
+ const { profile, releaseName, tenantName, verbose, workspaceId } = opts;
123
+ const params = new URLSearchParams({ per_page: '100', status: 'approved', tenant_name: tenantName });
124
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request?${params}`;
125
+ const response = await this.verboseFetch(apiUrl, {
126
+ headers: {
127
+ accept: 'application/json',
128
+ Authorization: `Bearer ${profile.access_token}`,
129
+ },
130
+ method: 'GET',
131
+ }, verbose, profile.access_token);
132
+ if (!response.ok) {
133
+ // A read-scope failure here means something is more broadly wrong (e.g.
134
+ // no workspace access at all) — surface it rather than silently treating
135
+ // it as "no approval found", which would misreport as approval-required.
136
+ const message = await this.parseApiError(response, 'Failed to check for an approved deploy request');
137
+ this.error(message);
138
+ }
139
+ const data = (await response.json());
140
+ const items = Array.isArray(data) ? data : (data.items ?? []);
141
+ return items.some((item) => item.status === 'approved' && item._release?.name === releaseName);
142
+ }
143
+ /**
144
+ * Does this tenant have the deploy approval gate enabled?
145
+ *
146
+ * Returns false if the tenant has `allow_quick_deploy` set — that flag lets a
147
+ * tenant skip the approval gate entirely for quick deploys, regardless of
148
+ * `required_reviewers`.
149
+ *
150
+ * Deliberately uses `tenant list` rather than `tenant get`: the single-tenant
151
+ * GET route's output whitelist (tenant-detail.yaml) omits `deploy_settings`
152
+ * (which holds `required_reviewers`, `allow_deploy_bypass`, and
153
+ * `allow_quick_deploy`) even though the list route's output includes it
154
+ * — confirmed live against a running instance. Filed as a backend bug; this
155
+ * is the workaround until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
156
+ */
157
+ async tenantRequiresApproval(profile, workspaceId, tenantName, verbose) {
158
+ const params = new URLSearchParams({ per_page: '100' });
159
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant?${params}`;
160
+ const response = await this.verboseFetch(apiUrl, {
161
+ headers: {
162
+ accept: 'application/json',
163
+ Authorization: `Bearer ${profile.access_token}`,
164
+ },
165
+ method: 'GET',
166
+ }, verbose, profile.access_token);
167
+ if (!response.ok) {
168
+ const message = await this.parseApiError(response, 'Failed to look up tenant');
169
+ this.error(message);
170
+ }
171
+ const data = (await response.json());
172
+ const items = Array.isArray(data) ? data : (data.items ?? []);
173
+ const tenant = items.find((item) => item.name === tenantName);
174
+ if (tenant?.deploy_settings?.allow_quick_deploy)
175
+ return false;
176
+ return (tenant?.deploy_settings?.required_reviewers ?? 0) > 0;
177
+ }
86
178
  }
@@ -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,9 +107,25 @@ 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 = {
120
+ deploy_settings: {
121
+ allow_deploy_bypass: current.deploy_settings?.allow_deploy_bypass ?? false,
122
+ allow_quick_deploy: flags.allow_quick_deploy !== undefined
123
+ ? flags.allow_quick_deploy
124
+ : (current.deploy_settings?.allow_quick_deploy ?? false),
125
+ required_reviewers: flags.required_reviewers !== undefined
126
+ ? flags.required_reviewers
127
+ : (current.deploy_settings?.required_reviewers ?? 0),
128
+ },
94
129
  description: flags.description !== undefined ? flags.description : (current.description ?? ''),
95
130
  display: flags.display !== undefined ? flags.display : (current.display ?? current.name),
96
131
  domain: flags.domain !== undefined ? flags.domain : (current.domain ?? ''),
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../../base-command.js';
3
+ import { formatPagingFooter } from '../../../../utils/paging.js';
3
4
  export default class TenantEnvList extends BaseCommand {
4
5
  static args = {
5
6
  tenant_name: Args.string({
@@ -68,6 +69,9 @@ Environment variables for tenant my-tenant:
68
69
  this.log(` - ${envVar.name}`);
69
70
  }
70
71
  }
72
+ const footer = formatPagingFooter({ items: envVars }, { noun: 'environment variable', tier: 'none' });
73
+ if (footer)
74
+ this.log(footer);
71
75
  }
72
76
  }
73
77
  catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { buildPagingJson } from '../../../utils/paging.js';
3
4
  export default class TenantList extends BaseCommand {
4
5
  static description = 'List all tenants in a workspace';
5
6
  static examples = [
@@ -65,28 +66,26 @@ Tenants in workspace 5:
65
66
  this.error('Unexpected API response format');
66
67
  }
67
68
  if (flags.output === 'json') {
68
- this.log(JSON.stringify(tenants, null, 2));
69
+ this.log(JSON.stringify(buildPagingJson({ items: tenants }, { tier: 'none' }), null, 2));
70
+ }
71
+ else if (tenants.length === 0) {
72
+ this.log('No tenants found');
69
73
  }
70
74
  else {
71
- if (tenants.length === 0) {
72
- this.log('No tenants found');
73
- }
74
- else {
75
- this.log(`Tenants in workspace ${workspaceId}:`);
76
- for (const tenant of tenants) {
77
- const state = tenant.state ? ` [${tenant.state}]` : '';
78
- const license = tenant.license ? ` - ${tenant.license}` : '';
79
- const typeLabel = tenant.type && tenant.type !== 'standard' ? ` [${tenant.type}]` : '';
80
- this.log(` - ${tenant.display || tenant.name} (${tenant.name})${state}${license}${typeLabel}`);
81
- if (tenant.cluster?.name)
82
- this.log(` Cluster: ${tenant.cluster.name}`);
83
- const releaseName = typeof tenant.release === 'string' ? tenant.release : tenant.release?.name;
84
- const releaseId = typeof tenant.release === 'object' ? tenant.release?.id : undefined;
85
- if (releaseName)
86
- this.log(` Release: ${releaseName} (ID: ${releaseId})`);
87
- if (tenant.platform?.name)
88
- this.log(` Platform: ${tenant.platform.name}`);
89
- }
75
+ this.log(`Tenants in workspace ${workspaceId}:`);
76
+ for (const tenant of tenants) {
77
+ const state = tenant.state ? ` [${tenant.state}]` : '';
78
+ const license = tenant.license ? ` - ${tenant.license}` : '';
79
+ const typeLabel = tenant.type && tenant.type !== 'standard' ? ` [${tenant.type}]` : '';
80
+ this.log(` - ${tenant.display || tenant.name} (${tenant.name})${state}${license}${typeLabel}`);
81
+ if (tenant.cluster?.name)
82
+ this.log(` Cluster: ${tenant.cluster.name}`);
83
+ const releaseName = typeof tenant.release === 'string' ? tenant.release : tenant.release?.name;
84
+ const releaseId = typeof tenant.release === 'object' ? tenant.release?.id : undefined;
85
+ if (releaseName)
86
+ this.log(` Release: ${releaseName} (ID: ${releaseId})`);
87
+ if (tenant.platform?.name)
88
+ this.log(` Platform: ${tenant.platform.name}`);
90
89
  }
91
90
  }
92
91
  }
@@ -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
  }