breakaway 2.0.0-main.98 → 2.0.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.
@@ -15,8 +15,12 @@ export const CHANGE_KINDS = ['create', 'update', 'delete', 'scale', 'restart'];
15
15
  export const BASE_CHANGES = ['create', 'update', 'delete'];
16
16
  /** The change kinds a provider may add per resource kind, for envelopes. */
17
17
  export const ENVELOPE_CHANGES = ['scale', 'restart'];
18
- /** A resource's health, from `observe`. */
19
- export const HEALTH_STATES = ['healthy', 'degraded', 'down', 'unknown'];
18
+ /**
19
+ * A resource's health, from `observe`. `idle` is deployed and reachable with no traffic and no errors seen (BRK-266):
20
+ * it counts as healthy, never as unknown. `unknown` is only for what the provider couldn't read, and its text says
21
+ * which call and what to do.
22
+ */
23
+ export const HEALTH_STATES = ['healthy', 'idle', 'degraded', 'down', 'unknown'];
20
24
  /** What a signal is about: health, the platform's alerts, and cost (BRK-172: no metrics, logs, or traces yet). */
21
25
  export const SIGNAL_KINDS = ['health', 'alert', 'cost'];
22
26
  /** How loud a signal is. */
@@ -61,6 +65,7 @@ export const COST_NOTE_MAX = 500;
61
65
  * What should exist in one environment, from its desired-state file (BRK-180).
62
66
  * @typedef {object} DesiredState
63
67
  * @property {Resource[]} resources
68
+ * @property {{ url: string }} [health] the owner's health URL, checked on each refresh (BRK-266); providers ignore it
64
69
  */
65
70
 
66
71
  /**
@@ -162,6 +167,8 @@ export const COST_NOTE_MAX = 500;
162
167
  * Without it, every attr is a setting.
163
168
  * @property {string} [scales] the whole-number setting a `scale` changes (like `instances`): required when the kind
164
169
  * declares `scale`, so an envelope (BRK-186) knows what its bounds are bounds of
170
+ * @property {boolean} [target] one of the kind can be an environment's target, what discovery starts from (a Worker):
171
+ * a change that adds one to an environment with no target gives it that target (BRK-291)
165
172
  */
166
173
 
167
174
  /**
@@ -191,6 +198,23 @@ export const COST_NOTE_MAX = 500;
191
198
  * @property {string} url where the owner makes one, on the platform
192
199
  * @property {(ctx: { token: string, fetch?: typeof fetch }) => Promise<TokenCheck>} [check] asks the platform about
193
200
  * a pasted token before the board keeps it
201
+ * @property {(permissions: TokenPermission[], name: string) => TokenTemplate} [template] the link that opens the
202
+ * platform's create-token page with what it can prefill (BRK-304)
203
+ */
204
+
205
+ /**
206
+ * A link to the platform's create-token page with some permissions filled in, and the ones to add by hand.
207
+ * @typedef {{ url: string, prefilled: string[], byHand: string[] }} TokenTemplate
208
+ */
209
+
210
+ /**
211
+ * Each environment's write token (BRK-304), which the board never holds: guided setup says what to make and where to
212
+ * put it, and checks the GitHub environment has a secret by that name. Never a token's value.
213
+ * @typedef {object} WriteToken
214
+ * @property {string} secret the secret the apply workflow reads it from, in the GitHub environment
215
+ * @property {(desired: DesiredState | null, options: { running: string[] }) => Array<TokenPermission & { scope: string, workers?: string[], once?: boolean }>} permissions
216
+ * what one environment's token needs, from its desired state and the targets that already run there
217
+ * @property {(permissions: TokenPermission[], name: string) => TokenTemplate} [template] the prefilled link
194
218
  */
195
219
 
