@xano/cli 1.3.0 → 1.3.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +269 -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/policy/catalogue/index.d.ts +15 -0
  12. package/dist/commands/policy/catalogue/index.js +29 -0
  13. package/dist/commands/policy/create/index.d.ts +18 -0
  14. package/dist/commands/policy/create/index.js +39 -0
  15. package/dist/commands/policy/delete/index.d.ts +19 -0
  16. package/dist/commands/policy/delete/index.js +61 -0
  17. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  18. package/dist/commands/policy/evaluate/index.js +71 -0
  19. package/dist/commands/policy/list/index.d.ts +14 -0
  20. package/dist/commands/policy/list/index.js +21 -0
  21. package/dist/commands/policy/parse/index.d.ts +19 -0
  22. package/dist/commands/policy/parse/index.js +18 -0
  23. package/dist/commands/policy/publish/index.d.ts +20 -0
  24. package/dist/commands/policy/publish/index.js +38 -0
  25. package/dist/commands/policy/runs/index.d.ts +54 -0
  26. package/dist/commands/policy/runs/index.js +273 -0
  27. package/dist/commands/policy/status/index.d.ts +21 -0
  28. package/dist/commands/policy/status/index.js +54 -0
  29. package/dist/commands/release/create/index.js +7 -0
  30. package/dist/commands/release/deploy/index.d.ts +28 -0
  31. package/dist/commands/release/deploy/index.js +90 -6
  32. package/dist/commands/release/import/index.js +5 -1
  33. package/dist/commands/release/list/index.d.ts +7 -0
  34. package/dist/commands/release/list/index.js +54 -10
  35. package/dist/commands/release/push/index.js +19 -1
  36. package/dist/commands/sandbox/push/index.js +6 -1
  37. package/dist/commands/skills/pull/index.d.ts +30 -0
  38. package/dist/commands/skills/pull/index.js +118 -0
  39. package/dist/commands/tenant/create/index.d.ts +3 -0
  40. package/dist/commands/tenant/create/index.js +21 -0
  41. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  42. package/dist/commands/tenant/deploy_release/index.js +186 -4
  43. package/dist/commands/tenant/edit/index.d.ts +2 -0
  44. package/dist/commands/tenant/edit/index.js +39 -4
  45. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  46. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  47. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  48. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  49. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  50. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  51. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  52. package/dist/commands/tenant_deploy_request/get/index.js +116 -0
  53. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  54. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  55. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  56. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  57. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  58. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  59. package/dist/commands/workspace/pull/index.d.ts +10 -0
  60. package/dist/commands/workspace/pull/index.js +70 -7
  61. package/dist/commands/workspace/push/index.d.ts +19 -0
  62. package/dist/commands/workspace/push/index.js +124 -5
  63. package/dist/policy-command.d.ts +90 -0
  64. package/dist/policy-command.js +94 -0
  65. package/dist/utils/document-parser.d.ts +10 -0
  66. package/dist/utils/document-parser.js +21 -0
  67. package/dist/utils/multidoc-push.d.ts +55 -2
  68. package/dist/utils/multidoc-push.js +103 -27
  69. package/dist/utils/policy/catalogue.d.ts +7 -0
  70. package/dist/utils/policy/catalogue.js +72 -0
  71. package/dist/utils/policy/errors.d.ts +12 -0
  72. package/dist/utils/policy/errors.js +61 -0
  73. package/dist/utils/policy/feedback.d.ts +68 -0
  74. package/dist/utils/policy/feedback.js +165 -0
  75. package/dist/utils/policy/findings.d.ts +23 -0
  76. package/dist/utils/policy/findings.js +65 -0
  77. package/dist/utils/policy/gate.d.ts +161 -0
  78. package/dist/utils/policy/gate.js +201 -0
  79. package/dist/utils/policy/goal.d.ts +46 -0
  80. package/dist/utils/policy/goal.js +95 -0
  81. package/dist/utils/policy/permission.d.ts +23 -0
  82. package/dist/utils/policy/permission.js +84 -0
  83. package/dist/utils/policy/push-gate.d.ts +5 -0
  84. package/dist/utils/policy/push-gate.js +29 -0
  85. package/dist/utils/policy/release.d.ts +85 -0
  86. package/dist/utils/policy/release.js +115 -0
  87. package/dist/utils/policy/request.d.ts +55 -0
  88. package/dist/utils/policy/request.js +115 -0
  89. package/dist/utils/policy/runs.d.ts +30 -0
  90. package/dist/utils/policy/runs.js +101 -0
  91. package/dist/utils/policy/status.d.ts +47 -0
  92. package/dist/utils/policy/status.js +117 -0
  93. package/dist/utils/policy/types.d.ts +185 -0
  94. package/dist/utils/policy/types.js +1 -0
  95. package/oclif.manifest.json +6132 -3133
  96. 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<void>;
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, flags, target);
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
- const filteredEntries = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
991
- if (filteredEntries.length === 0 && knowledgeObjects.length === 0) {
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 = filteredEntries.length > 0 ? filteredEntries.map((d) => d.content).join('\n---\n') : '';
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
- handlePushError(response, await response.text(), documentEntries, inputDir, command);
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
- command.error(`Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`);
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
- async function handleDryRunError(response, command, flags, target) {
1227
- const log = command.log.bind(command);
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
- command.warn(`Push preview failed (${response.status}). Skipping preview.`);
1271
- if (flags.verbose) {
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;