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 +1 -1
- package/scripts/tasks/install-check.js +41 -0
- package/scripts/tasks.mjs +20 -0
- package/src/install.js +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.4.0-main.
|
|
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
|
},
|