breakaway 1.4.0-main.60 → 1.4.0-main.62

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.60",
3
+ "version": "1.4.0-main.62",
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",
@@ -3,8 +3,13 @@
3
3
  * repos remove) name them from the checkout's breakaway.config.json. In breakaway's own checkout that's the template's
4
4
  * defaults, and under npx in another repository the package's, so before writing, the CLI checks the config is the
5
5
  * board's own install, as the board's health reports it (BRK-95). Pure, so it's tested without a board.
6
+ *
7
+ * Asking the board takes the token. When the owner has lost it, rotate-token can't ask, so the owner names the install
8
+ * instead (CLI-5): the Worker's name, or the Secrets Store's ID, has to match the config before anything is written.
6
9
  */
7
10
 
11
+ import { secretName } from '../../src/install.js';
12
+
8
13
  /** What the CLI compares, and how each is named when they differ. @type {[string, (v: any) => string][]} */
9
14
  const FIELDS = [
10
15
  ['worker', (v) => `the Worker ${v}`],
@@ -39,3 +44,52 @@ export function checkInstall(local, board, { command, configFile }) {
39
44
  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
45
  };
41
46
  }
47
+
48
+ /**
49
+ * Where rotate-token writes the new token, as the checkout's config says: the Secrets Store's secret, or the Worker's.
50
+ *
51
+ * @param {{ worker: string, secretsPrefix: string, secretsStore: string | null }} local
52
+ */
53
+ export function tokenTarget(local) {
54
+ return local.secretsStore
55
+ ? `${secretName(local, 'API_TOKEN')} in the Secrets Store ${local.secretsStore}, for the Worker ${local.worker}`
56
+ : `the secret TASKS_API_TOKEN on the Worker ${local.worker}`;
57
+ }
58
+
59
+ /**
60
+ * What rotate-token says when the board refused the token, or there's none (CLI-5): it can't ask the board which
61
+ * install it is, so it says where it would write and what the owner confirms.
62
+ *
63
+ * @param {{ worker: string, secretsPrefix: string, secretsStore: string | null }} local
64
+ * @param {{ configFile: string, token: boolean }} where `token`: whether there was a token the board refused
65
+ * @returns {string}
66
+ */
67
+ export function unverifiedInstall(local, { configFile, token }) {
68
+ return [
69
+ token
70
+ ? "The board refused this machine's token, so rotate-token can't ask it which install it is."
71
+ : "There's no token on this machine, so rotate-token can't ask the board which install it is.",
72
+ `${configFile} says the new token goes in ${tokenTarget(local)}.`,
73
+ `If that's this board, confirm with the Worker's name${local.secretsStore ? " or the Secrets Store's ID" : ''}; if it isn't, stop and run this from the checkout the board deploys from.`,
74
+ ].join('\n');
75
+ }
76
+
77
+ /**
78
+ * Whether the owner's answer names the install the config describes: the Worker's name, or with a Secrets Store, its ID.
79
+ * Without a match nothing is written.
80
+ *
81
+ * @param {{ worker: string, secretsStore: string | null }} local
82
+ * @param {string | null | undefined} answer
83
+ * @param {{ configFile: string }} where
84
+ * @returns {{ ok: boolean, message?: string }}
85
+ */
86
+ export function confirmInstall(local, answer, { configFile }) {
87
+ const given = String(answer ?? '').trim();
88
+ if (given && (given === local.worker || (local.secretsStore && given === local.secretsStore))) return { ok: true };
89
+ return {
90
+ ok: false,
91
+ message: given
92
+ ? `"${given.slice(0, 64)}" isn't the Worker ${local.worker}${local.secretsStore ? ` or the Secrets Store ${local.secretsStore}` : ''} that ${configFile} names, so nothing was written. Run npx breakaway rotate-token from the checkout the board deploys from.`
93
+ : `rotate-token needs the Worker's name to confirm, so nothing was written.`,
94
+ };
95
+ }
package/scripts/tasks.mjs CHANGED
@@ -45,7 +45,7 @@ import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/pro
45
45
  import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
46
46
  import { appInPlace } from './tasks/github-connect.js';
47
47
  import { unsupportedSystem, wranglerFailure } from './tasks/platform.js';
48
- import { checkInstall } from './tasks/install-check.js';
48
+ import { checkInstall, confirmInstall, tokenTarget, unverifiedInstall } from './tasks/install-check.js';
49
49
  import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
