@xano/cli 1.3.4 → 1.3.5-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +69 -2
  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 +14 -0
  5. package/dist/commands/branch/set_live/index.js +38 -0
  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 +39 -0
  9. package/dist/commands/function/edit/index.d.ts +15 -0
  10. package/dist/commands/function/edit/index.js +39 -0
  11. package/dist/commands/function/run/index.js +5 -11
  12. package/dist/commands/policy/catalogue/index.d.ts +16 -0
  13. package/dist/commands/policy/catalogue/index.js +60 -0
  14. package/dist/commands/policy/coverage/index.d.ts +17 -0
  15. package/dist/commands/policy/coverage/index.js +33 -0
  16. package/dist/commands/policy/create/index.d.ts +18 -0
  17. package/dist/commands/policy/create/index.js +44 -0
  18. package/dist/commands/policy/delete/index.d.ts +19 -0
  19. package/dist/commands/policy/delete/index.js +61 -0
  20. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  21. package/dist/commands/policy/evaluate/index.js +71 -0
  22. package/dist/commands/policy/list/index.d.ts +14 -0
  23. package/dist/commands/policy/list/index.js +21 -0
  24. package/dist/commands/policy/parse/index.d.ts +19 -0
  25. package/dist/commands/policy/parse/index.js +18 -0
  26. package/dist/commands/policy/publish/index.d.ts +20 -0
  27. package/dist/commands/policy/publish/index.js +38 -0
  28. package/dist/commands/policy/runs/index.d.ts +54 -0
  29. package/dist/commands/policy/runs/index.js +273 -0
  30. package/dist/commands/policy/status/index.d.ts +21 -0
  31. package/dist/commands/policy/status/index.js +54 -0
  32. package/dist/commands/release/create/index.js +4 -0
  33. package/dist/commands/release/deploy/index.d.ts +19 -0
  34. package/dist/commands/release/deploy/index.js +68 -6
  35. package/dist/commands/release/import/index.js +5 -1
  36. package/dist/commands/release/list/index.d.ts +7 -0
  37. package/dist/commands/release/list/index.js +46 -9
  38. package/dist/commands/release/push/index.js +19 -1
  39. package/dist/commands/sandbox/push/index.js +6 -1
  40. package/dist/commands/skills/pull/index.d.ts +30 -0
  41. package/dist/commands/skills/pull/index.js +118 -0
  42. package/dist/commands/tenant/create/index.d.ts +3 -0
  43. package/dist/commands/tenant/create/index.js +21 -0
  44. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  45. package/dist/commands/tenant/deploy_release/index.js +182 -4
  46. package/dist/commands/tenant/edit/index.d.ts +2 -0
  47. package/dist/commands/tenant/edit/index.js +39 -4
  48. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  49. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  50. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  51. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  52. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  53. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  54. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  55. package/dist/commands/tenant_deploy_request/get/index.js +113 -0
  56. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  57. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  58. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  59. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  60. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  61. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  62. package/dist/commands/workspace/pull/index.d.ts +10 -0
  63. package/dist/commands/workspace/pull/index.js +70 -7
  64. package/dist/commands/workspace/push/index.d.ts +19 -0
  65. package/dist/commands/workspace/push/index.js +135 -6
  66. package/dist/policy-command.d.ts +91 -0
  67. package/dist/policy-command.js +95 -0
  68. package/dist/utils/document-parser.d.ts +10 -0
  69. package/dist/utils/document-parser.js +21 -0
  70. package/dist/utils/multidoc-push.d.ts +57 -2
  71. package/dist/utils/multidoc-push.js +102 -33
  72. package/dist/utils/policy/catalogue.d.ts +85 -0
  73. package/dist/utils/policy/catalogue.js +196 -0
  74. package/dist/utils/policy/coverage.d.ts +19 -0
  75. package/dist/utils/policy/coverage.js +93 -0
  76. package/dist/utils/policy/errors.d.ts +20 -0
  77. package/dist/utils/policy/errors.js +93 -0
  78. package/dist/utils/policy/feedback.d.ts +90 -0
  79. package/dist/utils/policy/feedback.js +210 -0
  80. package/dist/utils/policy/findings.d.ts +29 -0
  81. package/dist/utils/policy/findings.js +82 -0
  82. package/dist/utils/policy/gate.d.ts +138 -0
  83. package/dist/utils/policy/gate.js +193 -0
  84. package/dist/utils/policy/permission.d.ts +7 -0
  85. package/dist/utils/policy/permission.js +55 -0
  86. package/dist/utils/policy/push-gate.d.ts +5 -0
  87. package/dist/utils/policy/push-gate.js +43 -0
  88. package/dist/utils/policy/release.d.ts +85 -0
  89. package/dist/utils/policy/release.js +117 -0
  90. package/dist/utils/policy/request.d.ts +55 -0
  91. package/dist/utils/policy/request.js +119 -0
  92. package/dist/utils/policy/runs.d.ts +30 -0
  93. package/dist/utils/policy/runs.js +101 -0
  94. package/dist/utils/policy/status.d.ts +55 -0
  95. package/dist/utils/policy/status.js +127 -0
  96. package/dist/utils/policy/template.d.ts +66 -0
  97. package/dist/utils/policy/template.js +152 -0
  98. package/dist/utils/policy/types.d.ts +295 -0
  99. package/dist/utils/policy/types.js +1 -0
  100. package/oclif.manifest.json +5782 -2573
  101. package/package.json +9 -1
@@ -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: 'Branch name (optional if set in profile, defaults to live)',
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 || profile.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
- this.log(formatWorkspacePushDefaultsBanner(pushDefaults.fromLocal, relativePath));
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: (params) => `${baseUrl}/multidoc?${params.toString()}`,
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<void>;
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.