breakaway 1.4.0-main.45 → 1.4.0-main.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.4.0-main.45",
3
+ "version": "1.4.0-main.47",
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",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The owner's commands that write an install's secrets (rotate-sync, rotate-token, github-connect, agents-connect,
3
+ * repos remove) name them from the checkout's breakaway.config.json. In breakaway's own checkout that's the template's
4
+ * defaults, and under npx in another repository the package's, so before writing, the CLI checks the config is the
5
+ * board's own install, as the board's health reports it (BRK-95). Pure, so it's tested without a board.
6
+ */
7
+
8
+ /** What the CLI compares, and how each is named when they differ. @type {[string, (v: any) => string][]} */
9
+ const FIELDS = [
10
+ ['worker', (v) => `the Worker ${v}`],
11
+ ['secretsPrefix', (v) => `secrets named ${v}…`],
12
+ ['secretsStore', (v) => (v ? `the Secrets Store ${v}` : 'Worker secrets, no Secrets Store')],
13
+ ];
14
+
15
+ /**
16
+ * Whether `local` (the checkout's install config, parsed) is the board's install (`board`, health's `install`).
17
+ * `ok` false stops the command before it writes anything; `warning` is for a board too old to say which it is.
18
+ *
19
+ * @param {{ worker: string, secretsPrefix: string, secretsStore: string | null }} local
20
+ * @param {{ worker?: string, secretsPrefix?: string, secretsStore?: string | null, installRepository?: string | null } | null | undefined} board
21
+ * @param {{ command: string, configFile: string }} where
22
+ * @returns {{ ok: boolean, message?: string, warning?: string }}
23
+ */
24
+ export function checkInstall(local, board, { command, configFile }) {
25
+ if (!board || typeof board !== 'object')
26
+ return {
27
+ ok: true,
28
+ warning: `the board doesn't say which install it is (it runs a release from before BRK-95), so ${command} can't check that ${configFile} is its config. Update the board when you can.`,
29
+ };
30
+ // A board deployed before its config carried the Secrets Store doesn't say which it uses: compare the rest.
31
+ const differ = FIELDS.filter(([key]) => board[key] !== undefined && board[key] !== local[key]);
32
+ if (!differ.length) return { ok: true };
33
+ const describe = (side) => differ.map(([key, name]) => name(side[key])).join(', ');
34
+ const from = board.installRepository
35
+ ? `a checkout of ${board.installRepository}, the repository the board deploys from`
36
+ : `the checkout whose breakaway.config.json is the board's (the Worker ${board.worker ?? 'it runs as'})`;
37
+ return {
38
+ ok: false,
39
+ message: `${configFile} isn't this board's install: it says ${describe(local)}, and the board is ${describe(board)}. Nothing was written. Run npx breakaway ${command} from ${from}.`,
40
+ };
41
+ }
package/scripts/tasks.mjs CHANGED
@@ -42,6 +42,7 @@ import { looksLikeSecret } from '../src/ping.js';
42
42
  import { promptPathOf } from '../src/repos.js';
43
43
  import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
44
44
  import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
45
+ import { checkInstall } from './tasks/install-check.js';
45
46
  import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
46
47
  import {
47
48
  CLI_PACKAGE,
@@ -128,6 +129,17 @@ function installConfig() {
128
129
  }
129
130
  }
130
131
 
132
+ /**
133
+ * Stops `command` before it writes a secret when this checkout's install config isn't the board's (BRK-95): its
134
+ * secrets would get the wrong names, or go to a Worker that isn't the board's. `health` saves a second request.
135
+ */
136
+ async function ensureBoardInstall(command, health = null) {
137
+ const board = (health ?? (await call('GET', 'health'))).install;
138
+ const result = checkInstall(installConfig(), board, { command, configFile: tildePath(CONFIG_FILE, homedir()) });
139
+ if (!result.ok) fail(result.message);
140
+ if (result.warning) console.error(`tasks: ${result.warning}`);
141
+ }
142
+
131
143
  /** A file in this checkout, or null when it isn't there. */