196
220
  /**
@@ -207,6 +231,7 @@ export const COST_NOTE_MAX = 500;
207
231
  * Worker, `scope.board`, or another environment's target, `scope.others`), in words, or null when it is; an act asks
208
232
  * it before it plans (BRK-251)
209
233
  * @property {ReadToken} [readToken] the read-only token it needs, if any
234
+ * @property {WriteToken} [writeToken] what each environment's write token needs, for guided setup (BRK-304)
210
235
  * @property {(ctx: ProviderContext) => Promise<Discovery>} discover
211
236
  * @property {(ctx: ProviderContext, desired: DesiredState) => Promise<PlanDiff>} plan
212
237
  * @property {(ctx: ProviderContext, plan: PlanDiff) => Promise<ApplyResult>} apply
@@ -217,6 +242,8 @@ export const COST_NOTE_MAX = 500;
217
242
  * month once `change` is applied, or null when the provider can't say; a plan's cost change uses it (BRK-178)
218
243
  * @property {(kind: string) => Editable | null} [editable] the settings the console may change on resources of `kind`,
219
244
  * or null for a kind it changes nothing on; checked by `checkEditable` (BRK-262)
245
+ * @property {(kind: string) => Creatable | null} [creatable] whether the console may add a resource of `kind`, its
246
+ * name's rule, and what a new one is given, or null for a kind it can't add; checked by `checkCreatable` (BRK-270)
220
247
  * @property {(ctx: ProviderContext, options: { board: string[] }) => Promise<AlertSetup>} [alerts] reads which of the
221
248
  * platform's alerts are set up and which reach the board (`board` is its https origins), for a provider whose
222
249
  * platform sends alerts to the board
@@ -251,13 +278,17 @@ const PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/u;
251
278
  * @property {string[]} [kinds] the kinds a `resource` may name
252
279
  * @property {BindingTarget[]} [targets] the binding types a `bindings` field changes: a binding of any other type (a
253
280
  * variable, a secret) is shown by its name and type, and kept as it is
281
+ * @property {Record<string, string>} [typeLabels] a `bindings` field's words for binding types its `targets` don't
282
+ * list (a Durable Object, static assets), so a change reads '+ CHAT (Durable Object)' (WEB-110)
254
283
  * @property {EditableField[]} [fields] a `rules` field's fields, inside one rule
255
284
  * @property {Record<string, unknown>} [template] what a new rule starts from
256
285
  */
257
286
 
258
287
  /**
259
288
  * 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'`).
289
+ * the field the provider's plan compares with what runs (BRK-285), holding the resource's ID on the platform (`by:
290
+ * 'id'`, its inventory ID after `<kind>:`) or its name (`by: 'name'`). One the plan still makes has no platform ID yet,
291
+ * so a `by: 'id'` binding names it by its ID in the desired state, as `{ name, type, resource }`.
261
292
  * @typedef {object} BindingTarget
262
293
  * @property {string} type the binding's type, as the platform calls it
263
294
  * @property {string} label what people call it
@@ -283,6 +314,39 @@ const PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/u;
283
314
  * @property {ShownSetting[]} [shown] settings shown by name, read only
284
315
  */
285
316
 