50
50
  import {
51
51
  CLI_PACKAGE,
@@ -145,6 +145,28 @@ async function ensureBoardInstall(command, health = null) {
145
145
  if (result.warning) console.error(`tasks: ${result.warning}`);
146
146
  }
147
147
 
148
+ /**
149
+ * rotate-token without a token the board accepts (CLI-5): it can't ask the board which install it is, so it says
150
+ * where it would write, and the owner names the Worker (or the Secrets Store) with --worker or when asked.
151
+ */
152
+ async function confirmUnverifiedInstall(hadToken) {
153
+ const local = installConfig();
154
+ const where = { configFile: tildePath(CONFIG_FILE, homedir()) };
155
+ console.log(unverifiedInstall(local, { ...where, token: hadToken }));
156
+ let answer = opts.worker;
157
+ if (answer === undefined) {
158
+ if (!process.stdin.isTTY) fail(`${NO_TERMINAL}, or pass --worker <name>.`);
159
+ const { createInterface } = await import('node:readline');
160
+ answer = await askIn("The Worker's name: ", {
161
+ input: process.stdin,
162
+ output: process.stdout,
163
+ createInterface,
164
+ }).catch((error) => fail(error.message));
165
+ }
166
+ const confirmed = confirmInstall(local, answer, where);
167
+ if (!confirmed.ok) fail(confirmed.message);
168
+ }
169
+
148
170
  /** Stops `command` before it does anything on a system the install doesn't support: Windows outside WSL (CLI-3). */
149
171
  function ensureSupportedSystem(command) {
150
172
  const message = unsupportedSystem(command, process.platform);
@@ -304,8 +326,9 @@ Working
304
326
 
305
327
  Setup (owner)
306
328
  setup connect this machine's Taskwarrior (writes taskrc in this machine's folder for the board, with every repository's report and context)
307
- rotate-sync new client ID and sync secret; the server re-encrypts its history
308
- rotate-token new API token; every browser is signed out
329
+ rotate-sync new client ID and sync secret; the server re-encrypts its history (the old secret isn't needed)
330
+ rotate-token new API token; every browser is signed out. Works without the old one: you confirm the install
331
+ by the Worker's name, asked for or given with --worker <name>
309
332
  github-connect <code> store the GitHub App's keys (the board's GitHub view gives the code); refuses when the board
310
333
  already has an App, unless --replace
311
334
  agents-connect store the agent routine's URL and token (docs/tasks.md#cloud-agents-from-the-board)
@@ -1643,12 +1666,17 @@ const commands = {
1643
1666
  /**
1644
1667
  * Owner: new client ID and sync secret. The server re-encrypts its whole history under the new
1645
1668
  * key in one step and keeps every version ID, so replicas carry on once they have the new
1646
- * values. The new values are saved before the server switches, so they can't be lost.
1669
+ * values. The new values are saved before the server switches, so they can't be lost. The old
1670
+ * secret isn't needed: the server re-encrypts with the key it holds, so this is also how an
1671
+ * owner who lost the secret everywhere gets Taskwarrior sync back (CLI-5).
1647
1672
  */
1648
1673
  async 'rotate-sync'() {
1649
1674
  ensureSupportedSystem('rotate-sync');
1650
1675
  const env = readEnvFile();
1651
- if (!readSetting('SECRET', { file: env })) fail(`${ENV_FILE} has no sync secret; this is for the owner's machine.`);
1676
+ if (!readSetting('SECRET', { file: env }))
1677
+ console.log(
1678
+ `${ENV_FILE} has no sync secret. That's fine: the board re-encrypts its history with the key it holds, and this writes new values.`,
1679
+ );
1652
1680
  const health = await call('GET', 'health');
1653
1681
  if (!health.ok) fail(`the server can't read its history (${health.replicaError}); fix that first (docs/tasks.md).`);
1654
1682
  await ensureBoardInstall('rotate-sync', health);
@@ -1679,10 +1707,23 @@ const commands = {
1679
1707
  ].join('\n'),
1680
1708
  );
1681
1709
  },
1682
- /** Owner: a new API token. Every browser is signed out; cloud environments need the new one. */
1710
+ /**
1711
+ * Owner: a new API token. Every browser is signed out; cloud environments need the new one. With the
1712
+ * old token lost, or refused, the board can't say which install it is, so the owner confirms the
1713
+ * one the checkout's config names (CLI-5): --worker <name>, or typed when asked.
1714
+ */
1683
1715
  async 'rotate-token'() {
1684
1716
  ensureSupportedSystem('rotate-token');
1685
- await ensureBoardInstall('rotate-token');
1717
+ const old = setting('TOKEN');
1718
+ let res;
1719
+ try {
1720
+ res = await fetch(`${BASE}/api/health`, { headers: old ? { Authorization: `Bearer ${old}` } : {} });
1721
+ } catch (error) {
1722
+ fail(`can't reach ${BASE} (${reasonOf(error)}), so nothing was changed.`);
1723
+ }
1724
+ if (res.status === 401) await confirmUnverifiedInstall(Boolean(old));
1725
+ else if (!res.ok) fail(`the board answered HTTP ${res.status} to health, so nothing was changed.`);
1726
+ else await ensureBoardInstall('rotate-token', await res.json());
1686
1727
  const env = readEnvFile();
1687
1728
  const token = randomBytes(32).toString('base64url');
1688
1729
  writePrivate(`${ENV_FILE}.next`, envFile({ ...env, [envName('TOKEN')]: token }));
@@ -1704,7 +1745,7 @@ const commands = {
1704
1745
  await new Promise((r) => setTimeout(r, 2000));
1705
1746
  }
1706
1747
  fail(
1707
- "the Secrets Store has the new token, but the server doesn't accept it yet. Try npx breakaway health in a minute.",
1748
+ `${tokenTarget(installConfig())} has the new token, but the server doesn't accept it yet. Try npx breakaway health in a minute.`,
1708
1749
  );
1709
1750
  },
1710
1751
  /** Owner, once for a new board: fresh credentials in tasks.env (the Secrets Store comes next). */