breakaway 2.0.0-main.61 → 2.0.0-main.63

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.61",
3
+ "version": "2.0.0-main.63",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "breakaway",
9
- "version": "2.0.0-main.61",
9
+ "version": "2.0.0-main.63",
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.61",
3
+ "version": "2.0.0-main.63",
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",
@@ -29,7 +29,7 @@ export const SUBCOMMANDS = {
29
29
  'listen',
30
30
  ],
31
31
  specs: ['list', 'show'],
32
- infra: ['init', 'runner', 'check', 'act', ...INFRA_READS],
32
+ infra: ['init', 'runner', 'check', 'adopt', 'act', ...INFRA_READS],
33
33
  };
34
34
 
35
35
  /** Commands that take nothing after their name, so a word there is a mistake (an old copy's missing subcommand, say). */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `npx breakaway infra adopt <environment>` (CLI-23, docs/specs/IDEA-19-architect.md, "Desired state"): writes the
3
+ * board's draft of an environment's desired state into the checkout, so nobody hand-writes
4
+ * `.github/breakaway-infra/<environment>.json`. The board drafts it from what runs there (BRK-240,
5
+ * GET /api/infra/environments/<environment>/draft, read only, so an agent's token may ask); this writes it where it
6
+ * goes, then checks it the way `infra check` does and says what to commit.
7
+ *
8
+ * Files only: it never makes a plan and never applies anything. It never overwrites a file without --force, and it
9
+ * writes nothing for an observe-only environment, which takes no desired state. The path is worked out here from the
10
+ * environment's name, never taken from the board's answer, so the file can't land anywhere else.
11
+ */
12
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { checkDesiredFile, desiredPath, environmentOfFile } from '../../src/infra-desired.js';
15
+ import { infraCheck, readInfraFolder } from './infra-check.js';
16
+
17
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
18
+
19
+ /**
20
+ * Runs `infra adopt`. `code` is what the CLI exits with: 1 when nothing was written, or the written file doesn't check.
21
+ * @param {string[]} args what follows `infra adopt`: the environment's name (or ID)
22
+ * @param {{ root: string, repo: string | null, force?: boolean, dryRun?: boolean,
23
+ * get: (path: string) => Promise<{ ok: boolean, status: number, data: any }>,
24
+ * post: (path: string, body: any) => Promise<{ ok: boolean, status: number, data: any }> }} ctx
25
+ * @returns {Promise<{ code: number, data: any, text: string }>}
26
+ */
27
+ export async function infraAdopt(args, { root, repo, get, post, force = false, dryRun = false }) {
28
+ const refused = (error, text = `${error}.`) => ({ code: 1, data: { ok: false, written: false, error }, text });
29
+ const ref = args[0];
30
+ if (!ref)
31
+ return refused(
32
+ 'name the environment',
33
+ 'Name the environment: infra adopt <environment>. npx breakaway infra lists this repository’s.',
34
+ );
35
+
36
+ const res = await get(
37
+ `infra/environments/${encodeURIComponent(ref)}/draft${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`,
38
+ );
39
+ if (!res.ok) {
40
+ if (res.status === 404 && /^no route for/u.test(String(res.data?.error ?? '')))
41
+ return refused(
42
+ 'this board has no draft yet',
43
+ 'This board can’t draft a desired state yet: its owner updates it to a release that can, then try again.',
44
+ );
45
+ const error = res.status === 0 ? `can’t reach the board (${res.data?.error ?? 'no answer'})` : res.data?.error;
46
+ return refused(error ?? `HTTP ${res.status}`, `Nothing was written: ${error ?? `HTTP ${res.status}`}.`);
47
+ }
48
+
49
+ const draft = res.data?.draft;
50
+ const named = environmentOfFile(`${draft?.environment ?? ''}.json`);
51
+ if (!draft || typeof draft.json !== 'string' || !named || !('environment' in named))
52
+ return refused('the board’s draft isn’t one this CLI can read: update npx breakaway, then try again');
53
+ const environment = named.environment;
54
+ const path = desiredPath(environment);
55
+ const notes = Array.isArray(draft.notes) ? draft.notes.map(String) : [];
56
+ const data = { environment, path, resources: draft.resources ?? 0, observeOnly: Boolean(draft.observeOnly), notes };
57
+ const withNotes = (lines) => [...lines, ...(notes.length ? ['', ...notes.map((n) => `- ${n}`)] : [])];
58
+
59
+ if (draft.observeOnly)
60
+ return {
61
+ code: 1,
62
+ data: { ...data, ok: false, written: false, error: `${environment} is observe only` },
63
+ text: withNotes([
64
+ `Nothing was written: ${environment} is observe only, so it takes no desired state and the board never changes it.`,
65
+ ]).join('\n'),
66
+ };
67
+ // The board checks its draft before it answers, so this only fails when the two disagree: write nothing bad.
68
+ const checked = checkDesiredFile(draft.json, {});
69
+ if ('error' in checked)
70
+ return refused(
71
+ `the draft doesn’t check at ${checked.error.field ?? 'the top'}: ${checked.error.message}`,
72
+ `Nothing was written: the board’s draft for ${environment} doesn’t check here (${checked.error.field ?? 'the top'}: ${checked.error.message}). Update npx breakaway, then try again.`,
73
+ );
74
+
75
+ const file = join(root, path);
76
+ const exists = existsSync(file);
77
+ if (exists && !force)
78
+ return {
79
+ code: 1,
80
+ data: { ...data, ok: false, written: false, error: `${path} is already there` },
81
+ text: `Nothing was written: ${path} is already there. See the draft with --dry-run, or replace the file with --force.`,
82
+ };
83
+
84
+ if (dryRun)
85
+ return {
86
+ code: 0,
87
+ data: { ...data, ok: true, written: false, json: draft.json },
88
+ text: withNotes([
89
+ `Would ${exists ? 'replace' : 'write'} ${path}, ${plural(data.resources, 'resource')}:`,
90
+ '',
91
+ draft.json.trimEnd(),
92
+ ]).join('\n'),
93
+ };
94
+
95
+ mkdirSync(dirname(file), { recursive: true });
96
+ writeFileSync(file, draft.json);
97
+ const check = await infraCheck([environment], { files: readInfraFolder(root), repo, post });
98
+ const next =
99
+ check.code === 0
100
+ ? [
101
+ '',
102
+ `Next: commit ${path} and open a pull request. Nothing changes until it merges: then the board plans from it, and the plan waits for you.`,
103
+ ]
104
+ : ['', `Fix what’s above, then npx breakaway infra check ${environment} before you commit ${path}.`];
105
+ return {
106
+ code: check.code,
107
+ data: { ...data, ok: check.code === 0, written: true, replaced: exists, check: check.data },
108
+ text: [
109
+ ...withNotes([`${exists ? 'Replaced' : 'Wrote'} ${path}, ${plural(data.resources, 'resource')}.`]),
110
+ '',
111
+ check.text,
112
+ ...next,
113
+ ].join('\n'),
114
+ };
115
+ }
package/scripts/tasks.mjs CHANGED
@@ -434,6 +434,10 @@ Infrastructure (init and add need no board: run them in the checkout of the re
434
434
  run it holds (--task <ID>, else the one routine run it has claimed here); it says whether
435
435
  the owner's envelope covers it or it waits for the owner, and names the plan. Only a
436
436
  runbook's run may; it's the one change a run may ask for, and it never applies anything
437
+ infra adopt <environment> write the board's draft of the environment's desired state, drafted from what runs
438
+ there, to .github/breakaway-infra/<environment>.json, then check it like infra check and say
439
+ what to commit; it never plans or applies, and writes nothing for an observe-only environment
440
+ [--force] replaces a file that's there [--dry-run] prints the draft instead
437
441
  infra add [<template>] [<environment>] [<input>=<value> …] a golden path: write the change one of the owner's
438
442
  templates makes (.github/breakaway-infra/templates/<name>/, else breakaway's example of that
439
443
  name) into the environment's file and the code it needs, for a pull request; it never
@@ -1441,6 +1445,22 @@ const commands = {
1441
1445
  process.exitCode = result.code;
1442
1446
  return;
1443
1447
  }
1448
+ if (args[0] === 'adopt') {
1449
+ // infra adopt (CLI-23): the board's draft of an environment's desired state, written into the checkout and checked.
1450
+ const { infraAdopt } = await import('./tasks/infra-adopt.js');
1451
+ const { topOf } = await import('./tasks/pipeline.js');
1452
+ const result = await infraAdopt(args.slice(1), {
1453
+ root: topOf(process.cwd()),
1454
+ repo: slug,
1455
+ force: Boolean(opts.force),
1456
+ dryRun: Boolean(opts['dry-run']),
1457
+ get: (path) => call('GET', path, undefined, { raw: true }),
1458
+ post: (path, body) => call('POST', path, body, { raw: true }),
1459
+ });
1460
+ print(result.data, () => result.text);
1461
+ process.exitCode = result.code;
1462
+ return;
1463
+ }
1444
1464
  if (args[0] === 'act') {
1445
1465
  // infra act (CLI-24): a runbook's agent asks for one scale or restart, for the run it holds; the board decides.
1446
1466
  const { infraAct, InfraActError } = await import('./tasks/infra-act.js');