@xano/cli 1.3.4 → 1.3.5-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -3
- 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/{tenant/snapshot/create → policy/coverage}/index.d.ts +4 -4
- 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/{tenant/snapshot → policy}/delete/index.d.ts +5 -5
- 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/{tenant/snapshot → policy}/list/index.d.ts +3 -5
- 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/snapshot/swap → tenant_deploy_request/revision}/index.d.ts +8 -6
- 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 +6829 -4006
- package/package.json +9 -1
- package/dist/commands/tenant/snapshot/create/index.js +0 -78
- package/dist/commands/tenant/snapshot/delete/index.js +0 -102
- package/dist/commands/tenant/snapshot/list/index.js +0 -100
- package/dist/commands/tenant/snapshot/swap/index.js +0 -103
|
@@ -2,8 +2,29 @@ import { Flags } from '@oclif/core';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import { basename, relative, resolve } from 'node:path';
|
|
4
4
|
import BaseCommand from '../../../base-command.js';
|
|
5
|
+
import { parseDocument } from '../../../utils/document-parser.js';
|
|
5
6
|
import { formatWorkspacePushDefaultsBanner, resolvePushDefaults } from '../../../utils/local-config.js';
|
|
6
|
-
import { executePush } from '../../../utils/multidoc-push.js';
|
|
7
|
+
import { executePush, FailedAfterImportError, } from '../../../utils/multidoc-push.js';
|
|
8
|
+
import { policyCodeGuidance } from '../../../utils/policy/errors.js';
|
|
9
|
+
import { isCoverageCheck, policiesSkippedNotice, policyCheckWarning, policyDocumentSummary, policyExitCode, policySummary, pushEvidence, } from '../../../utils/policy/feedback.js';
|
|
10
|
+
import { BLANK_POLICY_OVERRIDE, blankPolicyOverride } from '../../../utils/policy/gate.js';
|
|
11
|
+
import { policyFilePushGuidance } from '../../../utils/policy/permission.js';
|
|
12
|
+
import { PushPolicyGateError } from '../../../utils/policy/push-gate.js';
|
|
13
|
+
/** The `-o json` document for a push whose import ran: the import response and what was sent. */
|
|
14
|
+
function importDocument(result) {
|
|
15
|
+
return { ...result.response, documents: result.sent.length, imported: true, knowledge: result.knowledge };
|
|
16
|
+
}
|
|
17
|
+
/** The policy check the import answered with, if any. */
|
|
18
|
+
function policyCheck(result) {
|
|
19
|
+
return result.response?.policy_check;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The platform's one notice that it left the push's policy files out (the Policies feature is off):
|
|
23
|
+
* the import's, else its preview's, since a partial push may not send the files the preview named.
|
|
24
|
+
*/
|
|
25
|
+
function policiesSkipped(result) {
|
|
26
|
+
return policiesSkippedNotice(result.response) ?? policiesSkippedNotice(result.preview);
|
|
27
|
+
}
|
|
7
28
|
export default class Push extends BaseCommand {
|
|
8
29
|
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.';
|
|
9
30
|
static examples = [
|
|
@@ -78,7 +99,7 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
78
99
|
...BaseCommand.baseFlags,
|
|
79
100
|
branch: Flags.string({
|
|
80
101
|
char: 'b',
|
|
81
|
-
description:
|
|
102
|
+
description: "Branch name (defaults to profile branch or live; -b '' selects live)",
|
|
82
103
|
required: false,
|
|
83
104
|
}),
|
|
84
105
|
delete: Flags.boolean({
|
|
@@ -130,6 +151,20 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
130
151
|
multiple: true,
|
|
131
152
|
required: false,
|
|
132
153
|
}),
|
|
154
|
+
message: Flags.string({
|
|
155
|
+
char: 'm',
|
|
156
|
+
description: 'Labels the Version History entry of each policy this push changes; other objects get no message',
|
|
157
|
+
hidden: true,
|
|
158
|
+
required: false,
|
|
159
|
+
}),
|
|
160
|
+
output: Flags.string({
|
|
161
|
+
char: 'o',
|
|
162
|
+
default: 'summary',
|
|
163
|
+
description: 'Output format; JSON retains the complete import feedback',
|
|
164
|
+
options: ['summary', 'json'],
|
|
165
|
+
required: false,
|
|
166
|
+
}),
|
|
167
|
+
'policy-override': Flags.string({ description: 'Override a blocking live-branch policy gate with an audited reason (requires workspace:policy update)', hidden: true }),
|
|
133
168
|
records: Flags.boolean({
|
|
134
169
|
default: false,
|
|
135
170
|
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.',
|
|
@@ -171,8 +206,20 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
171
206
|
required: false,
|
|
172
207
|
}),
|
|
173
208
|
};
|
|
209
|
+
async catch(error) {
|
|
210
|
+
if (error instanceof PushPolicyGateError) {
|
|
211
|
+
if (this.isJsonOutput())
|
|
212
|
+
this.log(JSON.stringify({ imported: false, refused: error.payload }, null, 2));
|
|
213
|
+
this.error(error.message, { exit: 2 });
|
|
214
|
+
}
|
|
215
|
+
if (error instanceof FailedAfterImportError)
|
|
216
|
+
return this.catchAfterImport(error);
|
|
217
|
+
return this.catchAsOperational(error);
|
|
218
|
+
}
|
|
174
219
|
async run() {
|
|
175
220
|
const { flags } = await this.parse(Push);
|
|
221
|
+
if (blankPolicyOverride(flags['policy-override']))
|
|
222
|
+
this.error(BLANK_POLICY_OVERRIDE);
|
|
176
223
|
const { profile, profileName } = this.resolveProfile(flags);
|
|
177
224
|
// Determine workspace_id from flag or profile
|
|
178
225
|
let workspaceId;
|
|
@@ -194,8 +241,11 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
194
241
|
if (!fs.statSync(inputDir).isDirectory()) {
|
|
195
242
|
this.error(`Not a directory: ${inputDir}`);
|
|
196
243
|
}
|
|
197
|
-
const branch = flags.branch
|
|
244
|
+
const branch = flags.branch ?? profile.branch ?? '';
|
|
198
245
|
const baseUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}`;
|
|
246
|
+
const json = flags.output === 'json';
|
|
247
|
+
// Only the import writes Version History entries, so only the import carries the message.
|
|
248
|
+
const message = flags.message?.trim();
|
|
199
249
|
const pushDefaults = resolvePushDefaults({
|
|
200
250
|
flagFast: flags.fast,
|
|
201
251
|
flagWriteback: flags.writeback,
|
|
@@ -205,12 +255,25 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
205
255
|
});
|
|
206
256
|
if (pushDefaults.fromLocal.length > 0 && this.localProfile) {
|
|
207
257
|
const relativePath = relative(process.cwd(), this.localProfile.path) || basename(this.localProfile.path);
|
|
208
|
-
|
|
258
|
+
// Under `-o json` stdout carries one JSON document, so the banner goes to stderr.
|
|
259
|
+
const banner = formatWorkspacePushDefaultsBanner(pushDefaults.fromLocal, relativePath);
|
|
260
|
+
if (json)
|
|
261
|
+
this.logToStderr(banner);
|
|
262
|
+
else
|
|
263
|
+
this.log(banner);
|
|
209
264
|
}
|
|
210
265
|
const target = {
|
|
211
266
|
buildDryRunUrl: (params) => `${baseUrl}/multidoc/dry-run?${params.toString()}`,
|
|
212
|
-
buildPushUrl
|
|
267
|
+
buildPushUrl(params) {
|
|
268
|
+
const query = new URLSearchParams(params);
|
|
269
|
+
if (message)
|
|
270
|
+
query.set('message', message);
|
|
271
|
+
if (flags['policy-override']?.trim())
|
|
272
|
+
query.set('override_reason', flags['policy-override'].trim());
|
|
273
|
+
return `${baseUrl}/multidoc?${query.toString()}`;
|
|
274
|
+
},
|
|
213
275
|
cliVersion: this.config.version,
|
|
276
|
+
explainRefusal: (status, payload) => (status === 403 ? policyFilePushGuidance(payload) : policyCodeGuidance(payload) || undefined),
|
|
214
277
|
instanceOrigin: profile.instance_origin,
|
|
215
278
|
label: `workspace ${workspaceId}`,
|
|
216
279
|
supportsBranches: true,
|
|
@@ -235,7 +298,7 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
235
298
|
writebackDiff: flags['writeback-diff'],
|
|
236
299
|
writebackObjectLimit: this.localProfile?.config.writeback_object_limit,
|
|
237
300
|
};
|
|
238
|
-
await executePush({
|
|
301
|
+
const result = await executePush({
|
|
239
302
|
accessToken: profile.access_token,
|
|
240
303
|
branch,
|
|
241
304
|
command: this,
|
|
@@ -244,6 +307,8 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
244
307
|
listUrl: () => `${baseUrl}/knowledge/sync`,
|
|
245
308
|
rootDir: inputDir,
|
|
246
309
|
},
|
|
310
|
+
// Under `-o json` stdout carries one JSON document, so progress goes to stderr.
|
|
311
|
+
log: json ? this.logToStderr.bind(this) : undefined,
|
|
247
312
|
syncTarget: {
|
|
248
313
|
instanceOrigin: profile.instance_origin,
|
|
249
314
|
kind: 'workspace',
|
|
@@ -251,5 +316,69 @@ Full sync including knowledge files; removes server objects not present locally
|
|
|
251
316
|
},
|
|
252
317
|
verboseFetch: this.verboseFetch.bind(this),
|
|
253
318
|
}, target, pushFlags);
|
|
319
|
+
if (json) {
|
|
320
|
+
this.log(JSON.stringify(result.stopped
|
|
321
|
+
? { imported: false, preview: result.preview, reason: result.stopped }
|
|
322
|
+
: importDocument(result), null, 2));
|
|
323
|
+
}
|
|
324
|
+
// Policy feedback describes the multidoc import, so a push that imported none has none. Its
|
|
325
|
+
// preview may still say that the policy files are left out.
|
|
326
|
+
if (result.response) {
|
|
327
|
+
this.reportPolicyFeedback(result, json);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
const skipped = policiesSkipped(result);
|
|
331
|
+
if (skipped)
|
|
332
|
+
this.warn(skipped);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* A failure after the import landed. The import stands, so its policy feedback is reported as for
|
|
337
|
+
* any push and a blocking finding still exits 2; otherwise the failure exits 1. Under `-o json`
|
|
338
|
+
* stdout holds the import's document with the failure as `error`.
|
|
339
|
+
*/
|
|
340
|
+
catchAfterImport(error) {
|
|
341
|
+
const json = this.isJsonOutput();
|
|
342
|
+
const exit = policyExitCode(policyCheck(error.imported)) || 1;
|
|
343
|
+
if (json)
|
|
344
|
+
this.log(JSON.stringify({ ...importDocument(error.imported), error: { exit, message: error.message } }, null, 2));
|
|
345
|
+
this.reportPolicyFeedback(error.imported, json);
|
|
346
|
+
this.error(error, { exit });
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* What happened to the policy documents, then the policy check the import answered with. When the
|
|
350
|
+
* platform left the policy files out, its notice says so instead of a count of what was sent.
|
|
351
|
+
*/
|
|
352
|
+
reportPolicyFeedback(result, json) {
|
|
353
|
+
const check = policyCheck(result);
|
|
354
|
+
const skipped = policiesSkipped(result);
|
|
355
|
+
if (skipped)
|
|
356
|
+
this.warn(skipped);
|
|
357
|
+
if (!json) {
|
|
358
|
+
if (!skipped) {
|
|
359
|
+
const sentPolicies = result.sent.filter((entry) => parseDocument(entry.content)?.type === 'policy').length;
|
|
360
|
+
for (const line of policyDocumentSummary(result.preview, sentPolicies))
|
|
361
|
+
this.log(line);
|
|
362
|
+
}
|
|
363
|
+
for (const line of policySummary(check, pushEvidence(check)))
|
|
364
|
+
this.log(line);
|
|
365
|
+
}
|
|
366
|
+
const warning = policyCheckWarning(check);
|
|
367
|
+
if (warning && check && check.status !== 'disabled')
|
|
368
|
+
this.warn(warning);
|
|
369
|
+
const code = policyExitCode(check);
|
|
370
|
+
if (code) {
|
|
371
|
+
process.exitCode = code;
|
|
372
|
+
// A caller without workspace:policy read is told its own findings and no run, so the run reads would be refused.
|
|
373
|
+
if (!json) {
|
|
374
|
+
this.log(isCoverageCheck(check)
|
|
375
|
+
? ((check?.blocking_findings ?? []).length > 0
|
|
376
|
+
? 'Next: fix the blocking findings above, or `xano policy coverage <type>:<id>` for the policies that apply to one object.'
|
|
377
|
+
// Blocking as a reader's push would, on objects this credential is not told about.
|
|
378
|
+
: 'Next: the blocking findings are on objects this push did not list, which someone with workspace:policy read can see; `xano policy coverage <type>:<id>` shows the policies that apply to an object you may read.')
|
|
379
|
+
: 'Next: `xano policy status --run-detail` for the current standing, or `xano policy runs` for this run.');
|
|
380
|
+
}
|
|
381
|
+
this.warn('Workspace import completed with blocking policy findings; the imported changes were not rolled back.');
|
|
382
|
+
}
|
|
254
383
|
}
|
|
255
384
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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 hidden: boolean;
|
|
32
|
+
static policyFlags: {
|
|
33
|
+
branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
34
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
35
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
36
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
37
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
38
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
39
|
+
};
|
|
40
|
+
/** `publish -m`: the message on the Version History entry the save creates. */
|
|
41
|
+
static publishFlags: {
|
|
42
|
+
message: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
43
|
+
};
|
|
44
|
+
/** One positional, as CLAUDE.md requires: the file `--file` would otherwise name. */
|
|
45
|
+
static sourceArgs: {
|
|
46
|
+
file: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
47
|
+
};
|
|
48
|
+
static sourceFlags: {
|
|
49
|
+
file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
50
|
+
stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
51
|
+
};
|
|
52
|
+
/** Any failure exits 1; an error that is not the CLI's own names the command it broke. */
|
|
53
|
+
protected catch(error: Error & {
|
|
54
|
+
oclif?: {
|
|
55
|
+
exit?: number;
|
|
56
|
+
};
|
|
57
|
+
}): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Print each rule's warnings (a name, tag or value that makes the rule check nothing) to stderr,
|
|
60
|
+
* so piped source stays clean; nothing when there are none.
|
|
61
|
+
*/
|
|
62
|
+
protected logRuleWarnings(parsed: ParsedPolicy): void;
|
|
63
|
+
/** Print a run's recorded detail, or say it evaluated no policy. */
|
|
64
|
+
protected logRunDetail(run: PolicyRun): void;
|
|
65
|
+
/**
|
|
66
|
+
* Ask the parse route for the policy, its canonical source and each rule's warnings against the
|
|
67
|
+
* target branch, from XanoScript (`{source}`) or a policy document (`{data}`); the policy and source
|
|
68
|
+
* must come back.
|
|
69
|
+
*/
|
|
70
|
+
protected parseSource(request: PolicyRequest, input: {
|
|
71
|
+
data: unknown;
|
|
72
|
+
} | {
|
|
73
|
+
source: string;
|
|
74
|
+
}): Promise<ParsedPolicy>;
|
|
75
|
+
/**
|
|
76
|
+
* The workspace and branch from the flags or the profile, and a request helper bound to them for
|
|
77
|
+
* a route gated by `workspace:policy`: `/policy` unless another is named.
|
|
78
|
+
*/
|
|
79
|
+
protected policyTarget(flags: TargetFlags, route?: Pick<import("./utils/policy/request.js").PolicyRequestRoute, "path" | "label">): PolicyTarget;
|
|
80
|
+
/** Policy source from the positional, `--file` or `--stdin`; a file named twice must be named the same. */
|
|
81
|
+
protected readSource(flags: {
|
|
82
|
+
file?: string;
|
|
83
|
+
stdin?: boolean;
|
|
84
|
+
}, positional?: string): string;
|
|
85
|
+
/** `workspace 3 (feature)`, or just `workspace 3` for live. */
|
|
86
|
+
protected where({ branch, workspace }: {
|
|
87
|
+
branch: string;
|
|
88
|
+
workspace: string;
|
|
89
|
+
}): string;
|
|
90
|
+
}
|
|
91
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import BaseCommand from './base-command.js';
|
|
4
|
+
import { POLICY_ROUTE, policyRequest, policyScope } from './utils/policy/request.js';
|
|
5
|
+
import { policyRunDetail } from './utils/policy/runs.js';
|
|
6
|
+
/** Shared flags, request helper and output for the `policy *` commands and `skills pull`. */
|
|
7
|
+
export default class PolicyCommand extends BaseCommand {
|
|
8
|
+
static hidden = true;
|
|
9
|
+
static policyFlags = {
|
|
10
|
+
...BaseCommand.baseFlags,
|
|
11
|
+
branch: Flags.string({ char: 'b', description: "Branch label (defaults to profile branch or live; -b '' selects live)" }),
|
|
12
|
+
output: Flags.string({ char: 'o', default: 'summary', description: 'Output format', options: ['summary', 'json'] }),
|
|
13
|
+
workspace: Flags.string({ char: 'w', description: 'Workspace ID (defaults to profile workspace)' }),
|
|
14
|
+
};
|
|
15
|
+
/** `publish -m`: the message on the Version History entry the save creates. */
|
|
16
|
+
static publishFlags = {
|
|
17
|
+
message: Flags.string({ char: 'm', description: 'Message stored on the Version History entry this save creates' }),
|
|
18
|
+
};
|
|
19
|
+
/** One positional, as CLAUDE.md requires: the file `--file` would otherwise name. */
|
|
20
|
+
static sourceArgs = {
|
|
21
|
+
file: Args.string({ description: 'Policy XanoScript file (same as --file)', ignoreStdin: true, required: false }),
|
|
22
|
+
};
|
|
23
|
+
static sourceFlags = {
|
|
24
|
+
file: Flags.string({ char: 'f', description: 'Policy XanoScript file', exclusive: ['stdin'] }),
|
|
25
|
+
stdin: Flags.boolean({ default: false, description: 'Read policy XanoScript from stdin', exclusive: ['file'] }),
|
|
26
|
+
};
|
|
27
|
+
/** Any failure exits 1; an error that is not the CLI's own names the command it broke. */
|
|
28
|
+
async catch(error) {
|
|
29
|
+
if ('oclif' in error)
|
|
30
|
+
return this.catchAsOperational(error);
|
|
31
|
+
const action = this.id?.split(':').at(-1) ?? 'command';
|
|
32
|
+
return this.catchAsOperational(new Error(`Policy ${action} failed: ${error.message}`));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Print each rule's warnings (a name, tag or value that makes the rule check nothing) to stderr,
|
|
36
|
+
* so piped source stays clean; nothing when there are none.
|
|
37
|
+
*/
|
|
38
|
+
logRuleWarnings(parsed) {
|
|
39
|
+
const warned = parsed.rule_warnings ?? [];
|
|
40
|
+
if (warned.length === 0)
|
|
41
|
+
return;
|
|
42
|
+
this.logToStderr('Rule warnings (the rule may check nothing or everything; fix them before relying on it):');
|
|
43
|
+
for (const rule of warned) {
|
|
44
|
+
for (const warning of rule.warnings)
|
|
45
|
+
this.logToStderr(` ${rule.rule_id || `rule[${rule.rule}]`}: ${warning}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Print a run's recorded detail, or say it evaluated no policy. */
|
|
49
|
+
logRunDetail(run) {
|
|
50
|
+
const detail = policyRunDetail(run);
|
|
51
|
+
for (const line of detail)
|
|
52
|
+
this.log(line);
|
|
53
|
+
if (detail.length === 0)
|
|
54
|
+
this.log(run.id ? `Run ${run.id} evaluated no policies.` : 'No policies were evaluated.');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ask the parse route for the policy, its canonical source and each rule's warnings against the
|
|
58
|
+
* target branch, from XanoScript (`{source}`) or a policy document (`{data}`); the policy and source
|
|
59
|
+
* must come back.
|
|
60
|
+
*/
|
|
61
|
+
async parseSource(request, input) {
|
|
62
|
+
const answer = (await request('/parse', 'POST', input));
|
|
63
|
+
if (!answer?.policy?.key || typeof answer.source !== 'string')
|
|
64
|
+
this.error('The platform did not return a parsed policy and canonical source.');
|
|
65
|
+
return { ...answer, rule_warnings: Array.isArray(answer.rule_warnings) ? answer.rule_warnings : [] };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The workspace and branch from the flags or the profile, and a request helper bound to them for
|
|
69
|
+
* a route gated by `workspace:policy`: `/policy` unless another is named.
|
|
70
|
+
*/
|
|
71
|
+
policyTarget(flags, route = POLICY_ROUTE) {
|
|
72
|
+
const { profile } = this.resolveProfile(flags);
|
|
73
|
+
const { branch, workspace } = policyScope(flags, profile);
|
|
74
|
+
if (!workspace)
|
|
75
|
+
this.error('Workspace ID required. Use --workspace or set one in your profile.');
|
|
76
|
+
const request = policyRequest({ error: (message) => this.error(message), logToStderr: (message) => this.logToStderr(message), verboseFetch: (...args) => this.verboseFetch(...args) }, { ...route, branch, profile, verbose: flags.verbose, workspace });
|
|
77
|
+
return { branch, request, workspace };
|
|
78
|
+
}
|
|
79
|
+
/** Policy source from the positional, `--file` or `--stdin`; a file named twice must be named the same. */
|
|
80
|
+
readSource(flags, positional) {
|
|
81
|
+
const named = positional?.trim();
|
|
82
|
+
if (named && flags.stdin)
|
|
83
|
+
this.error('Provide policy source once: a file (positional or --file) or --stdin.');
|
|
84
|
+
if (named && flags.file && flags.file !== named)
|
|
85
|
+
this.error('Provide the file once: as a positional or as --file.');
|
|
86
|
+
const file = flags.file ?? named;
|
|
87
|
+
if (!file && !flags.stdin)
|
|
88
|
+
this.error('Provide the policy file (as an argument or --file) or --stdin.');
|
|
89
|
+
return fs.readFileSync(flags.stdin ? 0 : file, 'utf8');
|
|
90
|
+
}
|
|
91
|
+
/** `workspace 3 (feature)`, or just `workspace 3` for live. */
|
|
92
|
+
where({ branch, workspace }) {
|
|
93
|
+
return `workspace ${workspace}${branch ? ` (${branch})` : ''}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -11,6 +11,16 @@ export interface ParsedDocument {
|
|
|
11
11
|
type: string;
|
|
12
12
|
verb?: string;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The platform's policy key pattern, kept offline for file names. A command that already holds the
|
|
16
|
+
* catalogue validates a key against the catalogue's `document.key.pattern` instead.
|
|
17
|
+
*/
|
|
18
|
+
export declare const POLICY_KEY_PATTERN: RegExp;
|
|
19
|
+
/**
|
|
20
|
+
* The file name, without `.xs`, for a policy key. The pattern is the platform's own key pattern, so a
|
|
21
|
+
* key from the server can never name a path outside `policies/`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function policyBaseName(key: string): string;
|
|
14
24
|
/**
|
|
15
25
|
* Parse a single XanoScript document to extract its type, name, and optional verb/api_group.
|
|
16
26
|
* Skips leading comment lines (starting with //) to find the first meaningful line.
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform's policy key pattern, kept offline for file names. A command that already holds the
|
|
3
|
+
* catalogue validates a key against the catalogue's `document.key.pattern` instead.
|
|
4
|
+
*/
|
|
5
|
+
export const POLICY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
6
|
+
/**
|
|
7
|
+
* The file name, without `.xs`, for a policy key. The pattern is the platform's own key pattern, so a
|
|
8
|
+
* key from the server can never name a path outside `policies/`.
|
|
9
|
+
*/
|
|
10
|
+
export function policyBaseName(key) {
|
|
11
|
+
if (!POLICY_KEY_PATTERN.test(key))
|
|
12
|
+
throw new Error(`Invalid policy key: ${key}`);
|
|
13
|
+
return key;
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* Parse a single XanoScript document to extract its type, name, and optional verb/api_group.
|
|
3
17
|
* Skips leading comment lines (starting with //) to find the first meaningful line.
|
|
@@ -33,6 +47,9 @@ export function parseDocument(content) {
|
|
|
33
47
|
if (name.startsWith('"') && name.endsWith('"')) {
|
|
34
48
|
name = name.slice(1, -1);
|
|
35
49
|
}
|
|
50
|
+
// Policy bodies are opaque. Prose/parameters may mention guid or api_group; these are not document identity.
|
|
51
|
+
if (type === 'policy')
|
|
52
|
+
return { content, name, type };
|
|
36
53
|
// Extract verb if present (e.g., verb=GET)
|
|
37
54
|
let verb;
|
|
38
55
|
const verbMatch = rest.match(/verb=(\S+)/);
|
|
@@ -162,6 +179,10 @@ export function sanitizeDocumentName(name, snakeCaseFn) {
|
|
|
162
179
|
export function resolveDocumentPath(doc, outputDir, deps) {
|
|
163
180
|
const { getApiGroupFolder, getChannelServer, join, snakeCase } = deps;
|
|
164
181
|
const sanitize = (name) => sanitizeDocumentName(name, snakeCase);
|
|
182
|
+
if (doc.type === 'policy') {
|
|
183
|
+
// policy → policies/{key}.xs (the key is validated, never snake_cased)
|
|
184
|
+
return { baseName: policyBaseName(doc.name), typeDir: join(outputDir, 'policies') };
|
|
185
|
+
}
|
|
165
186
|
if (doc.type === 'workspace') {
|
|
166
187
|
// workspace → workspace/{name}.xs
|
|
167
188
|
return { baseName: sanitize(doc.name), typeDir: join(outputDir, 'workspace') };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Command } from '@oclif/core';
|
|
1
|
+
import { Command, Errors } from '@oclif/core';
|
|
2
2
|
import { type BadIndex, type BadReference } from './reference-checker.js';
|
|
3
3
|
import { type SyncFileEntry, type SyncTargetIdentity, type SyncTargetState } from './sync-state.js';
|
|
4
4
|
export interface PushFlags {
|
|
@@ -27,6 +27,11 @@ export interface PushTarget {
|
|
|
27
27
|
buildPushUrl: (queryParams: URLSearchParams) => string;
|
|
28
28
|
/** CLI version string */
|
|
29
29
|
cliVersion: string;
|
|
30
|
+
/**
|
|
31
|
+
* Guidance for a refusal this target recognises, given the HTTP status and the server's payload.
|
|
32
|
+
* A preview or import refused that way stops with the message and this guidance appended.
|
|
33
|
+
*/
|
|
34
|
+
explainRefusal?: (status: number, payload: unknown) => string | undefined;
|
|
30
35
|
/** Instance origin URL (e.g., "https://x123-abcd-1234.xano.io") */
|
|
31
36
|
instanceOrigin: string;
|
|
32
37
|
/** Human-readable label for log messages (e.g., "sandbox environment", "workspace 40") */
|
|
@@ -69,6 +74,8 @@ export interface PushContext {
|
|
|
69
74
|
inputDir: string;
|
|
70
75
|
/** Optional knowledge sync config. Only workspace push sets this. */
|
|
71
76
|
knowledge?: KnowledgeConfig;
|
|
77
|
+
/** Where progress and diagnostics go; defaults to the command's stdout. */
|
|
78
|
+
log?: (message: string) => void;
|
|
72
79
|
/** Optional sync-record target. Workspace and sandbox push set this when they want a record. */
|
|
73
80
|
syncTarget?: PushSyncTarget;
|
|
74
81
|
verboseFetch: (url: string, options: RequestInit, verbose: boolean, authToken?: string) => Promise<Response>;
|
|
@@ -79,6 +86,52 @@ export interface WritebackHttpResponse {
|
|
|
79
86
|
status: number;
|
|
80
87
|
text: () => Promise<string>;
|
|
81
88
|
}
|
|
89
|
+
export interface DryRunSummary {
|
|
90
|
+
created: number;
|
|
91
|
+
deleted: number;
|
|
92
|
+
truncated: number;
|
|
93
|
+
unchanged: number;
|
|
94
|
+
updated: number;
|
|
95
|
+
}
|
|
96
|
+
export interface DryRunOperation {
|
|
97
|
+
action: string;
|
|
98
|
+
details: string;
|
|
99
|
+
name: string;
|
|
100
|
+
reason?: string;
|
|
101
|
+
type: string;
|
|
102
|
+
}
|
|
103
|
+
export interface DryRunResult {
|
|
104
|
+
operations: DryRunOperation[];
|
|
105
|
+
/** True when the sandbox currently holds a different source workspace than the one being pushed. */
|
|
106
|
+
source_workspace_mismatch?: boolean;
|
|
107
|
+
summary: Record<string, DryRunSummary>;
|
|
108
|
+
workspace_name?: string;
|
|
109
|
+
}
|
|
110
|
+
/** What a push did, for the calling command to report. */
|
|
111
|
+
export interface PushResult {
|
|
112
|
+
knowledge: {
|
|
113
|
+
deleted: number;
|
|
114
|
+
imported: number;
|
|
115
|
+
};
|
|
116
|
+
preview: DryRunResult | null;
|
|
117
|
+
/** The multidoc import's response body; absent when no multidoc was imported. */
|
|
118
|
+
response?: Record<string, unknown>;
|
|
119
|
+
/** The documents the multidoc import carried. */
|
|
120
|
+
sent: Array<{
|
|
121
|
+
content: string;
|
|
122
|
+
filePath: string;
|
|
123
|
+
}>;
|
|
124
|
+
/** Why the push stopped before writing anything, when it did. */
|
|
125
|
+
stopped?: 'blocked' | 'cancelled' | 'dry-run' | 'no-changes';
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A push that failed after its multidoc import landed. The import is not rolled back, so the error
|
|
129
|
+
* carries what it did for the calling command to report beside the failure.
|
|
130
|
+
*/
|
|
131
|
+
export declare class FailedAfterImportError extends Errors.CLIError {
|
|
132
|
+
readonly imported: PushResult;
|
|
133
|
+
constructor(message: string, imported: PushResult);
|
|
134
|
+
}
|
|
82
135
|
/**
|
|
83
136
|
* Filter document entries down to the ones the dry-run preview reports as changed,
|
|
84
137
|
* used to send only the changed documents during a partial (non-`--sync`) push.
|
|
@@ -217,7 +270,7 @@ export declare function confirm(message: string): Promise<boolean>;
|
|
|
217
270
|
* Execute a multidoc push with preview, validation, partial mode, and GUID sync.
|
|
218
271
|
* Shared by both sandbox:push and workspace:push commands.
|
|
219
272
|
*/
|
|
220
|
-
export declare function executePush(ctx: PushContext, target: PushTarget, flags: PushFlags): Promise<
|
|
273
|
+
export declare function executePush(ctx: PushContext, target: PushTarget, flags: PushFlags): Promise<PushResult>;
|
|
221
274
|
export interface WritebackResult {
|
|
222
275
|
elapsedMs: number;
|
|
223
276
|
/** Object ids resolved by per-object writeback, keyed by relative path. */
|
|
@@ -236,6 +289,8 @@ export declare function writeBackFormattedDocuments(args: {
|
|
|
236
289
|
includeRecords: boolean;
|
|
237
290
|
inputDir?: string;
|
|
238
291
|
isLiveBranch?: boolean;
|
|
292
|
+
/** Where progress goes; defaults to the command's stdout. */
|
|
293
|
+
log?: (message: string) => void;
|
|
239
294
|
/**
|
|
240
295
|
* The bytes each file held when it was pushed (after GUID sync). A file whose disk content
|
|
241
296
|
* no longer matches was edited during the push, so writeback leaves it alone.
|