@xano/cli 1.3.0 → 1.3.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +273 -1
  2. package/dist/base-command.d.ts +12 -1
  3. package/dist/base-command.js +36 -22
  4. package/dist/commands/branch/set_live/index.d.ts +22 -0
  5. package/dist/commands/branch/set_live/index.js +60 -1
  6. package/dist/commands/ephemeral/push/index.js +6 -1
  7. package/dist/commands/function/create/index.d.ts +15 -0
  8. package/dist/commands/function/create/index.js +40 -0
  9. package/dist/commands/function/edit/index.d.ts +15 -0
  10. package/dist/commands/function/edit/index.js +40 -0
  11. package/dist/commands/function/run/index.d.ts +1 -0
  12. package/dist/commands/function/run/index.js +10 -2
  13. package/dist/commands/policy/catalogue/index.d.ts +15 -0
  14. package/dist/commands/policy/catalogue/index.js +29 -0
  15. package/dist/commands/policy/create/index.d.ts +18 -0
  16. package/dist/commands/policy/create/index.js +39 -0
  17. package/dist/commands/policy/delete/index.d.ts +19 -0
  18. package/dist/commands/policy/delete/index.js +61 -0
  19. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  20. package/dist/commands/policy/evaluate/index.js +71 -0
  21. package/dist/commands/policy/list/index.d.ts +14 -0
  22. package/dist/commands/policy/list/index.js +21 -0
  23. package/dist/commands/policy/parse/index.d.ts +19 -0
  24. package/dist/commands/policy/parse/index.js +18 -0
  25. package/dist/commands/policy/publish/index.d.ts +20 -0
  26. package/dist/commands/policy/publish/index.js +38 -0
  27. package/dist/commands/policy/runs/index.d.ts +54 -0
  28. package/dist/commands/policy/runs/index.js +273 -0
  29. package/dist/commands/policy/status/index.d.ts +21 -0
  30. package/dist/commands/policy/status/index.js +54 -0
  31. package/dist/commands/release/create/index.js +7 -0
  32. package/dist/commands/release/deploy/index.d.ts +28 -0
  33. package/dist/commands/release/deploy/index.js +90 -6
  34. package/dist/commands/release/import/index.js +5 -1
  35. package/dist/commands/release/list/index.d.ts +7 -0
  36. package/dist/commands/release/list/index.js +54 -10
  37. package/dist/commands/release/push/index.js +19 -1
  38. package/dist/commands/sandbox/push/index.js +6 -1
  39. package/dist/commands/skills/pull/index.d.ts +30 -0
  40. package/dist/commands/skills/pull/index.js +118 -0
  41. package/dist/commands/tenant/create/index.d.ts +3 -0
  42. package/dist/commands/tenant/create/index.js +21 -0
  43. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  44. package/dist/commands/tenant/deploy_release/index.js +186 -4
  45. package/dist/commands/tenant/edit/index.d.ts +2 -0
  46. package/dist/commands/tenant/edit/index.js +39 -4
  47. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  48. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  49. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  50. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  51. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  52. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  53. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  54. package/dist/commands/tenant_deploy_request/get/index.js +116 -0
  55. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  56. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  57. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  58. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  59. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  60. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  61. package/dist/commands/workspace/pull/index.d.ts +10 -0
  62. package/dist/commands/workspace/pull/index.js +70 -7
  63. package/dist/commands/workspace/push/index.d.ts +19 -0
  64. package/dist/commands/workspace/push/index.js +124 -5
  65. package/dist/policy-command.d.ts +90 -0
  66. package/dist/policy-command.js +94 -0
  67. package/dist/utils/document-parser.d.ts +10 -0
  68. package/dist/utils/document-parser.js +21 -0
  69. package/dist/utils/multidoc-push.d.ts +55 -2
  70. package/dist/utils/multidoc-push.js +103 -27
  71. package/dist/utils/policy/catalogue.d.ts +7 -0
  72. package/dist/utils/policy/catalogue.js +72 -0
  73. package/dist/utils/policy/errors.d.ts +12 -0
  74. package/dist/utils/policy/errors.js +61 -0
  75. package/dist/utils/policy/feedback.d.ts +68 -0
  76. package/dist/utils/policy/feedback.js +165 -0
  77. package/dist/utils/policy/findings.d.ts +23 -0
  78. package/dist/utils/policy/findings.js +65 -0
  79. package/dist/utils/policy/gate.d.ts +161 -0
  80. package/dist/utils/policy/gate.js +201 -0
  81. package/dist/utils/policy/goal.d.ts +46 -0
  82. package/dist/utils/policy/goal.js +95 -0
  83. package/dist/utils/policy/permission.d.ts +23 -0
  84. package/dist/utils/policy/permission.js +84 -0
  85. package/dist/utils/policy/push-gate.d.ts +5 -0
  86. package/dist/utils/policy/push-gate.js +29 -0
  87. package/dist/utils/policy/release.d.ts +85 -0
  88. package/dist/utils/policy/release.js +115 -0
  89. package/dist/utils/policy/request.d.ts +55 -0
  90. package/dist/utils/policy/request.js +115 -0
  91. package/dist/utils/policy/runs.d.ts +30 -0
  92. package/dist/utils/policy/runs.js +101 -0
  93. package/dist/utils/policy/status.d.ts +47 -0
  94. package/dist/utils/policy/status.js +117 -0
  95. package/dist/utils/policy/types.d.ts +185 -0
  96. package/dist/utils/policy/types.js +1 -0
  97. package/oclif.manifest.json +5520 -2511
  98. package/package.json +7 -1
