breakaway 1.4.0-main.50 → 1.4.0-main.52

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.50",
3
+ "version": "1.4.0-main.52",
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,50 @@
1
+ /**
2
+ * Before github-connect trades its code: whether the board already has a GitHub App (CLI-2). Each code comes
3
+ * from a new App on GitHub, so a second run on a board that has one would overwrite the keys of the App that works
4
+ * with a second App's. Read from the board's Connections report (`GET /api/connections`, its `github.app`
5
+ * row). Pure, so it's tested without a board.
6
+ */
7
+
8
+ /**
9
+ * `ok` false stops the command before the code is traded (it still works, within its hour); `note` is what
10
+ * a run that goes ahead says first.
11
+ *
12
+ * @param {{ connections?: { id: string, state: string, detail?: string, at?: string | null, link?: string | null }[] } | null | undefined} report
13
+ * @param {{ replace: boolean }} options
14
+ * @returns {{ ok: boolean, message?: string, note?: string }}
15
+ */
16
+ export function appInPlace(report, { replace }) {
17
+ const row = Array.isArray(report?.connections) ? report.connections.find((c) => c.id === 'github.app') : undefined;
18
+ if (!row)
19
+ return replace
20
+ ? {
21
+ ok: true,
22
+ note: "Couldn't ask the board whether it already has a GitHub App; connecting a new one (--replace).",
23
+ }
24
+ : {
25
+ ok: false,
26
+ message:
27
+ "couldn't ask the board whether it already has a GitHub App, so nothing was changed; the code still works within its hour. Check the board answers (npx breakaway connections), then run this again; or add --replace to connect a new App whatever is there.",
28
+ };
29
+ if (row.state === 'off') return { ok: true };
30
+ const what = `${row.detail}${row.link ? `, ${row.link}` : ''}`;
31
+ const deleteOld = `Once the new one works, delete the old one on GitHub (Settings → Developer settings → GitHub Apps)${row.link ? `: ${row.link}` : ''}.`;
32
+ if (replace) return { ok: true, note: `Replacing the board's GitHub App (${what}). ${deleteOld}` };
33
+ // Checked, and GitHub refused it: it doesn't work, so a new one takes its place.
34
+ if (row.state === 'attention' && row.at)
35
+ return {
36
+ ok: true,
37
+ note: `The board's GitHub App doesn't work (${row.detail}); connecting the new one in its place. ${deleteOld}`,
38
+ };
39
+ const kept =
40
+ "Nothing was changed: this code's App would replace that one. If you made the new App by mistake, delete it on GitHub (Settings → Developer settings → GitHub Apps). To switch the board to it, run npx breakaway github-connect <code> --replace within the code's hour, then delete the old one.";
41
+ if (row.state === 'working')
42
+ return {
43
+ ok: false,
44
+ message: `the board already has a working GitHub App: ${what}. ${kept}`,
45
+ };
46
+ return {
47
+ ok: false,
48
+ message: `the board already has a GitHub App's keys (${what}); press Check now on its Connections view to see whether it works. ${kept}`,
49
+ };
50
+ }
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 { appInPlace } from './tasks/github-connect.js';
45
46
  import { checkInstall } from './tasks/install-check.js';
46
47
  import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
47
48
  import {
@@ -296,10 +297,11 @@ Setup (owner)
296
297
  setup connect this machine's Taskwarrior (writes taskrc in this machine's folder for the board, with every repository's report and context)
297
298
  rotate-sync new client ID and sync secret; the server re-encrypts its history
298
299
  rotate-token new API token; every browser is signed out
299
- github-connect <code> store the GitHub App's keys (the board's GitHub view gives the code)
300
+ github-connect <code> store the GitHub App's keys (the board's GitHub view gives the code); refuses when the board
301
+ already has an App, unless --replace
300
302
  agents-connect store the agent routine's URL and token (docs/tasks.md#cloud-agents-from-the-board)
301
303
  --repo <slug> connects another repository's routine; --replace drops ones this machine doesn't hold
302
- init-secrets once, for a brand-new board
304
+ init-secrets once, for a brand-new board; --force starts again, keeping the old file as a .bak
303
305
 
304
306
  Install repository (no board needed: the files and steps that deploy a board from its own repository)
305
307
  install init [dir] write an install repository into <dir> (default: here): its config, breakaway.json, the Deploy and
@@ -1727,6 +1729,12 @@ const commands = {
1727
1729
  const code = need(args[0], 'code');
1728
1730
  // Before the code is traded: it works once, and the keys it gives have to go into the board's own secrets.
1729
1731
  await ensureBoardInstall('github-connect');
1732
+ // Each code comes from a new App: on a board that has one, it would replace the working App's keys (CLI-2).
1733
+ const inPlace = appInPlace(await call('GET', 'connections', undefined, { soft: true }), {
1734
+ replace: Boolean(opts.replace),
1735
+ });
1736
+ if (!inPlace.ok) fail(inPlace.message);
1737
+ if (inPlace.note) console.log(inPlace.note);
1730
1738
  const res = await fetch(`https://api.github.com/app-manifests/${enc(code)}/conversions`, {
1731
1739
  method: 'POST',
1732
1740
  headers: {
@@ -1775,6 +1783,9 @@ const commands = {
1775
1783
  const install = installConfig();
1776
1784
  // A new install has no address until it's deployed (CLD-139).
1777
1785
  const known = BOARD.from !== 'default' || install.url !== null;
1786
+ // --force never loses the only copy of the sync secret: the old file is kept the way rotations keep it (CLI-2).
1787
+ const kept = backupEnvFile();
1788
+ if (kept) console.log(`Kept the old one as ${kept} (0600).`);
1778
1789
  writePrivate(
1779
1790
  ENV_FILE,
1780
1791
  envFile({
@@ -2226,9 +2237,18 @@ function writePrivate(path, content) {
2226
2237
  chmodSync(path, 0o600);
2227
2238
  }
2228
2239
 
2240
+ /** Moves tasks.env aside as tasks.env.<date>.bak (0600), and says where; null when there's none. */
2241
+ function backupEnvFile() {
2242
+ if (!existsSync(ENV_FILE)) return null;
2243
+ const backup = `${ENV_FILE}.${new Date().toISOString().replace(/[:.]/gu, '-')}.bak`;
2244
+ renameSync(ENV_FILE, backup);
2245
+ chmodSync(backup, 0o600);
2246
+ return backup;
2247
+ }
2248
+
2229
2249
  /** tasks.env.next becomes tasks.env; the old one is kept as tasks.env.<date>.bak. */
2230
2250
  function promoteEnvFile() {
2231
- if (existsSync(ENV_FILE)) renameSync(ENV_FILE, `${ENV_FILE}.${new Date().toISOString().replace(/[:.]/gu, '-')}.bak`);
2251
+ backupEnvFile();
2232
2252
  renameSync(`${ENV_FILE}.next`, ENV_FILE);
2233
2253
  }
2234
2254