@xano/cli 1.3.4 → 1.3.5-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -2
- package/dist/base-command.d.ts +12 -1
- package/dist/base-command.js +36 -22
- package/dist/commands/branch/set_live/index.d.ts +14 -0
- package/dist/commands/branch/set_live/index.js +38 -0
- package/dist/commands/ephemeral/push/index.js +6 -1
- package/dist/commands/function/create/index.d.ts +15 -0
- package/dist/commands/function/create/index.js +39 -0
- package/dist/commands/function/edit/index.d.ts +15 -0
- package/dist/commands/function/edit/index.js +39 -0
- package/dist/commands/function/run/index.js +5 -11
- package/dist/commands/policy/catalogue/index.d.ts +16 -0
- package/dist/commands/policy/catalogue/index.js +60 -0
- package/dist/commands/policy/coverage/index.d.ts +17 -0
- package/dist/commands/policy/coverage/index.js +33 -0
- package/dist/commands/policy/create/index.d.ts +18 -0
- package/dist/commands/policy/create/index.js +44 -0
- package/dist/commands/policy/delete/index.d.ts +19 -0
- package/dist/commands/policy/delete/index.js +61 -0
- package/dist/commands/policy/evaluate/index.d.ts +32 -0
- package/dist/commands/policy/evaluate/index.js +71 -0
- package/dist/commands/policy/list/index.d.ts +14 -0
- package/dist/commands/policy/list/index.js +21 -0
- package/dist/commands/policy/parse/index.d.ts +19 -0
- package/dist/commands/policy/parse/index.js +18 -0
- package/dist/commands/policy/publish/index.d.ts +20 -0
- package/dist/commands/policy/publish/index.js +38 -0
- package/dist/commands/policy/runs/index.d.ts +54 -0
- package/dist/commands/policy/runs/index.js +273 -0
- package/dist/commands/policy/status/index.d.ts +21 -0
- package/dist/commands/policy/status/index.js +54 -0
- package/dist/commands/release/create/index.js +4 -0
- package/dist/commands/release/deploy/index.d.ts +19 -0
- package/dist/commands/release/deploy/index.js +68 -6
- package/dist/commands/release/import/index.js +5 -1
- package/dist/commands/release/list/index.d.ts +7 -0
- package/dist/commands/release/list/index.js +46 -9
- package/dist/commands/release/push/index.js +19 -1
- package/dist/commands/sandbox/push/index.js +6 -1
- package/dist/commands/skills/pull/index.d.ts +30 -0
- package/dist/commands/skills/pull/index.js +118 -0
- package/dist/commands/tenant/create/index.d.ts +3 -0
- package/dist/commands/tenant/create/index.js +21 -0
- package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
- package/dist/commands/tenant/deploy_release/index.js +182 -4
- package/dist/commands/tenant/edit/index.d.ts +2 -0
- package/dist/commands/tenant/edit/index.js +39 -4
- package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
- package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
- package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/create/index.js +108 -0
- package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
- package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
- package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
- package/dist/commands/tenant_deploy_request/get/index.js +113 -0
- package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
- package/dist/commands/tenant_deploy_request/list/index.js +122 -0
- package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
- package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
- package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
- package/dist/commands/workspace/pull/index.d.ts +10 -0
- package/dist/commands/workspace/pull/index.js +70 -7
- package/dist/commands/workspace/push/index.d.ts +19 -0
- package/dist/commands/workspace/push/index.js +135 -6
- package/dist/policy-command.d.ts +91 -0
- package/dist/policy-command.js +95 -0
- package/dist/utils/document-parser.d.ts +10 -0
- package/dist/utils/document-parser.js +21 -0
- package/dist/utils/multidoc-push.d.ts +57 -2
- package/dist/utils/multidoc-push.js +102 -33
- package/dist/utils/policy/catalogue.d.ts +85 -0
- package/dist/utils/policy/catalogue.js +196 -0
- package/dist/utils/policy/coverage.d.ts +19 -0
- package/dist/utils/policy/coverage.js +93 -0
- package/dist/utils/policy/errors.d.ts +20 -0
- package/dist/utils/policy/errors.js +93 -0
- package/dist/utils/policy/feedback.d.ts +90 -0
- package/dist/utils/policy/feedback.js +210 -0
- package/dist/utils/policy/findings.d.ts +29 -0
- package/dist/utils/policy/findings.js +82 -0
- package/dist/utils/policy/gate.d.ts +138 -0
- package/dist/utils/policy/gate.js +193 -0
- package/dist/utils/policy/permission.d.ts +7 -0
- package/dist/utils/policy/permission.js +55 -0
- package/dist/utils/policy/push-gate.d.ts +5 -0
- package/dist/utils/policy/push-gate.js +43 -0
- package/dist/utils/policy/release.d.ts +85 -0
- package/dist/utils/policy/release.js +117 -0
- package/dist/utils/policy/request.d.ts +55 -0
- package/dist/utils/policy/request.js +119 -0
- package/dist/utils/policy/runs.d.ts +30 -0
- package/dist/utils/policy/runs.js +101 -0
- package/dist/utils/policy/status.d.ts +55 -0
- package/dist/utils/policy/status.js +127 -0
- package/dist/utils/policy/template.d.ts +66 -0
- package/dist/utils/policy/template.js +152 -0
- package/dist/utils/policy/types.d.ts +295 -0
- package/dist/utils/policy/types.js +1 -0
- package/oclif.manifest.json +5782 -2573
- package/package.json +9 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
export default class TenantDeployRequestList extends BaseCommand {
|
|
4
|
+
static description = 'List tenant deploy requests (deployment approval requests) in a workspace';
|
|
5
|
+
static examples = [
|
|
6
|
+
`$ xano tenant_deploy_request list
|
|
7
|
+
Deploy requests in workspace 5:
|
|
8
|
+
- #12 "Deploy v1.2 to prod" [pending] -> prod / v1.2
|
|
9
|
+
`,
|
|
10
|
+
`$ xano tenant_deploy_request list --tenant prod --status pending`,
|
|
11
|
+
`$ xano tenant_deploy_request list --tenant prod --release v1.2`,
|
|
12
|
+
`$ xano tenant_deploy_request list --to-review -o json`,
|
|
13
|
+
];
|
|
14
|
+
static flags = {
|
|
15
|
+
...BaseCommand.baseFlags,
|
|
16
|
+
mine: Flags.boolean({
|
|
17
|
+
default: false,
|
|
18
|
+
description: 'Only requests you authored',
|
|
19
|
+
required: false,
|
|
20
|
+
}),
|
|
21
|
+
output: Flags.string({
|
|
22
|
+
char: 'o',
|
|
23
|
+
default: 'summary',
|
|
24
|
+
description: 'Output format',
|
|
25
|
+
options: ['summary', 'json'],
|
|
26
|
+
required: false,
|
|
27
|
+
}),
|
|
28
|
+
page: Flags.integer({
|
|
29
|
+
default: 1,
|
|
30
|
+
description: 'Page number',
|
|
31
|
+
required: false,
|
|
32
|
+
}),
|
|
33
|
+
'per-page': Flags.integer({
|
|
34
|
+
default: 25,
|
|
35
|
+
description: 'Items per page',
|
|
36
|
+
required: false,
|
|
37
|
+
}),
|
|
38
|
+
release: Flags.string({
|
|
39
|
+
char: 'r',
|
|
40
|
+
description: 'Filter to requests targeting this release. Applied client-side (the API has no release filter), after --page/--per-page, so a narrow page size can hide matches on other pages.',
|
|
41
|
+
required: false,
|
|
42
|
+
}),
|
|
43
|
+
status: Flags.string({
|
|
44
|
+
description: 'Filter by status',
|
|
45
|
+
options: ['draft', 'pending', 'changes_requested', 'approved', 'closed'],
|
|
46
|
+
required: false,
|
|
47
|
+
}),
|
|
48
|
+
tenant: Flags.string({
|
|
49
|
+
description: 'Filter to requests targeting this tenant',
|
|
50
|
+
required: false,
|
|
51
|
+
}),
|
|
52
|
+
'to-review': Flags.boolean({
|
|
53
|
+
default: false,
|
|
54
|
+
description: 'Only requests you are able to review',
|
|
55
|
+
required: false,
|
|
56
|
+
}),
|
|
57
|
+
workspace: Flags.string({
|
|
58
|
+
char: 'w',
|
|
59
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
60
|
+
required: false,
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
async run() {
|
|
64
|
+
const { flags } = await this.parse(TenantDeployRequestList);
|
|
65
|
+
const { profile } = this.resolveProfile(flags);
|
|
66
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
67
|
+
if (!workspaceId) {
|
|
68
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
69
|
+
}
|
|
70
|
+
const params = new URLSearchParams();
|
|
71
|
+
if (flags.status)
|
|
72
|
+
params.set('status', flags.status);
|
|
73
|
+
if (flags.tenant)
|
|
74
|
+
params.set('tenant_name', flags.tenant);
|
|
75
|
+
if (flags.mine)
|
|
76
|
+
params.set('mine', 'true');
|
|
77
|
+
if (flags['to-review'])
|
|
78
|
+
params.set('to_review', 'true');
|
|
79
|
+
params.set('page', String(flags.page));
|
|
80
|
+
params.set('per_page', String(flags['per-page']));
|
|
81
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request?${params}`;
|
|
82
|
+
try {
|
|
83
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
84
|
+
headers: {
|
|
85
|
+
accept: 'application/json',
|
|
86
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
87
|
+
},
|
|
88
|
+
method: 'GET',
|
|
89
|
+
}, flags.verbose, profile.access_token);
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
const message = await this.parseApiError(response, 'Failed to list tenant deploy requests');
|
|
92
|
+
this.error(message);
|
|
93
|
+
}
|
|
94
|
+
const data = (await response.json());
|
|
95
|
+
const allItems = Array.isArray(data) ? data : (data.items ?? []);
|
|
96
|
+
const items = flags.release ? allItems.filter((item) => item._release?.name === flags.release) : allItems;
|
|
97
|
+
if (flags.output === 'json') {
|
|
98
|
+
this.log(JSON.stringify(items, null, 2));
|
|
99
|
+
}
|
|
100
|
+
else if (items.length === 0) {
|
|
101
|
+
this.log('No tenant deploy requests found');
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
this.log(`Deploy requests in workspace ${workspaceId}:`);
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
const review = item.can_review ? ' (reviewable)' : '';
|
|
107
|
+
const tenantName = item._tenant?.name ?? '?';
|
|
108
|
+
const releaseName = item._release?.name ?? '?';
|
|
109
|
+
this.log(` - #${item.id} "${item.title}" [${item.status}]${review} -> ${tenantName} / ${releaseName}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof Error) {
|
|
115
|
+
this.error(`Failed to list tenant deploy requests: ${error.message}`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
this.error(`Failed to list tenant deploy requests: ${String(error)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class TenantDeployRequestRevision 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
|
+
note: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
release: 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
|
+
run(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
export default class TenantDeployRequestRevision extends BaseCommand {
|
|
4
|
+
static args = {
|
|
5
|
+
id: Args.integer({
|
|
6
|
+
description: 'Tenant deploy request ID',
|
|
7
|
+
required: true,
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Link a newer release candidate to an existing tenant deploy request after changes were requested. The previous candidate is kept in the revision history and the request returns to pending. Author only.';
|
|
11
|
+
static examples = [
|
|
12
|
+
`$ xano tenant_deploy_request revision 12 --release v1.2.1 --note "Added the missing migration"
|
|
13
|
+
Deploy request #12 is now: pending
|
|
14
|
+
`,
|
|
15
|
+
];
|
|
16
|
+
static flags = {
|
|
17
|
+
...BaseCommand.baseFlags,
|
|
18
|
+
note: Flags.string({
|
|
19
|
+
description: 'Note describing what changed in this revision',
|
|
20
|
+
required: false,
|
|
21
|
+
}),
|
|
22
|
+
output: Flags.string({
|
|
23
|
+
char: 'o',
|
|
24
|
+
default: 'summary',
|
|
25
|
+
description: 'Output format',
|
|
26
|
+
options: ['summary', 'json'],
|
|
27
|
+
required: false,
|
|
28
|
+
}),
|
|
29
|
+
release: Flags.string({
|
|
30
|
+
char: 'r',
|
|
31
|
+
description: 'Name of the new release to review',
|
|
32
|
+
required: true,
|
|
33
|
+
}),
|
|
34
|
+
workspace: Flags.string({
|
|
35
|
+
char: 'w',
|
|
36
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
37
|
+
required: false,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
async run() {
|
|
41
|
+
const { args, flags } = await this.parse(TenantDeployRequestRevision);
|
|
42
|
+
const { profile } = this.resolveProfile(flags);
|
|
43
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
44
|
+
if (!workspaceId) {
|
|
45
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
46
|
+
}
|
|
47
|
+
const body = {
|
|
48
|
+
note: flags.note ?? '',
|
|
49
|
+
release_name: flags.release,
|
|
50
|
+
};
|
|
51
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request/${args.id}/revision`;
|
|
52
|
+
try {
|
|
53
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
headers: {
|
|
56
|
+
accept: 'application/json',
|
|
57
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
},
|
|
60
|
+
method: 'POST',
|
|
61
|
+
}, flags.verbose, profile.access_token);
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const message = await this.parseApiError(response, 'Failed to add revision to tenant deploy request');
|
|
64
|
+
this.error(message);
|
|
65
|
+
}
|
|
66
|
+
const item = (await response.json());
|
|
67
|
+
if (flags.output === 'json') {
|
|
68
|
+
this.log(JSON.stringify(item, null, 2));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
this.log(`Deploy request #${item.id} is now: ${item.status}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof Error) {
|
|
76
|
+
this.error(`Failed to add revision to tenant deploy request: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
this.error(`Failed to add revision to tenant deploy request: ${String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class TenantDeployRequestSetStatus 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 | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
status: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
20
|
+
};
|
|
21
|
+
/** Set once the policy gate refuses the deploy an approval triggered, the one failure that exits 2. */
|
|
22
|
+
private refusedByPolicyGate;
|
|
23
|
+
/** Exit 2 is the policy gate's refusal; every other failure, flag errors included, exits 1. */
|
|
24
|
+
protected catch(error: Error & {
|
|
25
|
+
oclif?: {
|
|
26
|
+
exit?: number;
|
|
27
|
+
};
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
run(): Promise<void>;
|
|
30
|
+
/** `--override-policy` overrides the deploy an approve can trigger, and its reason is `--reason`. */
|
|
31
|
+
private checkOverride;
|
|
32
|
+
/**
|
|
33
|
+
* The deploy this approval triggered was refused by the policy gate: its verdict, then the refusal,
|
|
34
|
+
* exiting 2. The vote that completes the approvals is recorded before the deploy, so the request
|
|
35
|
+
* stays approved, and a later deploy of the pair that overrides the gate completes it.
|
|
36
|
+
*/
|
|
37
|
+
private refuseByPolicyGate;
|
|
38
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { gateOverrideHint, gateRefusal, gateRefusalOutput, quoted } from '../../../utils/policy/gate.js';
|
|
4
|
+
export default class TenantDeployRequestSetStatus extends BaseCommand {
|
|
5
|
+
static args = {
|
|
6
|
+
id: Args.integer({
|
|
7
|
+
description: 'Tenant deploy request ID',
|
|
8
|
+
required: true,
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
static description = "[CRITICAL] STOP and confirm with the user before running --status approve; if this is the vote that completes the tenant's required reviewer count, it deploys the release to the tenant immediately as part of approving. Change a tenant deploy request's status (draft, submit, approve, request_changes, close, or reopen).";
|
|
12
|
+
static examples = [
|
|
13
|
+
`$ xano tenant_deploy_request set_status 12 --status submit
|
|
14
|
+
Deploy request #12 is now: pending
|
|
15
|
+
`,
|
|
16
|
+
`$ xano tenant_deploy_request set_status 12 --status approve
|
|
17
|
+
Vote recorded on deploy request #12: 1/2 required reviewers.
|
|
18
|
+
`,
|
|
19
|
+
`$ xano tenant_deploy_request set_status 12 --status approve
|
|
20
|
+
Deploy request #12 is now: approved (2/2 required reviewers)
|
|
21
|
+
Deployed to tenant as part of this approval.
|
|
22
|
+
`,
|
|
23
|
+
`$ xano tenant_deploy_request set_status 12 --status request_changes --reason "Needs a migration note"`,
|
|
24
|
+
`$ xano tenant_deploy_request set_status 12 --status close --reason "Superseded by #14"`,
|
|
25
|
+
`$ xano tenant_deploy_request set_status 12 --status reopen`,
|
|
26
|
+
];
|
|
27
|
+
static flags = {
|
|
28
|
+
...BaseCommand.baseFlags,
|
|
29
|
+
output: Flags.string({
|
|
30
|
+
char: 'o',
|
|
31
|
+
default: 'summary',
|
|
32
|
+
description: 'Output format',
|
|
33
|
+
options: ['summary', 'json'],
|
|
34
|
+
required: false,
|
|
35
|
+
}),
|
|
36
|
+
'override-policy': Flags.boolean({
|
|
37
|
+
default: false,
|
|
38
|
+
description: 'With --status approve: if this vote deploys, proceed past a blocking policy gate, recording --reason as the override reason (needs the workspace:policy update permission; audited)',
|
|
39
|
+
hidden: true,
|
|
40
|
+
}),
|
|
41
|
+
reason: Flags.string({
|
|
42
|
+
description: 'Reason for this status change. Required for request_changes and close.',
|
|
43
|
+
required: false,
|
|
44
|
+
}),
|
|
45
|
+
status: Flags.string({
|
|
46
|
+
description: 'The status transition to apply',
|
|
47
|
+
options: ['draft', 'submit', 'approve', 'request_changes', 'close', 'reopen'],
|
|
48
|
+
required: true,
|
|
49
|
+
}),
|
|
50
|
+
workspace: Flags.string({
|
|
51
|
+
char: 'w',
|
|
52
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
53
|
+
required: false,
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
/** Set once the policy gate refuses the deploy an approval triggered, the one failure that exits 2. */
|
|
57
|
+
refusedByPolicyGate = false;
|
|
58
|
+
/** Exit 2 is the policy gate's refusal; every other failure, flag errors included, exits 1. */
|
|
59
|
+
async catch(error) {
|
|
60
|
+
if (this.refusedByPolicyGate)
|
|
61
|
+
return super.catch(error);
|
|
62
|
+
return this.catchAsOperational(error);
|
|
63
|
+
}
|
|
64
|
+
async run() {
|
|
65
|
+
const { args, flags } = await this.parse(TenantDeployRequestSetStatus);
|
|
66
|
+
const { profile } = this.resolveProfile(flags);
|
|
67
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
68
|
+
if (!workspaceId) {
|
|
69
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
70
|
+
}
|
|
71
|
+
if ((flags.status === 'request_changes' || flags.status === 'close') && !flags.reason) {
|
|
72
|
+
this.error(`--reason is required when --status is "${flags.status}"`);
|
|
73
|
+
}
|
|
74
|
+
if (flags['override-policy'])
|
|
75
|
+
this.checkOverride(flags);
|
|
76
|
+
const body = {
|
|
77
|
+
action: flags.status,
|
|
78
|
+
reason: flags.reason ?? '',
|
|
79
|
+
...(flags['override-policy'] ? { override_policy: true } : {}),
|
|
80
|
+
};
|
|
81
|
+
// A refusal by the policy gate exits 2 with its verdict; the catch that rewords other failures keeps it.
|
|
82
|
+
let refused = null;
|
|
83
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request/${args.id}/status`;
|
|
84
|
+
try {
|
|
85
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
headers: {
|
|
88
|
+
accept: 'application/json',
|
|
89
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
},
|
|
92
|
+
method: 'POST',
|
|
93
|
+
}, flags.verbose, profile.access_token);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
refused = await gateRefusal(response);
|
|
96
|
+
if (refused)
|
|
97
|
+
this.refuseByPolicyGate(refused, args.id, flags.output === 'json');
|
|
98
|
+
const message = await this.parseApiError(response, 'Failed to change tenant deploy request status');
|
|
99
|
+
this.error(message);
|
|
100
|
+
}
|
|
101
|
+
const item = (await response.json());
|
|
102
|
+
if (flags.output === 'json') {
|
|
103
|
+
this.log(JSON.stringify(item, null, 2));
|
|
104
|
+
}
|
|
105
|
+
else if (flags.status === 'approve' && item.fully_approved === false) {
|
|
106
|
+
// required_reviewers > 1: this vote didn't complete the quorum, so status
|
|
107
|
+
// stays "pending" and nothing was deployed yet.
|
|
108
|
+
this.log(`Vote recorded on deploy request #${item.id}: ${item.approvals_count}/${item.required_reviewers} required reviewers.`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const quorum = item.approvals_count !== undefined && item.required_reviewers !== undefined
|
|
112
|
+
? ` (${item.approvals_count}/${item.required_reviewers} required reviewers)`
|
|
113
|
+
: '';
|
|
114
|
+
this.log(`Deploy request #${item.id} is now: ${item.status}${quorum}`);
|
|
115
|
+
if (flags.status === 'approve') {
|
|
116
|
+
if (item.resolution?.deploy_status === 'deployed') {
|
|
117
|
+
this.log('Deployed to tenant as part of this approval.');
|
|
118
|
+
}
|
|
119
|
+
else if (item.resolution?.deploy_status === 'failed') {
|
|
120
|
+
this.log(`Approved, but the deploy failed: ${item.resolution.deploy_error ?? 'unknown error'}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (refused)
|
|
127
|
+
throw error;
|
|
128
|
+
if (error instanceof Error) {
|
|
129
|
+
this.error(`Failed to change tenant deploy request status: ${error.message}`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.error(`Failed to change tenant deploy request status: ${String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** `--override-policy` overrides the deploy an approve can trigger, and its reason is `--reason`. */
|
|
137
|
+
checkOverride(flags) {
|
|
138
|
+
if (flags.status !== 'approve')
|
|
139
|
+
this.error('--override-policy applies only to --status approve, the vote that can deploy.');
|
|
140
|
+
if (!flags.reason?.trim())
|
|
141
|
+
this.error('--override-policy needs --reason: say why this deploy may proceed past the policy gate.');
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The deploy this approval triggered was refused by the policy gate: its verdict, then the refusal,
|
|
145
|
+
* exiting 2. The vote that completes the approvals is recorded before the deploy, so the request
|
|
146
|
+
* stays approved, and a later deploy of the pair that overrides the gate completes it.
|
|
147
|
+
*/
|
|
148
|
+
refuseByPolicyGate(refused, id, json) {
|
|
149
|
+
for (const line of gateRefusalOutput(refused, json))
|
|
150
|
+
this.log(line);
|
|
151
|
+
const approved = refused.answer.gate === 'tenant_approve'
|
|
152
|
+
? '\nYour approval was recorded and the request is approved, but its release was not deployed.'
|
|
153
|
+
: '';
|
|
154
|
+
const release = refused.answer.release?.name?.trim();
|
|
155
|
+
const override = `xano tenant deploy_release <tenant> --release ${release ? quoted(release) : '<release>'} --override-reason "<why>" (\`xano tenant_deploy_request get ${id}\` names the tenant)`;
|
|
156
|
+
this.refusedByPolicyGate = true;
|
|
157
|
+
this.error(`${refused.message}${approved}${gateOverrideHint(refused.answer, override)}`, { exit: 2 });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -21,4 +21,14 @@ export default class Pull extends BaseCommand {
|
|
|
21
21
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
22
22
|
};
|
|
23
23
|
run(): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Policy keys are unique on the server without regard to case, but an older export or a local file
|
|
26
|
+
* can still differ only in case, and file names are not case-sensitive on every checkout. The
|
|
27
|
+
* policies whose file name differs only in case from another exported policy, or from a local file
|
|
28
|
+
* it would overwrite, are left out with a warning; everything else is written. Local policy files
|
|
29
|
+
* absent from the export are kept. They are reported as stale only when this credential reads the
|
|
30
|
+
* branch's policies, because an export leaves out the policies its credential cannot read, and
|
|
31
|
+
* every policy while the Policies feature is off.
|
|
32
|
+
*/
|
|
33
|
+
private policyClashes;
|
|
24
34
|
}
|
|
@@ -3,8 +3,9 @@ import snakeCase from 'lodash.snakecase';
|
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import BaseCommand from '../../../base-command.js';
|
|
6
|
-
import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
|
|
6
|
+
import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, policyBaseName, resolveDocumentPath, } from '../../../utils/document-parser.js';
|
|
7
7
|
import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
|
|
8
|
+
import { policyListing } from '../../../utils/policy/request.js';
|
|
8
9
|
import { recordEntriesForPaths, resolveTarget, saveSyncState, syncStateExists } from '../../../utils/sync-state.js';
|
|
9
10
|
/**
|
|
10
11
|
* Whether pull should write `.xano/sync_state.json`: only when asked to, or to keep an existing
|
|
@@ -41,7 +42,7 @@ Write .xano/sync_state.json after pulling; later pulls keep it current while it
|
|
|
41
42
|
...BaseCommand.baseFlags,
|
|
42
43
|
branch: Flags.string({
|
|
43
44
|
char: 'b',
|
|
44
|
-
description:
|
|
45
|
+
description: "Branch name (defaults to profile branch or live; -b '' selects live)",
|
|
45
46
|
required: false,
|
|
46
47
|
}),
|
|
47
48
|
directory: Flags.string({
|
|
@@ -92,8 +93,8 @@ Write .xano/sync_state.json after pulling; later pulls keep it current while it
|
|
|
92
93
|
` 1. Provide it as a flag: xano workspace pull -w <workspace_id>\n` +
|
|
93
94
|
` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
|
|
94
95
|
}
|
|
95
|
-
// Determine branch from flag or profile
|
|
96
|
-
const branch = flags.branch
|
|
96
|
+
// Determine branch from flag or profile; `-b ''` selects live whatever the profile's branch, as in push.
|
|
97
|
+
const branch = flags.branch ?? profile.branch ?? '';
|
|
97
98
|
// Build query parameters
|
|
98
99
|
const queryParams = new URLSearchParams({
|
|
99
100
|
branch,
|
|
@@ -142,12 +143,13 @@ Write .xano/sync_state.json after pulling; later pulls keep it current while it
|
|
|
142
143
|
documents.push(parsed);
|
|
143
144
|
}
|
|
144
145
|
}
|
|
146
|
+
// Resolve the output directory
|
|
147
|
+
const outputDir = path.resolve(flags.directory);
|
|
148
|
+
const clashing = await this.policyClashes(documents, outputDir, () => policyListing({ logToStderr: (message) => this.logToStderr(message), verboseFetch: (...args) => this.verboseFetch(...args) }, { branch, profile, verbose: flags.verbose, workspace: workspaceId }));
|
|
145
149
|
if (documents.length === 0) {
|
|
146
150
|
this.log('No documents found in response');
|
|
147
151
|
return;
|
|
148
152
|
}
|
|
149
|
-
// Resolve the output directory
|
|
150
|
-
const outputDir = path.resolve(flags.directory);
|
|
151
153
|
// Create the output directory if it doesn't exist
|
|
152
154
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
153
155
|
// Resolve api_group names to unique folder names, disambiguating collisions
|
|
@@ -160,7 +162,7 @@ Write .xano/sync_state.json after pulling; later pulls keep it current while it
|
|
|
160
162
|
const filenameCounters = new Map();
|
|
161
163
|
let writtenCount = 0;
|
|
162
164
|
const writtenPaths = [];
|
|
163
|
-
for (const doc of documents) {
|
|
165
|
+
for (const doc of documents.filter((document) => !clashing.has(document))) {
|
|
164
166
|
const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
|
|
165
167
|
getApiGroupFolder,
|
|
166
168
|
getChannelServer,
|
|
@@ -194,6 +196,12 @@ Write .xano/sync_state.json after pulling; later pulls keep it current while it
|
|
|
194
196
|
if (knowledgeCount > 0)
|
|
195
197
|
parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
|
|
196
198
|
this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
|
|
199
|
+
// Policy files state the rules; the skill the instance generates tells an agent how to follow them.
|
|
200
|
+
// Only a pull that carried policy files says so: an instance with the Policies feature off exports
|
|
201
|
+
// none, and its skill route answers 403.
|
|
202
|
+
if (documents.some((doc) => doc.type === 'policy')) {
|
|
203
|
+
this.log('Run `xano skills pull` to install the policies skill for your coding agent.');
|
|
204
|
+
}
|
|
197
205
|
const syncState = syncStateAction(flags['sync-state'], outputDir, flags.draft);
|
|
198
206
|
if (syncState === 'skip') {
|
|
199
207
|
return;
|
|
@@ -228,4 +236,59 @@ Write .xano/sync_state.json after pulling; later pulls keep it current while it
|
|
|
228
236
|
this.warn(`Skipped writing .xano/sync_state.json: ${error.message}`);
|
|
229
237
|
}
|
|
230
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Policy keys are unique on the server without regard to case, but an older export or a local file
|
|
241
|
+
* can still differ only in case, and file names are not case-sensitive on every checkout. The
|
|
242
|
+
* policies whose file name differs only in case from another exported policy, or from a local file
|
|
243
|
+
* it would overwrite, are left out with a warning; everything else is written. Local policy files
|
|
244
|
+
* absent from the export are kept. They are reported as stale only when this credential reads the
|
|
245
|
+
* branch's policies, because an export leaves out the policies its credential cannot read, and
|
|
246
|
+
* every policy while the Policies feature is off.
|
|
247
|
+
*/
|
|
248
|
+
async policyClashes(documents, outputDir, readsPolicies) {
|
|
249
|
+
const exported = new Map();
|
|
250
|
+
for (const doc of documents.filter(doc => doc.type === 'policy')) {
|
|
251
|
+
const filename = `${policyBaseName(doc.name)}.xs`;
|
|
252
|
+
exported.set(filename.toLowerCase(), [...(exported.get(filename.toLowerCase()) ?? []), filename]);
|
|
253
|
+
}
|
|
254
|
+
const clashes = new Map();
|
|
255
|
+
for (const [normalized, names] of exported) {
|
|
256
|
+
if (names.length > 1)
|
|
257
|
+
clashes.set(normalized, names.map(name => `policies/${name}`).join(' and '));
|
|
258
|
+
}
|
|
259
|
+
const policyDir = path.join(outputDir, 'policies');
|
|
260
|
+
const localFiles = fs.existsSync(policyDir)
|
|
261
|
+
? fs.readdirSync(policyDir, { withFileTypes: true })
|
|
262
|
+
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.xs'))
|
|
263
|
+
.map(entry => entry.name)
|
|
264
|
+
.sort()
|
|
265
|
+
: [];
|
|
266
|
+
const stale = [];
|
|
267
|
+
for (const filename of localFiles) {
|
|
268
|
+
const names = exported.get(filename.toLowerCase());
|
|
269
|
+
if (!names)
|
|
270
|
+
stale.push(`policies/${filename}`);
|
|
271
|
+
else if (!names.includes(filename) && !clashes.has(filename.toLowerCase())) {
|
|
272
|
+
clashes.set(filename.toLowerCase(), `policies/${names[0]} (local policies/${filename})`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (clashes.size > 0) {
|
|
276
|
+
this.warn(`Policy files that differ only in case are left out of this pull; everything else is written:\n ${[...clashes.values()].join('\n ')}\n` +
|
|
277
|
+
'Rename one of the policies, or move the local file aside, then pull again.');
|
|
278
|
+
}
|
|
279
|
+
if (stale.length > 0) {
|
|
280
|
+
// An export that carries a policy was made by a credential that reads them.
|
|
281
|
+
const listing = exported.size > 0 ? 'listed' : await readsPolicies();
|
|
282
|
+
if (listing === 'listed') {
|
|
283
|
+
this.warn(`Stale local policy files are absent from this export and were kept:\n ${stale.join('\n ')}\nReview them before pushing; a push can publish these policies again.`);
|
|
284
|
+
}
|
|
285
|
+
else if (listing === 'feature_off') {
|
|
286
|
+
this.log('Local policy files were kept: Policies are not enabled on this instance, so the export carries none.');
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
this.log(`Local policy files were kept: this credential cannot list this workspace's policies, and the export carries none.`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return new Set(documents.filter(doc => doc.type === 'policy' && clashes.has(`${doc.name}.xs`.toLowerCase())));
|
|
293
|
+
}
|
|
231
294
|
}
|
|
@@ -13,6 +13,9 @@ export default class Push extends BaseCommand {
|
|
|
13
13
|
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
14
|
guids: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
include: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
message: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
19
|
records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
20
|
refresh: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
21
|
sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -25,5 +28,21 @@ export default class Push extends BaseCommand {
|
|
|
25
28
|
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
26
29
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
27
30
|
};
|
|
31
|
+
protected catch(error: Error & {
|
|
32
|
+
oclif?: {
|
|
33
|
+
exit?: number;
|
|
34
|
+
};
|
|
35
|
+
}): Promise<void>;
|
|
28
36
|
run(): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* A failure after the import landed. The import stands, so its policy feedback is reported as for
|
|
39
|
+
* any push and a blocking finding still exits 2; otherwise the failure exits 1. Under `-o json`
|
|
40
|
+
* stdout holds the import's document with the failure as `error`.
|
|
41
|
+
*/
|
|
42
|
+
private catchAfterImport;
|
|
43
|
+
/**
|
|
44
|
+
* What happened to the policy documents, then the policy check the import answered with. When the
|
|
45
|
+
* platform left the policy files out, its notice says so instead of a count of what was sent.
|
|
46
|
+
*/
|
|
47
|
+
private reportPolicyFeedback;
|
|
29
48
|
}
|