@@ -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). That deploy is gated on the release's policy check: a refusal exits 2 and leaves the request approved; --override-policy with --reason proceeds past it.";
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 approve --override-policy --reason "Finding accepted; fix tracked in JIRA-12"`,
24
+ `$ xano tenant_deploy_request set_status 12 --status request_changes --reason "Needs a migration note"`,
25
+ `$ xano tenant_deploy_request set_status 12 --status close --reason "Superseded by #14"`,
26
+ `$ xano tenant_deploy_request set_status 12 --status reopen`,
27
+ ];
28
+ static flags = {
29
+ ...BaseCommand.baseFlags,
30
+ output: Flags.string({
31
+ char: 'o',
32
+ default: 'summary',
33
+ description: 'Output format',
34
+ options: ['summary', 'json'],
35
+ required: false,
36
+ }),
37
+ 'override-policy': Flags.boolean({
38
+ default: false,
39
+ 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)',
40
+ }),
41
+ reason: Flags.string({
42
+ description: 'Reason for this status change. Required for request_changes and close, and with --override-policy.',
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
+ }
@@ -14,4 +14,14 @@ export default class Pull extends BaseCommand {
14
14
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  };
16
16
  run(): Promise<void>;
17
+ /**
18
+ * Policy keys are unique on the server without regard to case, but an older export or a local file
19
+ * can still differ only in case, and file names are not case-sensitive on every checkout. The
20
+ * policies whose file name differs only in case from another exported policy, or from a local file
21
+ * it would overwrite, are left out with a warning; everything else is written. Local policy files
22
+ * absent from the export are kept. They are reported as stale only when this credential reads the
23
+ * branch's policies, because an export leaves out the policies its credential cannot read, and
24
+ * every policy while the Policies feature is off.
25
+ */
26
+ private policyClashes;
17
27
  }
@@ -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
  export default class Pull extends BaseCommand {
9
10
  static description = 'Pull a workspace multidoc from the Xano Metadata API and split into individual files';
10
11
  static examples = [
@@ -27,7 +28,7 @@ Pulled 58 documents
27
28
  ...BaseCommand.baseFlags,
28
29
  branch: Flags.string({
29
30
  char: 'b',
30
- description: 'Branch name (optional if set in profile, defaults to live)',
31
+ description: "Branch name (defaults to profile branch or live; -b '' selects live)",
31
32
  required: false,
32
33
  }),
33
34
  directory: Flags.string({
@@ -73,8 +74,8 @@ Pulled 58 documents
73
74
  ` 1. Provide it as a flag: xano workspace pull -w <workspace_id>\n` +
74
75
  ` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
