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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.96",
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.96",
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.96",
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",
@@ -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),
@@ -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');