breakaway 2.0.0-main.96 → 2.0.0-main.98
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/infra-cloudflare.js +242 -0
- package/src/infra-provider.js +191 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "2.0.0-main.
|
|
3
|
+
"version": "2.0.0-main.98",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "breakaway",
|
|
9
|
-
"version": "2.0.0-main.
|
|
9
|
+
"version": "2.0.0-main.98",
|
|
10
10
|
"license": "FSL-1.1-Apache-2.0",
|
|
11
11
|
"bin": {
|
|
12
12
|
"breakaway": "scripts/tasks.mjs"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "2.0.0-main.
|
|
3
|
+
"version": "2.0.0-main.98",
|
|
4
4
|
"description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/infra-cloudflare.js
CHANGED
|
@@ -75,6 +75,247 @@ export const CLOUDFLARE_KINDS = Object.fromEntries(
|
|
|
75
75
|
]),
|
|
76
76
|
);
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* How a Worker's binding names the resource it binds, for the console (BRK-262): by the resource's ID in the desired
|
|
80
|
+
* state, which the plan resolves, or a Worker by its name.
|
|
81
|
+
* @type {import('./infra-provider.js').BindingTarget[]}
|
|
82
|
+
*/
|
|
83
|
+
const BINDABLE = [
|
|
84
|
+
{ type: 'd1', label: 'D1 database', kind: 'd1', field: 'resource', by: 'id' },
|
|
85
|
+
{ type: 'kv_namespace', label: 'KV namespace', kind: 'kv', field: 'resource', by: 'id' },
|
|
86
|
+
{ type: 'r2_bucket', label: 'R2 bucket', kind: 'r2', field: 'resource', by: 'id' },
|
|
87
|
+
{ type: 'queue', label: 'Queue', kind: 'queue', field: 'resource', by: 'id' },
|
|
88
|
+
{ type: 'service', label: 'Worker', kind: 'worker', field: 'service', by: 'name' },
|
|
89
|
+
];
|
|
90
|
+
const CRON = '^\\S+( \\S+){4}$';
|
|
91
|
+
const VALUES_ELSEWHERE = 'Set with the Worker’s deploy, never here.';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The settings the console may change, by kind (BRK-262; docs/specs/BRK-258-plan-from-the-board.md): exactly what the
|
|
95
|
+
* plan manages (MANAGED), and nothing it can't. A Worker's variables and secrets are shown by name and never changed;
|
|
96
|
+
* a database, a KV namespace, and a Durable Object namespace have no setting Architect changes.
|
|
97
|
+
* @type {Record<string, import('./infra-provider.js').Editable>}
|
|
98
|
+
*/
|
|
99
|
+
export const EDITABLE = {
|
|
100
|
+
worker: {
|
|
101
|
+
fields: [
|
|
102
|
+
{
|
|
103
|
+
path: 'compatibilityDate',
|
|
104
|
+
label: 'Compatibility date',
|
|
105
|
+
type: 'text',
|
|
106
|
+
pattern: '^\\d{4}-\\d{2}-\\d{2}$',
|
|
107
|
+
help: 'The date of the Workers runtime the Worker runs as, like 2026-09-01.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
path: 'compatibilityFlags',
|
|
111
|
+
label: 'Compatibility flags',
|
|
112
|
+
type: 'names',
|
|
113
|
+
pattern: '^[a-z0-9_]+$',
|
|
114
|
+
help: 'Runtime features to turn on or off, like nodejs_compat.',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: 'usageModel',
|
|
118
|
+
label: 'Usage model',
|
|
119
|
+
type: 'choice',
|
|
120
|
+
optional: true,
|
|
121
|
+
options: [
|
|
122
|
+
{ value: 'standard', label: 'Standard' },
|
|
123
|
+
{ value: 'bundled', label: 'Bundled' },
|
|
124
|
+
{ value: 'unbound', label: 'Unbound' },
|
|
125
|
+
],
|
|
126
|
+
help: 'How Cloudflare bills its requests. Standard is the one new Workers get.',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
path: 'observability',
|
|
130
|
+
label: 'Workers Logs',
|
|
131
|
+
type: 'yesno',
|
|
132
|
+
help: 'Keep the Worker’s logs on Cloudflare, to read in its dashboard.',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
path: 'placement',
|
|
136
|
+
label: 'Placement',
|
|
137
|
+
type: 'choice',
|
|
138
|
+
optional: true,
|
|
139
|
+
options: [{ value: 'smart', label: 'Smart' }],
|
|
140
|
+
help: 'Smart runs it near what it calls most. Unset runs it near whoever sent the request.',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
path: 'crons',
|
|
144
|
+
label: 'Cron triggers',
|
|
145
|
+
type: 'names',
|
|
146
|
+
pattern: CRON,
|
|
147
|
+
help: 'When Cloudflare runs it on a schedule, in cron’s five fields, in UTC.',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
path: 'bindings',
|
|
151
|
+
label: 'Bindings',
|
|
152
|
+
type: 'bindings',
|
|
153
|
+
targets: BINDABLE,
|
|
154
|
+
help: 'The environment’s databases, namespaces, buckets, queues, and Workers it reaches, each by a name its code uses. Variables and secrets are kept as they are.',
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
shown: [{ path: 'secrets', label: 'Secrets', help: VALUES_ELSEWHERE }],
|
|
158
|
+
},
|
|
159
|
+
// Bounds from Cloudflare's docs: a delivery delay of 0 to 86,400 seconds
|
|
160
|
+
// (developers.cloudflare.com/queues/configuration/javascript-apis/), retention of 60 seconds to 14 days
|
|
161
|
+
// (Queues changelog, 14 Feb 2025). Concurrency's 250 is Queues' limit as last read; Cloudflare refuses past its real one.
|
|
162
|
+
queue: {
|
|
163
|
+
fields: [
|
|
164
|
+
{
|
|
165
|
+
path: 'deliveryDelay',
|
|
166
|
+
label: 'Delivery delay',
|
|
167
|
+
type: 'number',
|
|
168
|
+
integer: true,
|
|
169
|
+
min: 0,
|
|
170
|
+
max: 86_400,
|
|
171
|
+
unit: 'seconds',
|
|
172
|
+
optional: true,
|
|
173
|
+
help: 'How long a message waits before its consumer gets it.',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
path: 'deliveryPaused',
|
|
177
|
+
label: 'Delivery paused',
|
|
178
|
+
type: 'yesno',
|
|
179
|
+
help: 'Hold every message in the queue until delivery is turned back on.',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
path: 'retention',
|
|
183
|
+
label: 'Retention',
|
|
184
|
+
type: 'number',
|
|
185
|
+
integer: true,
|
|
186
|
+
min: 60,
|
|
187
|
+
max: 1_209_600,
|
|
188
|
+
unit: 'seconds',
|
|
189
|
+
optional: true,
|
|
190
|
+
help: 'How long a message is kept when nothing takes it.',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
path: 'maxConcurrency',
|
|
194
|
+
label: 'Most consumers at once',
|
|
195
|
+
type: 'number',
|
|
196
|
+
integer: true,
|
|
197
|
+
min: 1,
|
|
198
|
+
max: 250,
|
|
199
|
+
optional: true,
|
|
200
|
+
help: 'How many copies of its consumer Worker run at the same time. Unset lets Cloudflare decide.',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
r2: {
|
|
205
|
+
fields: [
|
|
206
|
+
{
|
|
207
|
+
path: 'cors',
|
|
208
|
+
label: 'CORS rules',
|
|
209
|
+
type: 'rules',
|
|
210
|
+
template: { allowed: { origins: [], methods: ['GET'] } },
|
|
211
|
+
fields: [
|
|
212
|
+
{
|
|
213
|
+
path: 'allowed.origins',
|
|
214
|
+
label: 'Origins',
|
|
215
|
+
type: 'names',
|
|
216
|
+
help: 'The sites whose pages may read the bucket, like https://acme.example.',
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
path: 'allowed.methods',
|
|
220
|
+
label: 'Methods',
|
|
221
|
+
type: 'names',
|
|
222
|
+
pattern: '^(GET|PUT|POST|DELETE|HEAD)$',
|
|
223
|
+
help: 'GET, PUT, POST, DELETE, or HEAD.',
|
|
224
|
+
},
|
|
225
|
+
{ path: 'allowed.headers', label: 'Headers', type: 'names', help: 'Request headers they may send.' },
|
|
226
|
+
{
|
|
227
|
+
path: 'exposeHeaders',
|
|
228
|
+
label: 'Exposed headers',
|
|
229
|
+
type: 'names',
|
|
230
|
+
help: 'Response headers their pages may read.',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
path: 'maxAgeSeconds',
|
|
234
|
+
label: 'Cache for',
|
|
235
|
+
type: 'number',
|
|
236
|
+
integer: true,
|
|
237
|
+
min: 0,
|
|
238
|
+
unit: 'seconds',
|
|
239
|
+
optional: true,
|
|
240
|
+
help: 'How long a browser keeps the answer before it asks again.',
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
help: 'Which other sites’ pages may read the bucket from a browser.',
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
path: 'lifecycle',
|
|
247
|
+
label: 'Lifecycle rules',
|
|
248
|
+
type: 'rules',
|
|
249
|
+
template: {
|
|
250
|
+
enabled: true,
|
|
251
|
+
conditions: { prefix: '' },
|
|
252
|
+
deleteObjectsTransition: { condition: { type: 'Age' } },
|
|
253
|
+
},
|
|
254
|
+
fields: [
|
|
255
|
+
{ path: 'id', label: 'Name', type: 'text', help: 'A name for the rule, unique in the bucket.' },
|
|
256
|
+
{ path: 'enabled', label: 'On', type: 'yesno', help: 'Whether the rule runs.' },
|
|
257
|
+
{
|
|
258
|
+
path: 'conditions.prefix',
|
|
259
|
+
label: 'Prefix',
|
|
260
|
+
type: 'text',
|
|
261
|
+
help: 'The objects it applies to, by the start of their key. Empty is every object.',
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
path: 'deleteObjectsTransition.condition.maxAge',
|
|
265
|
+
label: 'Delete after',
|
|
266
|
+
type: 'number',
|
|
267
|
+
integer: true,
|
|
268
|
+
min: 1,
|
|
269
|
+
unit: 'seconds',
|
|
270
|
+
optional: true,
|
|
271
|
+
help: 'How old an object gets before Cloudflare deletes it.',
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
help: 'When Cloudflare deletes old objects from the bucket.',
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
container: {
|
|
279
|
+
fields: [
|
|
280
|
+
{
|
|
281
|
+
path: 'maxInstances',
|
|
282
|
+
label: 'Most instances',
|
|
283
|
+
type: 'number',
|
|
284
|
+
integer: true,
|
|
285
|
+
min: 1,
|
|
286
|
+
help: 'How many of its containers may run at once: its scale.',
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
route: {
|
|
291
|
+
name: {
|
|
292
|
+
label: 'Pattern',
|
|
293
|
+
pattern: '^\\S+$',
|
|
294
|
+
help: 'The hostname and path it sends to its Worker, like api.acme.example/*.',
|
|
295
|
+
},
|
|
296
|
+
fields: [
|
|
297
|
+
{
|
|
298
|
+
path: 'worker',
|
|
299
|
+
label: 'Worker',
|
|
300
|
+
type: 'resource',
|
|
301
|
+
kinds: ['worker'],
|
|
302
|
+
help: 'The Worker it sends requests to.',
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
'custom-domain': {
|
|
307
|
+
fields: [
|
|
308
|
+
{ path: 'worker', label: 'Worker', type: 'resource', kinds: ['worker'], help: 'The Worker the hostname serves.' },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What the console may change on a resource of `kind` (BRK-262), or null when Architect changes nothing on it.
|
|
315
|
+
* @param {string} kind
|
|
316
|
+
*/
|
|
317
|
+
export const editable = (kind) => EDITABLE[kind] ?? null;
|
|
318
|
+
|
|
78
319
|
/** The scheduling policy whose container applications Cloudflare scales and rolls out; any other is the code's. */
|
|
79
320
|
export const DEFAULT_SCHEDULING = 'default';
|
|
80
321
|
|
|
@@ -1421,6 +1662,7 @@ export const cloudflare = {
|
|
|
1421
1662
|
},
|
|
1422
1663
|
refuses,
|
|
1423
1664
|
outside,
|
|
1665
|
+
editable,
|
|
1424
1666
|
discover,
|
|
1425
1667
|
plan: (ctx, desired) => plan(ctx, desired),
|
|
1426
1668
|
apply: (ctx, p) => apply(ctx, p),
|
package/src/infra-provider.js
CHANGED
|
@@ -215,11 +215,74 @@ export const COST_NOTE_MAX = 500;
|
|
|
215
215
|
* @property {(ctx: ProviderContext, since: string) => Promise<Signal[]>} events
|
|
216
216
|
* @property {(ctx: ProviderContext, change: Change) => Promise<Cost | null>} [estimate] what a resource would cost a
|
|
217
217
|
* month once `change` is applied, or null when the provider can't say; a plan's cost change uses it (BRK-178)
|
|
218
|
+
* @property {(kind: string) => Editable | null} [editable] the settings the console may change on resources of `kind`,
|
|
219
|
+
* or null for a kind it changes nothing on; checked by `checkEditable` (BRK-262)
|
|
218
220
|
* @property {(ctx: ProviderContext, options: { board: string[] }) => Promise<AlertSetup>} [alerts] reads which of the
|
|
219
221
|
* platform's alerts are set up and which reach the board (`board` is its https origins), for a provider whose
|
|
220
222
|
* platform sends alerts to the board
|
|
221
223
|
*/
|
|
222
224
|
|
|
225
|
+
/** The kinds of field the console draws for a setting it may change (BRK-262). */
|
|
226
|
+
export const EDITABLE_TYPES = ['text', 'number', 'yesno', 'choice', 'names', 'resource', 'bindings', 'rules'];
|
|
227
|
+
|
|
228
|
+
/** A path into a resource's attrs: names joined by dots, with no `attrs.` prefix (like `observability` or `allowed.origins`). */
|
|
229
|
+
const PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/u;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* One setting the console may change (BRK-262): where it is, what kind of field it is, and a line of help. The console
|
|
233
|
+
* draws its fields from these and never names a vendor.
|
|
234
|
+
* @typedef {object} EditableField
|
|
235
|
+
* @property {string} path where it is in the resource's attrs, as PATH; its first name is one of the kind's `settings`
|
|
236
|
+
* (what the plan compares), when the kind lists them. Inside a `rules` field's `fields`, the path is inside one rule.
|
|
237
|
+
* @property {string} label what the field is called, in a few words
|
|
238
|
+
* @property {string} type one of EDITABLE_TYPES: `text`; `number`; `yesno`; `choice` (one of `options`); `names` (a list
|
|
239
|
+
* of strings, like flags or schedules); `resource` (another resource of the environment, by its name: one of `kinds`);
|
|
240
|
+
* `bindings` (a list of named bindings, each to a resource of the environment, as `targets` says); `rules` (a list of
|
|
241
|
+
* objects, each with the `fields` the console offers: it keeps every other key of a rule as it is, and starts a new one
|
|
242
|
+
* from `template`)
|
|
243
|
+
* @property {string} help one line, in plain words
|
|
244
|
+
* @property {boolean} [optional] whether it can be left unset (null: the platform decides)
|
|
245
|
+
* @property {number} [min] a number's least value
|
|
246
|
+
* @property {number} [max] a number's greatest value
|
|
247
|
+
* @property {boolean} [integer] a number must be whole
|
|
248
|
+
* @property {string} [unit] what a number counts, like `seconds`
|
|
249
|
+
* @property {string} [pattern] a text's, or each name's, regular expression (without slashes)
|
|
250
|
+
* @property {Array<{ value: string, label: string }>} [options] a choice's options
|
|
251
|
+
* @property {string[]} [kinds] the kinds a `resource` may name
|
|
252
|
+
* @property {BindingTarget[]} [targets] the binding types a `bindings` field changes: a binding of any other type (a
|
|
253
|
+
* variable, a secret) is shown by its name and type, and kept as it is
|
|
254
|
+
* @property {EditableField[]} [fields] a `rules` field's fields, inside one rule
|
|
255
|
+
* @property {Record<string, unknown>} [template] what a new rule starts from
|
|
256
|
+
*/
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* One type of binding a `bindings` field changes, and how it names its resource: a binding is `{ name, type, [field]: … }`,
|
|
260
|
+
* with the resource's ID in the desired state (`by: 'id'`) or its name (`by: 'name'`).
|
|
261
|
+
* @typedef {object} BindingTarget
|
|
262
|
+
* @property {string} type the binding's type, as the platform calls it
|
|
263
|
+
* @property {string} label what people call it
|
|
264
|
+
* @property {string} kind the kind of resource it binds
|
|
265
|
+
* @property {string} field the binding's key that names the resource
|
|
266
|
+
* @property {'id' | 'name'} by
|
|
267
|
+
*/
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A setting the console shows by name and never changes, like a Worker's secrets.
|
|
271
|
+
* @typedef {object} ShownSetting
|
|
272
|
+
* @property {string} path
|
|
273
|
+
* @property {string} label
|
|
274
|
+
* @property {string} help why it's read only, and where it's set instead
|
|
275
|
+
*/
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* What the console may change on resources of one kind (BRK-262), from the provider's `editable(kind)`.
|
|
279
|
+
* @typedef {object} Editable
|
|
280
|
+
* @property {EditableField[]} fields
|
|
281
|
+
* @property {{ label: string, help: string, pattern?: string }} [name] the resource's name, when the plan compares it
|
|
282
|
+
* (a kind the platform names by what it does, like a route's pattern)
|
|
283
|
+
* @property {ShownSetting[]} [shown] settings shown by name, read only
|
|
284
|
+
*/
|
|
285
|
+
|
|
223
286
|
/**
|
|
224
287
|
* Which of a platform's alerts are set up, and which reach the board: each alert type with how many policies use it
|
|
225
288
|
* and whether one sends to the board, each policy (on or off, the routines on the board it fires), and how many
|
|
@@ -273,9 +336,137 @@ export function checkProvider(provider) {
|
|
|
273
336
|
if (provider.outside !== undefined && typeof provider.outside !== 'function') fail(what, 'outside is not a function');
|
|
274
337
|
if (provider.estimate !== undefined && typeof provider.estimate !== 'function')
|
|
275
338
|
fail(what, 'estimate is not a function');
|
|
339
|
+
if (provider.editable !== undefined) {
|
|
340
|
+
if (typeof provider.editable !== 'function') fail(what, 'editable is not a function');
|
|
341
|
+
for (const kind of Object.keys(provider.kinds)) checkEditable(provider, kind, provider.editable(kind));
|
|
342
|
+
}
|
|
276
343
|
return provider;
|
|
277
344
|
}
|
|
278
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Checks what a provider's `editable(kind)` returned, or throws saying what's wrong: every field has a path, a label, a
|
|
348
|
+
* type, and help; its path is one the plan compares (its first name is one of the kind's `settings`, when the kind lists
|
|
349
|
+
* them); nothing is editable twice or both editable and shown; and each type carries what the console needs to draw it.
|
|
350
|
+
* @param {Provider} provider
|
|
351
|
+
* @param {string} kind
|
|
352
|
+
* @param {Editable | null} editable
|
|
353
|
+
* @returns {Editable | null}
|
|
354
|
+
*/
|
|
355
|
+
export function checkEditable(provider, kind, editable) {
|
|
356
|
+
const what = `${provider.id} editable ${kind}`;
|
|
357
|
+
if (editable === null) return null;
|
|
358
|
+
if (!provider.kinds[kind]) fail(what, `${provider.id} doesn't declare ${kind}`);
|
|
359
|
+
if (!isObject(editable) || !Array.isArray(editable.fields)) fail(what, 'fields is not a list');
|
|
360
|
+
const settings = provider.kinds[kind].settings;
|
|
361
|
+
const paths = checkFields(provider, what, editable.fields, settings);
|
|
362
|
+
if (editable.name !== undefined) {
|
|
363
|
+
if (!isObject(editable.name) || !text(editable.name.label) || !text(editable.name.help))
|
|
364
|
+
fail(what, 'name has no label or no help');
|
|
365
|
+
if (editable.name.pattern !== undefined) checkPattern(what, 'name', editable.name.pattern);
|
|
366
|
+
}
|
|
367
|
+
if (editable.shown !== undefined) {
|
|
368
|
+
if (!Array.isArray(editable.shown)) fail(what, 'shown is not a list');
|
|
369
|
+
for (const s of editable.shown) {
|
|
370
|
+
if (!isObject(s) || !text(s.path) || !PATH.test(s.path)) fail(what, 'a shown setting has no path');
|
|
371
|
+
if (!text(s.label) || !text(s.help)) fail(what, `shown ${s.path} has no label or no help`);
|
|
372
|
+
if (paths.has(s.path)) fail(what, `${s.path} is both editable and shown`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return editable;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {Provider} provider
|
|
380
|
+
* @param {string} what
|
|
381
|
+
* @param {any} fields
|
|
382
|
+
* @param {string[] | undefined} settings the names a top-level path may start with; undefined inside a rule
|
|
383
|
+
*/
|
|
384
|
+
function checkFields(provider, what, fields, settings) {
|
|
385
|
+
if (!Array.isArray(fields)) fail(what, 'fields is not a list');
|
|
386
|
+
const paths = new Set();
|
|
387
|
+
for (const f of fields) {
|
|
388
|
+
if (!isObject(f) || !text(f.path) || !PATH.test(f.path))
|
|
389
|
+
fail(what, 'a field has no path, or one that isn’t names joined by dots');
|
|
390
|
+
const at = `${what} ${f.path}`;
|
|
391
|
+
if (paths.has(f.path)) fail(at, 'is listed twice');
|
|
392
|
+
paths.add(f.path);
|
|
393
|
+
if (settings && !settings.includes(f.path.split('.')[0])) fail(at, "isn't a setting the plan compares");
|
|
394
|
+
if (!text(f.label) || !text(f.help)) fail(at, 'has no label or no help');
|
|
395
|
+
if (!EDITABLE_TYPES.includes(f.type)) fail(at, `has unknown type "${f.type}"`);
|
|
396
|
+
if (f.optional !== undefined && typeof f.optional !== 'boolean') fail(at, 'optional is not true or false');
|
|
397
|
+
if (f.pattern !== undefined) {
|
|
398
|
+
if (f.type !== 'text' && f.type !== 'names') fail(at, 'only text and names take a pattern');
|
|
399
|
+
checkPattern(at, 'pattern', f.pattern);
|
|
400
|
+
}
|
|
401
|
+
for (const k of ['min', 'max'])
|
|
402
|
+
if (f[k] !== undefined && (f.type !== 'number' || typeof f[k] !== 'number' || !Number.isFinite(f[k])))
|
|
403
|
+
fail(at, `${k} is not a number, or the field isn't one`);
|
|
404
|
+
if (f.min !== undefined && f.max !== undefined && f.min > f.max) fail(at, 'min is more than max');
|
|
405
|
+
if (f.integer !== undefined && (f.type !== 'number' || typeof f.integer !== 'boolean'))
|
|
406
|
+
fail(at, 'integer is not true or false, or the field isn’t a number');
|
|
407
|
+
if (f.unit !== undefined && (f.type !== 'number' || !text(f.unit))) fail(at, 'only a number has a unit');
|
|
408
|
+
if (f.type === 'choice') {
|
|
409
|
+
if (!Array.isArray(f.options) || f.options.length === 0) fail(at, 'a choice has no options');
|
|
410
|
+
const values = new Set();
|
|
411
|
+
for (const o of f.options) {
|
|
412
|
+
if (!isObject(o) || !text(o.value) || !text(o.label)) fail(at, 'an option has no value or no label');
|
|
413
|
+
if (values.has(o.value)) fail(at, `option ${o.value} is listed twice`);
|
|
414
|
+
values.add(o.value);
|
|
415
|
+
}
|
|
416
|
+
} else if (f.options !== undefined) fail(at, 'only a choice has options');
|
|
417
|
+
if (f.type === 'resource') {
|
|
418
|
+
if (!Array.isArray(f.kinds) || f.kinds.length === 0) fail(at, 'a resource field names no kinds');
|
|
419
|
+
for (const k of f.kinds)
|
|
420
|
+
if (!provider.kinds[k]) fail(at, `names kind ${k}, which ${provider.id} doesn't declare`);
|
|
421
|
+
} else if (f.kinds !== undefined) fail(at, 'only a resource field has kinds');
|
|
422
|
+
if (f.type === 'bindings') {
|
|
423
|
+
if (!Array.isArray(f.targets) || f.targets.length === 0) fail(at, 'a bindings field has no targets');
|
|
424
|
+
const types = new Set();
|
|
425
|
+
for (const t of f.targets) {
|
|
426
|
+
if (!isObject(t) || !text(t.type) || !text(t.label) || !text(t.field))
|
|
427
|
+
fail(at, 'a target has no type, label, or field');
|
|
428
|
+
if (types.has(t.type)) fail(at, `binding type ${t.type} is listed twice`);
|
|
429
|
+
types.add(t.type);
|
|
430
|
+
if (!provider.kinds[t.kind]) fail(at, `binds kind ${t.kind}, which ${provider.id} doesn't declare`);
|
|
431
|
+
if (t.by !== 'id' && t.by !== 'name') fail(at, `${t.type} names its resource by neither id nor name`);
|
|
432
|
+
}
|
|
433
|
+
} else if (f.targets !== undefined) fail(at, 'only a bindings field has targets');
|
|
434
|
+
if (f.type === 'rules') {
|
|
435
|
+
if (!Array.isArray(f.fields) || f.fields.length === 0) fail(at, 'a rules field has no fields');
|
|
436
|
+
checkFields(provider, at, f.fields, undefined);
|
|
437
|
+
if (f.template !== undefined && !isObject(f.template)) fail(at, 'template is not an object');
|
|
438
|
+
} else if (f.fields !== undefined || f.template !== undefined) fail(at, 'only a rules field has fields');
|
|
439
|
+
}
|
|
440
|
+
return paths;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** @param {string} what @param {string} key @param {unknown} pattern */
|
|
444
|
+
function checkPattern(what, key, pattern) {
|
|
445
|
+
if (!text(pattern)) fail(what, `${key} is empty`);
|
|
446
|
+
try {
|
|
447
|
+
new RegExp(/** @type {string} */ (pattern), 'u');
|
|
448
|
+
} catch {
|
|
449
|
+
fail(what, `${key} isn't a regular expression`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* What the console may change on each of a provider's kinds (BRK-262): only the kinds it changes something on, checked.
|
|
455
|
+
* A provider without `editable` changes nothing from the console.
|
|
456
|
+
* @param {Provider} provider
|
|
457
|
+
* @returns {Record<string, Editable>}
|
|
458
|
+
*/
|
|
459
|
+
export function editableKinds(provider) {
|
|
460
|
+
if (typeof provider.editable !== 'function') return {};
|
|
461
|
+
/** @type {Record<string, Editable>} */
|
|
462
|
+
const out = {};
|
|
463
|
+
for (const kind of Object.keys(provider.kinds)) {
|
|
464
|
+
const e = checkEditable(provider, kind, provider.editable(kind));
|
|
465
|
+
if (e && (e.fields.length || e.name || e.shown?.length)) out[kind] = e;
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
279
470
|
/** @param {string} what @param {ReadToken} token */
|
|
280
471
|
function checkReadToken(what, token) {
|
|
281
472
|
if (!isObject(token)) fail(what, 'readToken is not an object');
|