@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.
- package/README.md +137 -3
- package/dist/base-command.d.ts +12 -1
- package/dist/base-command.js +36 -22
- package/dist/commands/branch/set_live/index.d.ts +14 -0
- package/dist/commands/branch/set_live/index.js +38 -0
- package/dist/commands/ephemeral/push/index.js +6 -1
- package/dist/commands/function/create/index.d.ts +15 -0
- package/dist/commands/function/create/index.js +39 -0
- package/dist/commands/function/edit/index.d.ts +15 -0
- package/dist/commands/function/edit/index.js +39 -0
- package/dist/commands/function/run/index.js +5 -11
- package/dist/commands/policy/catalogue/index.d.ts +16 -0
- package/dist/commands/policy/catalogue/index.js +60 -0
- package/dist/commands/policy/coverage/index.d.ts +17 -0
- package/dist/commands/policy/coverage/index.js +33 -0
- package/dist/commands/policy/create/index.d.ts +18 -0
- package/dist/commands/policy/create/index.js +44 -0
- package/dist/commands/policy/delete/index.d.ts +19 -0
- package/dist/commands/policy/delete/index.js +61 -0
- package/dist/commands/policy/evaluate/index.d.ts +32 -0
- package/dist/commands/policy/evaluate/index.js +71 -0
- package/dist/commands/policy/list/index.d.ts +14 -0
- package/dist/commands/policy/list/index.js +21 -0
- package/dist/commands/policy/parse/index.d.ts +19 -0
- package/dist/commands/policy/parse/index.js +18 -0
- package/dist/commands/policy/publish/index.d.ts +20 -0
- package/dist/commands/policy/publish/index.js +38 -0
- package/dist/commands/policy/runs/index.d.ts +54 -0
- package/dist/commands/policy/runs/index.js +273 -0
- package/dist/commands/policy/status/index.d.ts +21 -0
- package/dist/commands/policy/status/index.js +54 -0
- package/dist/commands/release/create/index.js +4 -0
- package/dist/commands/release/deploy/index.d.ts +19 -0
- package/dist/commands/release/deploy/index.js +68 -6
- package/dist/commands/release/import/index.js +5 -1
- package/dist/commands/release/list/index.d.ts +7 -0
- package/dist/commands/release/list/index.js +46 -9
- package/dist/commands/release/push/index.js +19 -1
- package/dist/commands/sandbox/delete/index.js +8 -0
- package/dist/commands/sandbox/pull/index.d.ts +7 -0
- package/dist/commands/sandbox/pull/index.js +49 -0
- package/dist/commands/sandbox/push/index.d.ts +4 -0
- package/dist/commands/sandbox/push/index.js +59 -2
- package/dist/commands/sandbox/reset/index.js +8 -0
- package/dist/commands/skills/pull/index.d.ts +30 -0
- package/dist/commands/skills/pull/index.js +118 -0
- package/dist/commands/tenant/create/index.d.ts +3 -0
- package/dist/commands/tenant/create/index.js +21 -0
- package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
- package/dist/commands/tenant/deploy_release/index.js +182 -4
- package/dist/commands/tenant/edit/index.d.ts +2 -0
- package/dist/commands/tenant/edit/index.js +39 -4
- package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
- package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
- package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/create/index.js +108 -0
- package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
- package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
- package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
- package/dist/commands/tenant_deploy_request/get/index.js +113 -0
- package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
- package/dist/commands/tenant_deploy_request/list/index.js +122 -0
- package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
- package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
- package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
- package/dist/commands/workspace/pull/index.d.ts +17 -0
- package/dist/commands/workspace/pull/index.js +124 -7
- package/dist/commands/workspace/push/index.d.ts +23 -0
- package/dist/commands/workspace/push/index.js +188 -6
- package/dist/policy-command.d.ts +91 -0
- package/dist/policy-command.js +95 -0
- package/dist/utils/document-parser.d.ts +10 -0
- package/dist/utils/document-parser.js +21 -0
- package/dist/utils/export-compare.d.ts +52 -0
- package/dist/utils/export-compare.js +134 -0
- package/dist/utils/local-config.d.ts +23 -0
- package/dist/utils/local-config.js +62 -2
- package/dist/utils/multidoc-push.d.ts +124 -4
- package/dist/utils/multidoc-push.js +670 -49
- package/dist/utils/object-fetch.d.ts +86 -0
- package/dist/utils/object-fetch.js +265 -0
- package/dist/utils/policy/catalogue.d.ts +85 -0
- package/dist/utils/policy/catalogue.js +196 -0
- package/dist/utils/policy/coverage.d.ts +19 -0
- package/dist/utils/policy/coverage.js +93 -0
- package/dist/utils/policy/errors.d.ts +20 -0
- package/dist/utils/policy/errors.js +93 -0
- package/dist/utils/policy/feedback.d.ts +90 -0
- package/dist/utils/policy/feedback.js +210 -0
- package/dist/utils/policy/findings.d.ts +29 -0
- package/dist/utils/policy/findings.js +82 -0
- package/dist/utils/policy/gate.d.ts +138 -0
- package/dist/utils/policy/gate.js +193 -0
- package/dist/utils/policy/permission.d.ts +7 -0
- package/dist/utils/policy/permission.js +55 -0
- package/dist/utils/policy/push-gate.d.ts +5 -0
- package/dist/utils/policy/push-gate.js +43 -0
- package/dist/utils/policy/release.d.ts +85 -0
- package/dist/utils/policy/release.js +117 -0
- package/dist/utils/policy/request.d.ts +55 -0
- package/dist/utils/policy/request.js +119 -0
- package/dist/utils/policy/runs.d.ts +30 -0
- package/dist/utils/policy/runs.js +101 -0
- package/dist/utils/policy/status.d.ts +55 -0
- package/dist/utils/policy/status.js +127 -0
- package/dist/utils/policy/template.d.ts +66 -0
- package/dist/utils/policy/template.js +152 -0
- package/dist/utils/policy/types.d.ts +295 -0
- package/dist/utils/policy/types.js +1 -0
- package/dist/utils/reference-checker.d.ts +3 -0
- package/dist/utils/reference-checker.js +3 -2
- package/dist/utils/sandbox-marker.d.ts +9 -0
- package/dist/utils/sandbox-marker.js +119 -0
- package/dist/utils/sync-state.d.ts +71 -0
- package/dist/utils/sync-state.js +353 -0
- package/oclif.manifest.json +6472 -3181
- 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 {
|
|
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:
|
|
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
|
|
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
|
|
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;
|