happy-stacks 0.1.0 → 0.2.0

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.
Files changed (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -0
  64. package/scripts/utils/dev_daemon.mjs +104 -0
  65. package/scripts/utils/dev_expo_web.mjs +112 -0
  66. package/scripts/utils/dev_server.mjs +183 -0
  67. package/scripts/utils/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/build.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
4
4
  import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/pm.mjs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { readFile, rm, mkdir, writeFile } from 'node:fs/promises';
7
7
  import { tailscaleServeHttpsUrl } from './tailscale.mjs';
8
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
9
9
 
10
10
  /**
11
11
  * Build a lightweight static web UI bundle (no Expo dev server).
@@ -22,17 +22,24 @@ async function main() {
22
22
  if (wantsHelp(argv, { flags })) {
23
23
  printResult({
24
24
  json,
25
- data: { flags: ['--tauri', '--no-tauri'], json: true },
25
+ data: { flags: ['--tauri', '--no-tauri', '--no-ui'], json: true },
26
26
  text: [
27
27
  '[build] usage:',
28
28
  ' happys build [--tauri] [--json]',
29
29
  ' (legacy in a cloned repo): pnpm build [-- --tauri] [--json]',
30
- ' node scripts/build.mjs [--tauri|--no-tauri] [--json]',
30
+ ' node scripts/build.mjs [--tauri|--no-tauri] [--no-ui] [--json]',
31
31
  ].join('\n'),
32
32
  });
33
33
  return;
34
34
  }
35
35
  const rootDir = getRootDir(import.meta.url);
36
+
37
+ // Optional: skip building the web UI bundle.
38
+ //
39
+ // This is useful for evidence capture flows that validate non-UI components (e.g. `happy-cli`)
40
+ // but still require a "build" step.
41
+ const skipUi = flags.has('--no-ui');
42
+
36
43
  const uiDir = getComponentDir(rootDir, 'happy');
37
44
  await requireDir('happy', uiDir);
38
45
 
@@ -49,6 +56,19 @@ async function main() {
49
56
 
50
57
  // UI is served at root; /ui redirects to /.
51
58
 
59
+ if (skipUi) {
60
+ // Ensure the output dir exists so server-light doesn't crash if used, but do not run Expo export.
61
+ await rm(outDir, { recursive: true, force: true });
62
+ await mkdir(outDir, { recursive: true });
63
+ await writeFile(join(outDir, '.happy-stacks-build-skipped'), 'no-ui\n', 'utf-8');
64
+ if (json) {
65
+ printResult({ json, data: { ok: true, outDir, skippedUi: true, tauriBuilt: false } });
66
+ } else {
67
+ console.log(`[local] skipping UI export (--no-ui); created empty UI dir at ${outDir}`);
68
+ }
69
+ return;
70
+ }
71
+
52
72
  await ensureDepsInstalled(uiDir, 'happy');
53
73
 
54
74
  // Clean output to avoid stale assets.
@@ -1,8 +1,8 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { getComponentDir, getRootDir } from './utils/paths.mjs';
4
4
  import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
5
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
5
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
6
6
 
7
7
  /**
8
8
  * Link the local Happy CLI wrapper into your PATH.
@@ -11,7 +11,7 @@ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
11
11
  *
12
12
  * What it does:
13
13
  * - optionally builds `components/happy-cli` (controlled by env/flags)
14
- * - installs `happy`/`happys` shims under `~/.happy-stacks/bin` (recommended over `npm link`)
14
+ * - installs `happy`/`happys` shims under `<homeDir>/bin` (default: `~/.happy-stacks/bin`) (recommended over `npm link`)
15
15
  *
16
16
  * Env:
17
17
  * - HAPPY_LOCAL_CLI_BUILD=0 to skip building happy-cli
@@ -5,11 +5,13 @@ import { existsSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
 
8
- import { parseArgs } from './utils/args.mjs';
9
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
+ import { parseArgs } from './utils/cli/args.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
10
  import { runCapture } from './utils/proc.mjs';
11
- import { getHappysRegistry } from './utils/cli_registry.mjs';
11
+ import { getHappysRegistry } from './utils/cli/cli_registry.mjs';
12
+ import { expandHome } from './utils/canonical_home.mjs';
12
13
  import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
13
15
 
14
16
  function detectShell() {
15
17
  const raw = (process.env.SHELL ?? '').toLowerCase();
@@ -18,10 +20,6 @@ function detectShell() {
18
20
  return 'zsh';
19
21
  }
20
22
 
21
- function expandHome(p) {
22
- return p.replace(/^~(?=\/)/, homedir());
23
- }
24
-
25
23
  function parseShellArg({ argv, kv }) {
26
24
  const fromKv = (kv.get('--shell') ?? '').trim();
27
25
  const fromEnv = (process.env.HAPPY_STACKS_SHELL ?? '').trim();
@@ -217,6 +215,9 @@ function completionPaths({ homeDir, shell }) {
217
215
  }
218
216
 
219
217
  async function ensureShellInstall({ homeDir, shell }) {
218
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
219
+ return { updated: false, path: null, skipped: 'sandbox' };
220
+ }
220
221
  const shellPath = (process.env.SHELL ?? '').toLowerCase();
221
222
  const isDarwin = process.platform === 'darwin';
222
223
 
@@ -338,7 +339,10 @@ async function main() {
338
339
  await writeFile(file, contents, 'utf-8');
339
340
 
340
341
  // fish loads completions automatically; zsh/bash need a tiny shell config hook.
341
- const hook = shell === 'fish' ? { updated: false, path: null } : await ensureShellInstall({ homeDir, shell });
342
+ const hook =
343
+ shell === 'fish'
344
+ ? { updated: false, path: null }
345
+ : await ensureShellInstall({ homeDir, shell });
342
346
 
343
347
  printResult({
344
348
  json,
@@ -346,6 +350,9 @@ async function main() {
346
350
  text: [
347
351
  `[completion] installed: ${file}`,
348
352
  hook?.path ? (hook.updated ? `[completion] enabled via: ${hook.path}` : `[completion] already enabled in: ${hook.path}`) : null,
353
+ hook?.skipped === 'sandbox'
354
+ ? '[completion] note: skipped editing shell rc files (sandbox mode). To enable this, re-run with HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
355
+ : null,
349
356
  '[completion] note: restart your terminal (or source your shell config) to pick it up.',
350
357
  ]
351
358
  .filter(Boolean)
@@ -1,4 +1,7 @@
1
1
  import { spawnProc, run, runCapture } from './utils/proc.mjs';
2
+ import { resolveAuthSeedFromEnv } from './utils/stack_startup.mjs';
3
+ import { getStacksStorageRoot } from './utils/paths.mjs';
4
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
2
5
  import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
3
6
  import { chmod, copyFile, mkdir } from 'node:fs/promises';
4
7
  import { join } from 'node:path';
@@ -15,7 +18,7 @@ import { homedir } from 'node:os';
15
18
  * - printing actionable diagnostics
16
19
  */
17
20
 
18
- export function cleanupStaleDaemonState(homeDir) {
21
+ export async function cleanupStaleDaemonState(homeDir) {
19
22
  const statePath = join(homeDir, 'daemon.state.json');
20
23
  const lockPath = join(homeDir, 'daemon.state.json.lock');
21
24
 
@@ -23,14 +26,27 @@ export function cleanupStaleDaemonState(homeDir) {
23
26
  return;
24
27
  }
25
28
 
26
- // If lock PID exists and is running, keep lock/state.
29
+ const lsofHasPath = async (pid, pathNeedle) => {
30
+ try {
31
+ const out = await runCapture('sh', ['-lc', `command -v lsof >/dev/null 2>&1 && lsof -nP -p ${pid} 2>/dev/null || true`]);
32
+ return out.includes(pathNeedle);
33
+ } catch {
34
+ return false;
35
+ }
36
+ };
37
+
38
+ // If lock PID exists and is running, keep lock/state ONLY if it still owns the lock file path.
27
39
  try {
28
40
  const raw = readFileSync(lockPath, 'utf-8').trim();
29
41
  const pid = Number(raw);
30
42
  if (Number.isFinite(pid) && pid > 0) {
31
43
  try {
32
44
  process.kill(pid, 0);
33
- return;
45
+ // If PID was recycled, refuse to trust it unless we can prove it's associated with this home dir.
46
+ // This prevents cross-stack daemon kills due to stale lock files.
47
+ if (await lsofHasPath(pid, lockPath)) {
48
+ return;
49
+ }
34
50
  } catch {
35
51
  // stale pid
36
52
  }
@@ -47,7 +63,10 @@ export function cleanupStaleDaemonState(homeDir) {
47
63
  if (pid) {
48
64
  try {
49
65
  process.kill(pid, 0);
50
- return;
66
+ // Only keep if we can prove it still uses this home dir (via state path).
67
+ if (await lsofHasPath(pid, statePath)) {
68
+ return;
69
+ }
51
70
  } catch {
52
71
  // stale pid
53
72
  }
@@ -61,6 +80,88 @@ export function cleanupStaleDaemonState(homeDir) {
61
80
  try { unlinkSync(statePath); } catch { /* ignore */ }
62
81
  }
63
82
 
83
+ export function checkDaemonState(cliHomeDir) {
84
+ const statePath = join(cliHomeDir, 'daemon.state.json');
85
+ const lockPath = join(cliHomeDir, 'daemon.state.json.lock');
86
+
87
+ const alive = (pid) => {
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ };
95
+
96
+ if (existsSync(statePath)) {
97
+ try {
98
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'));
99
+ const pid = Number(state?.pid);
100
+ if (Number.isFinite(pid) && pid > 0) {
101
+ return alive(pid) ? { status: 'running', pid } : { status: 'stale_state', pid };
102
+ }
103
+ return { status: 'bad_state', pid: null };
104
+ } catch {
105
+ return { status: 'bad_state', pid: null };
106
+ }
107
+ }
108
+
109
+ if (existsSync(lockPath)) {
110
+ try {
111
+ const pid = Number(readFileSync(lockPath, 'utf-8').trim());
112
+ if (Number.isFinite(pid) && pid > 0) {
113
+ return alive(pid) ? { status: 'starting', pid } : { status: 'stale_lock', pid };
114
+ }
115
+ return { status: 'bad_lock', pid: null };
116
+ } catch {
117
+ return { status: 'bad_lock', pid: null };
118
+ }
119
+ }
120
+
121
+ return { status: 'stopped', pid: null };
122
+ }
123
+
124
+ export function isDaemonRunning(cliHomeDir) {
125
+ const s = checkDaemonState(cliHomeDir);
126
+ return s.status === 'running' || s.status === 'starting';
127
+ }
128
+
129
+ async function readDaemonPsEnv(pid) {
130
+ const n = Number(pid);
131
+ if (!Number.isFinite(n) || n <= 1) return null;
132
+ if (process.platform === 'win32') return null;
133
+ try {
134
+ const out = await runCapture('ps', ['eww', '-p', String(n)]);
135
+ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
136
+ // Usually: header + one line.
137
+ return lines.length >= 2 ? lines[1] : lines[0] ?? null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ async function daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl }) {
144
+ const line = await readDaemonPsEnv(pid);
145
+ if (!line) return null; // unknown
146
+ const home = String(cliHomeDir ?? '').trim();
147
+ const server = String(internalServerUrl ?? '').trim();
148
+ const web = String(publicServerUrl ?? '').trim();
149
+
150
+ // Must be for the same stack home dir.
151
+ if (home && !line.includes(`HAPPY_HOME_DIR=${home}`)) {
152
+ return false;
153
+ }
154
+ // If we have a desired server URL, require it (prevents ephemeral port mismatches).
155
+ if (server && !line.includes(`HAPPY_SERVER_URL=${server}`)) {
156
+ return false;
157
+ }
158
+ // Public URL mismatch is less fatal, but prefer it stable too when provided.
159
+ if (web && !line.includes(`HAPPY_WEBAPP_URL=${web}`)) {
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+
64
165
  function getLatestDaemonLogPath(homeDir) {
65
166
  try {
66
167
  const logsDir = join(homeDir, 'logs');
@@ -95,12 +196,28 @@ function authLoginHint() {
95
196
  return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
96
197
  }
97
198
 
199
+ function authCopyFromSeedHint() {
200
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
201
+ if (stackName === 'main') return null;
202
+ const seed = resolveAuthSeedFromEnv(process.env);
203
+ return `happys stack auth ${stackName} copy-from ${seed}`;
204
+ }
205
+
98
206
  async function seedCredentialsIfMissing({ cliHomeDir }) {
207
+ const stacksRoot = getStacksStorageRoot();
208
+ const allowGlobal = sandboxAllowsGlobalSideEffects();
209
+
99
210
  const sources = [
100
- // Legacy happy-local storage root (most common for existing users).
101
- join(homedir(), '.happy', 'local', 'cli'),
102
- // Older global location.
103
- join(homedir(), '.happy'),
211
+ // New layout: main stack credentials (preferred).
212
+ join(stacksRoot, 'main', 'cli'),
213
+ ...((!isSandboxed() || allowGlobal)
214
+ ? [
215
+ // Legacy happy-local storage root (most common for existing users).
216
+ join(homedir(), '.happy', 'local', 'cli'),
217
+ // Older global location.
218
+ join(homedir(), '.happy'),
219
+ ]
220
+ : []),
104
221
  ];
105
222
 
106
223
  const copyIfMissing = async ({ relPath, mode, label }) => {
@@ -176,6 +293,22 @@ async function killDaemonFromLockFile({ cliHomeDir }) {
176
293
  return false;
177
294
  }
178
295
 
296
+ // Hard safety: only kill if we can prove the PID is associated with this stack home dir.
297
+ // We do this by checking that `lsof -p <pid>` includes the lock path (or state file path).
298
+ let ownsLock = false;
299
+ try {
300
+ const out = await runCapture('sh', ['-lc', `command -v lsof >/dev/null 2>&1 && lsof -nP -p ${pid} 2>/dev/null || true`]);
301
+ ownsLock = out.includes(lockPath) || out.includes(join(cliHomeDir, 'daemon.state.json')) || out.includes(join(cliHomeDir, 'logs'));
302
+ } catch {
303
+ ownsLock = false;
304
+ }
305
+ if (!ownsLock) {
306
+ console.warn(
307
+ `[local] refusing to kill pid ${pid} from lock file (could be unrelated; lsof did not show ownership of ${cliHomeDir})`
308
+ );
309
+ return false;
310
+ }
311
+
179
312
  try {
180
313
  process.kill(pid, 'SIGTERM');
181
314
  } catch {
@@ -247,24 +380,43 @@ export async function startLocalDaemonWithAuth({
247
380
  internalServerUrl,
248
381
  publicServerUrl,
249
382
  isShuttingDown,
383
+ forceRestart = false,
250
384
  }) {
385
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
251
386
  const baseEnv = { ...process.env };
252
387
  const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
253
388
 
254
389
  // If this is a migrated/new stack home dir, seed credentials from the user's existing login (best-effort)
255
390
  // to avoid requiring an interactive auth flow under launchd.
256
- await seedCredentialsIfMissing({ cliHomeDir });
391
+ const migrateCreds = (baseEnv.HAPPY_STACKS_MIGRATE_CREDENTIALS ?? baseEnv.HAPPY_LOCAL_MIGRATE_CREDENTIALS ?? '1').trim() !== '0';
392
+ if (migrateCreds) {
393
+ await seedCredentialsIfMissing({ cliHomeDir });
394
+ }
257
395
 
258
- // Stop any existing daemon (best-effort) in both legacy and local home dirs.
259
- const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
260
- try {
261
- await new Promise((resolve) => {
262
- const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], legacyEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
263
- proc.on('exit', () => resolve());
264
- });
265
- } catch {
266
- // ignore
396
+ const existing = checkDaemonState(cliHomeDir);
397
+ if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
398
+ const pid = existing.pid;
399
+ const matches = await daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl });
400
+ if (matches === true) {
401
+ // eslint-disable-next-line no-console
402
+ console.log(`[local] daemon already running for stack home (pid=${pid})`);
403
+ return;
404
+ }
405
+ if (matches === false) {
406
+ // eslint-disable-next-line no-console
407
+ console.warn(
408
+ `[local] daemon is running but pointed at a different server URL; restarting (pid=${pid}).\n` +
409
+ `[local] expected: ${internalServerUrl}\n`
410
+ );
411
+ } else {
412
+ // unknown: best-effort keep running to avoid killing an unrelated process
413
+ // eslint-disable-next-line no-console
414
+ console.warn(`[local] daemon status is running but could not verify env; not restarting (pid=${pid})`);
415
+ return;
416
+ }
267
417
  }
418
+
419
+ // Stop any existing daemon for THIS stack home dir.
268
420
  try {
269
421
  await new Promise((resolve) => {
270
422
  const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], daemonEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
@@ -274,13 +426,27 @@ export async function startLocalDaemonWithAuth({
274
426
  // ignore
275
427
  }
276
428
 
429
+ // Best-effort: for the main stack, also stop the legacy global daemon home (~/.happy) to prevent legacy overlap.
430
+ if (stackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
431
+ const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
432
+ try {
433
+ await new Promise((resolve) => {
434
+ const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], legacyEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
435
+ proc.on('exit', () => resolve());
436
+ });
437
+ } catch {
438
+ // ignore
439
+ }
440
+ // If state is missing and stop couldn't find it, force-stop the lock PID (otherwise repeated restarts accumulate daemons).
441
+ await killDaemonFromLockFile({ cliHomeDir: join(homedir(), '.happy') });
442
+ await cleanupStaleDaemonState(join(homedir(), '.happy'));
443
+ }
444
+
277
445
  // If state is missing and stop couldn't find it, force-stop the lock PID (otherwise repeated restarts accumulate daemons).
278
- await killDaemonFromLockFile({ cliHomeDir: join(homedir(), '.happy') });
279
446
  await killDaemonFromLockFile({ cliHomeDir });
280
447
 
281
448
  // Clean up stale lock/state files that can block daemon start.
282
- cleanupStaleDaemonState(join(homedir(), '.happy'));
283
- cleanupStaleDaemonState(cliHomeDir);
449
+ await cleanupStaleDaemonState(cliHomeDir);
284
450
 
285
451
  const credentialsPath = join(cliHomeDir, 'access.key');
286
452
 
@@ -294,7 +460,9 @@ export async function startLocalDaemonWithAuth({
294
460
  return { ok: true, exitCode, excerpt: null, logPath: null };
295
461
  }
296
462
 
297
- const logPath = getLatestDaemonLogPath(cliHomeDir) || getLatestDaemonLogPath(join(homedir(), '.happy'));
463
+ const logPath =
464
+ getLatestDaemonLogPath(cliHomeDir) ||
465
+ ((!isSandboxed() || sandboxAllowsGlobalSideEffects()) ? getLatestDaemonLogPath(join(homedir(), '.happy')) : null);
298
466
  const excerpt = logPath ? readLastLines(logPath, 120) : null;
299
467
  return { ok: false, exitCode, excerpt, logPath };
300
468
  };
@@ -308,11 +476,13 @@ export async function startLocalDaemonWithAuth({
308
476
  }
309
477
 
310
478
  if (excerptIndicatesMissingAuth(first.excerpt)) {
479
+ const copyHint = authCopyFromSeedHint();
311
480
  console.error(
312
481
  `[local] daemon is not authenticated yet (expected on first run).\n` +
313
482
  `[local] Keeping the server running so you can login.\n` +
314
483
  `[local] In another terminal, run:\n` +
315
484
  `${authLoginHint()}\n` +
485
+ (copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '') +
316
486
  `[local] Waiting for credentials at ${credentialsPath}...`
317
487
  );
318
488
 
@@ -330,7 +500,14 @@ export async function startLocalDaemonWithAuth({
330
500
  throw new Error('Failed to start daemon (after credentials were created)');
331
501
  }
332
502
  } else {
333
- console.error(`[local] To authenticate against this local server, run:\n${authLoginHint()}`);
503
+ const copyHint = authCopyFromSeedHint();
504
+ console.error(
505
+ `[local] daemon failed to start (server returned an error).\n` +
506
+ `[local] Try:\n` +
507
+ `- happys doctor\n` +
508
+ (copyHint ? `- ${copyHint}\n` : '') +
509
+ `- ${authLoginHint()}`
510
+ );
334
511
  throw new Error('Failed to start daemon');
335
512
  }
336
513
  }