@xano/cli 1.3.3 → 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 (118) hide show
  1. package/README.md +137 -3
  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/delete/index.js +8 -0
  40. package/dist/commands/sandbox/pull/index.d.ts +7 -0
  41. package/dist/commands/sandbox/pull/index.js +49 -0
  42. package/dist/commands/sandbox/push/index.d.ts +4 -0
  43. package/dist/commands/sandbox/push/index.js +59 -2
  44. package/dist/commands/sandbox/reset/index.js +8 -0
  45. package/dist/commands/skills/pull/index.d.ts +30 -0
  46. package/dist/commands/skills/pull/index.js +118 -0
  47. package/dist/commands/tenant/create/index.d.ts +3 -0
  48. package/dist/commands/tenant/create/index.js +21 -0
  49. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  50. package/dist/commands/tenant/deploy_release/index.js +182 -4
  51. package/dist/commands/tenant/edit/index.d.ts +2 -0
  52. package/dist/commands/tenant/edit/index.js +39 -4
  53. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  54. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  55. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  56. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  57. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  58. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  59. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  60. package/dist/commands/tenant_deploy_request/get/index.js +113 -0
  61. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  62. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  63. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  64. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  65. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  66. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  67. package/dist/commands/workspace/pull/index.d.ts +17 -0
  68. package/dist/commands/workspace/pull/index.js +124 -7
  69. package/dist/commands/workspace/push/index.d.ts +23 -0
  70. package/dist/commands/workspace/push/index.js +188 -6
  71. package/dist/policy-command.d.ts +91 -0
  72. package/dist/policy-command.js +95 -0
  73. package/dist/utils/document-parser.d.ts +10 -0
  74. package/dist/utils/document-parser.js +21 -0
  75. package/dist/utils/export-compare.d.ts +52 -0
  76. package/dist/utils/export-compare.js +134 -0
  77. package/dist/utils/local-config.d.ts +23 -0
  78. package/dist/utils/local-config.js +62 -2
  79. package/dist/utils/multidoc-push.d.ts +124 -4
  80. package/dist/utils/multidoc-push.js +670 -49
  81. package/dist/utils/object-fetch.d.ts +86 -0
  82. package/dist/utils/object-fetch.js +265 -0
  83. package/dist/utils/policy/catalogue.d.ts +85 -0
  84. package/dist/utils/policy/catalogue.js +196 -0
  85. package/dist/utils/policy/coverage.d.ts +19 -0
  86. package/dist/utils/policy/coverage.js +93 -0
  87. package/dist/utils/policy/errors.d.ts +20 -0
  88. package/dist/utils/policy/errors.js +93 -0
  89. package/dist/utils/policy/feedback.d.ts +90 -0
  90. package/dist/utils/policy/feedback.js +210 -0
  91. package/dist/utils/policy/findings.d.ts +29 -0
  92. package/dist/utils/policy/findings.js +82 -0
  93. package/dist/utils/policy/gate.d.ts +138 -0
  94. package/dist/utils/policy/gate.js +193 -0
  95. package/dist/utils/policy/permission.d.ts +7 -0
  96. package/dist/utils/policy/permission.js +55 -0
  97. package/dist/utils/policy/push-gate.d.ts +5 -0
  98. package/dist/utils/policy/push-gate.js +43 -0
  99. package/dist/utils/policy/release.d.ts +85 -0
  100. package/dist/utils/policy/release.js +117 -0
  101. package/dist/utils/policy/request.d.ts +55 -0
  102. package/dist/utils/policy/request.js +119 -0
  103. package/dist/utils/policy/runs.d.ts +30 -0
  104. package/dist/utils/policy/runs.js +101 -0
  105. package/dist/utils/policy/status.d.ts +55 -0
  106. package/dist/utils/policy/status.js +127 -0
  107. package/dist/utils/policy/template.d.ts +66 -0
  108. package/dist/utils/policy/template.js +152 -0
  109. package/dist/utils/policy/types.d.ts +295 -0
  110. package/dist/utils/policy/types.js +1 -0
  111. package/dist/utils/reference-checker.d.ts +3 -0
  112. package/dist/utils/reference-checker.js +3 -2
  113. package/dist/utils/sandbox-marker.d.ts +9 -0
  114. package/dist/utils/sandbox-marker.js +119 -0
  115. package/dist/utils/sync-state.d.ts +71 -0
  116. package/dist/utils/sync-state.js +353 -0
  117. package/oclif.manifest.json +6472 -3181
  118. package/package.json +9 -1
@@ -1,8 +1,30 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
- import { resolve } from 'node:path';
3
+ import { basename, relative, resolve } from 'node:path';
4
4
  import BaseCommand from '../../../base-command.js';
