beehaven 0.8.24 → 0.8.27
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/dist/agent-state.js +19 -0
- package/dist/browser-login.js +283 -0
- package/dist/build-channel.js +2 -2
- package/dist/cli.js +13 -265
- package/dist/commands/agent.js +186 -59
- package/dist/commands/canvas.js +1 -1
- package/dist/commands/compose.js +2 -2
- package/dist/commands/help.js +2 -0
- package/dist/commands/self-update.js +2 -2
- package/dist/commands/workspace.js +8 -3
- package/dist/daemon.js +9 -9
- package/dist/folder-sync.js +1 -1
- package/dist/local-app-manager.js +1 -1
- package/dist/setup-relay.js +20 -235
- package/package.json +1 -1
- package/skills/clearly-agent/SKILL.md +14 -3
- package/skills/clearly-canvas/SKILL.md +9 -0
- package/skills/clearly-canvas/authoring.md +4 -4
- package/skills/index.json +2 -2
package/dist/agent-state.js
CHANGED
|
@@ -421,6 +421,25 @@ export function clearAgentSession(name) {
|
|
|
421
421
|
}
|
|
422
422
|
catch { /* best-effort cleanup */ }
|
|
423
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* Forget the ended work session's id, and nothing else (AWO-225 `agent handoff`).
|
|
426
|
+
*
|
|
427
|
+
* ⚠ NOT `clearAgentSession`: that also drops `lastLoginAt`, which `agentForScope` uses to choose
|
|
428
|
+
* between identities bound to one scope. A handoff ends a stretch of work, not the sign-in, so the
|
|
429
|
+
* terminal must keep resolving to this agent.
|
|
430
|
+
*/
|
|
431
|
+
export function clearAgentSessionId(name) {
|
|
432
|
+
try {
|
|
433
|
+
const rec = loadAgent(name);
|
|
434
|
+
if (!rec)
|
|
435
|
+
return;
|
|
436
|
+
const raw = { ...rec };
|
|
437
|
+
delete raw.sessionId;
|
|
438
|
+
delete raw.session;
|
|
439
|
+
saveAgent(name, raw);
|
|
440
|
+
}
|
|
441
|
+
catch { /* best-effort cleanup */ }
|
|
442
|
+
}
|
|
424
443
|
/** The last session id we started for this agent, if any. */
|
|
425
444
|
export function loadAgentSession(name) {
|
|
426
445
|
try {
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE browser sign-in: localhost loopback OAuth against `<web origin>/cli/auth`.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ AWO-224 — THERE WERE TWO, AND ONE POINTED AT A ROUTE THAT NO LONGER EXISTS. `beehaven login`
|
|
5
|
+
* carried this flow inline in cli.ts; the daemon's interactive fallback (no token on start, or an
|
|
6
|
+
* expired session) had its own copy in setup-relay.ts that opened `/auth/beehaven?callback=` and
|
|
7
|
+
* waited for a single `token`. The web app removed that route, so the fallback opened a 404 and
|
|
8
|
+
* hung for two minutes before exiting. Both callers now use this module; the daemon fallback
|
|
9
|
+
* cannot drift from the command people actually run.
|
|
10
|
+
*
|
|
11
|
+
* The pattern is firebase-tools / wrangler / gh / vercel / fly / supabase: start an ephemeral
|
|
12
|
+
* HTTP server on a free port, open the browser to `/cli/auth?state=…&redirect=http://127.0.0.1:
|
|
13
|
+
* <port>/callback`. After the user signs in and approves, the page navigates the browser to the
|
|
14
|
+
* loopback with the Firebase tokens. The CLI validates state, stores the tokens and shuts the
|
|
15
|
+
* server down. The web origin and the Worker are NOT in the token-exchange path: tokens go from
|
|
16
|
+
* the browser's authed session straight to localhost, and state (anti-CSRF) stops a malicious site
|
|
17
|
+
* sending tokens to a CLI that did not ask for them.
|
|
18
|
+
*
|
|
19
|
+
* What a caller does AFTER signing in (restart the daemon, install agent skills) stays with the
|
|
20
|
+
* caller: the daemon fallback runs INSIDE the daemon and must not restart itself.
|
|
21
|
+
*/
|
|
22
|
+
import http from 'http';
|
|
23
|
+
import net from 'net';
|
|
24
|
+
import { spawn } from 'child_process';
|
|
25
|
+
import crypto from 'crypto';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
27
|
+
import { homedir } from 'os';
|
|
28
|
+
import { join } from 'path';
|
|
29
|
+
import { color } from './spinner.js';
|
|
30
|
+
import { resolveRelaySelection } from './relay-presets.js';
|
|
31
|
+
import { withUtm } from './utm.js';
|
|
32
|
+
import { keychain } from './keychain.js';
|
|
33
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the web origin from the active env so staging users sign in against staging's Pages
|
|
36
|
+
* alias (or local dev), prod users against prod.
|
|
37
|
+
*/
|
|
38
|
+
const ENV_AUTH_ORIGINS = {
|
|
39
|
+
prod: 'https://www.clearly.sh',
|
|
40
|
+
staging: 'https://localhost:3000',
|
|
41
|
+
dev: 'https://localhost:3000',
|
|
42
|
+
};
|
|
43
|
+
const configPath = () => join(homedir(), '.beehaven', 'config.json');
|
|
44
|
+
function readConfig() {
|
|
45
|
+
try {
|
|
46
|
+
const p = configPath();
|
|
47
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function resolveAuthOrigin(cfg = readConfig(), env = process.env) {
|
|
54
|
+
let activeEnv = 'prod';
|
|
55
|
+
try {
|
|
56
|
+
// Derive env from the canonical endpoint, not the cfg.envName alias (a fresh login / Mac-app
|
|
57
|
+
// install may write only `endpoint`).
|
|
58
|
+
const { envName } = resolveRelaySelection(cfg);
|
|
59
|
+
if (ENV_AUTH_ORIGINS[envName])
|
|
60
|
+
activeEnv = envName;
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
return { authOrigin: env.BEEHAVEN_AUTH_ORIGIN ?? ENV_AUTH_ORIGINS[activeEnv], activeEnv };
|
|
64
|
+
}
|
|
65
|
+
/** The page the web app serves for this flow: app/(auth)/cli/auth. */
|
|
66
|
+
export function cliAuthUrl(authOrigin, state, callbackUrl) {
|
|
67
|
+
return withUtm(`${authOrigin}/cli/auth?state=${state}&redirect=${encodeURIComponent(callbackUrl)}`);
|
|
68
|
+
}
|
|
69
|
+
/** Pre-warning: a non-prod env requires a local dev server. */
|
|
70
|
+
async function warnIfLocalDevDown() {
|
|
71
|
+
const probe = await new Promise((resolve) => {
|
|
72
|
+
const t = setTimeout(() => resolve(false), 800);
|
|
73
|
+
const sock = net.createConnection(3000, '127.0.0.1');
|
|
74
|
+
sock.once('connect', () => {
|
|
75
|
+
clearTimeout(t);
|
|
76
|
+
sock.destroy();
|
|
77
|
+
resolve(true);
|
|
78
|
+
});
|
|
79
|
+
sock.once('error', () => {
|
|
80
|
+
clearTimeout(t);
|
|
81
|
+
resolve(false);
|
|
82
|
+
});
|
|
83
|
+
}).catch(() => false);
|
|
84
|
+
if (!probe) {
|
|
85
|
+
console.log('');
|
|
86
|
+
console.log(` ${color.yellow('!')} localhost:3000 isn't responding.`);
|
|
87
|
+
console.log(` Run ${color.cyan('npm run dev')} (with --experimental-https) in the clearly monorepo first.`);
|
|
88
|
+
console.log('');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Best-effort browser open. macOS `open` dispatches to the user's default browser; xdg-open on
|
|
93
|
+
* Linux; start on Windows. Failure is SURFACED — silent failure was the bug — so the person can
|
|
94
|
+
* paste the URL themselves.
|
|
95
|
+
*/
|
|
96
|
+
function openInBrowser(url) {
|
|
97
|
+
try {
|
|
98
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
99
|
+
const proc = spawn(opener, [url], { stdio: 'ignore', detached: true });
|
|
100
|
+
proc.unref();
|
|
101
|
+
proc.on('error', (err) => {
|
|
102
|
+
console.log(` ${color.yellow('!')} Couldn't auto-open browser: ${err.message}`);
|
|
103
|
+
console.log(` Paste this URL into your browser instead:`);
|
|
104
|
+
console.log(` ${color.cyan(url)}`);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
console.log(` ${color.yellow('!')} Couldn't spawn opener: ${err?.message ?? err}`);
|
|
109
|
+
console.log(` Paste this URL into your browser instead:`);
|
|
110
|
+
console.log(` ${color.cyan(url)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Tiny success page the browser sees AFTER it navigates to the loopback. Self-contained — no
|
|
115
|
+
* external assets, works offline. Auto-close after 1.5s on supported browsers (Chrome/Firefox allow
|
|
116
|
+
* window.close on self-opened or scripted-navigated tabs).
|
|
117
|
+
*/
|
|
118
|
+
const successHtml = `<!doctype html>
|
|
119
|
+
<html><head><meta charset="utf-8"><title>beehaven CLI signed in</title>
|
|
120
|
+
<style>
|
|
121
|
+
body { font: 15px -apple-system, sans-serif; background: #fafaf9; color: #292524;
|
|
122
|
+
display: grid; place-items: center; min-height: 100vh; margin: 0; }
|
|
123
|
+
.card { background: white; padding: 32px 40px; border-radius: 14px;
|
|
124
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
|
|
125
|
+
max-width: 420px; }
|
|
126
|
+
h1 { font-size: 20px; margin: 0 0 8px; letter-spacing: -0.01em; }
|
|
127
|
+
p { color: #57534e; margin: 8px 0; }
|
|
128
|
+
.ok { color: #16a34a; }
|
|
129
|
+
.eyebrow { font-size: 10px; letter-spacing: .22em; color: #a8a29e;
|
|
130
|
+
text-transform: uppercase; margin-bottom: 8px; }
|
|
131
|
+
</style></head>
|
|
132
|
+
<body><div class="card">
|
|
133
|
+
<p class="eyebrow">01 — beehaven CLI</p>
|
|
134
|
+
<h1><span class="ok">✓</span> Signed in</h1>
|
|
135
|
+
<p>The CLI now has your credentials. You can close this tab and
|
|
136
|
+
return to your terminal.</p>
|
|
137
|
+
</div>
|
|
138
|
+
<script>setTimeout(() => { try { window.close(); } catch (e) {} }, 1500);</script>
|
|
139
|
+
</body></html>`;
|
|
140
|
+
const errorHtml = (msg) => `<!doctype html>
|
|
141
|
+
<html><head><meta charset="utf-8"><title>beehaven CLI error</title>
|
|
142
|
+
<style>body{font:15px -apple-system,sans-serif;padding:40px;color:#292524}
|
|
143
|
+
.err{color:#dc2626}</style></head>
|
|
144
|
+
<body><h1 class="err">✗ Sign-in error</h1><p>${msg}</p>
|
|
145
|
+
<p><small>Run <code>beehaven login</code> again from your terminal.</small></p>
|
|
146
|
+
</body></html>`;
|
|
147
|
+
/**
|
|
148
|
+
* Spin up the loopback server (the OS picks a free port), open the browser and resolve with the
|
|
149
|
+
* tokens the `/cli/auth` page hands back. `open` is injectable so the contract can be exercised
|
|
150
|
+
* without a browser.
|
|
151
|
+
*/
|
|
152
|
+
export function captureBrowserLogin(opts) {
|
|
153
|
+
const { authOrigin, timeoutMs = LOGIN_TIMEOUT_MS, open = openInBrowser } = opts;
|
|
154
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const server = http.createServer((req, res) => {
|
|
157
|
+
if (req.method !== 'GET' || !(req.url ?? '').startsWith('/callback')) {
|
|
158
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
159
|
+
res.end('Not Found');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Tokens arrive as ?state=…&idToken=…&refreshToken=…&userId=… because the web side
|
|
163
|
+
// window.location.assign'd here — fetch() from HTTPS to an HTTP loopback is mixed-content
|
|
164
|
+
// blocked by every browser since 2020, so top-level navigation is the standard pattern.
|
|
165
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
166
|
+
const presentedState = url.searchParams.get('state') ?? '';
|
|
167
|
+
const idToken = url.searchParams.get('idToken') ?? '';
|
|
168
|
+
const refreshToken = url.searchParams.get('refreshToken') ?? '';
|
|
169
|
+
const userId = url.searchParams.get('userId') ?? '';
|
|
170
|
+
const email = url.searchParams.get('email') ?? undefined;
|
|
171
|
+
const displayName = url.searchParams.get('displayName') ?? undefined;
|
|
172
|
+
if (presentedState !== state) {
|
|
173
|
+
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
174
|
+
res.end(errorHtml("State mismatch — the CLI didn't initiate this sign-in."));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!idToken || !refreshToken || !userId) {
|
|
178
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
179
|
+
res.end(errorHtml('Missing token fields. Try <code>beehaven login</code> again.'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
183
|
+
res.end(successHtml);
|
|
184
|
+
// Tear down after the response flushes so the browser can render the success page before
|
|
185
|
+
// the connection drops.
|
|
186
|
+
setTimeout(() => {
|
|
187
|
+
try {
|
|
188
|
+
server.close();
|
|
189
|
+
}
|
|
190
|
+
catch { }
|
|
191
|
+
}, 500);
|
|
192
|
+
resolve({ idToken, refreshToken, userId, email, displayName });
|
|
193
|
+
});
|
|
194
|
+
server.on('error', reject);
|
|
195
|
+
// An upper bound so a forgotten browser tab doesn't hang the terminal forever.
|
|
196
|
+
const timeout = setTimeout(() => {
|
|
197
|
+
try {
|
|
198
|
+
server.close();
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
reject(new Error(`Sign-in timed out after ${Math.round(timeoutMs / 60_000)} minutes.`));
|
|
202
|
+
}, timeoutMs);
|
|
203
|
+
server.on('close', () => clearTimeout(timeout));
|
|
204
|
+
server.listen(0, '127.0.0.1', () => {
|
|
205
|
+
const addr = server.address();
|
|
206
|
+
const port = addr && typeof addr === 'object' ? addr.port : 0;
|
|
207
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
208
|
+
const url = cliAuthUrl(authOrigin, state, callbackUrl);
|
|
209
|
+
console.log('');
|
|
210
|
+
console.log(` ${color.cyan('→')} Opening browser to ${authOrigin}/cli/auth …`);
|
|
211
|
+
console.log(` ${color.dim("If it doesn't open: ")}${color.dim(url)}`);
|
|
212
|
+
console.log(` ${color.dim('Listening on ' + callbackUrl + ` (${Math.round(timeoutMs / 60_000)}-min timeout).`)}`);
|
|
213
|
+
console.log('');
|
|
214
|
+
open(url);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Persist the captured tokens. Mirror the Mac app's installDaemon flow exactly: the keychain stores
|
|
220
|
+
* the **idToken** under service='beehaven', account='daemon-token', because that is what
|
|
221
|
+
* relay.ts:Relay.loadConfig reads on every daemon launch. The refresh token stays in config.json
|
|
222
|
+
* where relay.ts:refreshIdToken reads it.
|
|
223
|
+
*
|
|
224
|
+
* Earlier the refreshToken went into the keychain — the wrong slot. The daemon then authenticated
|
|
225
|
+
* its WebSocket with the refresh token, got 401, refreshed successfully but wrote the new idToken
|
|
226
|
+
* to config.json (not the keychain), and the loop repeated on every restart.
|
|
227
|
+
*
|
|
228
|
+
* Throws when config.json cannot be written; a sign-in that saved nothing has not signed anyone in.
|
|
229
|
+
*/
|
|
230
|
+
export function persistBrowserLogin(captured) {
|
|
231
|
+
try {
|
|
232
|
+
keychain.setToken(captured.idToken);
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
try {
|
|
236
|
+
const cfgDir = join(homedir(), '.beehaven');
|
|
237
|
+
if (!existsSync(cfgDir))
|
|
238
|
+
mkdirSync(cfgDir, { recursive: true, mode: 0o700 });
|
|
239
|
+
const existing = readConfig();
|
|
240
|
+
/**
|
|
241
|
+
* ⚠⚠ SIGNING IN WRITES A DIAL TARGET. It used to write only credentials and lean on
|
|
242
|
+
* `...existing` to carry `endpoint` forward — which PRESERVES one and can never CREATE one.
|
|
243
|
+
* So on a config that had none (a fresh install before the Mac app's `install()` ran, or any
|
|
244
|
+
* config after `logout`, which deletes `endpoint` by name) login produced valid tokens with
|
|
245
|
+
* nothing to connect to, and the daemon reported "Token refresh failed" while the tray read
|
|
246
|
+
* "agent offline". A login that leaves the user unable to connect has not signed anyone in.
|
|
247
|
+
*
|
|
248
|
+
* ⚠ RESOLVED FROM THE CONFIG, NEVER A HARDCODED CONSTANT — `resolveRelaySelection` keys off
|
|
249
|
+
* whichever key is present and defaults to prod only when neither is. Pinning a literal here is
|
|
250
|
+
* the bug the old setup-relay.ts flow had: it sent every staging login to the prod relay.
|
|
251
|
+
*
|
|
252
|
+
* ⚠ BOTH KEYS, KEPT IN SYNC, exactly as `beehaven env` writes them — `endpoint` is what the
|
|
253
|
+
* relay client dials and `relayUrl` is the alias other surfaces read.
|
|
254
|
+
*/
|
|
255
|
+
const { url: dialUrl } = resolveRelaySelection(existing);
|
|
256
|
+
const next = {
|
|
257
|
+
...existing,
|
|
258
|
+
onboarded: true,
|
|
259
|
+
tier: existing.tier ?? 'connected',
|
|
260
|
+
userId: captured.userId,
|
|
261
|
+
email: captured.email ?? null,
|
|
262
|
+
displayName: captured.displayName ?? null,
|
|
263
|
+
tokenStorage: 'keychain',
|
|
264
|
+
refreshToken: captured.refreshToken,
|
|
265
|
+
token: captured.idToken,
|
|
266
|
+
endpoint: dialUrl,
|
|
267
|
+
relayUrl: dialUrl,
|
|
268
|
+
};
|
|
269
|
+
writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
throw new Error(`Failed to write config: ${err?.message ?? err}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Resolve the origin, warn about a missing local dev server, capture and persist. */
|
|
276
|
+
export async function browserLogin() {
|
|
277
|
+
const { authOrigin, activeEnv } = resolveAuthOrigin();
|
|
278
|
+
if (activeEnv !== 'prod')
|
|
279
|
+
await warnIfLocalDevDown();
|
|
280
|
+
const captured = await captureBrowserLogin({ authOrigin });
|
|
281
|
+
persistBrowserLogin(captured);
|
|
282
|
+
return captured;
|
|
283
|
+
}
|
package/dist/build-channel.js
CHANGED
|
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
|
|
|
53
53
|
* ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
|
|
54
54
|
* carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
|
|
55
55
|
*/
|
|
56
|
-
export const BUILD_SHA = '
|
|
56
|
+
export const BUILD_SHA = 'db2b3eaeb90b';
|
|
57
57
|
/** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
|
|
58
|
-
export const BUILD_TIME = '2026-10-
|
|
58
|
+
export const BUILD_TIME = '2026-10-07T02:00Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
package/dist/cli.js
CHANGED
|
@@ -2940,7 +2940,7 @@ async function run() {
|
|
|
2940
2940
|
console.error(` beehaven start --headless`);
|
|
2941
2941
|
console.error(``);
|
|
2942
2942
|
console.error(` The Mac tray is only required for the LOCAL hive — avatar`);
|
|
2943
|
-
console.error(` building, voice and mood. Install: ${color.dim('https://clearly.sh/docs/beehaven')}`);
|
|
2943
|
+
console.error(` building, voice and mood. Install: ${color.dim('https://www.clearly.sh/docs/beehaven')}`);
|
|
2944
2944
|
process.exit(1);
|
|
2945
2945
|
}
|
|
2946
2946
|
console.log(` Launching ${opened}…`);
|
|
@@ -3005,272 +3005,20 @@ async function run() {
|
|
|
3005
3005
|
await login(emailArg, password);
|
|
3006
3006
|
break;
|
|
3007
3007
|
}
|
|
3008
|
-
// ─── Localhost loopback OAuth (
|
|
3009
|
-
//
|
|
3010
|
-
//
|
|
3011
|
-
//
|
|
3012
|
-
//
|
|
3013
|
-
//
|
|
3014
|
-
//
|
|
3015
|
-
|
|
3016
|
-
// validates state, stores in keychain + config, and shuts
|
|
3017
|
-
// down the server. Single round-trip, instant feedback.
|
|
3018
|
-
//
|
|
3019
|
-
// The clearly.sh web origin and the worker are NOT in the
|
|
3020
|
-
// token-exchange path — tokens go from the browser's authed
|
|
3021
|
-
// session straight to localhost. State (anti-CSRF) prevents
|
|
3022
|
-
// a malicious site from sending tokens to a CLI that didn't
|
|
3023
|
-
// request them.
|
|
3024
|
-
const http = await import('http');
|
|
3025
|
-
const netMod = await import('net');
|
|
3026
|
-
const childProcess = await import('child_process');
|
|
3027
|
-
const cryptoMod = await import('crypto');
|
|
3028
|
-
const state = cryptoMod.randomBytes(16).toString('hex');
|
|
3029
|
-
// Resolve the web origin from the active env so staging
|
|
3030
|
-
// users sign in against staging's Pages alias (or local
|
|
3031
|
-
// dev), prod users against prod.
|
|
3032
|
-
const ENV_AUTH_ORIGINS = {
|
|
3033
|
-
prod: 'https://clearly.sh',
|
|
3034
|
-
staging: 'https://localhost:3000',
|
|
3035
|
-
dev: 'https://localhost:3000',
|
|
3036
|
-
};
|
|
3037
|
-
let activeEnv = 'prod';
|
|
3008
|
+
// ─── Localhost loopback OAuth — ONE implementation (AWO-224) ───
|
|
3009
|
+
// `browser-login.ts` owns the loopback server, the /cli/auth URL, the
|
|
3010
|
+
// state check and where the tokens are stored. The daemon's interactive
|
|
3011
|
+
// fallback (setup-relay.ts) calls the same function; it used to carry its
|
|
3012
|
+
// own copy aimed at /auth/beehaven, a route the web app no longer serves.
|
|
3013
|
+
// What follows the sign-in — restarting the daemon, teaching the agents —
|
|
3014
|
+
// stays here, because the daemon fallback must not restart itself.
|
|
3015
|
+
let captured;
|
|
3038
3016
|
try {
|
|
3039
|
-
const
|
|
3040
|
-
|
|
3041
|
-
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
3042
|
-
// Derive env from the canonical endpoint, not the cfg.envName
|
|
3043
|
-
// alias (a fresh login / Mac-app install may write only `endpoint`).
|
|
3044
|
-
const { envName } = resolveRelaySelection(cfg);
|
|
3045
|
-
if (ENV_AUTH_ORIGINS[envName])
|
|
3046
|
-
activeEnv = envName;
|
|
3047
|
-
}
|
|
3048
|
-
}
|
|
3049
|
-
catch { }
|
|
3050
|
-
const authOrigin = process.env.BEEHAVEN_AUTH_ORIGIN ?? ENV_AUTH_ORIGINS[activeEnv];
|
|
3051
|
-
// Pre-warning: non-prod env requires a local dev server.
|
|
3052
|
-
if (activeEnv !== 'prod') {
|
|
3053
|
-
const probe = await new Promise((resolve) => {
|
|
3054
|
-
const t = setTimeout(() => resolve(false), 800);
|
|
3055
|
-
const sock = netMod.createConnection(3000, '127.0.0.1');
|
|
3056
|
-
sock.once('connect', () => {
|
|
3057
|
-
clearTimeout(t);
|
|
3058
|
-
sock.destroy();
|
|
3059
|
-
resolve(true);
|
|
3060
|
-
});
|
|
3061
|
-
sock.once('error', () => {
|
|
3062
|
-
clearTimeout(t);
|
|
3063
|
-
resolve(false);
|
|
3064
|
-
});
|
|
3065
|
-
}).catch(() => false);
|
|
3066
|
-
if (!probe) {
|
|
3067
|
-
console.log('');
|
|
3068
|
-
console.log(` ${color.yellow('!')} localhost:3000 isn't responding.`);
|
|
3069
|
-
console.log(` Run ${color.cyan('npm run dev')} (with --experimental-https) in the clearly monorepo first.`);
|
|
3070
|
-
console.log('');
|
|
3071
|
-
}
|
|
3072
|
-
}
|
|
3073
|
-
// Spin up the loopback server. OS picks a free port (port 0).
|
|
3074
|
-
const captured = await new Promise((resolve, reject) => {
|
|
3075
|
-
// Tiny success page the browser sees AFTER it navigates to
|
|
3076
|
-
// the loopback. Self-contained — no external assets,
|
|
3077
|
-
// works offline. Auto-close after 1.5s on supported
|
|
3078
|
-
// browsers (Chrome/Firefox allow window.close on
|
|
3079
|
-
// self-opened or scripted-navigated tabs).
|
|
3080
|
-
const successHtml = `<!doctype html>
|
|
3081
|
-
<html><head><meta charset="utf-8"><title>beehaven CLI signed in</title>
|
|
3082
|
-
<style>
|
|
3083
|
-
body { font: 15px -apple-system, sans-serif; background: #fafaf9; color: #292524;
|
|
3084
|
-
display: grid; place-items: center; min-height: 100vh; margin: 0; }
|
|
3085
|
-
.card { background: white; padding: 32px 40px; border-radius: 14px;
|
|
3086
|
-
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
|
|
3087
|
-
max-width: 420px; }
|
|
3088
|
-
h1 { font-size: 20px; margin: 0 0 8px; letter-spacing: -0.01em; }
|
|
3089
|
-
p { color: #57534e; margin: 8px 0; }
|
|
3090
|
-
.ok { color: #16a34a; }
|
|
3091
|
-
.eyebrow { font-size: 10px; letter-spacing: .22em; color: #a8a29e;
|
|
3092
|
-
text-transform: uppercase; margin-bottom: 8px; }
|
|
3093
|
-
</style></head>
|
|
3094
|
-
<body><div class="card">
|
|
3095
|
-
<p class="eyebrow">01 — beehaven CLI</p>
|
|
3096
|
-
<h1><span class="ok">✓</span> Signed in</h1>
|
|
3097
|
-
<p>The CLI now has your credentials. You can close this tab and
|
|
3098
|
-
return to your terminal.</p>
|
|
3099
|
-
</div>
|
|
3100
|
-
<script>setTimeout(() => { try { window.close(); } catch (e) {} }, 1500);</script>
|
|
3101
|
-
</body></html>`;
|
|
3102
|
-
const errorHtml = (msg) => `<!doctype html>
|
|
3103
|
-
<html><head><meta charset="utf-8"><title>beehaven CLI error</title>
|
|
3104
|
-
<style>body{font:15px -apple-system,sans-serif;padding:40px;color:#292524}
|
|
3105
|
-
.err{color:#dc2626}</style></head>
|
|
3106
|
-
<body><h1 class="err">✗ Sign-in error</h1><p>${msg}</p>
|
|
3107
|
-
<p><small>Run <code>beehaven login</code> again from your terminal.</small></p>
|
|
3108
|
-
</body></html>`;
|
|
3109
|
-
const server = http.createServer((req, res) => {
|
|
3110
|
-
if (req.method !== 'GET' || !(req.url ?? '').startsWith('/callback')) {
|
|
3111
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
3112
|
-
res.end('Not Found');
|
|
3113
|
-
return;
|
|
3114
|
-
}
|
|
3115
|
-
// Parse query params from the GET URL. Tokens arrive
|
|
3116
|
-
// as ?state=…&idToken=…&refreshToken=…&userId=…
|
|
3117
|
-
// because the web side window.location.assign'd here
|
|
3118
|
-
// — fetch() from HTTPS to HTTP loopback is mixed-
|
|
3119
|
-
// content blocked by every browser since 2020, so
|
|
3120
|
-
// top-level navigation is the standard pattern (gh,
|
|
3121
|
-
// wrangler, vercel, firebase-tools all use this).
|
|
3122
|
-
const url = new URL(req.url, `http://127.0.0.1`);
|
|
3123
|
-
const presentedState = url.searchParams.get('state') ?? '';
|
|
3124
|
-
const idToken = url.searchParams.get('idToken') ?? '';
|
|
3125
|
-
const refreshToken = url.searchParams.get('refreshToken') ?? '';
|
|
3126
|
-
const userId = url.searchParams.get('userId') ?? '';
|
|
3127
|
-
const email = url.searchParams.get('email') ?? undefined;
|
|
3128
|
-
const displayName = url.searchParams.get('displayName') ?? undefined;
|
|
3129
|
-
if (presentedState !== state) {
|
|
3130
|
-
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
3131
|
-
res.end(errorHtml("State mismatch — the CLI didn't initiate this sign-in."));
|
|
3132
|
-
return;
|
|
3133
|
-
}
|
|
3134
|
-
if (!idToken || !refreshToken || !userId) {
|
|
3135
|
-
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
3136
|
-
res.end(errorHtml('Missing token fields. Try <code>beehaven login</code> again.'));
|
|
3137
|
-
return;
|
|
3138
|
-
}
|
|
3139
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
3140
|
-
res.end(successHtml);
|
|
3141
|
-
// Tear down the server after the response flushes so
|
|
3142
|
-
// the browser has a chance to render the success
|
|
3143
|
-
// page before the connection drops.
|
|
3144
|
-
setTimeout(() => {
|
|
3145
|
-
try {
|
|
3146
|
-
server.close();
|
|
3147
|
-
}
|
|
3148
|
-
catch { }
|
|
3149
|
-
}, 500);
|
|
3150
|
-
resolve({ idToken, refreshToken, userId, email, displayName });
|
|
3151
|
-
});
|
|
3152
|
-
server.on('error', reject);
|
|
3153
|
-
// 5-minute upper bound so a forgotten browser tab doesn't
|
|
3154
|
-
// hang the terminal forever.
|
|
3155
|
-
const timeout = setTimeout(() => {
|
|
3156
|
-
try {
|
|
3157
|
-
server.close();
|
|
3158
|
-
}
|
|
3159
|
-
catch { }
|
|
3160
|
-
reject(new Error('Sign-in timed out after 5 minutes.'));
|
|
3161
|
-
}, 5 * 60 * 1000);
|
|
3162
|
-
server.on('close', () => clearTimeout(timeout));
|
|
3163
|
-
server.listen(0, '127.0.0.1', () => {
|
|
3164
|
-
const addr = server.address();
|
|
3165
|
-
const port = addr && typeof addr === 'object' ? addr.port : 0;
|
|
3166
|
-
const callbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
3167
|
-
const url = withUtm(`${authOrigin}/cli/auth?state=${state}&redirect=${encodeURIComponent(callbackUrl)}`);
|
|
3168
|
-
console.log('');
|
|
3169
|
-
console.log(` ${color.cyan('→')} Opening browser to ${authOrigin}/cli/auth …`);
|
|
3170
|
-
console.log(` ${color.dim("If it doesn't open: ")}${color.dim(url)}`);
|
|
3171
|
-
console.log(` ${color.dim('Listening on ' + callbackUrl + ' (5-min timeout).')}`);
|
|
3172
|
-
console.log('');
|
|
3173
|
-
try {
|
|
3174
|
-
// Best-effort browser open. macOS `open` dispatches
|
|
3175
|
-
// to the user's default browser; xdg-open on Linux;
|
|
3176
|
-
// start on Windows.
|
|
3177
|
-
const opener = process.platform === 'darwin'
|
|
3178
|
-
? 'open'
|
|
3179
|
-
: process.platform === 'win32'
|
|
3180
|
-
? 'start'
|
|
3181
|
-
: 'xdg-open';
|
|
3182
|
-
const proc = childProcess.spawn(opener, [url], {
|
|
3183
|
-
stdio: 'ignore',
|
|
3184
|
-
detached: true,
|
|
3185
|
-
});
|
|
3186
|
-
proc.unref();
|
|
3187
|
-
proc.on('error', (err) => {
|
|
3188
|
-
// Surface — silent failure was the bug. If
|
|
3189
|
-
// `open` isn't on PATH (e.g. unusual env),
|
|
3190
|
-
// tell the user so they can paste manually.
|
|
3191
|
-
console.log(` ${color.yellow('!')} Couldn't auto-open browser: ${err.message}`);
|
|
3192
|
-
console.log(` Paste this URL into your browser instead:`);
|
|
3193
|
-
console.log(` ${color.cyan(url)}`);
|
|
3194
|
-
});
|
|
3195
|
-
}
|
|
3196
|
-
catch (err) {
|
|
3197
|
-
console.log(` ${color.yellow('!')} Couldn't spawn opener: ${err?.message ?? err}`);
|
|
3198
|
-
console.log(` Paste this URL into your browser instead:`);
|
|
3199
|
-
console.log(` ${color.cyan(url)}`);
|
|
3200
|
-
}
|
|
3201
|
-
});
|
|
3202
|
-
});
|
|
3203
|
-
// Persist the captured tokens. Mirror the Mac app's
|
|
3204
|
-
// installDaemon flow exactly: keychain stores the **idToken**
|
|
3205
|
-
// under service='beehaven', account='daemon-token' (because
|
|
3206
|
-
// that's what relay.ts:Relay.loadConfig reads on every
|
|
3207
|
-
// daemon launch — line 116). The refresh token stays in
|
|
3208
|
-
// config.json where relay.ts:refreshIdToken reads it from.
|
|
3209
|
-
//
|
|
3210
|
-
// Earlier this code was storing the refreshToken in the
|
|
3211
|
-
// keychain — wrong slot. The daemon then tried to authenticate
|
|
3212
|
-
// its WebSocket with the refresh token (treating it as an ID
|
|
3213
|
-
// token), got 401, kicked off a refresh that succeeded but
|
|
3214
|
-
// wrote the new idToken to config.json (not keychain), so on
|
|
3215
|
-
// the next daemon restart the loop repeated. Storing idToken
|
|
3216
|
-
// in keychain breaks the loop.
|
|
3217
|
-
const { keychain } = await import('./keychain.js');
|
|
3218
|
-
try {
|
|
3219
|
-
keychain.setToken(captured.idToken);
|
|
3220
|
-
}
|
|
3221
|
-
catch { }
|
|
3222
|
-
try {
|
|
3223
|
-
// Reuse saveCredentials() for the email/identity record.
|
|
3224
|
-
// Tokens themselves go to keychain (above). Plain-file
|
|
3225
|
-
// copy lives in config.json so the daemon can identify
|
|
3226
|
-
// the user without unlocking the keychain on every read.
|
|
3227
|
-
const { existsSync, mkdirSync, writeFileSync } = await import('fs');
|
|
3228
|
-
const cfgDir = join(homedir(), '.beehaven');
|
|
3229
|
-
if (!existsSync(cfgDir))
|
|
3230
|
-
mkdirSync(cfgDir, { recursive: true, mode: 0o700 });
|
|
3231
|
-
const cfgPath = join(cfgDir, 'config.json');
|
|
3232
|
-
let existing = {};
|
|
3233
|
-
if (existsSync(cfgPath)) {
|
|
3234
|
-
try {
|
|
3235
|
-
existing = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
3236
|
-
}
|
|
3237
|
-
catch { }
|
|
3238
|
-
}
|
|
3239
|
-
/**
|
|
3240
|
-
* ⚠⚠ SIGNING IN WRITES A DIAL TARGET. It used to write only credentials and lean on
|
|
3241
|
-
* `...existing` to carry `endpoint` forward — which PRESERVES one and can never CREATE one.
|
|
3242
|
-
* So on a config that had none (a fresh install before the Mac app's `install()` ran, or
|
|
3243
|
-
* any config after `logout`, which deletes `endpoint` by name) login produced valid tokens
|
|
3244
|
-
* with nothing to connect to, and the daemon reported "Token refresh failed" while the tray
|
|
3245
|
-
* read "agent offline". A login that leaves the user unable to connect has not signed
|
|
3246
|
-
* anyone in.
|
|
3247
|
-
*
|
|
3248
|
-
* ⚠ RESOLVED FROM THE CONFIG, NEVER A HARDCODED CONSTANT — `resolveRelaySelection` keys off
|
|
3249
|
-
* whichever key is present and defaults to prod only when neither is. Pinning a literal
|
|
3250
|
-
* here is the bug `setup-relay.ts` had: it sent every staging login to the prod relay.
|
|
3251
|
-
*
|
|
3252
|
-
* ⚠ BOTH KEYS, KEPT IN SYNC, exactly as `beehaven env` writes them — `endpoint` is what the
|
|
3253
|
-
* relay client dials and `relayUrl` is the alias other surfaces read. Writing one and
|
|
3254
|
-
* leaving the other stale is how they disagree about which environment you are signed into.
|
|
3255
|
-
*/
|
|
3256
|
-
const { url: dialUrl } = resolveRelaySelection(existing);
|
|
3257
|
-
const next = {
|
|
3258
|
-
...existing,
|
|
3259
|
-
onboarded: true,
|
|
3260
|
-
tier: existing.tier ?? 'connected',
|
|
3261
|
-
userId: captured.userId,
|
|
3262
|
-
email: captured.email ?? null,
|
|
3263
|
-
displayName: captured.displayName ?? null,
|
|
3264
|
-
tokenStorage: 'keychain',
|
|
3265
|
-
refreshToken: captured.refreshToken,
|
|
3266
|
-
token: captured.idToken,
|
|
3267
|
-
endpoint: dialUrl,
|
|
3268
|
-
relayUrl: dialUrl,
|
|
3269
|
-
};
|
|
3270
|
-
writeFileSync(cfgPath, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
3017
|
+
const { browserLogin } = await import('./browser-login.js');
|
|
3018
|
+
captured = await browserLogin();
|
|
3271
3019
|
}
|
|
3272
3020
|
catch (err) {
|
|
3273
|
-
console.error(` ${color.red('✗')}
|
|
3021
|
+
console.error(` ${color.red('✗')} ${err?.message ?? err}`);
|
|
3274
3022
|
process.exit(1);
|
|
3275
3023
|
}
|
|
3276
3024
|
console.log('');
|
|
@@ -3418,7 +3166,7 @@ async function run() {
|
|
|
3418
3166
|
const signupUrl = withUtm(
|
|
3419
3167
|
// Was /avatar/install, which now 301s to the homepage — a signup CTA that lands on
|
|
3420
3168
|
// the marketing page loses the person it was meant to convert. /signup is live.
|
|
3421
|
-
process.env.BEEHAVEN_SIGNUP_URL ?? 'https://clearly.sh/signup');
|
|
3169
|
+
process.env.BEEHAVEN_SIGNUP_URL ?? 'https://www.clearly.sh/signup');
|
|
3422
3170
|
console.log('');
|
|
3423
3171
|
console.log(` Sign up at ${color.cyan(signupUrl)}.`);
|
|
3424
3172
|
console.log(' Then run "beehaven login" to wire the CLI to your account.');
|