happy-stacks 0.1.2 → 0.3.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 (116) hide show
  1. package/README.md +164 -89
  2. package/bin/happys.mjs +70 -10
  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/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -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/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
package/scripts/build.mjs CHANGED
@@ -1,11 +1,12 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
4
- import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/pm.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
4
+ import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/proc/pm.mjs';
5
+ import { resolveServerPortFromEnv } from './utils/server/urls.mjs';
5
6
  import { dirname, join } from 'node:path';
6
7
  import { readFile, rm, mkdir, writeFile } from 'node:fs/promises';
7
8
  import { tailscaleServeHttpsUrl } from './tailscale.mjs';
8
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
9
10
 
10
11
  /**
11
12
  * Build a lightweight static web UI bundle (no Expo dev server).
@@ -22,23 +23,28 @@ async function main() {
22
23
  if (wantsHelp(argv, { flags })) {
23
24
  printResult({
24
25
  json,
25
- data: { flags: ['--tauri', '--no-tauri'], json: true },
26
+ data: { flags: ['--tauri', '--no-tauri', '--no-ui'], json: true },
26
27
  text: [
27
28
  '[build] usage:',
28
29
  ' happys build [--tauri] [--json]',
29
30
  ' (legacy in a cloned repo): pnpm build [-- --tauri] [--json]',
30
- ' node scripts/build.mjs [--tauri|--no-tauri] [--json]',
31
+ ' node scripts/build.mjs [--tauri|--no-tauri] [--no-ui] [--json]',
31
32
  ].join('\n'),
32
33
  });
33
34
  return;
34
35
  }
35
36
  const rootDir = getRootDir(import.meta.url);
37
+
38
+ // Optional: skip building the web UI bundle.
39
+ //
40
+ // This is useful for evidence capture flows that validate non-UI components (e.g. `happy-cli`)
41
+ // but still require a "build" step.
42
+ const skipUi = flags.has('--no-ui');
43
+
36
44
  const uiDir = getComponentDir(rootDir, 'happy');
37
45
  await requireDir('happy', uiDir);
38
46
 
39
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
40
- ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
41
- : 3005;
47
+ const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
42
48
 
43
49
  // For Tauri builds we embed an explicit API base URL (tauri:// origins cannot use window.location.origin).
44
50
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
@@ -49,6 +55,19 @@ async function main() {
49
55
 
50
56
  // UI is served at root; /ui redirects to /.
51
57
 
58
+ if (skipUi) {
59
+ // Ensure the output dir exists so server-light doesn't crash if used, but do not run Expo export.
60
+ await rm(outDir, { recursive: true, force: true });
61
+ await mkdir(outDir, { recursive: true });
62
+ await writeFile(join(outDir, '.happy-stacks-build-skipped'), 'no-ui\n', 'utf-8');
63
+ if (json) {
64
+ printResult({ json, data: { ok: true, outDir, skippedUi: true, tauriBuilt: false } });
65
+ } else {
66
+ console.log(`[local] skipping UI export (--no-ui); created empty UI dir at ${outDir}`);
67
+ }
68
+ return;
69
+ }
70
+
52
71
  await ensureDepsInstalled(uiDir, 'happy');
53
72
 
54
73
  // Clean output to avoid stale assets.
@@ -1,8 +1,8 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { getComponentDir, getRootDir } from './utils/paths.mjs';
4
- import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
5
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
4
+ import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/proc/pm.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
@@ -1,15 +1,17 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
 
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  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';
10
- import { runCapture } from './utils/proc.mjs';
11
- import { getHappysRegistry } from './utils/cli_registry.mjs';
12
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
8
+ import { parseArgs } from './utils/cli/args.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
+ import { runCapture } from './utils/proc/proc.mjs';
11
+ import { getHappysRegistry } from './utils/cli/cli_registry.mjs';
12
+ import { expandHome } from './utils/paths/canonical_home.mjs';
13
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths/paths.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/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,9 @@
1
- import { spawnProc, run, runCapture } from './utils/proc.mjs';
1
+ import { spawnProc, run, runCapture } from './utils/proc/proc.mjs';
2
+ import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
3
+ import { getStacksStorageRoot } from './utils/paths/paths.mjs';
4
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
5
+ import { runCaptureIfCommandExists } from './utils/proc/commands.mjs';
6
+ import { readLastLines } from './utils/fs/tail.mjs';
2
7
  import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
3
8
  import { chmod, copyFile, mkdir } from 'node:fs/promises';
4
9
  import { join } from 'node:path';
@@ -15,7 +20,7 @@ import { homedir } from 'node:os';
15
20
  * - printing actionable diagnostics
16
21
  */