5
- import { executePush } from '../../../utils/multidoc-push.js';
5
+ import { parseDocument } from '../../../utils/document-parser.js';
6
+ import { formatWorkspacePushDefaultsBanner, resolvePushDefaults } from '../../../utils/local-config.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
+ }
6
28
  export default class Push extends BaseCommand {
7
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.';
8
30
  static examples = [
@@ -23,6 +45,18 @@ Preview changes without pushing
23
45
  `,
24
46
  `$ xano workspace push --force
25
47
  Skip preview and push immediately (for CI/CD)
48
+ `,
49
+ `$ xano workspace push --fast
50
+ Push only files that changed since the last sync, and write .xano/sync_state.json
51
+ `,
52
+ `$ xano workspace push --fast --refresh
53
+ Re-compare local files against the server export, then push changes
54
+ `,
55
+ `$ xano workspace push --no-fast
56
+ Push without --fast even when profile.yaml sets fast: true
57
+ `,
58
+ `$ xano workspace push --writeback
59
+ Push and write server-formatted XanoScript back to local files
26
60
  `,
27
61
  `$ xano workspace push -d ./output -w 40
28
62
  Pushed 15 documents from ./output
@@ -65,7 +99,7 @@ Full sync including knowledge files; removes server objects not present locally
65
99
  ...BaseCommand.baseFlags,
66
100
  branch: Flags.string({
67
101
  char: 'b',
68
- description: 'Branch name (optional if set in profile, defaults to live)',
102
+ description: "Branch name (defaults to profile branch or live; -b '' selects live)",
69
103
  required: false,
70
104
  }),
71
105
  delete: Flags.boolean({
@@ -95,6 +129,11 @@ Full sync including knowledge files; removes server objects not present locally
95
129
  multiple: true,
96
130
  required: false,
97
131
  }),
132
+ fast: Flags.boolean({
133
+ allowNo: true,
134
+ description: 'Skip files whose contents match .xano/sync_state.json, and write or update that record. Without a record, compares against the server export once. Can be set as `fast: true` in profile.yaml.',
135
+ required: false,
136
+ }),
98
137
  force: Flags.boolean({
99
138
  default: false,
100
139
  description: '[CRITICAL] NEVER run without explicit user confirmation. Skips preview and confirmation prompt (for CI/CD pipelines).',
@@ -112,11 +151,30 @@ Full sync including knowledge files; removes server objects not present locally
112
151
  multiple: true,
113
152
  required: false,
114
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 }),
115
168
  records: Flags.boolean({
116
169
  default: false,
117
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.',
118
171
  required: false,
119
172
  }),
173
+ refresh: Flags.boolean({
174
+ default: false,
175
+ description: 'With --fast, re-compare local files against the server export even when a sync record exists',
176
+ required: false,
177
+ }),
120
178
  sync: Flags.boolean({
121
179
  default: false,
122
180
  description: 'Full push — send all files, not just changed ones. Required for --delete.',
@@ -138,9 +196,30 @@ Full sync including knowledge files; removes server objects not present locally
138
196
  description: 'Workspace ID (optional if set in profile)',
139
197
  required: false,
140
198
  }),
199
+ writeback: Flags.boolean({
200
+ allowNo: true,
201
+ description: 'After a successful push, write server-formatted XanoScript back to the files that were pushed. Can be set as `writeback: true` in profile.yaml.',
202
+ required: false,
203
+ }),
204
+ 'writeback-diff': Flags.string({
205
+ description: 'With --writeback, save local vs server copies of rewritten files under this directory',
206
+ required: false,
207
+ }),
141
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
+ }
142
219
  async run() {
143
220
  const { flags } = await this.parse(Push);
221
+ if (blankPolicyOverride(flags['policy-override']))
222
+ this.error(BLANK_POLICY_OVERRIDE);
144
223
  const { profile, profileName } = this.resolveProfile(flags);
145
224
  // Determine workspace_id from flag or profile
146
225
  let workspaceId;
@@ -162,12 +241,39 @@ Full sync including knowledge files; removes server objects not present locally
162
241
  if (!fs.statSync(inputDir).isDirectory()) {
163
242
  this.error(`Not a directory: ${inputDir}`);
164
243
  }
165
- const branch = flags.branch || profile.branch || '';
244
+ const branch = flags.branch ?? profile.branch ?? '';
166
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();
249
+ const pushDefaults = resolvePushDefaults({
250
+ flagFast: flags.fast,
251
+ flagWriteback: flags.writeback,
252
+ localFast: this.localProfile?.config.fast,
253
+ localWriteback: this.localProfile?.config.writeback,
254
+ sync: flags.sync,
255
+ });
256
+ if (pushDefaults.fromLocal.length > 0 && this.localProfile) {
257
+ const relativePath = relative(process.cwd(), this.localProfile.path) || basename(this.localProfile.path);
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);
264
+ }
167
265
  const target = {
168
266
  buildDryRunUrl: (params) => `${baseUrl}/multidoc/dry-run?${params.toString()}`,
169
- 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
+ },
170
275
  cliVersion: this.config.version,
276
+ explainRefusal: (status, payload) => (status === 403 ? policyFilePushGuidance(payload) : policyCodeGuidance(payload) || undefined),
171
277
  instanceOrigin: profile.instance_origin,
172
278
  label: `workspace ${workspaceId}`,
173
279
  supportsBranches: true,
@@ -178,16 +284,21 @@ Full sync including knowledge files; removes server objects not present locally
178
284
  'dry-run': flags['dry-run'],
179
285
  env: flags.env,
180
286
  exclude: flags.exclude,
287
+ fast: pushDefaults.fast,
181
288
  force: flags.force,
182
289
  guids: flags.guids,
183
290
  include: flags.include,
184
291
  records: flags.records,
292
+ refresh: flags.refresh,
185
293
  sync: flags.sync,
186
294
  transaction: flags.transaction,
187
295
  truncate: flags.truncate,
188
296
  verbose: flags.verbose,
297
+ writeback: pushDefaults.writeback,
298
+ writebackDiff: flags['writeback-diff'],
299
+ writebackObjectLimit: this.localProfile?.config.writeback_object_limit,
189
300
  };
