happy-stacks 0.1.2 → 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 (91) hide show
  1. package/README.md +121 -83
  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 +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/dev.mjs CHANGED
@@ -1,19 +1,36 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { killProcessTree } from './utils/proc.mjs';
4
4
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
5
- import { killPortListeners, pickNextFreeTcpPort } from './utils/ports.mjs';
6
- import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server.mjs';
7
- import { ensureDepsInstalled, pmSpawnBin, pmSpawnScript, requireDir } from './utils/pm.mjs';
5
+ import { killPortListeners } from './utils/ports.mjs';
6
+ import { getServerComponentName, isHappyServerRunning } from './utils/server.mjs';
7
+ import { requireDir } from './utils/pm.mjs';
8
8
  import { join } from 'node:path';
9
9
  import { setTimeout as delay } from 'node:timers/promises';
10
10
  import { homedir } from 'node:os';
11
- import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
12
- import { resolvePublicServerUrl } from './tailscale.mjs';
13
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
11
+ import { isDaemonRunning, stopLocalDaemon } from './daemon.mjs';
12
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
14
13
  import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/validate.mjs';
15
- import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/happy_server_infra.mjs';
16
- import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo.mjs';
14
+ import { getExpoStatePaths, isStateProcessRunning } from './utils/expo.mjs';
15
+ import { isPidAlive, readStackRuntimeStateFile, recordStackRuntimeStart } from './utils/stack_runtime_state.mjs';
16
+ import { resolveStackContext } from './utils/stack_context.mjs';
17
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server_urls.mjs';
18
+ import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev_daemon.mjs';
19
+ import { startDevServer, watchDevServerAndRestart } from './utils/dev_server.mjs';
20
+ import { startDevExpoWebUi } from './utils/dev_expo_web.mjs';
21
+ import { resolveLocalhostHost } from './utils/localhost_host.mjs';
22
+ import { openUrlInBrowser } from './utils/browser.mjs';
23
+ import { waitForHttpOk } from './utils/server.mjs';
24
+
25
+ function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
26
+ const s = String(raw ?? '')
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9-]+/g, '-')
29
+ .replace(/-+/g, '-')
30
+ .replace(/^-+/, '')
31
+ .replace(/-+$/, '');
32
+ return s || fallback;
33
+ }
17
34
 