75
76
  }
76
- // Determine branch from flag or profile
77
- const branch = flags.branch || profile.branch || '';
77
+ // Determine branch from flag or profile; `-b ''` selects live whatever the profile's branch, as in push.
78
+ const branch = flags.branch ?? profile.branch ?? '';
78
79
  // Build query parameters
79
80
  const queryParams = new URLSearchParams({
80
81
  branch,
@@ -123,12 +124,13 @@ Pulled 58 documents
123
124
  documents.push(parsed);
124
125
  }
125
126
  }
127
+ // Resolve the output directory
128
+ const outputDir = path.resolve(flags.directory);
129
+ 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 }));
126
130
  if (documents.length === 0) {
127
131
  this.log('No documents found in response');
128
132
  return;
129
133
  }
130
- // Resolve the output directory
131
- const outputDir = path.resolve(flags.directory);
132
134
  // Create the output directory if it doesn't exist
133
135
  fs.mkdirSync(outputDir, { recursive: true });
134
136
  // Resolve api_group names to unique folder names, disambiguating collisions
@@ -140,7 +142,7 @@ Pulled 58 documents
140
142
  // Track filenames per type to handle duplicates
141
143
  const filenameCounters = new Map();
142
144
  let writtenCount = 0;
143
- for (const doc of documents) {
145
+ for (const doc of documents.filter((document) => !clashing.has(document))) {
144
146
  const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
145
147
  getApiGroupFolder,
146
148
  getChannelServer,
@@ -173,5 +175,66 @@ Pulled 58 documents
173
175
  if (knowledgeCount > 0)
174
176
  parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
175
177
  this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
178
+ // Policy files state the rules; the skill the instance generates tells an agent how to follow them.
179
+ // Only a pull that carried policy files says so: an instance with the Policies feature off exports
180
+ // none, and its skill route answers 403.
181
+ if (documents.some((doc) => doc.type === 'policy')) {
182
+ this.log('Run `xano skills pull` to install the policies skill for your coding agent.');
183
+ }
184
+ }
185
+ /**
186
+ * Policy keys are unique on the server without regard to case, but an older export or a local file
187
+ * can still differ only in case, and file names are not case-sensitive on every checkout. The
188
+ * policies whose file name differs only in case from another exported policy, or from a local file
189
+ * it would overwrite, are left out with a warning; everything else is written. Local policy files
190
+ * absent from the export are kept. They are reported as stale only when this credential reads the
191
+ * branch's policies, because an export leaves out the policies its credential cannot read, and
192
+ * every policy while the Policies feature is off.
193
+ */
194
+ async policyClashes(documents, outputDir, readsPolicies) {
195
+ const exported = new Map();
196
+ for (const doc of documents.filter(doc => doc.type === 'policy')) {
197
+ const filename = `${policyBaseName(doc.name)}.xs`;
198
+ exported.set(filename.toLowerCase(), [...(exported.get(filename.toLowerCase()) ?? []), filename]);
199
+ }
200
+ const clashes = new Map();
201
+ for (const [normalized, names] of exported) {
202
+ if (names.length > 1)
203
+ clashes.set(normalized, names.map(name => `policies/${name}`).join(' and '));
204
+ }
205
+ const policyDir = path.join(outputDir, 'policies');
206
+ const localFiles = fs.existsSync(policyDir)
207
+ ? fs.readdirSync(policyDir, { withFileTypes: true })
208
+ .filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.xs'))
209
+ .map(entry => entry.name)
210
+ .sort()
211
+ : [];
212
+ const stale = [];
213
+ for (const filename of localFiles) {
214
+ const names = exported.get(filename.toLowerCase());
215
+ if (!names)
216
+ stale.push(`policies/${filename}`);
217
+ else if (!names.includes(filename) && !clashes.has(filename.toLowerCase())) {
218
+ clashes.set(filename.toLowerCase(), `policies/${names[0]} (local policies/${filename})`);
219
+ }
220
+ }
221
+ if (clashes.size > 0) {
222
+ 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` +
223
+ 'Rename one of the policies, or move the local file aside, then pull again.');
224
+ }
225
+ if (stale.length > 0) {
226
+ // An export that carries a policy was made by a credential that reads them.
227
+ const listing = exported.size > 0 ? 'listed' : await readsPolicies();
228
+ if (listing === 'listed') {
229
+ 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.`);
230
+ }
231
+ else if (listing === 'feature_off') {
232
+ this.log('Local policy files were kept: Policies are not enabled on this instance, so the export carries none.');
233
+ }
234
+ else {
235
+ this.log(`Local policy files were kept: this credential cannot list this workspace's policies, and the export carries none.`);
236
+ }
237
+ }
238
+ return new Set(documents.filter(doc => doc.type === 'policy' && clashes.has(`${doc.name}.xs`.toLowerCase())));
176
239
  }