17
22
 
18
- export function cleanupStaleDaemonState(homeDir) {
23
+ export async function cleanupStaleDaemonState(homeDir) {
19
24
  const statePath = join(homeDir, 'daemon.state.json');
20
25
  const lockPath = join(homeDir, 'daemon.state.json.lock');
21
26
 
@@ -23,14 +28,27 @@ export function cleanupStaleDaemonState(homeDir) {
23
28
  return;
24
29
  }
25
30
 
26
- // If lock PID exists and is running, keep lock/state.
31
+ const lsofHasPath = async (pid, pathNeedle) => {
32
+ try {
33
+ const out = await runCaptureIfCommandExists('lsof', ['-nP', '-p', String(pid)]);
34
+ return out.includes(pathNeedle);
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+
40
+ // If lock PID exists and is running, keep lock/state ONLY if it still owns the lock file path.
27
41
  try {
28
42
  const raw = readFileSync(lockPath, 'utf-8').trim();
29
43
  const pid = Number(raw);
30
44
  if (Number.isFinite(pid) && pid > 0) {
31
45
  try {
32
46
  process.kill(pid, 0);
33
- return;
47
+ // If PID was recycled, refuse to trust it unless we can prove it's associated with this home dir.
48
+ // This prevents cross-stack daemon kills due to stale lock files.
49
+ if (await lsofHasPath(pid, lockPath)) {
50
+ return;
51
+ }
34
52
  } catch {
35
53
  // stale pid
36
54
  }
@@ -47,7 +65,10 @@ export function cleanupStaleDaemonState(homeDir) {
47
65
  if (pid) {
48
66
  try {
49
67
  process.kill(pid, 0);
50
- return;
68
+ // Only keep if we can prove it still uses this home dir (via state path).
69
+ if (await lsofHasPath(pid, statePath)) {
70
+ return;
71
+ }
51
72
  } catch {
52
73
  // stale pid
53
74
  }
@@ -107,22 +128,48 @@ export function isDaemonRunning(cliHomeDir) {
107
128
  return s.status === 'running' || s.status === 'starting';
108
129
  }
109
130
 
110
- function getLatestDaemonLogPath(homeDir) {
131
+ async function readDaemonPsEnv(pid) {
132
+ const n = Number(pid);
133
+ if (!Number.isFinite(n) || n <= 1) return null;
134
+ if (process.platform === 'win32') return null;
111
135
  try {
112
- const logsDir = join(homeDir, 'logs');
113
- const files = readdirSync(logsDir).filter((f) => f.endsWith('-daemon.log')).sort();
114
- if (!files.length) return null;
115
- return join(logsDir, files[files.length - 1]);
136
+ const out = await runCapture('ps', ['eww', '-p', String(n)]);
137
+ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
138
+ // Usually: header + one line.
139
+ return lines.length >= 2 ? lines[1] : lines[0] ?? null;
116
140
  } catch {
117
141
  return null;
118
142
  }
119
143
  }
120
144
 
121
- function readLastLines(path, lines = 60) {
145
+ async function daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl }) {
146
+ const line = await readDaemonPsEnv(pid);
147
+ if (!line) return null; // unknown
148
+ const home = String(cliHomeDir ?? '').trim();
149
+ const server = String(internalServerUrl ?? '').trim();
150
+ const web = String(publicServerUrl ?? '').trim();
151
+
152
+ // Must be for the same stack home dir.
153
+ if (home && !line.includes(`HAPPY_HOME_DIR=${home}`)) {
154
+ return false;
155
+ }
156
+ // If we have a desired server URL, require it (prevents ephemeral port mismatches).
157
+ if (server && !line.includes(`HAPPY_SERVER_URL=${server}`)) {
158
+ return false;
159
+ }
160
+ // Public URL mismatch is less fatal, but prefer it stable too when provided.
161
+ if (web && !line.includes(`HAPPY_WEBAPP_URL=${web}`)) {
162
+ return false;
163
+ }
164
+ return true;
165
+ }
166
+
167
+ function getLatestDaemonLogPath(homeDir) {
122
168
  try {
123
- const content = readFileSync(path, 'utf-8');
124
- const parts = content.split('\n');
125
- return parts.slice(Math.max(0, parts.length - lines)).join('\n');
169
+ const logsDir = join(homeDir, 'logs');
170
+ const files = readdirSync(logsDir).filter((f) => f.endsWith('-daemon.log')).sort();
171
+ if (!files.length) return null;
172
+ return join(logsDir, files[files.length - 1]);
126
173
  } catch {
127
174
  return null;
128
175
  }
@@ -141,17 +188,28 @@ function authLoginHint() {
141
188
  return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
142
189
  }
143
190
 
144
- function authCopyFromMainHint() {
191
+ function authCopyFromSeedHint() {
145
192
  const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
146
- return stackName === 'main' ? null : `happys stack auth ${stackName} copy-from main`;
193
+ if (stackName === 'main') return null;
194
+ const seed = resolveAuthSeedFromEnv(process.env);
195
+ return `happys stack auth ${stackName} copy-from ${seed}`;
147
196
  }
148
197
 
149
198
  async function seedCredentialsIfMissing({ cliHomeDir }) {
199
+ const stacksRoot = getStacksStorageRoot();
200
+ const allowGlobal = sandboxAllowsGlobalSideEffects();
201
+
150
202
  const sources = [
151
- // Legacy happy-local storage root (most common for existing users).
152
- join(homedir(), '.happy', 'local', 'cli'),
153
- // Older global location.
154
- join(homedir(), '.happy'),
203
+ // New layout: main stack credentials (preferred).
204
+ join(stacksRoot, 'main', 'cli'),
205
+ ...((!isSandboxed() || allowGlobal)
206
+ ? [
207
+ // Legacy happy-local storage root (most common for existing users).
208
+ join(homedir(), '.happy', 'local', 'cli'),
209
+ // Older global location.
210
+ join(homedir(), '.happy'),
211
+ ]
212
+ : []),
155
213
  ];
156
214
 
157
215
  const copyIfMissing = async ({ relPath, mode, label }) => {
@@ -227,6 +285,22 @@ async function killDaemonFromLockFile({ cliHomeDir }) {
227
285
  return false;
228
286
  }
229
287
 
288
+ // Hard safety: only kill if we can prove the PID is associated with this stack home dir.
289
+ // We do this by checking that `lsof -p <pid>` includes the lock path (or state file path).
290
+ let ownsLock = false;
291
+ try {
292
+ const out = await runCaptureIfCommandExists('lsof', ['-nP', '-p', String(pid)]);
293
+ ownsLock = out.includes(lockPath) || out.includes(join(cliHomeDir, 'daemon.state.json')) || out.includes(join(cliHomeDir, 'logs'));
294
+ } catch {
295
+ ownsLock = false;
296
+ }
297
+ if (!ownsLock) {
298
+ console.warn(
299
+ `[local] refusing to kill pid ${pid} from lock file (could be unrelated; lsof did not show ownership of ${cliHomeDir})`
300
+ );
301
+ return false;
302
+ }
303
+
230
304
  try {
231
305
  process.kill(pid, 'SIGTERM');
232
306
  } catch {
@@ -306,13 +380,32 @@ export async function startLocalDaemonWithAuth({
306
380
 
307
381
  // If this is a migrated/new stack home dir, seed credentials from the user's existing login (best-effort)
308
382
  // to avoid requiring an interactive auth flow under launchd.
309
- await seedCredentialsIfMissing({ cliHomeDir });
383
+ const migrateCreds = (baseEnv.HAPPY_STACKS_MIGRATE_CREDENTIALS ?? baseEnv.HAPPY_LOCAL_MIGRATE_CREDENTIALS ?? '1').trim() !== '0';
384
+ if (migrateCreds) {
385
+ await seedCredentialsIfMissing({ cliHomeDir });
386
+ }
310
387
 
311
388
  const existing = checkDaemonState(cliHomeDir);
312
389
  if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
313
- // eslint-disable-next-line no-console
314
- console.log(`[local] daemon already running for stack home (pid=${existing.pid})`);
315
- return;
390
+ const pid = existing.pid;
391
+ const matches = await daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl });
392
+ if (matches === true) {
393
+ // eslint-disable-next-line no-console
394
+ console.log(`[local] daemon already running for stack home (pid=${pid})`);
395
+ return;
396
+ }
397
+ if (matches === false) {
398
+ // eslint-disable-next-line no-console
399
+ console.warn(
400
+ `[local] daemon is running but pointed at a different server URL; restarting (pid=${pid}).\n` +
401
+ `[local] expected: ${internalServerUrl}\n`
402
+ );
403
+ } else {
404
+ // unknown: best-effort keep running to avoid killing an unrelated process
405
+ // eslint-disable-next-line no-console
406
+ console.warn(`[local] daemon status is running but could not verify env; not restarting (pid=${pid})`);
407
+ return;
408
+ }
316
409
  }
317
410
 
318
411
  // Stop any existing daemon for THIS stack home dir.
@@ -326,7 +419,7 @@ export async function startLocalDaemonWithAuth({
326
419
  }
327
420
 
328
421
  // Best-effort: for the main stack, also stop the legacy global daemon home (~/.happy) to prevent legacy overlap.
329
- if (stackName === 'main') {
422
+ if (stackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
330
423
  const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
331
424
  try {
332
425
  await new Promise((resolve) => {
@@ -338,14 +431,14 @@ export async function startLocalDaemonWithAuth({
338
431
  }
339
432
  // If state is missing and stop couldn't find it, force-stop the lock PID (otherwise repeated restarts accumulate daemons).
340
433
  await killDaemonFromLockFile({ cliHomeDir: join(homedir(), '.happy') });
341
- cleanupStaleDaemonState(join(homedir(), '.happy'));
434
+ await cleanupStaleDaemonState(join(homedir(), '.happy'));
342
435
  }
343
436
 
344
437
  // If state is missing and stop couldn't find it, force-stop the lock PID (otherwise repeated restarts accumulate daemons).
345
438
  await killDaemonFromLockFile({ cliHomeDir });
346
439
 
347
440
  // Clean up stale lock/state files that can block daemon start.
348
- cleanupStaleDaemonState(cliHomeDir);
441
+ await cleanupStaleDaemonState(cliHomeDir);
349
442
 
350
443
  const credentialsPath = join(cliHomeDir, 'access.key');
351
444
 
@@ -359,8 +452,10 @@ export async function startLocalDaemonWithAuth({
359
452
  return { ok: true, exitCode, excerpt: null, logPath: null };
360
453
  }
361
454
 
362
- const logPath = getLatestDaemonLogPath(cliHomeDir) || getLatestDaemonLogPath(join(homedir(), '.happy'));
363
- const excerpt = logPath ? readLastLines(logPath, 120) : null;
455
+ const logPath =
456
+ getLatestDaemonLogPath(cliHomeDir) ||
457
+ ((!isSandboxed() || sandboxAllowsGlobalSideEffects()) ? getLatestDaemonLogPath(join(homedir(), '.happy')) : null);
458
+ const excerpt = logPath ? await readLastLines(logPath, 120) : null;
364
459
  return { ok: false, exitCode, excerpt, logPath };
365
460
  };
366
461
 
@@ -373,7 +468,7 @@ export async function startLocalDaemonWithAuth({
373
468
  }
374
469
 
375
470
  if (excerptIndicatesMissingAuth(first.excerpt)) {
376
- const copyHint = authCopyFromMainHint();
471
+ const copyHint = authCopyFromSeedHint();
377
472
  console.error(
378
473
  `[local] daemon is not authenticated yet (expected on first run).\n` +
379
474
  `[local] Keeping the server running so you can login.\n` +
@@ -397,7 +492,14 @@ export async function startLocalDaemonWithAuth({
397
492
  throw new Error('Failed to start daemon (after credentials were created)');
398
493
  }
399
494
  } else {
400
- console.error(`[local] To authenticate against this local server, run:\n${authLoginHint()}`);
495
+ const copyHint = authCopyFromSeedHint();
496
+ console.error(
497
+ `[local] daemon failed to start (server returned an error).\n` +
498
+ `[local] Try:\n` +
499
+ `- happys doctor\n` +
500
+ (copyHint ? `- ${copyHint}\n` : '') +
501
+ `- ${authLoginHint()}`
502
+ );
401
503
  throw new Error('Failed to start daemon');
402
504
  }
403
505
  }