18
35
  /**
19
36
  * Dev mode stack:
@@ -29,10 +46,13 @@ async function main() {
29
46
  if (wantsHelp(argv, { flags })) {
30
47
  printResult({
31
48
  json,
32
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart'], json: true },
49
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser'], json: true },
33
50
  text: [
34
51
  '[dev] usage:',
35
52
  ' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
53
+ ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
54
+ ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
55
+ ' happys dev --no-browser # do not open the UI in your browser automatically',
36
56
  ' note: --json prints the resolved config (dry-run) and exits.',
37
57
  ].join('\n'),
38
58
  });
@@ -40,21 +60,6 @@ async function main() {
40
60
  }
41
61
  const rootDir = getRootDir(import.meta.url);
42
62
 
43
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
44
- ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
45
- : 3005;
46
-
47
- const internalServerUrl = `http://127.0.0.1:${serverPort}`;
48
- const defaultPublicUrl = `http://localhost:${serverPort}`;
49
- const envPublicUrl = process.env.HAPPY_LOCAL_SERVER_URL?.trim() ? process.env.HAPPY_LOCAL_SERVER_URL.trim() : '';
50
- const resolved = await resolvePublicServerUrl({
51
- internalServerUrl,
52
- defaultPublicUrl,
53
- envPublicUrl,
54
- allowEnable: true,
55
- });
56
- const publicServerUrl = resolved.publicServerUrl;
57
-
58
63
  const serverComponentName = getServerComponentName({ kv });
59
64
  if (serverComponentName === 'both') {
60
65
  throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
@@ -62,7 +67,7 @@ async function main() {
62
67
 
63
68
  const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
64
69
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
65
- const restart = flags.has('--restart');
70
+ const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
66
71
 
67
72
  const serverDir = getComponentDir(rootDir, serverComponentName);
68
73
  const uiDir = getComponentDir(rootDir, 'happy');
@@ -77,6 +82,28 @@ async function main() {
77
82
 
78
83
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
79
84
  const autostart = getDefaultAutostartPaths();
85
+ const baseEnv = { ...process.env };
86
+ const stackCtx = resolveStackContext({ env: baseEnv, autostart });
87
+ const { stackMode, runtimeStatePath, stackName, envPath, ephemeral } = stackCtx;
88
+
89
+ const serverPort = resolveServerPortFromEnv({ env: baseEnv, defaultPort: 3005 });
90
+ // IMPORTANT:
91
+ // - Only the main stack should ever auto-enable (or prefer) Tailscale Serve by default.
92
+ // - Non-main stacks should default to localhost URLs unless the user explicitly configured a public URL
93
+ // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
94
+ const allowEnableTailscale = !stackMode || stackName === 'main';
95
+ const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
96
+ const internalServerUrl = resolvedUrls.internalServerUrl;
97
+ let publicServerUrl = resolvedUrls.publicServerUrl;
98
+ if (stackMode && stackName !== 'main' && !resolvedUrls.envPublicUrl) {
99
+ const src = String(resolvedUrls.publicServerUrlSource ?? '');
100
+ const hasStackScopedTailscale = src.startsWith('tailscale-');
101
+ if (!hasStackScopedTailscale) {
102
+ publicServerUrl = resolvedUrls.defaultPublicUrl;
103
+ }
104
+ }
105
+ const uiApiUrl = resolvedUrls.defaultPublicUrl;
106
+ const restart = flags.has('--restart');
80
107
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
81
108
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
82
109
  : join(autostart.baseDir, 'cli');
@@ -103,7 +130,16 @@ async function main() {
103
130
 
104
131
  const children = [];
105
132
  let shuttingDown = false;
106
- const baseEnv = { ...process.env };
133
+
134
+ // Ensure happy-cli is install+build ready before starting the daemon.
135
+ // Worktrees often don't have dist/ built yet, which causes MODULE_NOT_FOUND on dist/index.mjs.
136
+ const buildCli = (baseEnv.HAPPY_STACKS_CLI_BUILD ?? baseEnv.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
137
+ await ensureDevCliReady({ cliDir, buildCli });
138
+
139
+ // Watch mode (interactive only by default): rebuild happy-cli and restart daemon when code changes.
140
+ const watchEnabled =
141
+ flags.has('--watch') || (!flags.has('--no-watch') && Boolean(process.stdin.isTTY && process.stdout.isTTY));
142
+ const watchers = [];
107
143
 
108
144
  const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
109
145
  const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
@@ -118,63 +154,39 @@ async function main() {
118
154
  return;
119
155
  }
120
156
 
157
+ if (stackMode && runtimeStatePath) {
158
+ await recordStackRuntimeStart(runtimeStatePath, {
159
+ stackName,
160
+ script: 'dev.mjs',
161
+ ephemeral,
162
+ ownerPid: process.pid,
163
+ ports: { server: serverPort },
164
+ }).catch(() => {});
165
+ }
166
+
121
167
  // Start server (only if not already healthy)
122
- if (!serverAlreadyRunning || restart) {
168
+ // NOTE: In stack mode we avoid killing arbitrary port listeners (fail-closed instead).
169
+ if ((!serverAlreadyRunning || restart) && !stackMode) {
123
170
  await killPortListeners(serverPort, { label: 'server' });
124
171
  }
125
- const serverEnv = {
126
- ...baseEnv,
127
- PORT: String(serverPort),
128
- PUBLIC_URL: publicServerUrl,
129
- // Avoid noisy failures if a previous run left the metrics port busy.
130
- METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
131
- };
132
- if (serverComponentName === 'happy-server-light') {
133
- const dataDir = baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR?.trim()
134
- ? baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR.trim()
135
- : join(autostart.baseDir, 'server-light');
136
- serverEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
137
- serverEnv.HAPPY_SERVER_LIGHT_FILES_DIR = baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR?.trim()
138
- ? baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR.trim()
139
- : join(dataDir, 'files');
140
- }
141
- if (serverComponentName === 'happy-server') {
142
- const managed = (baseEnv.HAPPY_STACKS_MANAGED_INFRA ?? baseEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1') !== '0';
143
- if (managed) {
144
- const envPath = baseEnv.HAPPY_STACKS_ENV_FILE ?? baseEnv.HAPPY_LOCAL_ENV_FILE ?? '';
145
- const infra = await ensureHappyServerManagedInfra({
146
- stackName: autostart.stackName,
147
- baseDir: autostart.baseDir,
148
- serverPort,
149
- publicServerUrl,
150
- envPath,
151
- env: baseEnv,
152
- });
153
- Object.assign(serverEnv, infra.env);
154
- }
155
172
 
156
- const autoMigrate = (baseEnv.HAPPY_STACKS_PRISMA_MIGRATE ?? baseEnv.HAPPY_LOCAL_PRISMA_MIGRATE ?? '1') !== '0';
157
- if (autoMigrate) {
158
- await applyHappyServerMigrations({ serverDir, env: serverEnv });
159
- }
160
- }
161
- await ensureDepsInstalled(serverDir, serverComponentName);
162
- // For happy-server: the upstream `dev` script is not stack-safe (kills fixed ports, reads .env.dev).
163
- // Use `start` and rely on stack-scoped env + optional migrations above.
164
- //
165
- // For happy-server-light: the upstream `dev` script runs `prisma db push` automatically. If you want to skip
166
- // it (e.g. big sqlite DB), set HAPPY_STACKS_PRISMA_PUSH=0 to use `start` even in dev mode.
167
- const prismaPush = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '1').trim() !== '0';
168
- const serverScript =
169
- serverComponentName === 'happy-server'
170
- ? 'start'
171
- : serverComponentName === 'happy-server-light' && !prismaPush
172
- ? 'start'
173
- : 'dev';
173
+ const { serverEnv, serverScript, serverProc } = await startDevServer({
174
+ serverComponentName,
175
+ serverDir,
176
+ autostart,
177
+ baseEnv,
178
+ serverPort,
179
+ internalServerUrl,
180
+ publicServerUrl,
181
+ envPath,
182
+ stackMode,
183
+ runtimeStatePath,
184
+ serverAlreadyRunning,
185
+ restart,
186
+ children,
187
+ });
188
+
174
189
  if (!serverAlreadyRunning || restart) {
175
- const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
176
- children.push(server);
177
- await waitForServerReady(internalServerUrl);
178
190
  console.log(`[local] server ready at ${internalServerUrl}`);
179
191
  } else {
180
192
  console.log(`[local] server already running at ${internalServerUrl}`);
@@ -186,66 +198,114 @@ async function main() {
186
198
  `export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
187
199
  );
188
200
 
189
- // Start daemon (detached daemon process managed by happy-cli)
190
- if (startDaemon) {
191
- await startLocalDaemonWithAuth({
192
- cliBin,
193
- cliHomeDir,
194
- internalServerUrl,
195
- publicServerUrl,
196
- isShuttingDown: () => shuttingDown,
197
- forceRestart: restart,
198
- });
201
+ // Reliability before daemon start:
202
+ // - Ensure schema exists (server-light: db push; happy-server: migrate deploy if tables missing)
203
+ // - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
204
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
205
+ await prepareDaemonAuthSeed({
206
+ rootDir,
207
+ env: baseEnv,
208
+ stackName,
209
+ cliHomeDir,
210
+ startDaemon,
211
+ isInteractive,
212
+ serverComponentName,
213
+ serverDir,
214
+ serverEnv,
215
+ quiet: false,
216
+ });
217
+
218
+ await startDevDaemon({
219
+ startDaemon,
220
+ cliBin,
221
+ cliHomeDir,
222
+ internalServerUrl,
223
+ publicServerUrl,
224
+ restart,
225
+ isShuttingDown: () => shuttingDown,
226
+ });
227
+
228
+ const cliWatcher = watchHappyCliAndRestartDaemon({
229
+ enabled: watchEnabled,
230
+ startDaemon,
231
+ buildCli,
232
+ cliDir,
233
+ cliBin,
234
+ cliHomeDir,
235
+ internalServerUrl,
236
+ publicServerUrl,
237
+ isShuttingDown: () => shuttingDown,
238
+ });
239
+ if (cliWatcher) watchers.push(cliWatcher);
240
+
241
+ const serverProcRef = { current: serverProc };
242
+ if (stackMode && runtimeStatePath && !serverProcRef.current?.pid) {
243
+ // If the server was already running when we started dev, `startDevServer` won't spawn a new process
244
+ // (and therefore we don't have a ChildProcess handle). For safe watch/restart we need a PID.
245
+ const state = await readStackRuntimeStateFile(runtimeStatePath);
246
+ const pid = state?.processes?.serverPid;
247
+ if (isPidAlive(pid)) {
248
+ serverProcRef.current = { pid: Number(pid), exitCode: null };
249
+ }
250
+ }
251
+ const serverWatcher = watchDevServerAndRestart({
252
+ enabled: watchEnabled && Boolean(serverProcRef.current?.pid),
253
+ stackMode,
254
+ serverComponentName,
255
+ serverDir,
256
+ serverPort,
257
+ internalServerUrl,
258
+ serverScript,
259
+ serverEnv,
260
+ runtimeStatePath,
261
+ stackName,
262
+ envPath,
263
+ children,
264
+ serverProcRef,
265
+ isShuttingDown: () => shuttingDown,
266
+ });
267
+ if (serverWatcher) watchers.push(serverWatcher);
268
+ if (watchEnabled && stackMode && serverComponentName === 'happy-server' && !serverWatcher) {
269
+ console.warn(
270
+ `[local] watch: server restart is disabled because the running server PID is unknown.\n` +
271
+ `[local] watch: fix: re-run with --restart so Happy Stacks can (re)spawn the server and track its PID.`
272
+ );
199
273
  }
200
274
 
201
- // Start UI (Expo web dev server)
275
+ const uiRes = await startDevExpoWebUi({
276
+ startUi,
277
+ uiDir,
278
+ autostart,
279
+ baseEnv,
280
+ apiServerUrl: uiApiUrl,
281
+ restart,
282
+ stackMode,
283
+ runtimeStatePath,
284
+ stackName,
285
+ envPath,
286
+ children,
287
+ });
202
288
  if (startUi) {
203
- await ensureDepsInstalled(uiDir, 'happy');
204
- const uiEnv = { ...baseEnv };
205
- delete uiEnv.CI;
206
- uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = publicServerUrl;
207
- uiEnv.EXPO_PUBLIC_DEBUG = uiEnv.EXPO_PUBLIC_DEBUG ?? '1';
208
-
209
- await ensureExpoIsolationEnv({
210
- env: uiEnv,
211
- stateDir: uiPaths.stateDir,
212
- expoHomeDir: uiPaths.expoHomeDir,
213
- tmpDir: uiPaths.tmpDir,
214
- });
215
-
216
- // Expo uses Metro (default 8081). If it's already used by another worktree/stack,
217
- // Expo prompts to pick another port, which fails in non-interactive mode.
218
- // Pick a free port up-front to make LLM/CI/service runs reliable.
219
- const defaultMetroPort = 8081;
220
- const metroPort = await pickNextFreeTcpPort(defaultMetroPort);
221
- uiEnv.RCT_METRO_PORT = String(metroPort);
222
- // eslint-disable-next-line no-console
223
- console.log(`[local] ui: starting Expo web (metro port=${metroPort})`);
224
-
225
- const uiArgs = ['start', '--web', '--port', String(metroPort)];
226
- if (wantsExpoClearCache({ env: baseEnv })) {
227
- uiArgs.push('--clear');
289
+ const host = resolveLocalhostHost({ stackMode, stackName });
290
+ if (uiRes?.reason === 'already_running' && uiRes.port) {
291
+ console.log(`[local] ui already running (pid=${uiRes.pid}, port=${uiRes.port})`);
292
+ console.log(`[local] ui: open http://${host}:${uiRes.port}`);
293
+ } else if (uiRes?.skipped === false && uiRes.port) {
294
+ console.log(`[local] ui: open http://${host}:${uiRes.port}`);
295
+ } else if (uiRes?.skipped && uiRes?.reason === 'already_running') {
296
+ console.log('[local] ui already running (skipping Expo start)');
228
297
  }
229
298
 
230
- if (!uiAlreadyRunning || restart) {
231
- if (restart && uiRunning.state?.pid) {
232
- const prevPid = Number(uiRunning.state.pid);
233
- const prevPort = Number(uiRunning.state.port);
234
- if (Number.isFinite(prevPort) && prevPort > 0) {
235
- await killPortListeners(prevPort, { label: 'ui' });
236
- }
237
- await killPid(prevPid);
238
- uiAlreadyRunning = false;
239
- }
240
- const ui = await pmSpawnBin({ label: 'ui', dir: uiDir, bin: 'expo', args: uiArgs, env: uiEnv });
241
- children.push(ui);
242
- try {
243
- await writePidState(uiPaths.statePath, { pid: ui.pid, port: metroPort, uiDir, startedAt: new Date().toISOString() });
244
- } catch {
245
- // ignore
299
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
300
+ const shouldOpen = isInteractive && !noBrowser && Boolean(uiRes?.port);
301
+ if (shouldOpen) {
302
+ const url = `http://${host}:${uiRes.port}`;
303
+ // Prefer localhost for readiness checks (faster/more reliable), but open the stack-scoped hostname.
304
+ await waitForHttpOk(`http://localhost:${uiRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
305
+ const res = await openUrlInBrowser(url);
306
+ if (!res.ok) {
307
+ console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
246
308
  }
247
- } else {
248
- console.log('[local] ui already running (skipping Expo start)');
249
309
  }
250
310
  }
251
311
 
@@ -256,6 +316,14 @@ async function main() {
256
316
  shuttingDown = true;
257
317
  console.log('\n[local] shutting down...');
258
318
 
319
+ for (const w of watchers) {
320
+ try {
321
+ w.close();
322
+ } catch {
323
+ // ignore
324
+ }
325
+ }
326
+
259
327
  if (startDaemon) {
260
328
  await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
261
329
  }
@@ -1,19 +1,21 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { pathExists } from './utils/fs.mjs';
4
4
  import { runCapture } from './utils/proc.mjs';
5
5
  import { getComponentDir, getDefaultAutostartPaths, getHappyStacksHomeDir, getRootDir, getWorkspaceDir, resolveStackEnvPath } from './utils/paths.mjs';
6
6
  import { killPortListeners } from './utils/ports.mjs';
7
7
  import { getServerComponentName } from './utils/server.mjs';
8
8
  import { daemonStatusSummary } from './daemon.mjs';
9
- import { tailscaleServeStatus, resolvePublicServerUrl } from './tailscale.mjs';
9
+ import { tailscaleServeStatus } from './tailscale.mjs';
10
10
  import { homedir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { readFile } from 'node:fs/promises';
14
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
14
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
15
  import { getRuntimeDir } from './utils/runtime.mjs';
16
16
  import { assertServerComponentDirMatches } from './utils/validate.mjs';
17
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server_urls.mjs';
18
+ import { resolveStackContext } from './utils/stack_context.mjs';
17
19
 
18
20
  /**
19
21
  * Doctor script for common happy-stacks failure modes.
@@ -115,27 +117,23 @@ async function main() {
115
117
  const runtimeVersion = await readPkgVersion(runtimePkgJson);
116
118
  const updateCache = existsSync(updateCachePath) ? await readJsonSafe(updateCachePath) : null;
117
119
 
118
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT?.trim() ? Number(process.env.HAPPY_LOCAL_SERVER_PORT) : 3005;
119
- const internalServerUrl = `http://127.0.0.1:${serverPort}`;
120
+ const autostart = getDefaultAutostartPaths();
121
+ const stackCtx = resolveStackContext({ env: process.env, autostart });
122
+ const stackMode = stackCtx.stackMode;
120
123
 
121
- const defaultPublicUrl = `http://localhost:${serverPort}`;
122
- const envPublicUrl = process.env.HAPPY_LOCAL_SERVER_URL?.trim() ? process.env.HAPPY_LOCAL_SERVER_URL.trim() : '';
123
- const resolved = await resolvePublicServerUrl({
124
- internalServerUrl,
125
- defaultPublicUrl,
126
- envPublicUrl,
127
- allowEnable: false,
128
- });
129
- const publicServerUrl = resolved.publicServerUrl;
124
+ const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
125
+ const resolvedUrls = await resolveServerUrls({ serverPort, allowEnable: false });
126
+ const internalServerUrl = resolvedUrls.internalServerUrl;
127
+ const publicServerUrl = resolvedUrls.publicServerUrl;
130
128
 
131
129
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
132
130
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
133
- : join(getDefaultAutostartPaths().baseDir, 'cli');
131
+ : join(autostart.baseDir, 'cli');
134
132
 
135
133
  const serveUi = (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
136
134
  const uiBuildDir = process.env.HAPPY_LOCAL_UI_BUILD_DIR?.trim()
137
135
  ? process.env.HAPPY_LOCAL_UI_BUILD_DIR.trim()
138
- : join(getDefaultAutostartPaths().baseDir, 'ui');
136
+ : join(autostart.baseDir, 'ui');
139
137
 
140
138
  const serverComponentName = getServerComponentName({ kv });
141
139
  if (serverComponentName === 'both') {
@@ -206,8 +204,15 @@ async function main() {
206
204
  report.checks.serverHealth = { ok: false };
207
205
  if (!json) console.log(`❌ server health: unreachable (${internalServerUrl})`);
208
206
  if (fix) {
209
- if (!json) console.log(`↪ attempting fix: freeing tcp:${serverPort}`);
210
- await killPortListeners(serverPort, { label: 'doctor' });
207
+ if (stackMode) {
208
+ if (!json) {
209
+ console.log(`↪ fix skipped: refusing to kill unknown port listeners in stack mode.`);
210
+ console.log(`↪ Fix: use stack-safe controls instead: happys stack stop ${process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? 'main'} --aggressive`);
211
+ }
212
+ } else {
213
+ if (!json) console.log(`↪ attempting fix: freeing tcp:${serverPort}`);
214
+ await killPortListeners(serverPort, { label: 'doctor' });
215
+ }
211
216
  }
212
217
  }
213
218
 
@@ -304,7 +309,7 @@ async function main() {
304
309
  }
305
310
  } catch {
306
311
  report.checks.happyOnPath = { ok: false };
307
- if (!json) console.log('ℹ️ happy on PATH: not found (run: happys init --install-path, or add ~/.happy-stacks/bin to PATH)');
312
+ if (!json) console.log(`ℹ️ happy on PATH: not found (run: happys init --install-path, or add ${join(getHappyStacksHomeDir(), 'bin')} to PATH)`);
308
313
  }
309
314
 
310
315
  // happys on PATH
@@ -316,7 +321,7 @@ async function main() {
316
321
  }
317
322
  } catch {
318
323
  report.checks.happysOnPath = { ok: false };
319
- if (!json) console.log('ℹ️ happys on PATH: not found (run: happys init --install-path, or add ~/.happy-stacks/bin to PATH)');
324
+ if (!json) console.log(`ℹ️ happys on PATH: not found (run: happys init --install-path, or add ${join(getHappyStacksHomeDir(), 'bin')} to PATH)`);
320
325
  }
321
326
 
322
327
  if (!json) {
@@ -326,7 +331,7 @@ async function main() {
326
331
  console.log('- Install a stable runtime (recommended for SwiftBar/services): happys self update');
327
332
  }
328
333
  if (!report.checks.happysOnPath?.ok) {
329
- console.log('- Add shims to PATH: export PATH="$HOME/.happy-stacks/bin:$PATH" (or: happys init --install-path)');
334
+ console.log(`- Add shims to PATH: export PATH="${join(getHappyStacksHomeDir(), 'bin')}:$PATH" (or: happys init --install-path)`);
330
335
  }
331
336
  console.log('');
332
337
  }