177
240
  }
@@ -12,6 +12,9 @@ export default class Push extends BaseCommand {
12
12
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
13
  guids: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
14
  include: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ message: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
17
+ 'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
18
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
19
  sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
20
  transaction: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -21,5 +24,21 @@ export default class Push extends BaseCommand {
21
24
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
25
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
23
26
  };
27
+ protected catch(error: Error & {
28
+ oclif?: {
29
+ exit?: number;
30
+ };
31
+ }): Promise<void>;
24
32
  run(): Promise<void>;
33
+ /**
34
+ * A failure after the import landed. The import stands, so its policy feedback is reported as for
35
+ * any push and a blocking finding still exits 2; otherwise the failure exits 1. Under `-o json`
36
+ * stdout holds the import's document with the failure as `error`.
37
+ */
38
+ private catchAfterImport;
39
+ /**
40
+ * What happened to the policy documents, then the policy check the import answered with. When the
41
+ * platform left the policy files out, its notice says so instead of a count of what was sent.
42
+ */
43
+ private reportPolicyFeedback;
25
44
  }
@@ -2,7 +2,28 @@ import { Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import BaseCommand from '../../../base-command.js';
5
- import { executePush } from '../../../utils/multidoc-push.js';
5
+ import { parseDocument } from '../../../utils/document-parser.js';
6
+ import { executePush, FailedAfterImportError, } from '../../../utils/multidoc-push.js';
7
+ import { policyCodeGuidance } from '../../../utils/policy/errors.js';
8
+ import { policiesSkippedNotice, policyCheckWarning, policyDocumentSummary, policyExitCode, policySummary, pushEvidence, } from '../../../utils/policy/feedback.js';
9
+ import { BLANK_POLICY_OVERRIDE, blankPolicyOverride } from '../../../utils/policy/gate.js';
10
+ import { policyFilePushGuidance } from '../../../utils/policy/permission.js';
11
+ import { PushPolicyGateError } from '../../../utils/policy/push-gate.js';
12
+ /** The `-o json` document for a push whose import ran: the import response and what was sent. */
13
+ function importDocument(result) {
14
+ return { ...result.response, documents: result.sent.length, imported: true, knowledge: result.knowledge };
15
+ }
16
+ /** The policy check the import answered with, if any. */
17
+ function policyCheck(result) {
18
+ return result.response?.policy_check;
19
+ }
20
+ /**
21
+ * The platform's one notice that it left the push's policy files out (the Policies feature is off):
22
+ * the import's, else its preview's, since a partial push may not send the files the preview named.
23
+ */
24
+ function policiesSkipped(result) {
25
+ return policiesSkippedNotice(result.response) ?? policiesSkippedNotice(result.preview);
26
+ }
6
27
  export default class Push extends BaseCommand {
7
28
  static description = '[IMPORTANT] ALWAYS run --dry-run first and show the user the output before pushing. Push local documents to a workspace. By default, only changed files are pushed (partial mode). Use --sync to push all files. Shows a preview of changes before pushing unless --force is specified. Use --dry-run to preview only.';
8
29
  static examples = [
@@ -56,6 +77,9 @@ Push functions but exclude test files
56
77
  `,
57
78
  `$ xano workspace push -i "knowledge/**"
58
79
  Push only knowledge files (agents.md / skills / docs)
80
+ `,
81
+ `$ xano workspace push -m "Tightened the auth policies"
82
+ Label the Version History entry of each policy this push changes; other objects get no message
59
83
  `,
60
84
  `$ xano workspace push --sync --delete
61
85
  Full sync including knowledge files; removes server objects not present locally
@@ -65,7 +89,7 @@ Full sync including knowledge files; removes server objects not present locally
65
89
  ...BaseCommand.baseFlags,
66
90
  branch: Flags.string({
67
91
  char: 'b',
68
- description: 'Branch name (optional if set in profile, defaults to live)',
92
+ description: "Branch name (defaults to profile branch or live; -b '' selects live)",
69
93
  required: false,
70
94
  }),
71
95
  delete: Flags.boolean({
@@ -112,6 +136,19 @@ Full sync including knowledge files; removes server objects not present locally
112
136
  multiple: true,
113
137
  required: false,
114
138
  }),
139
+ message: Flags.string({
140
+ char: 'm',
141
+ description: 'Labels the Version History entry of each policy this push changes; other objects get no message',
142
+ required: false,
143
+ }),
144
+ output: Flags.string({
145
+ char: 'o',
146
+ default: 'summary',
147
+ description: 'Output format; JSON retains the complete import and policy feedback',
148
+ options: ['summary', 'json'],
149
+ required: false,
150
+ }),
151
+ 'policy-override': Flags.string({ description: 'Override a blocking live-branch policy gate with an audited reason (requires workspace:policy update)' }),
115
152
  records: Flags.boolean({
116
153
  default: false,
117
154
  description: '[CRITICAL] STOP and ALWAYS run --dry-run first to show the user a preview before pushing live table records. Includes table records in import.',
@@ -139,8 +176,20 @@ Full sync including knowledge files; removes server objects not present locally
139
176
  required: false,
140
177
  }),
141
178
  };
179
+ async catch(error) {
180
+ if (error instanceof PushPolicyGateError) {
181
+ if (this.isJsonOutput())
182
+ this.log(JSON.stringify({ imported: false, refused: error.payload }, null, 2));
183
+ this.error(error.message, { exit: 2 });
184
+ }
185
+ if (error instanceof FailedAfterImportError)
186
+ return this.catchAfterImport(error);
187
+ return this.catchAsOperational(error);
188
+ }
142
189
  async run() {
143
190
  const { flags } = await this.parse(Push);
191
+ if (blankPolicyOverride(flags['policy-override']))
192
+ this.error(BLANK_POLICY_OVERRIDE);
144
193
  const { profile, profileName } = this.resolveProfile(flags);
145
194
  // Determine workspace_id from flag or profile
146
195
  let workspaceId;
@@ -162,12 +211,23 @@ Full sync including knowledge files; removes server objects not present locally
162
211
  if (!fs.statSync(inputDir).isDirectory()) {
163
212
  this.error(`Not a directory: ${inputDir}`);
164
213
  }
165
- const branch = flags.branch || profile.branch || '';
214
+ const branch = flags.branch ?? profile.branch ?? '';
166
215
  const baseUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}`;
216
+ const json = flags.output === 'json';
217
+ // Only the import writes Version History entries, so only the import carries the message.
218
+ const message = flags.message?.trim();
167
219
  const target = {
168
220
  buildDryRunUrl: (params) => `${baseUrl}/multidoc/dry-run?${params.toString()}`,
169
- buildPushUrl: (params) => `${baseUrl}/multidoc?${params.toString()}`,
221
+ buildPushUrl(params) {
222
+ const query = new URLSearchParams(params);
223
+ if (message)
224
+ query.set('message', message);
225
+ if (flags['policy-override']?.trim())
226
+ query.set('override_reason', flags['policy-override'].trim());
227
+ return `${baseUrl}/multidoc?${query.toString()}`;
228
+ },
170
229
  cliVersion: this.config.version,
230
+ explainRefusal: (status, payload) => (status === 403 ? policyFilePushGuidance(payload) : policyCodeGuidance(payload) || undefined),
171
231
  instanceOrigin: profile.instance_origin,
172
232
  label: `workspace ${workspaceId}`,
173
233
  supportsBranches: true,
@@ -187,7 +247,7 @@ Full sync including knowledge files; removes server objects not present locally
187
247
  truncate: flags.truncate,
188
248
  verbose: flags.verbose,
189
249
  };
190
- await executePush({
250
+ const result = await executePush({
191
251
  accessToken: profile.access_token,
192
252
  branch,
193
253
  command: this,
@@ -196,7 +256,66 @@ Full sync including knowledge files; removes server objects not present locally
196
256
  listUrl: () => `${baseUrl}/knowledge/sync`,
197
257
  rootDir: inputDir,
198
258
  },
259
+ // Under `-o json` stdout carries one JSON document, so progress goes to stderr.
260
+ log: json ? this.logToStderr.bind(this) : undefined,
199
261
  verboseFetch: this.verboseFetch.bind(this),
200
262
  }, target, pushFlags);
263
+ if (json) {
264
+ this.log(JSON.stringify(result.stopped
265
+ ? { imported: false, preview: result.preview, reason: result.stopped }
266
+ : importDocument(result), null, 2));
267
+ }
268
+ // Policy feedback describes the multidoc import, so a push that imported none has none. Its
269
+ // preview may still say that the policy files are left out.
270
+ if (result.response) {
271
+ this.reportPolicyFeedback(result, json);
272
+ }
273
+ else {
274
+ const skipped = policiesSkipped(result);
275
+ if (skipped)
276
+ this.warn(skipped);
277
+ }
278
+ }
279
+ /**
280
+ * A failure after the import landed. The import stands, so its policy feedback is reported as for
281
+ * any push and a blocking finding still exits 2; otherwise the failure exits 1. Under `-o json`
282
+ * stdout holds the import's document with the failure as `error`.
283
+ */
284
+ catchAfterImport(error) {
285
+ const json = this.isJsonOutput();
286
+ const exit = policyExitCode(policyCheck(error.imported)) || 1;
287
+ if (json)
288
+ this.log(JSON.stringify({ ...importDocument(error.imported), error: { exit, message: error.message } }, null, 2));
289
+ this.reportPolicyFeedback(error.imported, json);
290
+ this.error(error, { exit });
291
+ }
292
+ /**
293
+ * What happened to the policy documents, then the policy check the import answered with. When the
294
+ * platform left the policy files out, its notice says so instead of a count of what was sent.
295
+ */
296
+ reportPolicyFeedback(result, json) {
297
+ const check = policyCheck(result);
298
+ const skipped = policiesSkipped(result);
299
+ if (skipped)
300
+ this.warn(skipped);
301
+ if (!json) {
302
+ if (!skipped) {
303
+ const sentPolicies = result.sent.filter((entry) => parseDocument(entry.content)?.type === 'policy').length;
304
+ for (const line of policyDocumentSummary(result.preview, sentPolicies))
305
+ this.log(line);
306
+ }
307
+ for (const line of policySummary(check, pushEvidence(check)))
308
+ this.log(line);
309
+ }
310
+ const warning = policyCheckWarning(check);
311
+ if (warning)
312
+ this.warn(warning);
313
+ const code = policyExitCode(check);
314
+ if (code) {
315
+ process.exitCode = code;
316
+ if (!json)
317
+ this.log('Next: `xano policy status --run-detail` for the current standing, or `xano policy runs` for this run.');
318
+ this.warn('Workspace import completed with blocking policy findings; the imported changes were not rolled back.');
319
+ }
201
320
  }
202
321
  }
@@ -0,0 +1,90 @@
1
+ import type { Policy, PolicyRun } from './utils/policy/types.js';
2
+ import BaseCommand from './base-command.js';
3
+ import { type PolicyRequest } from './utils/policy/request.js';
4
+ /** One rule's warnings from the parse route: its 0-based index, id and sentences. */
5
+ export interface RuleWarnings {
6
+ rule: number;
7
+ rule_id: string;
8
+ warnings: string[];
9
+ }
10
+ /** What the parse route answers. */
11
+ export interface ParsedPolicy {
12
+ policy: Policy;
13
+ rule_warnings: RuleWarnings[];
14
+ source: string;
15
+ }
16
+ /** The workspace and branch a policy command acts on, and requests bound to them. */
17
+ export interface PolicyTarget {
18
+ branch: string;
19
+ request: PolicyRequest;
20
+ workspace: string;
21
+ }
22
+ interface TargetFlags {
23
+ branch?: string;
24
+ config?: string;
25
+ profile?: string;
26
+ verbose: boolean;
27
+ workspace?: string;
28
+ }
29
+ /** Shared flags, request helper and output for the `policy *` commands and `skills pull`. */
30
+ export default abstract class PolicyCommand extends BaseCommand {
31
+ static policyFlags: {
32
+ branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
33
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
34
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
35
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
36
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
37
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
38
+ };
39
+ /** `publish -m`: the message on the Version History entry the save creates. */
40
+ static publishFlags: {
41
+ message: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
42
+ };
43
+ /** One positional, as CLAUDE.md requires: the file `--file` would otherwise name. */
44
+ static sourceArgs: {
45
+ file: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
46
+ };
47
+ static sourceFlags: {
48
+ file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
49
+ stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
50
+ };
51
+ /** Any failure exits 1; an error that is not the CLI's own names the command it broke. */
52
+ protected catch(error: Error & {
53
+ oclif?: {
54
+ exit?: number;
55
+ };
56
+ }): Promise<void>;
57
+ /**
58
+ * Print each rule's warnings (a name, tag or value that makes the rule check nothing) to stderr,
59
+ * so piped source stays clean; nothing when there are none.
60
+ */
61
+ protected logRuleWarnings(parsed: ParsedPolicy): void;
62
+ /** Print a run's recorded detail, or say it evaluated no policy. */
63
+ protected logRunDetail(run: PolicyRun): void;
64
+ /**
65
+ * Ask the parse route for the policy, its canonical source and each rule's warnings against the
66
+ * target branch, from XanoScript (`{source}`) or a policy document (`{data}`); the policy and source
67
+ * must come back.
68
+ */
69
+ protected parseSource(request: PolicyRequest, input: {
70
+ data: unknown;
71
+ } | {
72
+ source: string;
73
+ }): Promise<ParsedPolicy>;
74
+ /**
75
+ * The workspace and branch from the flags or the profile, and a request helper bound to them for
76
+ * a route gated by `workspace:policy`: `/policy` unless another is named.
77
+ */
78
+ protected policyTarget(flags: TargetFlags, route?: Pick<import("./utils/policy/request.js").PolicyRequestRoute, "path" | "label">): PolicyTarget;
79
+ /** Policy source from the positional, `--file` or `--stdin`; a file named twice must be named the same. */
80
+ protected readSource(flags: {
81
+ file?: string;
82
+ stdin?: boolean;
83
+ }, positional?: string): string;
84
+ /** `workspace 3 (feature)`, or just `workspace 3` for live. */
85
+ protected where({ branch, workspace }: {
86
+ branch: string;
87
+ workspace: string;
88
+ }): string;
89
+ }
90
+ export {};