@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.
- package/README.md +273 -1
- package/dist/base-command.d.ts +12 -1
- package/dist/base-command.js +36 -22
- package/dist/commands/branch/set_live/index.d.ts +22 -0
- package/dist/commands/branch/set_live/index.js +60 -1
- 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 +40 -0
- package/dist/commands/function/edit/index.d.ts +15 -0
- package/dist/commands/function/edit/index.js +40 -0
- package/dist/commands/function/run/index.d.ts +1 -0
- package/dist/commands/function/run/index.js +10 -2
- package/dist/commands/policy/catalogue/index.d.ts +15 -0
- package/dist/commands/policy/catalogue/index.js +29 -0
- package/dist/commands/policy/create/index.d.ts +18 -0
- package/dist/commands/policy/create/index.js +39 -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 +7 -0
- package/dist/commands/release/deploy/index.d.ts +28 -0
- package/dist/commands/release/deploy/index.js +90 -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 +54 -10
- 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 +186 -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 +116 -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 +124 -5
- package/dist/policy-command.d.ts +90 -0
- package/dist/policy-command.js +94 -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 +55 -2
- package/dist/utils/multidoc-push.js +103 -27
- package/dist/utils/policy/catalogue.d.ts +7 -0
- package/dist/utils/policy/catalogue.js +72 -0
- package/dist/utils/policy/errors.d.ts +12 -0
- package/dist/utils/policy/errors.js +61 -0
- package/dist/utils/policy/feedback.d.ts +68 -0
- package/dist/utils/policy/feedback.js +165 -0
- package/dist/utils/policy/findings.d.ts +23 -0
- package/dist/utils/policy/findings.js +65 -0
- package/dist/utils/policy/gate.d.ts +161 -0
- package/dist/utils/policy/gate.js +201 -0
- package/dist/utils/policy/goal.d.ts +46 -0
- package/dist/utils/policy/goal.js +95 -0
- package/dist/utils/policy/permission.d.ts +23 -0
- package/dist/utils/policy/permission.js +84 -0
- package/dist/utils/policy/push-gate.d.ts +5 -0
- package/dist/utils/policy/push-gate.js +29 -0
- package/dist/utils/policy/release.d.ts +85 -0
- package/dist/utils/policy/release.js +115 -0
- package/dist/utils/policy/request.d.ts +55 -0
- package/dist/utils/policy/request.js +115 -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 +47 -0
- package/dist/utils/policy/status.js +117 -0
- package/dist/utils/policy/types.d.ts +185 -0
- package/dist/utils/policy/types.js +1 -0
- package/oclif.manifest.json +5520 -2511
- package/package.json +7 -1
|
@@ -0,0 +1,94 @@
|
|
|
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 policyFlags = {
|
|
9
|
+
...BaseCommand.baseFlags,
|
|
10
|
+
branch: Flags.string({ char: 'b', description: "Branch label (defaults to profile branch or live; -b '' selects live)" }),
|
|
11
|
+
output: Flags.string({ char: 'o', default: 'summary', description: 'Output format', options: ['summary', 'json'] }),
|
|
12
|
+
workspace: Flags.string({ char: 'w', description: 'Workspace ID (defaults to profile workspace)' }),
|
|
13
|
+
};
|
|
14
|
+
/** `publish -m`: the message on the Version History entry the save creates. */
|
|
15
|
+
static publishFlags = {
|
|
16
|
+
message: Flags.string({ char: 'm', description: 'Message stored on the Version History entry this save creates' }),
|
|
17
|
+
};
|
|
18
|
+
/** One positional, as CLAUDE.md requires: the file `--file` would otherwise name. */
|
|
19
|
+
static sourceArgs = {
|
|
20
|
+
file: Args.string({ description: 'Policy XanoScript file (same as --file)', ignoreStdin: true, required: false }),
|
|
21
|
+
};
|
|
22
|
+
static sourceFlags = {
|
|
23
|
+
file: Flags.string({ char: 'f', description: 'Policy XanoScript file', exclusive: ['stdin'] }),
|
|
24
|
+
stdin: Flags.boolean({ default: false, description: 'Read policy XanoScript from stdin', exclusive: ['file'] }),
|
|
25
|
+
};
|
|
26
|
+
/** Any failure exits 1; an error that is not the CLI's own names the command it broke. */
|
|
27
|
+
async catch(error) {
|
|
28
|
+
if ('oclif' in error)
|
|
29
|
+
return this.catchAsOperational(error);
|
|
30
|
+
const action = this.id?.split(':').at(-1) ?? 'command';
|
|
31
|
+
return this.catchAsOperational(new Error(`Policy ${action} failed: ${error.message}`));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Print each rule's warnings (a name, tag or value that makes the rule check nothing) to stderr,
|
|
35
|
+
* so piped source stays clean; nothing when there are none.
|
|
36
|
+
*/
|
|
37
|
+
logRuleWarnings(parsed) {
|
|
38
|
+
const warned = parsed.rule_warnings ?? [];
|
|
39
|
+
if (warned.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
this.logToStderr('Rule warnings (the rule may check nothing or everything; fix them before relying on it):');
|
|
42
|
+
for (const rule of warned) {
|
|
43
|
+
for (const warning of rule.warnings)
|
|
44
|
+
this.logToStderr(` ${rule.rule_id || `rule[${rule.rule}]`}: ${warning}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Print a run's recorded detail, or say it evaluated no policy. */
|
|
48
|
+
logRunDetail(run) {
|
|
49
|
+
const detail = policyRunDetail(run);
|
|
50
|
+
for (const line of detail)
|
|
51
|
+
this.log(line);
|
|
52
|
+
if (detail.length === 0)
|
|
53
|
+
this.log(run.id ? `Run ${run.id} evaluated no policies.` : 'No policies were evaluated.');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Ask the parse route for the policy, its canonical source and each rule's warnings against the
|
|
57
|
+
* target branch, from XanoScript (`{source}`) or a policy document (`{data}`); the policy and source
|
|
58
|
+
* must come back.
|
|
59
|
+
*/
|
|
60
|
+
async parseSource(request, input) {
|
|
61
|
+
const answer = (await request('/parse', 'POST', input));
|
|
62
|
+
if (!answer?.policy?.key || typeof answer.source !== 'string')
|
|
63
|
+
this.error('The platform did not return a parsed policy and canonical source.');
|
|
64
|
+
return { ...answer, rule_warnings: Array.isArray(answer.rule_warnings) ? answer.rule_warnings : [] };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The workspace and branch from the flags or the profile, and a request helper bound to them for
|
|
68
|
+
* a route gated by `workspace:policy`: `/policy` unless another is named.
|
|
69
|
+
*/
|
|
70
|
+
policyTarget(flags, route = POLICY_ROUTE) {
|
|
71
|
+
const { profile } = this.resolveProfile(flags);
|
|
72
|
+
const { branch, workspace } = policyScope(flags, profile);
|
|
73
|
+
if (!workspace)
|
|
74
|
+
this.error('Workspace ID required. Use --workspace or set one in your profile.');
|
|
75
|
+
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 });
|
|
76
|
+
return { branch, request, workspace };
|
|
77
|
+
}
|
|
78
|
+
/** Policy source from the positional, `--file` or `--stdin`; a file named twice must be named the same. */
|
|
79
|
+
readSource(flags, positional) {
|
|
80
|
+
const named = positional?.trim();
|
|
81
|
+
if (named && flags.stdin)
|
|
82
|
+
this.error('Provide policy source once: a file (positional or --file) or --stdin.');
|
|
83
|
+
if (named && flags.file && flags.file !== named)
|
|
84
|
+
this.error('Provide the file once: as a positional or as --file.');
|
|
85
|
+
const file = flags.file ?? named;
|
|
86
|
+
if (!file && !flags.stdin)
|
|
87
|
+
this.error('Provide the policy file (as an argument or --file) or --stdin.');
|
|
88
|
+
return fs.readFileSync(flags.stdin ? 0 : file, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
/** `workspace 3 (feature)`, or just `workspace 3` for live. */
|
|
91
|
+
where({ branch, workspace }) {
|
|
92
|
+
return `workspace ${workspace}${branch ? ` (${branch})` : ''}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -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
|
export interface PushFlags {
|
|
4
4
|
delete: boolean;
|
|
@@ -21,6 +21,11 @@ export interface PushTarget {
|
|
|
21
21
|
buildPushUrl: (queryParams: URLSearchParams) => string;
|
|
22
22
|
/** CLI version string */
|
|
23
23
|
cliVersion: string;
|
|
24
|
+
/**
|
|
25
|
+
* Guidance for a refusal this target recognises, given the HTTP status and the server's payload.
|
|
26
|
+
* A preview or import refused that way stops with the message and this guidance appended.
|
|
27
|
+
*/
|
|
28
|
+
explainRefusal?: (status: number, payload: unknown) => string | undefined;
|
|
24
29
|
/** Instance origin URL (e.g., "https://x123-abcd-1234.xano.io") */
|
|
25
30
|
instanceOrigin: string;
|
|
26
31
|
/** Human-readable label for log messages (e.g., "sandbox environment", "workspace 40") */
|
|
@@ -54,8 +59,56 @@ export interface PushContext {
|
|
|
54
59
|
inputDir: string;
|
|
55
60
|
/** Optional knowledge sync config. Only workspace push sets this. */
|
|
56
61
|
knowledge?: KnowledgeConfig;
|
|
62
|
+
/** Where progress and diagnostics go; defaults to the command's stdout. */
|
|
63
|
+
log?: (message: string) => void;
|
|
57
64
|
verboseFetch: (url: string, options: RequestInit, verbose: boolean, authToken?: string) => Promise<Response>;
|
|
58
65
|
}
|
|
66
|
+
export interface DryRunSummary {
|
|
67
|
+
created: number;
|
|
68
|
+
deleted: number;
|
|
69
|
+
truncated: number;
|
|
70
|
+
unchanged: number;
|
|
71
|
+
updated: number;
|
|
72
|
+
}
|
|
73
|
+
export interface DryRunOperation {
|
|
74
|
+
action: string;
|
|
75
|
+
details: string;
|
|
76
|
+
name: string;
|
|
77
|
+
reason?: string;
|
|
78
|
+
type: string;
|
|
79
|
+
}
|
|
80
|
+
export interface DryRunResult {
|
|
81
|
+
operations: DryRunOperation[];
|
|
82
|
+
/** True when the sandbox currently holds a different source workspace than the one being pushed. */
|
|
83
|
+
source_workspace_mismatch?: boolean;
|
|
84
|
+
summary: Record<string, DryRunSummary>;
|
|
85
|
+
workspace_name?: string;
|
|
86
|
+
}
|
|
87
|
+
/** What a push did, for the calling command to report. */
|
|
88
|
+
export interface PushResult {
|
|
89
|
+
knowledge: {
|
|
90
|
+
deleted: number;
|
|
91
|
+
imported: number;
|
|
92
|
+
};
|
|
93
|
+
preview: DryRunResult | null;
|
|
94
|
+
/** The multidoc import's response body; absent when no multidoc was imported. */
|
|
95
|
+
response?: Record<string, unknown>;
|
|
96
|
+
/** The documents the multidoc import carried. */
|
|
97
|
+
sent: Array<{
|
|
98
|
+
content: string;
|
|
99
|
+
filePath: string;
|
|
100
|
+
}>;
|
|
101
|
+
/** Why the push stopped before writing anything, when it did. */
|
|
102
|
+
stopped?: 'blocked' | 'cancelled' | 'dry-run' | 'no-changes';
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A push that failed after its multidoc import landed. The import is not rolled back, so the error
|
|
106
|
+
* carries what it did for the calling command to report beside the failure.
|
|
107
|
+
*/
|
|
108
|
+
export declare class FailedAfterImportError extends Errors.CLIError {
|
|
109
|
+
readonly imported: PushResult;
|
|
110
|
+
constructor(message: string, imported: PushResult);
|
|
111
|
+
}
|
|
59
112
|
/**
|
|
60
113
|
* Filter document entries down to the ones the dry-run preview reports as changed,
|
|
61
114
|
* used to send only the changed documents during a partial (non-`--sync`) push.
|
|
@@ -192,7 +245,7 @@ export declare function confirm(message: string): Promise<boolean>;
|
|
|
192
245
|
* Execute a multidoc push with preview, validation, partial mode, and GUID sync.
|
|
193
246
|
* Shared by both sandbox:push and workspace:push commands.
|
|
194
247
|
*/
|
|
195
|
-
export declare function executePush(ctx: PushContext, target: PushTarget, flags: PushFlags): Promise<
|
|
248
|
+
export declare function executePush(ctx: PushContext, target: PushTarget, flags: PushFlags): Promise<PushResult>;
|
|
196
249
|
/**
|
|
197
250
|
* Turn a thrown fetch/network error into an actionable message.
|
|
198
251
|
*
|
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
import { ux } from '@oclif/core';
|
|
1
|
+
import { Errors, ux } from '@oclif/core';
|
|
2
2
|
import { minimatch } from 'minimatch';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { buildDocumentKey, findFilesWithGuid, parseDocument } from './document-parser.js';
|
|
6
6
|
import { flattenBundleFile } from './flatten.js';
|
|
7
7
|
import { collectKnowledgeObjects, fetchKnowledge, knowledgePreview, pushKnowledge, syncGuidToFrontmatter, toPushItems, } from './knowledge-sync.js';
|
|
8
|
+
import { policyWeakeningGuidance, WEAKENING_REFUSAL } from './policy/permission.js';
|
|
9
|
+
import { PushPolicyGateError } from './policy/push-gate.js';
|
|
8
10
|
import { checkReferences, checkTableIndexes } from './reference-checker.js';
|
|
11
|
+
/**
|
|
12
|
+
* A push that failed after its multidoc import landed. The import is not rolled back, so the error
|
|
13
|
+
* carries what it did for the calling command to report beside the failure.
|
|
14
|
+
*/
|
|
15
|
+
export class FailedAfterImportError extends Errors.CLIError {
|
|
16
|
+
imported;
|
|
17
|
+
constructor(message, imported) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.imported = imported;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
9
22
|
/**
|
|
10
23
|
* Filter document entries down to the ones the dry-run preview reports as changed,
|
|
11
24
|
* used to send only the changed documents during a partial (non-`--sync`) push.
|
|
@@ -579,7 +592,9 @@ function mergeKnowledgePreview(preview, knowledge) {
|
|
|
579
592
|
*/
|
|
580
593
|
export async function executePush(ctx, target, flags) {
|
|
581
594
|
const { accessToken, command, inputDir, verboseFetch } = ctx;
|
|
582
|
-
const log = command.log.bind(command);
|
|
595
|
+
const log = ctx.log ?? command.log.bind(command);
|
|
596
|
+
let dryRunPreview = null;
|
|
597
|
+
const stop = (stopped) => ({ knowledge: { deleted: 0, imported: 0 }, preview: dryRunPreview, sent: [], stopped });
|
|
583
598
|
// ── Collect knowledge entries (before file check so knowledge-only push works) ─
|
|
584
599
|
let knowledgeObjects = [];
|
|
585
600
|
if (ctx.knowledge) {
|
|
@@ -638,7 +653,7 @@ export async function executePush(ctx, target, flags) {
|
|
|
638
653
|
const doFlatten = await confirm('Flatten these file(s) in place and continue the push?');
|
|
639
654
|
if (!doFlatten) {
|
|
640
655
|
log('Push cancelled. Run `xano flatten <file>` yourself, or re-run to be prompted again.');
|
|
641
|
-
return;
|
|
656
|
+
return stop('cancelled');
|
|
642
657
|
}
|
|
643
658
|
}
|
|
644
659
|
else {
|
|
@@ -741,7 +756,6 @@ export async function executePush(ctx, target, flags) {
|
|
|
741
756
|
'Content-Type': 'text/x-xanoscript',
|
|
742
757
|
};
|
|
743
758
|
// ── Dry-run / Preview ─────────────────────────────────────────────────
|
|
744
|
-
let dryRunPreview = null;
|
|
745
759
|
const dryRunUrl = knowledgeOnly ? null : target.buildDryRunUrl(queryParams);
|
|
746
760
|
if (dryRunUrl && (flags['dry-run'] || !flags.force)) {
|
|
747
761
|
const dryRunParams = new URLSearchParams(queryParams);
|
|
@@ -833,7 +847,7 @@ export async function executePush(ctx, target, flags) {
|
|
|
833
847
|
log('');
|
|
834
848
|
log(ux.colorize('red', `Push blocked: ${criticalOps.length} critical error(s) found.`));
|
|
835
849
|
if (!flags.force) {
|
|
836
|
-
return;
|
|
850
|
+
return stop('blocked');
|
|
837
851
|
}
|
|
838
852
|
log(ux.colorize('yellow', 'Proceeding anyway due to --force flag.'));
|
|
839
853
|
}
|
|
@@ -863,10 +877,10 @@ export async function executePush(ctx, target, flags) {
|
|
|
863
877
|
if (!hasChanges && !hasLocalRecords) {
|
|
864
878
|
log('');
|
|
865
879
|
log('No changes to push.');
|
|
866
|
-
return;
|
|
880
|
+
return stop('no-changes');
|
|
867
881
|
}
|
|
868
882
|
if (flags['dry-run']) {
|
|
869
|
-
return;
|
|
883
|
+
return stop('dry-run');
|
|
870
884
|
}
|
|
871
885
|
// Warn when the sandbox currently holds a different source workspace than the one being
|
|
872
886
|
// pushed. The backend compares the source workspace id we sent against the id stored on
|
|
@@ -884,7 +898,7 @@ export async function executePush(ctx, target, flags) {
|
|
|
884
898
|
const proceed = await confirm('Continue with push anyway?');
|
|
885
899
|
if (!proceed) {
|
|
886
900
|
log('Push cancelled. Run `xano sandbox reset` then retry.');
|
|
887
|
-
return;
|
|
901
|
+
return stop('cancelled');
|
|
888
902
|
}
|
|
889
903
|
mismatchConfirmed = true;
|
|
890
904
|
}
|
|
@@ -905,7 +919,7 @@ export async function executePush(ctx, target, flags) {
|
|
|
905
919
|
const confirmed = await confirm(message);
|
|
906
920
|
if (!confirmed) {
|
|
907
921
|
log('Push cancelled.');
|
|
908
|
-
return;
|
|
922
|
+
return stop('cancelled');
|
|
909
923
|
}
|
|
910
924
|
}
|
|
911
925
|
else {
|
|
@@ -922,15 +936,17 @@ export async function executePush(ctx, target, flags) {
|
|
|
922
936
|
}
|
|
923
937
|
}
|
|
924
938
|
else {
|
|
925
|
-
await handleDryRunError(dryRunResponse, command,
|
|
939
|
+
await handleDryRunError(dryRunResponse, command, target, { log, verbose: flags.verbose });
|
|
926
940
|
// If we get here, the user confirmed to proceed without preview
|
|
927
941
|
}
|
|
928
942
|
}
|
|
929
943
|
catch (error) {
|
|
930
944
|
// Ctrl+C or SIGINT
|
|
945
|
+
if (error instanceof PushPolicyGateError)
|
|
946
|
+
throw error;
|
|
931
947
|
if (error.name === 'AbortError' || error.code === 'ERR_USE_AFTER_CLOSE') {
|
|
932
948
|
log('\nPush cancelled.');
|
|
933
|
-
return;
|
|
949
|
+
return stop('cancelled');
|
|
934
950
|
}
|
|
935
951
|
// Re-throw oclif errors
|
|
936
952
|
if (error instanceof Error && 'oclif' in error) {
|
|
@@ -961,16 +977,16 @@ export async function executePush(ctx, target, flags) {
|
|
|
961
977
|
if (!hasChanges) {
|
|
962
978
|
log('');
|
|
963
979
|
log('No changes to push.');
|
|
964
|
-
return;
|
|
980
|
+
return stop('no-changes');
|
|
965
981
|
}
|
|
966
982
|
if (flags['dry-run']) {
|
|
967
|
-
return;
|
|
983
|
+
return stop('dry-run');
|
|
968
984
|
}
|
|
969
985
|
if (process.stdin.isTTY) {
|
|
970
986
|
const confirmed = await confirm('Proceed with push?');
|
|
971
987
|
if (!confirmed) {
|
|
972
988
|
log('Push cancelled.');
|
|
973
|
-
return;
|
|
989
|
+
return stop('cancelled');
|
|
974
990
|
}
|
|
975
991
|
}
|
|
976
992
|
else {
|
|
@@ -986,17 +1002,19 @@ export async function executePush(ctx, target, flags) {
|
|
|
986
1002
|
}
|
|
987
1003
|
}
|
|
988
1004
|
// ── Partial push: filter to changed documents only ────────────────────
|
|
1005
|
+
let sent = documentEntries;
|
|
989
1006
|
if (!knowledgeOnly && isPartial && dryRunPreview) {
|
|
990
|
-
|
|
991
|
-
if (
|
|
1007
|
+
sent = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
|
|
1008
|
+
if (sent.length === 0 && knowledgeObjects.length === 0) {
|
|
992
1009
|
log('No changes to push.');
|
|
993
|
-
return;
|
|
1010
|
+
return stop('no-changes');
|
|
994
1011
|
}
|
|
995
|
-
multidoc =
|
|
1012
|
+
multidoc = sent.map((d) => d.content).join('\n---\n');
|
|
996
1013
|
}
|
|
997
1014
|
// ── Execute the actual push ───────────────────────────────────────────
|
|
998
1015
|
const startTime = Date.now();
|
|
999
1016
|
let pushedDocCount = 0;
|
|
1017
|
+
let pushResponse;
|
|
1000
1018
|
if (!knowledgeOnly && multidoc) {
|
|
1001
1019
|
const apiUrl = target.buildPushUrl(queryParams);
|
|
1002
1020
|
try {
|
|
@@ -1006,14 +1024,19 @@ export async function executePush(ctx, target, flags) {
|
|
|
1006
1024
|
method: 'POST',
|
|
1007
1025
|
}, flags.verbose, accessToken);
|
|
1008
1026
|
if (!response.ok) {
|
|
1009
|
-
|
|
1027
|
+
const errorText = await response.text();
|
|
1028
|
+
refuseIfExplained(command, target, response.status, errorText);
|
|
1029
|
+
handlePushError(response, errorText, documentEntries, inputDir, command);
|
|
1010
1030
|
}
|
|
1011
1031
|
// Parse response for GUID map
|
|
1012
1032
|
const responseText = await response.text();
|
|
1013
1033
|
let guidMap = [];
|
|
1034
|
+
pushResponse = {};
|
|
1014
1035
|
if (responseText && responseText !== 'null') {
|
|
1015
1036
|
try {
|
|
1016
1037
|
const responseJson = JSON.parse(responseText);
|
|
1038
|
+
if (responseJson && typeof responseJson === 'object')
|
|
1039
|
+
pushResponse = responseJson;
|
|
1017
1040
|
if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
|
|
1018
1041
|
guidMap = responseJson.guid_map;
|
|
1019
1042
|
}
|
|
@@ -1039,7 +1062,7 @@ export async function executePush(ctx, target, flags) {
|
|
|
1039
1062
|
let updatedCount = 0;
|
|
1040
1063
|
let canonicalCount = 0;
|
|
1041
1064
|
for (const entry of guidMap) {
|
|
1042
|
-
if (!entry.guid)
|
|
1065
|
+
if (!entry.guid || entry.type === 'policy')
|
|
1043
1066
|
continue;
|
|
1044
1067
|
const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
|
|
1045
1068
|
let filePath = documentFileMap.get(key);
|
|
@@ -1085,6 +1108,8 @@ export async function executePush(ctx, target, flags) {
|
|
|
1085
1108
|
pushedDocCount = multidoc.split('\n---\n').length;
|
|
1086
1109
|
}
|
|
1087
1110
|
catch (error) {
|
|
1111
|
+
if (error instanceof PushPolicyGateError)
|
|
1112
|
+
throw error;
|
|
1088
1113
|
if (error instanceof Error && 'oclif' in error)
|
|
1089
1114
|
throw error;
|
|
1090
1115
|
const elapsedMs = Date.now() - startTime;
|
|
@@ -1094,6 +1119,12 @@ export async function executePush(ctx, target, flags) {
|
|
|
1094
1119
|
// ── Push knowledge ────────────────────────────────────────────────────
|
|
1095
1120
|
let knowledgeImported = 0;
|
|
1096
1121
|
let knowledgeDeleted = 0;
|
|
1122
|
+
const pushed = () => ({
|
|
1123
|
+
knowledge: { deleted: knowledgeDeleted, imported: knowledgeImported },
|
|
1124
|
+
preview: dryRunPreview,
|
|
1125
|
+
response: pushResponse,
|
|
1126
|
+
sent: pushResponse ? sent : [],
|
|
1127
|
+
});
|
|
1097
1128
|
if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
|
|
1098
1129
|
const listUrl = ctx.knowledge.listUrl();
|
|
1099
1130
|
try {
|
|
@@ -1131,7 +1162,10 @@ export async function executePush(ctx, target, flags) {
|
|
|
1131
1162
|
if (error instanceof Error && 'oclif' in error)
|
|
1132
1163
|
throw error;
|
|
1133
1164
|
const elapsedMs = Date.now() - startTime;
|
|
1134
|
-
|
|
1165
|
+
const message = `Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`;
|
|
1166
|
+
if (pushResponse)
|
|
1167
|
+
throw new FailedAfterImportError(message, pushed());
|
|
1168
|
+
command.error(message);
|
|
1135
1169
|
}
|
|
1136
1170
|
}
|
|
1137
1171
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
@@ -1145,6 +1179,7 @@ export async function executePush(ctx, target, flags) {
|
|
|
1145
1179
|
parts.push(kParts.join(', '));
|
|
1146
1180
|
}
|
|
1147
1181
|
log(`Pushed ${parts.join(' + ')} to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
|
|
1182
|
+
return pushed();
|
|
1148
1183
|
}
|
|
1149
1184
|
// ── Error Handlers ──────────────────────────────────────────────────────────
|
|
1150
1185
|
/**
|
|
@@ -1223,10 +1258,43 @@ function formatFailureDuration(elapsedMs) {
|
|
|
1223
1258
|
? ` (failed after ~${human}, near a common ${boundaries.find((b) => Math.abs(totalSeconds - b) <= b * 0.05)}s timeout — likely a server or proxy cutoff)`
|
|
1224
1259
|
: ` (failed after ${human})`;
|
|
1225
1260
|
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1261
|
+
/** Stop with the target's own guidance when it recognises this refusal. */
|
|
1262
|
+
function refuseIfExplained(command, target, status, body) {
|
|
1263
|
+
let message = body;
|
|
1264
|
+
let payload;
|
|
1265
|
+
try {
|
|
1266
|
+
const parsed = JSON.parse(body);
|
|
1267
|
+
if (typeof parsed?.message === 'string')
|
|
1268
|
+
message = parsed.message;
|
|
1269
|
+
payload = parsed?.payload;
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
// Not JSON
|
|
1273
|
+
}
|
|
1274
|
+
const refusal = payload;
|
|
1275
|
+
if (status === 403 && refusal?.code === 'policy_gate')
|
|
1276
|
+
throw new PushPolicyGateError(refusal);
|
|
1277
|
+
// Policy files that weaken a mandatory policy of the live branch, from a credential without
|
|
1278
|
+
// workspace:policy update: a permission refusal (exit 1), not a blocking finding. The platform's
|
|
1279
|
+
// sentence already reads "Push refused: … Nothing was imported.", so it is printed as it is.
|
|
1280
|
+
if (status === 403 && refusal?.code === WEAKENING_REFUSAL) {
|
|
1281
|
+
command.error(`${message}${target.explainRefusal?.(status, payload) ?? policyWeakeningGuidance(payload, [], true)}`);
|
|
1282
|
+
}
|
|
1283
|
+
// The plan's policy cap: the platform's message names the plan, its cap and the remedy, and the
|
|
1284
|
+
// push itself would be refused the same way, so the preview stops here rather than skipping.
|
|
1285
|
+
if (status === 403 && refusal?.code === 'policy_plan_limit')
|
|
1286
|
+
command.error(`Push refused (${status}): ${message}`);
|
|
1287
|
+
if (refusal?.code === 'policy_gate_transaction_required') {
|
|
1288
|
+
command.error('Push refused: the live branch policy gate requires a transaction. Remove --no-transaction and retry. Nothing was imported.');
|
|
1289
|
+
}
|
|
1290
|
+
const guidance = target.explainRefusal?.(status, payload);
|
|
1291
|
+
if (guidance)
|
|
1292
|
+
command.error(`Push refused (${status}): ${message}${guidance}`);
|
|
1293
|
+
}
|
|
1294
|
+
async function handleDryRunError(response, command, target, { log, verbose }) {
|
|
1295
|
+
const errorText = await response.text();
|
|
1296
|
+
refuseIfExplained(command, target, response.status, errorText);
|
|
1228
1297
|
if (response.status === 404) {
|
|
1229
|
-
const errorText = await response.text();
|
|
1230
1298
|
try {
|
|
1231
1299
|
const errorJson = JSON.parse(errorText);
|
|
1232
1300
|
if (errorJson.message) {
|
|
@@ -1244,7 +1312,6 @@ async function handleDryRunError(response, command, flags, target) {
|
|
|
1244
1312
|
log('');
|
|
1245
1313
|
}
|
|
1246
1314
|
else {
|
|
1247
|
-
const errorText = await response.text();
|
|
1248
1315
|
// Check if push is disabled
|
|
1249
1316
|
try {
|
|
1250
1317
|
const errorJson = JSON.parse(errorText);
|
|
@@ -1267,8 +1334,17 @@ async function handleDryRunError(response, command, flags, target) {
|
|
|
1267
1334
|
catch {
|
|
1268
1335
|
// Not JSON, fall through
|
|
1269
1336
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1337
|
+
let reason = '';
|
|
1338
|
+
try {
|
|
1339
|
+
const errorJson = JSON.parse(errorText);
|
|
1340
|
+
if (typeof errorJson?.message === 'string' && errorJson.message.trim())
|
|
1341
|
+
reason = `: ${errorJson.message.trim().replace(/\.$/, '')}`;
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
1344
|
+
// Not JSON
|
|
1345
|
+
}
|
|
1346
|
+
command.warn(`Push preview failed (${response.status})${reason}. Skipping preview.`);
|
|
1347
|
+
if (verbose) {
|
|
1272
1348
|
log(ux.colorize('dim', errorText));
|
|
1273
1349
|
}
|
|
1274
1350
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PolicyCatalogueEntry } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* `--check` narrows the catalogue to one check. An id that is not in it names the closest matches
|
|
4
|
+
* instead of reprinting the whole list: the usual mistake is a typo one character from a real id.
|
|
5
|
+
*/
|
|
6
|
+
export declare function selectCatalogueCheck(checks: PolicyCatalogueEntry[], id: string): PolicyCatalogueEntry[];
|
|
7
|
+
export declare function policyCatalogueSummary(checks: PolicyCatalogueEntry[]): string[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--check` narrows the catalogue to one check. An id that is not in it names the closest matches
|
|
3
|
+
* instead of reprinting the whole list: the usual mistake is a typo one character from a real id.
|
|
4
|
+
*/
|
|
5
|
+
export function selectCatalogueCheck(checks, id) {
|
|
6
|
+
const wanted = id.trim().toLowerCase();
|
|
7
|
+
const exact = checks.filter(check => check.id.toLowerCase() === wanted);
|
|
8
|
+
if (exact.length > 0)
|
|
9
|
+
return exact;
|
|
10
|
+
const near = checks.map(check => check.id).filter(known => {
|
|
11
|
+
const lower = known.toLowerCase();
|
|
12
|
+
return lower.includes(wanted) || wanted.includes(lower) || editDistanceWithin(lower, wanted, 2);
|
|
13
|
+
}).sort();
|
|
14
|
+
throw new Error(`"${id}" is not a policy check.${near.length > 0
|
|
15
|
+
? ` Did you mean ${near.join(', ')}?`
|
|
16
|
+
: ' Run `xano policy catalogue` for the full list.'}`);
|
|
17
|
+
}
|
|
18
|
+
/** Cheap bounded Levenshtein: true when `a` and `b` are at most `max` edits apart. */
|
|
19
|
+
function editDistanceWithin(a, b, max) {
|
|
20
|
+
if (Math.abs(a.length - b.length) > max)
|
|
21
|
+
return false;
|
|
22
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
23
|
+
for (let i = 1; i <= a.length; i++) {
|
|
24
|
+
const current = [i];
|
|
25
|
+
for (let j = 1; j <= b.length; j++) {
|
|
26
|
+
current[j] = a[i - 1] === b[j - 1]
|
|
27
|
+
? previous[j - 1]
|
|
28
|
+
: 1 + Math.min(previous[j - 1], previous[j], current[j - 1]);
|
|
29
|
+
}
|
|
30
|
+
if (Math.min(...current) > max)
|
|
31
|
+
return false;
|
|
32
|
+
previous = current;
|
|
33
|
+
}
|
|
34
|
+
return previous[b.length] <= max;
|
|
35
|
+
}
|
|
36
|
+
export function policyCatalogueSummary(checks) {
|
|
37
|
+
if (checks.length === 0)
|
|
38
|
+
return ['No policy checks found.'];
|
|
39
|
+
const rows = [
|
|
40
|
+
['Check ID', 'Label / Description', 'Object kinds', 'Required params'],
|
|
41
|
+
...checks.map(check => [
|
|
42
|
+
check.id,
|
|
43
|
+
[check.label.trim(), check.description, check.fix_hint && `Fix hint: ${check.fix_hint}`].filter(Boolean).join('\n'),
|
|
44
|
+
(check.object_kinds ?? []).join(', ') || '—',
|
|
45
|
+
requiredParams(check),
|
|
46
|
+
]),
|
|
47
|
+
];
|
|
48
|
+
const widths = rows[0].map((_, column) => Math.min([36, 46, 24, 32][column], Math.max(...rows.map(row => row[column].length))));
|
|
49
|
+
return rows.flatMap(row => {
|
|
50
|
+
const cells = row.map((cell, column) => cell.split('\n').flatMap(paragraph => {
|
|
51
|
+
const lines = [''];
|
|
52
|
+
for (const word of paragraph.split(/\s+/)) {
|
|
53
|
+
const last = lines.length - 1;
|
|
54
|
+
if (lines[last] && lines[last].length + word.length + 1 > widths[column])
|
|
55
|
+
lines.push(word);
|
|
56
|
+
else
|
|
57
|
+
lines[last] += `${lines[last] ? ' ' : ''}${word}`;
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
60
|
+
}));
|
|
61
|
+
return Array.from({ length: Math.max(...cells.map(cell => cell.length)) }, (_, line) => cells.map((cell, column) => (cell[line] ?? '').padEnd(widths[column])).join(' ').trimEnd());
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** The params a rule must set: each required one, and any "one of" group. */
|
|
65
|
+
function requiredParams(check) {
|
|
66
|
+
const required = Object.entries(check.params ?? {}).filter(([, schema]) => schema.required)
|
|
67
|
+
.map(([name, schema]) => `${name}: ${schema.type ?? 'any'}`);
|
|
68
|
+
const oneOf = check.requires_one_of ?? [];
|
|
69
|
+
if (oneOf.length > 0)
|
|
70
|
+
required.push(`one of: ${oneOf.join(' | ')}`);
|
|
71
|
+
return required.join(', ') || 'none';
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Guidance appended to a refusal whose `payload.code` the CLI has advice for, or `''`. */
|
|
2
|
+
export declare function policyCodeGuidance(payload: unknown): string;
|
|
3
|
+
/** A failed policy-route response: one message to print, and the refusal's payload. */
|
|
4
|
+
export interface PolicyError {
|
|
5
|
+
message: string;
|
|
6
|
+
payload?: unknown;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A failed policy-route response as one message: the platform's code and message, and where a parse
|
|
10
|
+
* error is. Traces are dropped, and an empty 404 names the branch the request selected.
|
|
11
|
+
*/
|
|
12
|
+
export declare function describePolicyError(text: string, status: number, requestUrl: string): PolicyError;
|