@clearlysh/beehaven 0.8.23 → 0.8.25

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/cli.js CHANGED
@@ -1302,7 +1302,25 @@ async function run() {
1302
1302
  await shorthandCmd(rawVerb, process.argv.slice(3), {});
1303
1303
  return;
1304
1304
  }
1305
+ /**
1306
+ * ⚠ `--x -3000` IS A NEGATIVE COORDINATE, NOT A FLAG. node's parseArgs reads any value starting
1307
+ * with a dash as a missing argument and dies with "Option '--x' argument is ambiguous" — which is
1308
+ * how `beehaven canvas add-image … --x -3000` failed (CDS-25). Join a negative NUMBER onto the
1309
+ * coordinate/size flag before it; nothing else is rewritten.
1310
+ */
1311
+ const NUMERIC_FLAGS = new Set(['--x', '--y', '--width', '--height', '--w', '--h', '--dx', '--dy']);
1312
+ const argv = [];
1313
+ for (let i = 2; i < process.argv.length; i++) {
1314
+ const a = process.argv[i], b = process.argv[i + 1];
1315
+ if (NUMERIC_FLAGS.has(a) && typeof b === 'string' && /^-\d+(\.\d+)?$/.test(b)) {
1316
+ argv.push(`${a}=${b}`);
1317
+ i++;
1318
+ continue;
1319
+ }
1320
+ argv.push(a);
1321
+ }
1305
1322
  const { values, positionals } = parseArgs({
1323
+ args: argv,
1306
1324
  allowPositionals: true,
1307
1325
  options: {
1308
1326
  project: { type: 'string' },
@@ -2922,7 +2940,7 @@ async function run() {
2922
2940
  console.error(` beehaven start --headless`);
2923
2941
  console.error(``);
2924
2942
  console.error(` The Mac tray is only required for the LOCAL hive — avatar`);
2925
- 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')}`);
2926
2944
  process.exit(1);
2927
2945
  }
2928
2946
  console.log(` Launching ${opened}…`);
@@ -2949,11 +2967,24 @@ async function run() {
2949
2967
  // omitted BEEHAVEN_HEADLESS=1, so the "restarted" daemon hit the
2950
2968
  // parent-death watchdog and exited ~3s later — restart reported a PID and
2951
2969
  // left you with nothing running. See spawnStandaloneDaemon's comment.
2952
- const { spawnStandaloneDaemon } = await import('./commands/daemon-cmd.js');
2970
+ const { spawnStandaloneDaemon, waitForServingDaemon } = await import('./commands/daemon-cmd.js');
2953
2971
  const { resolveRelayUrlForDaemon } = await import('./daemon.js');
2954
2972
  const relayUrl = resolveRelayUrlForDaemon();
2955
2973
  const pid = spawnStandaloneDaemon(values);
2956
- console.log(` Daemon restarted (PID ${pid}).`);
2974
+ // ⚠ Report the daemon that is ACTUALLY serving — see waitForServingDaemon.
2975
+ const served = await waitForServingDaemon(pid);
2976
+ if (served.ready && served.spawnedAlive) {
2977
+ console.log(` Daemon restarted (PID ${pid}). Ready — socket answering.`);
2978
+ }
2979
+ else if (served.ready) {
2980
+ console.log(` Another supervisor (the Clearly app) restarted the daemon first — PID ${served.livePids.join(', ')} is serving. Ready.`);
2981
+ }
2982
+ else {
2983
+ console.error(served.livePids.length
2984
+ ? ` Daemon process is up (PID ${served.livePids.join(', ')}) but its socket is not answering yet. 'beehaven status' shows state.`
2985
+ : ` The daemon did not stay up (spawned PID ${pid} exited). Run 'beehaven start --headless', or reopen the Clearly app.`);
2986
+ process.exitCode = 1;
2987
+ }
2957
2988
  console.log(` Relay: ${color.dim(relayUrl)}`);
2958
2989
  break;
2959
2990
  }
@@ -2974,272 +3005,20 @@ async function run() {
2974
3005
  await login(emailArg, password);
2975
3006
  break;
2976
3007
  }
2977
- // ─── Localhost loopback OAuth (firebase-tools / wrangler /
2978
- // gh / vercel / fly / supabase pattern) ─────────────────
2979
- //
2980
- // The CLI starts an ephemeral HTTP server on a free port,
2981
- // opens the user's browser to clearly.sh/cli/auth?state=…
2982
- // &redirect=http://127.0.0.1:<port>/callback. After the user
2983
- // signs in + clicks Approve, the web page POSTs the user's
2984
- // Firebase tokens directly to the loopback. The CLI captures,
2985
- // validates state, stores in keychain + config, and shuts
2986
- // down the server. Single round-trip, instant feedback.
2987
- //
2988
- // The clearly.sh web origin and the worker are NOT in the
2989
- // token-exchange path — tokens go from the browser's authed
2990
- // session straight to localhost. State (anti-CSRF) prevents
2991
- // a malicious site from sending tokens to a CLI that didn't
2992
- // request them.
2993
- const http = await import('http');
2994
- const netMod = await import('net');
2995
- const childProcess = await import('child_process');
2996
- const cryptoMod = await import('crypto');
2997
- const state = cryptoMod.randomBytes(16).toString('hex');
2998
- // Resolve the web origin from the active env so staging
2999
- // users sign in against staging's Pages alias (or local
3000
- // dev), prod users against prod.
3001
- const ENV_AUTH_ORIGINS = {
3002
- prod: 'https://clearly.sh',
3003
- staging: 'https://localhost:3000',
3004
- dev: 'https://localhost:3000',
3005
- };
3006
- let activeEnv = 'prod';
3007
- try {
3008
- const cfgPath = join(homedir(), '.beehaven', 'config.json');
3009
- if (existsSync(cfgPath)) {
3010
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
3011
- // Derive env from the canonical endpoint, not the cfg.envName
3012
- // alias (a fresh login / Mac-app install may write only `endpoint`).
3013
- const { envName } = resolveRelaySelection(cfg);
3014
- if (ENV_AUTH_ORIGINS[envName])
3015
- activeEnv = envName;
3016
- }
3017
- }
3018
- catch { }
3019
- const authOrigin = process.env.BEEHAVEN_AUTH_ORIGIN ?? ENV_AUTH_ORIGINS[activeEnv];
3020
- // Pre-warning: non-prod env requires a local dev server.
3021
- if (activeEnv !== 'prod') {
3022
- const probe = await new Promise((resolve) => {
3023
- const t = setTimeout(() => resolve(false), 800);
3024
- const sock = netMod.createConnection(3000, '127.0.0.1');
3025
- sock.once('connect', () => {
3026
- clearTimeout(t);
3027
- sock.destroy();
3028
- resolve(true);
3029
- });
3030
- sock.once('error', () => {
3031
- clearTimeout(t);
3032
- resolve(false);
3033
- });
3034
- }).catch(() => false);
3035
- if (!probe) {
3036
- console.log('');
3037
- console.log(` ${color.yellow('!')} localhost:3000 isn't responding.`);
3038
- console.log(` Run ${color.cyan('npm run dev')} (with --experimental-https) in the clearly monorepo first.`);
3039
- console.log('');
3040
- }
3041
- }
3042
- // Spin up the loopback server. OS picks a free port (port 0).
3043
- const captured = await new Promise((resolve, reject) => {
3044
- // Tiny success page the browser sees AFTER it navigates to
3045
- // the loopback. Self-contained — no external assets,
3046
- // works offline. Auto-close after 1.5s on supported
3047
- // browsers (Chrome/Firefox allow window.close on
3048
- // self-opened or scripted-navigated tabs).
3049
- const successHtml = `<!doctype html>
3050
- <html><head><meta charset="utf-8"><title>beehaven CLI signed in</title>
3051
- <style>
3052
- body { font: 15px -apple-system, sans-serif; background: #fafaf9; color: #292524;
3053
- display: grid; place-items: center; min-height: 100vh; margin: 0; }
3054
- .card { background: white; padding: 32px 40px; border-radius: 14px;
3055
- box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
3056
- max-width: 420px; }
3057
- h1 { font-size: 20px; margin: 0 0 8px; letter-spacing: -0.01em; }
3058
- p { color: #57534e; margin: 8px 0; }
3059
- .ok { color: #16a34a; }
3060
- .eyebrow { font-size: 10px; letter-spacing: .22em; color: #a8a29e;
3061
- text-transform: uppercase; margin-bottom: 8px; }
3062
- </style></head>
3063
- <body><div class="card">
3064
- <p class="eyebrow">01 — beehaven CLI</p>
3065
- <h1><span class="ok">✓</span> Signed in</h1>
3066
- <p>The CLI now has your credentials. You can close this tab and
3067
- return to your terminal.</p>
3068
- </div>
3069
- <script>setTimeout(() => { try { window.close(); } catch (e) {} }, 1500);</script>
3070
- </body></html>`;
3071
- const errorHtml = (msg) => `<!doctype html>
3072
- <html><head><meta charset="utf-8"><title>beehaven CLI error</title>
3073
- <style>body{font:15px -apple-system,sans-serif;padding:40px;color:#292524}
3074
- .err{color:#dc2626}</style></head>
3075
- <body><h1 class="err">✗ Sign-in error</h1><p>${msg}</p>
3076
- <p><small>Run <code>beehaven login</code> again from your terminal.</small></p>
3077
- </body></html>`;
3078
- const server = http.createServer((req, res) => {
3079
- if (req.method !== 'GET' || !(req.url ?? '').startsWith('/callback')) {
3080
- res.writeHead(404, { 'Content-Type': 'text/plain' });
3081
- res.end('Not Found');
3082
- return;
3083
- }
3084
- // Parse query params from the GET URL. Tokens arrive
3085
- // as ?state=…&idToken=…&refreshToken=…&userId=…
3086
- // because the web side window.location.assign'd here
3087
- // — fetch() from HTTPS to HTTP loopback is mixed-
3088
- // content blocked by every browser since 2020, so
3089
- // top-level navigation is the standard pattern (gh,
3090
- // wrangler, vercel, firebase-tools all use this).
3091
- const url = new URL(req.url, `http://127.0.0.1`);
3092
- const presentedState = url.searchParams.get('state') ?? '';
3093
- const idToken = url.searchParams.get('idToken') ?? '';
3094
- const refreshToken = url.searchParams.get('refreshToken') ?? '';
3095
- const userId = url.searchParams.get('userId') ?? '';
3096
- const email = url.searchParams.get('email') ?? undefined;
3097
- const displayName = url.searchParams.get('displayName') ?? undefined;
3098
- if (presentedState !== state) {
3099
- res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
3100
- res.end(errorHtml("State mismatch — the CLI didn't initiate this sign-in."));
3101
- return;
3102
- }
3103
- if (!idToken || !refreshToken || !userId) {
3104
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
3105
- res.end(errorHtml('Missing token fields. Try <code>beehaven login</code> again.'));
3106
- return;
3107
- }
3108
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
3109
- res.end(successHtml);
3110
- // Tear down the server after the response flushes so
3111
- // the browser has a chance to render the success
3112
- // page before the connection drops.
3113
- setTimeout(() => {
3114
- try {
3115
- server.close();
3116
- }
3117
- catch { }
3118
- }, 500);
3119
- resolve({ idToken, refreshToken, userId, email, displayName });
3120
- });
3121
- server.on('error', reject);
3122
- // 5-minute upper bound so a forgotten browser tab doesn't
3123
- // hang the terminal forever.
3124
- const timeout = setTimeout(() => {
3125
- try {
3126
- server.close();
3127
- }
3128
- catch { }
3129
- reject(new Error('Sign-in timed out after 5 minutes.'));
3130
- }, 5 * 60 * 1000);
3131
- server.on('close', () => clearTimeout(timeout));
3132
- server.listen(0, '127.0.0.1', () => {
3133
- const addr = server.address();
3134
- const port = addr && typeof addr === 'object' ? addr.port : 0;
3135
- const callbackUrl = `http://127.0.0.1:${port}/callback`;
3136
- const url = withUtm(`${authOrigin}/cli/auth?state=${state}&redirect=${encodeURIComponent(callbackUrl)}`);
3137
- console.log('');
3138
- console.log(` ${color.cyan('→')} Opening browser to ${authOrigin}/cli/auth …`);
3139
- console.log(` ${color.dim("If it doesn't open: ")}${color.dim(url)}`);
3140
- console.log(` ${color.dim('Listening on ' + callbackUrl + ' (5-min timeout).')}`);
3141
- console.log('');
3142
- try {
3143
- // Best-effort browser open. macOS `open` dispatches
3144
- // to the user's default browser; xdg-open on Linux;
3145
- // start on Windows.
3146
- const opener = process.platform === 'darwin'
3147
- ? 'open'
3148
- : process.platform === 'win32'
3149
- ? 'start'
3150
- : 'xdg-open';
3151
- const proc = childProcess.spawn(opener, [url], {
3152
- stdio: 'ignore',
3153
- detached: true,
3154
- });
3155
- proc.unref();
3156
- proc.on('error', (err) => {
3157
- // Surface — silent failure was the bug. If
3158
- // `open` isn't on PATH (e.g. unusual env),
3159
- // tell the user so they can paste manually.
3160
- console.log(` ${color.yellow('!')} Couldn't auto-open browser: ${err.message}`);
3161
- console.log(` Paste this URL into your browser instead:`);
3162
- console.log(` ${color.cyan(url)}`);
3163
- });
3164
- }
3165
- catch (err) {
3166
- console.log(` ${color.yellow('!')} Couldn't spawn opener: ${err?.message ?? err}`);
3167
- console.log(` Paste this URL into your browser instead:`);
3168
- console.log(` ${color.cyan(url)}`);
3169
- }
3170
- });
3171
- });
3172
- // Persist the captured tokens. Mirror the Mac app's
3173
- // installDaemon flow exactly: keychain stores the **idToken**
3174
- // under service='beehaven', account='daemon-token' (because
3175
- // that's what relay.ts:Relay.loadConfig reads on every
3176
- // daemon launch — line 116). The refresh token stays in
3177
- // config.json where relay.ts:refreshIdToken reads it from.
3178
- //
3179
- // Earlier this code was storing the refreshToken in the
3180
- // keychain — wrong slot. The daemon then tried to authenticate
3181
- // its WebSocket with the refresh token (treating it as an ID
3182
- // token), got 401, kicked off a refresh that succeeded but
3183
- // wrote the new idToken to config.json (not keychain), so on
3184
- // the next daemon restart the loop repeated. Storing idToken
3185
- // in keychain breaks the loop.
3186
- const { keychain } = await import('./keychain.js');
3187
- try {
3188
- keychain.setToken(captured.idToken);
3189
- }
3190
- catch { }
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;
3191
3016
  try {
3192
- // Reuse saveCredentials() for the email/identity record.
3193
- // Tokens themselves go to keychain (above). Plain-file
3194
- // copy lives in config.json so the daemon can identify
3195
- // the user without unlocking the keychain on every read.
3196
- const { existsSync, mkdirSync, writeFileSync } = await import('fs');
3197
- const cfgDir = join(homedir(), '.beehaven');
3198
- if (!existsSync(cfgDir))
3199
- mkdirSync(cfgDir, { recursive: true, mode: 0o700 });
3200
- const cfgPath = join(cfgDir, 'config.json');
3201
- let existing = {};
3202
- if (existsSync(cfgPath)) {
3203
- try {
3204
- existing = JSON.parse(readFileSync(cfgPath, 'utf8'));
3205
- }
3206
- catch { }
3207
- }
3208
- /**
3209
- * ⚠⚠ SIGNING IN WRITES A DIAL TARGET. It used to write only credentials and lean on
3210
- * `...existing` to carry `endpoint` forward — which PRESERVES one and can never CREATE one.
3211
- * So on a config that had none (a fresh install before the Mac app's `install()` ran, or
3212
- * any config after `logout`, which deletes `endpoint` by name) login produced valid tokens
3213
- * with nothing to connect to, and the daemon reported "Token refresh failed" while the tray
3214
- * read "agent offline". A login that leaves the user unable to connect has not signed
3215
- * anyone in.
3216
- *
3217
- * ⚠ RESOLVED FROM THE CONFIG, NEVER A HARDCODED CONSTANT — `resolveRelaySelection` keys off
3218
- * whichever key is present and defaults to prod only when neither is. Pinning a literal
3219
- * here is the bug `setup-relay.ts` had: it sent every staging login to the prod relay.
3220
- *
3221
- * ⚠ BOTH KEYS, KEPT IN SYNC, exactly as `beehaven env` writes them — `endpoint` is what the
3222
- * relay client dials and `relayUrl` is the alias other surfaces read. Writing one and
3223
- * leaving the other stale is how they disagree about which environment you are signed into.
3224
- */
3225
- const { url: dialUrl } = resolveRelaySelection(existing);
3226
- const next = {
3227
- ...existing,
3228
- onboarded: true,
3229
- tier: existing.tier ?? 'connected',
3230
- userId: captured.userId,
3231
- email: captured.email ?? null,
3232
- displayName: captured.displayName ?? null,
3233
- tokenStorage: 'keychain',
3234
- refreshToken: captured.refreshToken,
3235
- token: captured.idToken,
3236
- endpoint: dialUrl,
3237
- relayUrl: dialUrl,
3238
- };
3239
- writeFileSync(cfgPath, JSON.stringify(next, null, 2), { mode: 0o600 });
3017
+ const { browserLogin } = await import('./browser-login.js');
3018
+ captured = await browserLogin();
3240
3019
  }
3241
3020
  catch (err) {
3242
- console.error(` ${color.red('✗')} Failed to write config: ${err?.message ?? err}`);
3021
+ console.error(` ${color.red('✗')} ${err?.message ?? err}`);
3243
3022
  process.exit(1);
3244
3023
  }
3245
3024
  console.log('');
@@ -3387,7 +3166,7 @@ async function run() {
3387
3166
  const signupUrl = withUtm(
3388
3167
  // Was /avatar/install, which now 301s to the homepage — a signup CTA that lands on
3389
3168
  // the marketing page loses the person it was meant to convert. /signup is live.
3390
- process.env.BEEHAVEN_SIGNUP_URL ?? 'https://clearly.sh/signup');
3169
+ process.env.BEEHAVEN_SIGNUP_URL ?? 'https://www.clearly.sh/signup');
3391
3170
  console.log('');
3392
3171
  console.log(` Sign up at ${color.cyan(signupUrl)}.`);
3393
3172
  console.log(' Then run "beehaven login" to wire the CLI to your account.');