317
+ /**
318
+ * A resource kind the console may add (BRK-270), from the provider's `creatable(kind)`. A new one is the kind's
319
+ * editable fields (BRK-262) and `fields`, with `defaults` filled in and `required` given; a `create` edit
320
+ * (src/infra-changes.js) checks them, and the plan says what it makes.
321
+ * @typedef {object} Creatable
322
+ * @property {string} label what one is called, like `Queue`
323
+ * @property {string} help one line on what it's for, in plain words
324
+ * @property {{ label: string, help: string, pattern?: string, max?: number }} name its name's rule: a regular
325
+ * expression and a length, and help saying them. A name is unique among the kind in the environment's file and what
326
+ * runs there; the plan checks the rest of the platform's account.
327
+ * @property {EditableField[]} [fields] what only a new one is given (like a route's zone), beyond its editable fields:
328
+ * the plan reads them when it makes it, and never compares them
329
+ * @property {string[]} [required] the paths, of `fields` or the editable fields, a new one must be given
330
+ * @property {Record<string, unknown>} [defaults] what a new one starts with, by path
331
+ * @property {{ kind: string, list: string, target: BindingTarget, required?: boolean }} [bind] how a resource of another
332
+ * kind (`kind`, like a Worker) binds a new one: a binding in its `list` setting, as `target` says; `required` when the
333
+ * plan makes one only if something binds it, so a `create` edit must bind it. None for a kind nothing binds.
334
+ * @property {string} [needsCode] what code must exist before the plan can make one (a class, an image), in words: the
335
+ * console adds it to the file, and the plan waits until the code's deploy has made it
336
+ */
337
+
338
+ /**
339
+ * A kind the console may add, as the board answers it (BRK-270): one list of fields, the kind's editable ones after
340
+ * its own, each marked `required` or carrying its `default`.
341
+ * @typedef {object} CreatableKind
342
+ * @property {string} label
343
+ * @property {string} help
344
+ * @property {{ label: string, help: string, pattern?: string, max?: number }} name
345
+ * @property {Array<EditableField & { required?: boolean, default?: unknown }>} fields
346
+ * @property {{ kind: string, list: string, target: BindingTarget, required?: boolean }} [bind]
347
+ * @property {string} [needsCode]
348
+ */
349
+
286
350
  /**
287
351
  * Which of a platform's alerts are set up, and which reach the board: each alert type with how many policies use it
288
352
  * and whether one sends to the board, each policy (on or off, the routines on the board it fires), and how many
@@ -330,8 +394,11 @@ export function checkProvider(provider) {
330
394
  fail(what, `${kind}'s settings is not a list of settings' names`);
331
395
  if (spec.changes.includes('scale') ? !text(spec.scales) : spec.scales !== undefined)
332
396
  fail(what, `${kind}'s scales names the setting a scale changes, and only a kind that scales has one`);
397
+ if (spec.target !== undefined && typeof spec.target !== 'boolean')
398
+ fail(what, `${kind}'s target is not true or false`);
333
399
  }
334
400
  if (provider.readToken !== undefined) checkReadToken(what, provider.readToken);
401
+ if (provider.writeToken !== undefined) checkWriteToken(what, provider.writeToken);
335
402
  if (provider.refuses !== undefined && typeof provider.refuses !== 'function') fail(what, 'refuses is not a function');
336
403
  if (provider.outside !== undefined && typeof provider.outside !== 'function') fail(what, 'outside is not a function');
337
404
  if (provider.estimate !== undefined && typeof provider.estimate !== 'function')
@@ -340,6 +407,10 @@ export function checkProvider(provider) {
340
407
  if (typeof provider.editable !== 'function') fail(what, 'editable is not a function');
341
408
  for (const kind of Object.keys(provider.kinds)) checkEditable(provider, kind, provider.editable(kind));
342
409
  }
410
+ if (provider.creatable !== undefined) {
411
+ if (typeof provider.creatable !== 'function') fail(what, 'creatable is not a function');
412
+ for (const kind of Object.keys(provider.kinds)) checkCreatable(provider, kind, provider.creatable(kind));
413
+ }
343
414
  return provider;
344
415
  }
345
416
 
@@ -467,6 +538,137 @@ export function editableKinds(provider) {
467
538
  return out;
468
539
  }
469
540
 
541
+ /**
542
+ * What's wrong with a value for one of the console's fields (BRK-262's types), in words, or null when it fits. A
543
+ * `resource` field's value is a name: whether the environment has it is the caller's to check.
544
+ * @param {EditableField} field
545
+ * @param {unknown} value
546
+ * @returns {string | null}
547
+ */
548
+ export function fieldProblem(field, value) {
549
+ const label = field.label;
550
+ const fits = (pattern, v) => !pattern || new RegExp(pattern, 'u').test(v);
551
+ switch (field.type) {
552
+ case 'number': {
553
+ if (typeof value !== 'number' || !Number.isFinite(value)) return `${label} is a number`;
554
+ if (field.integer && !Number.isInteger(value)) return `${label} is a whole number`;
555
+ const unit = field.unit ? ` ${field.unit}` : '';
556
+ if (field.min !== undefined && value < field.min) return `${label} is at least ${field.min}${unit}`;
557
+ if (field.max !== undefined && value > field.max) return `${label} is at most ${field.max}${unit}`;
558
+ return null;
559
+ }
560
+ case 'yesno':
561
+ return typeof value === 'boolean' ? null : `${label} is yes or no (true or false)`;
562
+ case 'choice':
563
+ return field.options?.some((o) => o.value === value)
564
+ ? null
565
+ : `${label} is one of ${(field.options ?? []).map((o) => o.value).join(', ')}`;
566
+ case 'text':
567
+ case 'resource':
568
+ if (typeof value !== 'string' || !value.trim()) return `${label} is text`;
569
+ return fits(field.pattern, value) ? null : `${value} doesn’t fit ${label}: ${field.help}`;
570
+ case 'names':
571
+ if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) return `${label} is a list of names`;
572
+ for (const v of value) if (!fits(field.pattern, v)) return `${v} doesn’t fit ${label}: ${field.help}`;
573
+ return null;
574
+ default:
575
+ return Array.isArray(value) ? null : `${label} is a list`;
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Checks what a provider's `creatable(kind)` returned, or throws saying what's wrong: a label, help, and a name's rule;
581
+ * its own fields are well formed and none is also editable; what's required or defaulted is one of the fields; each
582
+ * default fits its field; and a binding names a kind the provider declares, binding this one.
583
+ * @param {Provider} provider
584
+ * @param {string} kind
585
+ * @param {Creatable | null} creatable
586
+ * @returns {Creatable | null}
587
+ */
588
+ export function checkCreatable(provider, kind, creatable) {
589
+ const what = `${provider.id} creatable ${kind}`;
590
+ if (creatable === null) return null;
591
+ if (!provider.kinds[kind]) fail(what, `${provider.id} doesn't declare ${kind}`);
592
+ if (!isObject(creatable) || !text(creatable.label) || !text(creatable.help)) fail(what, 'has no label or no help');
593
+ const name = creatable.name;
594
+ if (!isObject(name) || !text(name.label) || !text(name.help)) fail(what, 'name has no label or no help');
595
+ if (name.pattern !== undefined) checkPattern(what, 'name', name.pattern);
596
+ if (name.max !== undefined && (!Number.isInteger(name.max) || name.max < 1))
597
+ fail(what, 'name’s max is not a whole number of characters');
598
+ const own = creatable.fields ?? [];
599
+ checkFields(provider, what, own, undefined);
600
+ const editable = typeof provider.editable === 'function' ? (provider.editable(kind)?.fields ?? []) : [];
601
+ for (const f of own) if (editable.some((e) => e.path === f.path)) fail(`${what} ${f.path}`, 'is editable already');
602
+ const byPath = new Map([...own, ...editable].map((f) => [f.path, f]));
603
+ if (creatable.required !== undefined) {
604
+ if (!Array.isArray(creatable.required)) fail(what, 'required is not a list');
605
+ for (const p of creatable.required) if (!byPath.has(p)) fail(what, `requires ${p}, which isn't one of its fields`);
606
+ }
607
+ if (creatable.defaults !== undefined) {
608
+ if (!isObject(creatable.defaults)) fail(what, 'defaults is not an object');
609
+ for (const [p, v] of Object.entries(creatable.defaults)) {
610
+ const f = byPath.get(p);
611
+ if (!f) fail(what, `has a default for ${p}, which isn't one of its fields`);
612
+ const problem = fieldProblem(/** @type {EditableField} */ (f), v);
613
+ if (problem) fail(`${what} default ${p}`, problem);
614
+ }
615
+ }
616
+ if (creatable.bind !== undefined) {
617
+ const b = creatable.bind;
618
+ if (!isObject(b) || !provider.kinds[b.kind]) fail(what, 'bind names no kind the provider declares');
619
+ if (!text(b.list) || !PATH.test(b.list)) fail(what, 'bind names no list');
620
+ const t = b.target;
621
+ if (!isObject(t) || !text(t.type) || !text(t.label) || !text(t.field))
622
+ fail(what, 'bind’s target has no type, label, or field');
623
+ if (t.kind !== kind) fail(what, `bind’s target binds ${t.kind}, not ${kind}`);
624
+ if (t.by !== 'id' && t.by !== 'name') fail(what, 'bind’s target names its resource by neither id nor name');
625
+ if (b.required !== undefined && typeof b.required !== 'boolean') fail(what, 'bind’s required is not true or false');
626
+ }
627
+ if (creatable.needsCode !== undefined && !text(creatable.needsCode)) fail(what, 'needsCode is empty');
628
+ return creatable;
629
+ }
630
+
631
+ /**
632
+ * The kinds one of which can be an environment's target (BRK-291): the provider's kinds marked `target`.
633
+ * @param {Provider} provider
634
+ * @returns {string[]}
635
+ */
636
+ export const targetKinds = (provider) =>
637
+ Object.entries(provider.kinds)
638
+ .filter(([, spec]) => spec.target === true)
639
+ .map(([kind]) => kind);
640
+
641
+ /**
642
+ * The kinds the console may add (BRK-270), checked, each with one list of fields: its own, then its editable ones,
643
+ * marked `required` or carrying their `default`. A provider without `creatable` adds nothing from the console.
644
+ * @param {Provider} provider
645
+ * @returns {Record<string, CreatableKind>}
646
+ */
647
+ export function creatableKinds(provider) {
648
+ if (typeof provider.creatable !== 'function') return {};
649
+ /** @type {Record<string, CreatableKind>} */
650
+ const out = {};
651
+ for (const kind of Object.keys(provider.kinds)) {
652
+ const c = checkCreatable(provider, kind, provider.creatable(kind));
653
+ if (!c) continue;
654
+ const editable = typeof provider.editable === 'function' ? (provider.editable(kind)?.fields ?? []) : [];
655
+ const fields = [...(c.fields ?? []), ...editable].map((f) => ({
656
+ ...f,
657
+ ...(c.required?.includes(f.path) ? { required: true } : {}),
658
+ ...(c.defaults && Object.hasOwn(c.defaults, f.path) ? { default: structuredClone(c.defaults[f.path]) } : {}),
659
+ }));
660
+ out[kind] = {
661
+ label: c.label,
662
+ help: c.help,
663
+ name: { ...c.name },
664
+ fields,
665
+ ...(c.bind ? { bind: structuredClone(c.bind) } : {}),
666
+ ...(c.needsCode ? { needsCode: c.needsCode } : {}),
667
+ };
668
+ }
669
+ return out;
670
+ }
671
+
470
672
  /** @param {string} what @param {ReadToken} token */
471
673
  function checkReadToken(what, token) {
472
674
  if (!isObject(token)) fail(what, 'readToken is not an object');
@@ -478,6 +680,18 @@ function checkReadToken(what, token) {
478
680
  }
479
681
  if (typeof token.url !== 'string' || !token.url.startsWith('https://')) fail(what, 'readToken has no https url');
480
682
  if (token.check !== undefined && typeof token.check !== 'function') fail(what, 'readToken check is not a function');
683
+ if (token.template !== undefined && typeof token.template !== 'function')
684
+ fail(what, 'readToken template is not a function');
685
+ }
686
+
687
+ /** What guided setup needs of a provider's write token (BRK-304): a secret's name and the permissions, from a function. */
688
+ function checkWriteToken(what, token) {
689
+ if (!isObject(token)) fail(what, 'writeToken is not an object');
690
+ if (!text(token.secret) || !/^[A-Z][A-Z0-9_]{0,63}$/u.test(token.secret))
691
+ fail(what, 'writeToken secret is not a GitHub secret’s name');
692
+ if (typeof token.permissions !== 'function') fail(what, 'writeToken permissions is not a function');
693
+ if (token.template !== undefined && typeof token.template !== 'function')
694
+ fail(what, 'writeToken template is not a function');
481
695
  }
482
696
 
483
697
  /**
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The apply workflow, rendered (CLI-12, BRK-307; docs/specs/IDEA-19-architect.md, "Executor"):
3
+ * `.github/workflows/breakaway-infra.yml` from template/infra/apply.yml, for every environment with a desired-state
4
+ * file. `npx breakaway infra init` writes it in a checkout, and the board's change brings it in the same commit as the
5
+ * desired state (store-infra-changes.js), so both render byte-identical files from the same template: the CLI reads
6
+ * it from disk, the Worker from src/infra-runner-template.json, which scripts/board-files.mjs generates from it.
7
+ *
8
+ * Pure and Node-safe: no store, no network, no disk.
9
+ */
10
+ import { DESIRED_DIR, environmentOfFile } from './infra-desired.js';
11
+ import { RUNNER_WORKFLOW } from './infra-runner.js';
12
+ import { SHORT_LIVED_FILE } from './infra-short-lived.js';
13
+ import { fill, lintWorkflow } from './workflow-render.js';
14
+
15
+ /** Where the template is, in breakaway's own files. */
16
+ export const RUNNER_TEMPLATE = 'template/infra/apply.yml';
17
+
18
+ /** The first line of the rendered workflow: how `--update`, and the board, know the file is theirs to replace. */
19
+ export const RUNNER_RENDER_HEADER = `Rendered by npx breakaway infra init from the environments in ${DESIRED_DIR}/: add one there and run npx breakaway infra init --update, not this file.`;
20
+
21
+ /** What the change card and the plan check say when the board couldn't bring the workflow itself. */
22
+ export const RUNNER_NEEDS_WORKFLOWS = `Applying needs ${RUNNER_WORKFLOW}: give the board’s GitHub App Workflows: write and propose again, or run npx breakaway infra init in the repository and merge it.`;
23
+
24
+ /** The same, for a pull request the board didn't write, where proposing again isn't the way. */
25
+ export const RUNNER_NEEDS_INIT = `Applying needs ${RUNNER_WORKFLOW}: run npx breakaway infra init in the repository and merge it.`;
26
+
27
+ const BRANCH = /^(?!.*\.\.)[A-Za-z0-9._/-]{1,100}$/u;
28
+ const VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/u;
29
+
30
+ /** A reason the workflow can't be rendered, in words for the owner or the agent. */
31
+ export class RunnerRenderError extends Error {}
32
+ const bad = (message) => {
33
+ throw new RunnerRenderError(message);
34
+ };
35
+
36
+ /**
37
+ * The environments the runner offers, from the file names in the desired-state folder: each `<environment>.json`,
38
+ * sorted, skipping policy.json, scaling.json, and anything that isn't JSON.
39
+ * @param {string[]} names
40
+ */
41
+ export function environmentsIn(names) {
42
+ const environments = new Set();
43
+ for (const name of names) {
44
+ const found = environmentOfFile(name);
45
+ if (!found) continue;
46
+ if ('problem' in found) throw new RunnerRenderError(`${DESIRED_DIR}/${found.problem}.`);
47
+ environments.add(found.environment);
48
+ }
49
+ if (!environments.size)
50
+ bad(
51
+ `${DESIRED_DIR}/ has no environment's file, so there's nothing to apply to: write <environment>.json for each (like staging.json), then run this again.`,
52
+ );
53
+ return [...environments].sort();
54
+ }
55
+
56
+ /**
57
+ * The runner's workflow for these environments, applied from `branch`, running breakaway `version`. With `shortLived`
58
+ * (the folder has short-lived.json), the environment is any name, since each short-lived one is new, and the run may
59
+ * name the GitHub environment that holds the write token: `short-lived` for all of them (BRK-242).
60
+ * @param {{ environments: string[], branch: string, version: string, shortLived?: boolean }} input
61
+ * @param {string} template template/infra/apply.yml
62
+ * @returns {{ path: string, text: string }}
63
+ */
64
+ export function renderRunner({ environments, branch, version, shortLived = false }, template) {
65
+ if (!BRANCH.test(branch)) bad(`${branch.slice(0, 80)} isn't a branch's name.`);
66
+ if (!VERSION.test(version)) bad(`${version} isn't a breakaway release.`);
67
+ const text = fill(template, {
68
+ header: RUNNER_RENDER_HEADER,
69
+ branchName: branch,
70
+ version,
71
+ shortLived,
72
+ fixedOnly: !shortLived,
73
+ environments: environments.map((name) => `- ${JSON.stringify(name)}`),
74
+ });
75
+ const problems = lintWorkflow(text);
76
+ if (problems.length) throw new Error(`${RUNNER_WORKFLOW} renders with problems: ${problems.join('; ')}`);
77
+ return { path: RUNNER_WORKFLOW, text };
78
+ }
79
+
80
+ /** Whether a workflow's text is the board's own render, which `--update` and the board may replace. */
81
+ export const isRenderedRunner = (text) => String(text ?? '').startsWith(`# ${RUNNER_RENDER_HEADER}`);
82
+
83
+ /**
84
+ * What to do with the rendered file, given what's there (null when nothing): write it, leave it (the same), or refuse
85
+ * it (the repository's own, or rendered before when `update` isn't set).
86
+ * @param {{ path: string, text: string }} file
87
+ * @param {string | null} there
88
+ * @param {{ update?: boolean }} [options]
89
+ * @returns {{ write: boolean, same?: boolean, refused?: string }}
90
+ */
91
+ export function runnerStep(file, there, { update = false } = {}) {
92
+ if (there === null) return { write: true };
93
+ if (there === file.text) return { write: false, same: true };
94
+ if (!isRenderedRunner(there))
95
+ return { write: false, refused: "it's the repository's own, not rendered: rename it, then run infra init again" };
96
+ if (!update) return { write: false, refused: 'it differs from what infra init renders: run infra init --update' };
97
+ return { write: true };
98
+ }
99
+
100
+ /**
101
+ * What a change does with the apply workflow (BRK-307): the environments come from the desired-state folder's file
102
+ * names on the default branch plus the environment the change writes, and the workflow is rendered for them.
103
+ * - `add`: there's none; `update`: it's the board's own render and differs (an environment, the branch, or the
104
+ * release); the change commits `file` either way.
105
+ * - `current`: it's already what the board renders.
106
+ * - `own`: it's the repository's own, which the board never overwrites; `missing` names the environments it doesn't
107
+ * offer, as far as the board can tell.
108
+ * @param {{ names: string[], environment: string, there: string | null, branch: string, version: string,
109
+ * template: string }} input
110
+ * @returns {{ action: 'add' | 'update' | 'current' | 'own', file: { path: string, text: string },
111
+ * environments: string[], missing: string[] }}
112
+ */
113
+ export function runnerChange({ names, environment, there, branch, version, template }) {
114
+ const environments = environmentsIn([...names, `${environment}.json`]);
115
+ const shortLived = names.includes(SHORT_LIVED_FILE);
116
+ const file = renderRunner({ environments, branch, version, shortLived }, template);
117
+ const out = { file, environments, missing: [] };
118
+ if (there === null) return { ...out, action: 'add' };
119
+ if (there === file.text) return { ...out, action: 'current' };
120
+ if (isRenderedRunner(there)) return { ...out, action: 'update' };
121
+ return { ...out, action: 'own', missing: runnerMissing(there, environments) };
122
+ }
123
+
124
+ /**
125
+ * The environments a workflow doesn't offer: none when it takes any environment's name (an input of type string, as
126
+ * the render does with short-lived environments, or a repository's own that the board can't read), else the ones not
127
+ * among its environment input's choices.
128
+ * @param {string} text
129
+ * @param {string[]} environments
130
+ * @returns {string[]}
131
+ */
132
+ export function runnerMissing(text, environments) {
133
+ const options = /\n {6}environment:\n(?: {8}.*\n)*? {8}options:\n((?: {10}- .*\n?)+)/u.exec(String(text ?? ''));
134
+ if (!options) return [];
135
+ const offered = new Set(
136
+ options[1]
137
+ .split('\n')
138
+ .map((line) => line.replace(/^\s*-\s*/u, '').trim())
139
+ .filter(Boolean)
140
+ .map((value) => {
141
+ try {
142
+ return String(JSON.parse(value));
143
+ } catch {
144
+ return value.replace(/^['"]|['"]$/gu, '');
145
+ }
146
+ }),
147
+ );
148
+ return environments.filter((name) => !offered.has(name));
149
+ }
150
+
151
+ /** The words for an apply workflow the board left alone because it's the repository's own. */
152
+ export function runnerOwnWords(missing) {
153
+ if (!missing.length) return null;
154
+ return `${RUNNER_WORKFLOW} is the repository’s own, and it doesn’t offer ${missing.join(', ')}: add ${missing.length === 1 ? 'it' : 'them'} to its environment choices, or rename it and run npx breakaway infra init, then merge it.`;
155
+ }
156
+
157
+ /**
158
+ * What a pull request's plan check says about the apply workflow (BRK-307), given the workflow at its head (null when
159
+ * there's none) and the environments it plans: null when the workflow offers them all, else what to do before
160
+ * Approve. A waiting note, never a failure.
161
+ * @param {string | null} there
162
+ * @param {string[]} environments
163
+ * @returns {string | null}
164
+ */
165
+ export function runnerNote(there, environments) {
166
+ if (!environments.length) return null;
167
+ if (there === null) return RUNNER_NEEDS_INIT;
168
+ const missing = runnerMissing(there, environments);
169
+ if (!missing.length) return null;
170
+ if (!isRenderedRunner(there)) return runnerOwnWords(missing);
171
+ return `${RUNNER_WORKFLOW} doesn’t offer ${missing.join(', ')} yet: run npx breakaway infra init --update in the repository and merge it.`;
172
+ }
package/src/init.js CHANGED
@@ -19,7 +19,7 @@ export { routinePrompt };
19
19
  * The CLI on npm (BRK-7), as a repository runs it: `npx breakaway`, pinned to the major (BRK-47), so a breaking change
20
20
  * never reaches a repository by itself. It was the `next` channel until the first stable release.
21
21
  */
22
- export const CLI_PACKAGE = 'breakaway@1';
22
+ export const CLI_PACKAGE = 'breakaway@2';
23
23
  /**
24
24
  * Where the CLI starts: the command, and the two session hooks. repos init no longer copies them (BRK-7): an old copy
25
25
  * is replaced by npx, and these still version the CLI and say which files an old copy holds.