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/dev.mjs CHANGED
@@ -1,19 +1,27 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { killProcessTree } from './utils/proc.mjs';
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';
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { killProcessTree } from './utils/proc/proc.mjs';
4
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
5
+ import { killPortListeners } from './utils/net/ports.mjs';
6
+ import { getServerComponentName, isHappyServerRunning } from './utils/server/server.mjs';
7
+ import { requireDir } from './utils/proc/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';
14
- 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';
11
+ import { isDaemonRunning, stopLocalDaemon } from './daemon.mjs';
12
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
13
+ import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/server/validate.mjs';
14
+ import { getExpoStatePaths, isStateProcessRunning } from './utils/expo/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/paths/localhost_host.mjs';
22
+ import { openUrlInBrowser } from './utils/ui/browser.mjs';
23
+ import { waitForHttpOk } from './utils/server/server.mjs';
24
+ import { sanitizeDnsLabel } from './utils/net/dns.mjs';
17
25
 
18
26
  /**
19
27
  * Dev mode stack:
@@ -29,10 +37,13 @@ async function main() {
29
37
  if (wantsHelp(argv, { flags })) {
30
38
  printResult({
31
39
  json,
32
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart'], json: true },
40
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser'], json: true },
33
41
  text: [
34
42
  '[dev] usage:',
35
43
  ' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
44
+ ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
45
+ ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
46
+ ' happys dev --no-browser # do not open the UI in your browser automatically',
36
47
  ' note: --json prints the resolved config (dry-run) and exits.',
37
48
  ].join('\n'),
38
49
  });
@@ -40,21 +51,6 @@ async function main() {
40
51
  }
41
52
  const rootDir = getRootDir(import.meta.url);
42
53
 
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
54
  const serverComponentName = getServerComponentName({ kv });
59
55
  if (serverComponentName === 'both') {
60
56
  throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
@@ -62,7 +58,7 @@ async function main() {
62
58
 
63
59
  const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
64
60
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
65
- const restart = flags.has('--restart');
61
+ const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
66
62
 
67
63
  const serverDir = getComponentDir(rootDir, serverComponentName);
68
64
  const uiDir = getComponentDir(rootDir, 'happy');
@@ -77,6 +73,28 @@ async function main() {
77
73
 
78
74
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
79
75
  const autostart = getDefaultAutostartPaths();
76
+ const baseEnv = { ...process.env };
77
+ const stackCtx = resolveStackContext({ env: baseEnv, autostart });
78
+ const { stackMode, runtimeStatePath, stackName, envPath, ephemeral } = stackCtx;
79
+
80
+ const serverPort = resolveServerPortFromEnv({ env: baseEnv, defaultPort: 3005 });
81
+ // IMPORTANT:
82
+ // - Only the main stack should ever auto-enable (or prefer) Tailscale Serve by default.
83
+ // - Non-main stacks should default to localhost URLs unless the user explicitly configured a public URL
84
+ // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
85
+ const allowEnableTailscale = !stackMode || stackName === 'main';
86
+ const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
87
+ const internalServerUrl = resolvedUrls.internalServerUrl;
88
+ let publicServerUrl = resolvedUrls.publicServerUrl;
89
+ if (stackMode && stackName !== 'main' && !resolvedUrls.envPublicUrl) {
90
+ const src = String(resolvedUrls.publicServerUrlSource ?? '');
91
+ const hasStackScopedTailscale = src.startsWith('tailscale-');
92
+ if (!hasStackScopedTailscale) {
93
+ publicServerUrl = resolvedUrls.defaultPublicUrl;
94
+ }
95
+ }
96
+ const uiApiUrl = resolvedUrls.defaultPublicUrl;
97
+ const restart = flags.has('--restart');
80
98
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
81
99
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
82
100
  : join(autostart.baseDir, 'cli');
@@ -103,7 +121,16 @@ async function main() {
103
121
 
104
122
  const children = [];
105
123
  let shuttingDown = false;
106
- const baseEnv = { ...process.env };
124
+
125
+ // Ensure happy-cli is install+build ready before starting the daemon.
126
+ // Worktrees often don't have dist/ built yet, which causes MODULE_NOT_FOUND on dist/index.mjs.
127
+ const buildCli = (baseEnv.HAPPY_STACKS_CLI_BUILD ?? baseEnv.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
128
+ await ensureDevCliReady({ cliDir, buildCli });
129
+
130
+ // Watch mode (interactive only by default): rebuild happy-cli and restart daemon when code changes.
131
+ const watchEnabled =
132
+ flags.has('--watch') || (!flags.has('--no-watch') && Boolean(process.stdin.isTTY && process.stdout.isTTY));
133
+ const watchers = [];
107
134
 
108
135
  const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
109
136
  const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
@@ -118,63 +145,39 @@ async function main() {
118
145
  return;
119
146
  }
120
147
 
148
+ if (stackMode && runtimeStatePath) {
149
+ await recordStackRuntimeStart(runtimeStatePath, {
150
+ stackName,
151
+ script: 'dev.mjs',
152
+ ephemeral,
153
+ ownerPid: process.pid,
154
+ ports: { server: serverPort },
155
+ }).catch(() => {});
156
+ }
157
+
121
158
  // Start server (only if not already healthy)
122
- if (!serverAlreadyRunning || restart) {
159
+ // NOTE: In stack mode we avoid killing arbitrary port listeners (fail-closed instead).
160
+ if ((!serverAlreadyRunning || restart) && !stackMode) {
123
161
  await killPortListeners(serverPort, { label: 'server' });
124
162
  }
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
163
 
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';
164
+ const { serverEnv, serverScript, serverProc } = await startDevServer({
165
+ serverComponentName,
166
+ serverDir,
167
+ autostart,
168
+ baseEnv,
169
+ serverPort,
170
+ internalServerUrl,
171
+ publicServerUrl,
172
+ envPath,
173
+ stackMode,
174
+ runtimeStatePath,
175
+ serverAlreadyRunning,
176
+ restart,
177
+ children,
178
+ });
179
+
174
180
  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
181
  console.log(`[local] server ready at ${internalServerUrl}`);
179
182
  } else {
180
183
  console.log(`[local] server already running at ${internalServerUrl}`);
@@ -186,66 +189,114 @@ async function main() {
186
189
  `export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
187
190
  );
188
191
 
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
- });
192
+ // Reliability before daemon start:
193
+ // - Ensure schema exists (server-light: db push; happy-server: migrate deploy if tables missing)
194
+ // - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
195
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
196
+ await prepareDaemonAuthSeed({
197
+ rootDir,
198
+ env: baseEnv,
199
+ stackName,
200
+ cliHomeDir,
201
+ startDaemon,
202
+ isInteractive,
203
+ serverComponentName,
204
+ serverDir,
205
+ serverEnv,
206
+ quiet: false,
207
+ });
208
+
209
+ await startDevDaemon({
210
+ startDaemon,
211
+ cliBin,
212
+ cliHomeDir,
213
+ internalServerUrl,
214
+ publicServerUrl,
215
+ restart,
216
+ isShuttingDown: () => shuttingDown,
217
+ });
218
+
219
+ const cliWatcher = watchHappyCliAndRestartDaemon({
220
+ enabled: watchEnabled,
221
+ startDaemon,
222
+ buildCli,
223
+ cliDir,
224
+ cliBin,
225
+ cliHomeDir,
226
+ internalServerUrl,
227
+ publicServerUrl,
228
+ isShuttingDown: () => shuttingDown,
229
+ });
230
+ if (cliWatcher) watchers.push(cliWatcher);
231
+
232
+ const serverProcRef = { current: serverProc };
233
+ if (stackMode && runtimeStatePath && !serverProcRef.current?.pid) {
234
+ // If the server was already running when we started dev, `startDevServer` won't spawn a new process
235
+ // (and therefore we don't have a ChildProcess handle). For safe watch/restart we need a PID.
236
+ const state = await readStackRuntimeStateFile(runtimeStatePath);
237
+ const pid = state?.processes?.serverPid;
238
+ if (isPidAlive(pid)) {
239
+ serverProcRef.current = { pid: Number(pid), exitCode: null };
240
+ }
241
+ }
242
+ const serverWatcher = watchDevServerAndRestart({
243
+ enabled: watchEnabled && Boolean(serverProcRef.current?.pid),
244
+ stackMode,
245
+ serverComponentName,
246
+ serverDir,
247
+ serverPort,
248
+ internalServerUrl,
249
+ serverScript,
250
+ serverEnv,
251
+ runtimeStatePath,
252
+ stackName,
253
+ envPath,
254
+ children,
255
+ serverProcRef,
256
+ isShuttingDown: () => shuttingDown,
257
+ });
258
+ if (serverWatcher) watchers.push(serverWatcher);
259
+ if (watchEnabled && stackMode && serverComponentName === 'happy-server' && !serverWatcher) {
260
+ console.warn(
261
+ `[local] watch: server restart is disabled because the running server PID is unknown.\n` +
262
+ `[local] watch: fix: re-run with --restart so Happy Stacks can (re)spawn the server and track its PID.`
263
+ );
199
264
  }
200
265
 
201
- // Start UI (Expo web dev server)
266
+ const uiRes = await startDevExpoWebUi({
267
+ startUi,
268
+ uiDir,
269
+ autostart,
270
+ baseEnv,
271
+ apiServerUrl: uiApiUrl,
272
+ restart,
273
+ stackMode,
274
+ runtimeStatePath,
275
+ stackName,
276
+ envPath,
277
+ children,
278
+ });
202
279
  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');
280
+ const host = resolveLocalhostHost({ stackMode, stackName });
281
+ if (uiRes?.reason === 'already_running' && uiRes.port) {
282
+ console.log(`[local] ui already running (pid=${uiRes.pid}, port=${uiRes.port})`);
283
+ console.log(`[local] ui: open http://${host}:${uiRes.port}`);
284
+ } else if (uiRes?.skipped === false && uiRes.port) {
285
+ console.log(`[local] ui: open http://${host}:${uiRes.port}`);
286
+ } else if (uiRes?.skipped && uiRes?.reason === 'already_running') {
287
+ console.log('[local] ui already running (skipping Expo start)');
228
288
  }
229
289
 
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;
290
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
291
+ const shouldOpen = isInteractive && !noBrowser && Boolean(uiRes?.port);
292
+ if (shouldOpen) {
293
+ const url = `http://${host}:${uiRes.port}`;
294
+ // Prefer localhost for readiness checks (faster/more reliable), but open the stack-scoped hostname.
295
+ await waitForHttpOk(`http://localhost:${uiRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
296
+ const res = await openUrlInBrowser(url);
297
+ if (!res.ok) {
298
+ console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
239
299
  }
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
246
- }
247
- } else {
248
- console.log('[local] ui already running (skipping Expo start)');
249
300
  }
250
301
  }
251
302
 
@@ -256,6 +307,14 @@ async function main() {
256
307
  shuttingDown = true;
257
308
  console.log('\n[local] shutting down...');
258
309
 
310
+ for (const w of watchers) {
311
+ try {
312
+ w.close();
313
+ } catch {
314
+ // ignore
315
+ }
316
+ }
317
+
259
318
  if (startDaemon) {
260
319
  await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
261
320
  }
@@ -1,19 +1,24 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { pathExists } from './utils/fs.mjs';
4
- import { runCapture } from './utils/proc.mjs';
5
- import { getComponentDir, getDefaultAutostartPaths, getHappyStacksHomeDir, getRootDir, getWorkspaceDir, resolveStackEnvPath } from './utils/paths.mjs';
6
- import { killPortListeners } from './utils/ports.mjs';
7
- import { getServerComponentName } from './utils/server.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { pathExists } from './utils/fs/fs.mjs';
4
+ import { runCapture } from './utils/proc/proc.mjs';
5
+ import { resolveCommandPath } from './utils/proc/commands.mjs';
6
+ import { getComponentDir, getDefaultAutostartPaths, getHappyStacksHomeDir, getRootDir, getWorkspaceDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
7
+ import { killPortListeners } from './utils/net/ports.mjs';
8
+ import { getServerComponentName } from './utils/server/server.mjs';
9
+ import { fetchHappyHealth } from './utils/server/server.mjs';
8
10
  import { daemonStatusSummary } from './daemon.mjs';
9
- import { tailscaleServeStatus, resolvePublicServerUrl } from './tailscale.mjs';
11
+ import { tailscaleServeStatus } from './tailscale.mjs';
10
12
  import { homedir } from 'node:os';
11
13
  import { join } from 'node:path';
12
14
  import { existsSync } from 'node:fs';
13
- import { readFile } from 'node:fs/promises';
14
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
15
- import { getRuntimeDir } from './utils/runtime.mjs';
16
- import { assertServerComponentDirMatches } from './utils/validate.mjs';
15
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
16
+ import { getRuntimeDir } from './utils/paths/runtime.mjs';
17
+ import { assertServerComponentDirMatches } from './utils/server/validate.mjs';
18
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
19
+ import { resolveStackContext } from './utils/stack/context.mjs';
20
+ import { readJsonIfExists } from './utils/fs/json.mjs';
21
+ import { readPackageJsonVersion } from './utils/fs/package_json.mjs';
17
22
 
18
23
  /**
19
24
  * Doctor script for common happy-stacks failure modes.
@@ -41,7 +46,8 @@ async function fetchHealth(url) {
41
46
  };
42
47
 
43
48
  // Prefer /health when available, but fall back to / (matches waitForServerReady).
44
- const health = await tryGet('/health');
49
+ const healthRaw = await fetchHappyHealth(url);
50
+ const health = { ok: healthRaw.ok, status: healthRaw.status, body: healthRaw.text ? healthRaw.text.trim() : null };
45
51
  if (health.ok) {
46
52
  return health;
47
53
  }
@@ -52,26 +58,6 @@ async function fetchHealth(url) {
52
58
  return health.ok ? health : root;
53
59
  }
54
60
 
55
- async function readJsonSafe(path) {
56
- try {
57
- const raw = await readFile(path, 'utf-8');
58
- return JSON.parse(raw);
59
- } catch {
60
- return null;
61
- }
62
- }
63
-
64
- async function readPkgVersion(path) {
65
- try {
66
- const raw = await readFile(path, 'utf-8');
67
- const pkg = JSON.parse(raw);
68
- const v = String(pkg.version ?? '').trim();
69
- return v || null;
70
- } catch {
71
- return null;
72
- }
73
- }
74
-
75
61
  async function resolveSwiftbarPluginsDir() {
76
62
  if (process.platform !== 'darwin') {
77
63
  return null;
@@ -112,30 +98,26 @@ async function main() {
112
98
  const workspaceDir = getWorkspaceDir(rootDir);
113
99
  const updateCachePath = join(homeDir, 'cache', 'update.json');
114
100
  const runtimePkgJson = join(runtimeDir, 'node_modules', 'happy-stacks', 'package.json');
115
- const runtimeVersion = await readPkgVersion(runtimePkgJson);
116
- const updateCache = existsSync(updateCachePath) ? await readJsonSafe(updateCachePath) : null;
101
+ const runtimeVersion = await readPackageJsonVersion(runtimePkgJson);
102
+ const updateCache = await readJsonIfExists(updateCachePath, { defaultValue: null });
117
103
 
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}`;
104
+ const autostart = getDefaultAutostartPaths();
105
+ const stackCtx = resolveStackContext({ env: process.env, autostart });
106
+ const stackMode = stackCtx.stackMode;
120
107
 
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;
108
+ const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
109
+ const resolvedUrls = await resolveServerUrls({ serverPort, allowEnable: false });
110
+ const internalServerUrl = resolvedUrls.internalServerUrl;
111
+ const publicServerUrl = resolvedUrls.publicServerUrl;
130
112
 
131
113
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
132
114
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
133
- : join(getDefaultAutostartPaths().baseDir, 'cli');
115
+ : join(autostart.baseDir, 'cli');
134
116
 
135
117
  const serveUi = (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
136
118
  const uiBuildDir = process.env.HAPPY_LOCAL_UI_BUILD_DIR?.trim()
137
119
  ? process.env.HAPPY_LOCAL_UI_BUILD_DIR.trim()
138
- : join(getDefaultAutostartPaths().baseDir, 'ui');
120
+ : join(autostart.baseDir, 'ui');
139
121
 
140
122
  const serverComponentName = getServerComponentName({ kv });
141
123
  if (serverComponentName === 'both') {
@@ -206,8 +188,15 @@ async function main() {
206
188
  report.checks.serverHealth = { ok: false };
207
189
  if (!json) console.log(`❌ server health: unreachable (${internalServerUrl})`);
208
190
  if (fix) {
209
- if (!json) console.log(`↪ attempting fix: freeing tcp:${serverPort}`);
210
- await killPortListeners(serverPort, { label: 'doctor' });
191
+ if (stackMode) {
192
+ if (!json) {
193
+ console.log(`↪ fix skipped: refusing to kill unknown port listeners in stack mode.`);
194
+ console.log(`↪ Fix: use stack-safe controls instead: happys stack stop ${process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? 'main'} --aggressive`);
195
+ }
196
+ } else {
197
+ if (!json) console.log(`↪ attempting fix: freeing tcp:${serverPort}`);
198
+ await killPortListeners(serverPort, { label: 'doctor' });
199
+ }
211
200
  }
212
201
  }
213
202
 
@@ -297,26 +286,26 @@ async function main() {
297
286
 
298
287
  // happy wrapper
299
288
  try {
300
- const happyPath = (await runCapture('sh', ['-lc', 'command -v happy'])).trim();
289
+ const happyPath = await resolveCommandPath('happy');
301
290
  if (happyPath) {
302
291
  report.checks.happyOnPath = { ok: true, path: happyPath };
303
292
  if (!json) console.log(`✅ happy on PATH: ${happyPath}`);
304
293
  }
305
294
  } catch {
306
295
  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)');
296
+ if (!json) console.log(`ℹ️ happy on PATH: not found (run: happys init --install-path, or add ${join(getHappyStacksHomeDir(), 'bin')} to PATH)`);
308
297
  }
309
298
 
310
299
  // happys on PATH
311
300
  try {
312
- const happysPath = (await runCapture('sh', ['-lc', 'command -v happys'])).trim();
301
+ const happysPath = await resolveCommandPath('happys');
313
302
  if (happysPath) {
314
303
  report.checks.happysOnPath = { ok: true, path: happysPath };
315
304
  if (!json) console.log(`✅ happys on PATH: ${happysPath}`);
316
305
  }
317
306
  } catch {
318
307
  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)');
308
+ if (!json) console.log(`ℹ️ happys on PATH: not found (run: happys init --install-path, or add ${join(getHappyStacksHomeDir(), 'bin')} to PATH)`);
320
309
  }
321
310
 
322
311
  if (!json) {
@@ -326,7 +315,7 @@ async function main() {
326
315
  console.log('- Install a stable runtime (recommended for SwiftBar/services): happys self update');
327
316
  }
328
317
  if (!report.checks.happysOnPath?.ok) {
329
- console.log('- Add shims to PATH: export PATH="$HOME/.happy-stacks/bin:$PATH" (or: happys init --install-path)');
318
+ console.log(`- Add shims to PATH: export PATH="${join(getHappyStacksHomeDir(), 'bin')}:$PATH" (or: happys init --install-path)`);
330
319
  }
331
320
  console.log('');
332
321
  }