132
144
  function readOptional(path) {
133
145
  try {
@@ -1609,6 +1621,7 @@ const commands = {
1609
1621
  if (!readSetting('SECRET', { file: env })) fail(`${ENV_FILE} has no sync secret; this is for the owner's machine.`);
1610
1622
  const health = await call('GET', 'health');
1611
1623
  if (!health.ok) fail(`the server can't read its history (${health.replicaError}); fix that first (docs/tasks.md).`);
1624
+ await ensureBoardInstall('rotate-sync', health);
1612
1625
  const next = { ...env, ...newSyncCredentials() };
1613
1626
  writePrivate(`${ENV_FILE}.next`, envFile(next));
1614
1627
  const result = await call('POST', 'admin/rekey', {
@@ -1638,6 +1651,7 @@ const commands = {
1638
1651
  },
1639
1652
  /** Owner: a new API token. Every browser is signed out; cloud environments need the new one. */
1640
1653
  async 'rotate-token'() {
1654
+ await ensureBoardInstall('rotate-token');
1641
1655
  const env = readEnvFile();
1642
1656
  const token = randomBytes(32).toString('base64url');
1643
1657
  writePrivate(`${ENV_FILE}.next`, envFile({ ...env, [envName('TOKEN')]: token }));
@@ -1672,6 +1686,7 @@ const commands = {
1672
1686
  const { repos, default: fallback } = await call('GET', 'repos');
1673
1687
  const slug = opts.repo ? String(opts.repo).toLowerCase() : fallback;
1674
1688
  if (!repos.some((r) => r.slug === slug)) fail(unknownRepo(slug, repos));
1689
+ await ensureBoardInstall('agents-connect');
1675
1690
  if (!process.stdin.isTTY) fail(NO_TERMINAL);
1676
1691
  const others = slug !== fallback ? await heldRoutines(fallback, slug, 'storing this one') : null;
1677
1692
  const { createInterface } = await import('node:readline');
@@ -1709,6 +1724,8 @@ const commands = {
1709
1724
  */
1710
1725
  async 'github-connect'() {
1711
1726
  const code = need(args[0], 'code');
1727
+ // Before the code is traded: it works once, and the keys it gives have to go into the board's own secrets.
1728
+ await ensureBoardInstall('github-connect');
1712
1729
  const res = await fetch(`https://api.github.com/app-manifests/${enc(code)}/conversions`, {
1713
1730
  method: 'POST',
1714
1731
  headers: {
@@ -2129,6 +2146,9 @@ async function promptAnswers() {
2129
2146
  /** repos remove (CLD-191): the board takes it off, then its routine leaves the Secrets Store and this machine's copy. */
2130
2147
  async function removeRepo(slug, signer) {
2131
2148
  const { default: fallback } = await call('GET', 'repos');
2149
+ // Dropping a routine this machine connected rewrites the ROUTINES secret: check before anything changes.
2150
+ if (existsSync(ROUTINES_FILE) && JSON.parse(readFileSync(ROUTINES_FILE, 'utf8'))[slug])
2151
+ await ensureBoardInstall('repos remove');
2132
2152
  const res = await call('DELETE', `repos/${enc(slug)}`, { by: signer, ...(opts.force ? { force: true } : {}) });
2133
2153
  const lines = [
2134
2154
  `Took ${res.removed.slug} (${res.removed.github}) off the board. Its tasks stay, readable, and its slug and prefixes (${res.removed.areas.map((a) => a.prefix).join(', ')}) stay its own.`,
package/src/install.js CHANGED
@@ -121,9 +121,10 @@ export function parseInstall(raw) {
121
121
  * The install a Worker runs as: its `TASKS_INSTALL` var, or a new install's defaults when it has none. An install
122
122
  * without a `url` (a new install's, on workers.dev) has a null `url`: the board
123
123
  * then goes by the address it was last opened at (the Durable Object's homeUrl()).
124
+ * `secretsStore` is undefined when the var doesn't say (a Worker deployed before BRK-95), not null: then nobody knows.
124
125
  *
125
126
  * @param {any} [env]
126
- * @returns {{ name: string, worker: string, url: string | null, secretsPrefix: string, store: string, docs: string | null, installRepository?: string | null, channel?: string }}
127
+ * @returns {{ name: string, worker: string, url: string | null, secretsPrefix: string, secretsStore?: string | null, store: string, docs: string | null, installRepository?: string | null, channel?: string }}
127
128
  */
128
129
  export function install(env) {
129
130
  const set = env?.TASKS_INSTALL;
@@ -134,6 +135,7 @@ export function install(env) {
134
135
  worker: String(raw.worker || DEFAULTS.worker),
135
136
  url: raw.url ? String(raw.url) : null,
136
137
  secretsPrefix: String(raw.secretsPrefix || DEFAULTS.secretsPrefix),
138
+ ...('secretsStore' in raw ? { secretsStore: raw.secretsStore ? String(raw.secretsStore) : null } : {}),
137
139
  store: String(raw.store || DEFAULTS.store),
138
140
  docs: raw.docs ? String(raw.docs) : null,
139
141
  installRepository: raw.installRepository ? String(raw.installRepository) : null,
@@ -174,6 +176,9 @@ export function wranglerConfig(config, { local = false, root = '.' } = {}) {
174
176
  worker: c.worker,
175
177
  url: c.url,
176
178
  secretsPrefix: c.secretsPrefix,
179
+ // Which Secrets Store its secrets are in, so the owner's commands that write them can check they're on this
180
+ // install's (BRK-95). Not a secret: the same ID is in secrets_store_secrets below.
181
+ secretsStore: local ? null : c.secretsStore,
177
182
  store: c.store,
178
183
  docs: c.docs,
179
184
  },