190
- await executePush({
301
+ const result = await executePush({
191
302
  accessToken: profile.access_token,
192
303
  branch,
193
304
  command: this,
@@ -196,7 +307,78 @@ Full sync including knowledge files; removes server objects not present locally
196
307
  listUrl: () => `${baseUrl}/knowledge/sync`,
197
308
  rootDir: inputDir,
198
309
  },
310
+ // Under `-o json` stdout carries one JSON document, so progress goes to stderr.
311
+ log: json ? this.logToStderr.bind(this) : undefined,
312
+ syncTarget: {
313
+ instanceOrigin: profile.instance_origin,
314
+ kind: 'workspace',
315
+ workspace: workspaceId,
316
+ },
199
317
  verboseFetch: this.verboseFetch.bind(this),
200
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
+ }
201
383
  }
202
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') };
@@ -0,0 +1,52 @@
1
+ import { type ParsedDocument } from './document-parser.js';
2
+ export declare const ITEMS_BLOCK_REGEX: RegExp;
3
+ /**
4
+ * Split a GET /multidoc response into parsed documents, skipping empty or invalid segments.
5
+ */
6
+ export declare function parseExportDocuments(multidoc: string): ParsedDocument[];
7
+ /**
8
+ * Reinsert a local table `items = [...]` block when the server export omitted records.
9
+ * If the export already has an items block, the server copy wins.
10
+ */
11
+ export declare function preserveLocalTableRecords(localContent: string, serverContent: string): string;
12
+ export interface DocumentIndex {
13
+ byGuid: Map<string, string>;
14
+ byKey: Map<string, string>;
15
+ byKeyLower: Map<string, string>;
16
+ }
17
+ /**
18
+ * Index local files by document key (and GUID) so an export document can be paired
19
+ * with the file it belongs to. Ambiguous case-insensitive keys are marked empty
20
+ * rather than guessed.
21
+ */
22
+ export declare function indexLocalDocuments(files: Map<string, string>, localContents: Map<string, string>): DocumentIndex;
23
+ export declare function resolveMatchedPath(doc: ParsedDocument, index: DocumentIndex): string | undefined;
24
+ export declare function selectWritebacks(exported: ParsedDocument[], pushedFiles: Map<string, string>, localContents: Map<string, string>, options: {
25
+ includeRecords: boolean;
26
+ }): Array<{
27
+ content: string;
28
+ filePath: string;
29
+ }>;
30
+ export interface ExportCompareResult {
31
+ candidates: Array<{
32
+ content: string;
33
+ filePath: string;
34
+ relPath: string;
35
+ }>;
36
+ identical: Array<{
37
+ content: string;
38
+ filePath: string;
39
+ relPath: string;
40
+ }>;
41
+ serverOnly: ParsedDocument[];
42
+ unmatchedLocal: Array<{
43
+ content: string;
44
+ filePath: string;
45
+ relPath: string;
46
+ }>;
47
+ }
48
+ export declare function compareLocalToExport(localEntries: Array<{
49
+ content: string;
50
+ filePath: string;
51
+ relPath: string;
52
+ }>, exported: ParsedDocument[]): ExportCompareResult;