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/dev.mjs CHANGED
@@ -1,17 +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
5
  import { killPortListeners } from './utils/ports.mjs';
6
- import { getServerComponentName, waitForServerReady } from './utils/server.mjs';
7
- import { ensureDepsInstalled, pmSpawnScript, requireDir } from './utils/pm.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 { startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
12
- import { resolvePublicServerUrl } from './tailscale.mjs';
13
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
14
- import { assertServerComponentDirMatches } from './utils/validate.mjs';
11
+ import { isDaemonRunning, stopLocalDaemon } from './daemon.mjs';
12
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
13
+ import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/validate.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
+ }
15
34
 
16
35
  /**
17
36
  * Dev mode stack:
@@ -27,10 +46,13 @@ async function main() {
27
46
  if (wantsHelp(argv, { flags })) {
28
47
  printResult({
29
48
  json,
30
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon'], json: true },
49
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser'], json: true },
31
50
  text: [
32
51
  '[dev] usage:',
33
- ' happys dev [--server=happy-server|happy-server-light] [--json]',
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',
34
56
  ' note: --json prints the resolved config (dry-run) and exits.',
35
57
  ].join('\n'),
36
58
  });
@@ -38,21 +60,6 @@ async function main() {
38
60
  }
39
61
  const rootDir = getRootDir(import.meta.url);
40
62
 
41
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
42
- ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
43
- : 3005;
44
-
45
- const internalServerUrl = `http://127.0.0.1:${serverPort}`;
46
- const defaultPublicUrl = `http://localhost:${serverPort}`;
47
- const envPublicUrl = process.env.HAPPY_LOCAL_SERVER_URL?.trim() ? process.env.HAPPY_LOCAL_SERVER_URL.trim() : '';
48
- const resolved = await resolvePublicServerUrl({
49
- internalServerUrl,
50
- defaultPublicUrl,
51
- envPublicUrl,
52
- allowEnable: true,
53
- });
54
- const publicServerUrl = resolved.publicServerUrl;
55
-
56
63
  const serverComponentName = getServerComponentName({ kv });
57
64
  if (serverComponentName === 'both') {
58
65
  throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
@@ -60,21 +67,46 @@ async function main() {
60
67
 
61
68
  const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
62
69
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
70
+ const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
63
71
 
64
72
  const serverDir = getComponentDir(rootDir, serverComponentName);
65
73
  const uiDir = getComponentDir(rootDir, 'happy');
66
74
  const cliDir = getComponentDir(rootDir, 'happy-cli');
67
75
 
68
76
  assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
77
+ assertServerPrismaProviderMatches({ serverComponentName, serverDir });
69
78
 
70
79
  await requireDir(serverComponentName, serverDir);
71
80
  await requireDir('happy', uiDir);
72
81
  await requireDir('happy-cli', cliDir);
73
82
 
74
83
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
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');
75
107
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
76
108
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
77
- : join(getDefaultAutostartPaths().baseDir, 'cli');
109
+ : join(autostart.baseDir, 'cli');
78
110
 
79
111
  if (json) {
80
112
  printResult({
@@ -98,23 +130,67 @@ async function main() {
98
130
 
99
131
  const children = [];
100
132
  let shuttingDown = false;
101
- const baseEnv = { ...process.env };
102
133
 
103
- // Start server
104
- await killPortListeners(serverPort, { label: 'server' });
105
- const serverEnv = {
106
- ...baseEnv,
107
- PORT: String(serverPort),
108
- PUBLIC_URL: publicServerUrl,
109
- // Avoid noisy failures if a previous run left the metrics port busy.
110
- METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
111
- };
112
- await ensureDepsInstalled(serverDir, serverComponentName);
113
- const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'dev', env: serverEnv });
114
- children.push(server);
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 });
115
138
 
116
- await waitForServerReady(internalServerUrl);
117
- console.log(`[local] server ready at ${internalServerUrl}`);
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 = [];
143
+
144
+ const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
145
+ const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
146
+
147
+ // UI dev server state (worktree-scoped)
148
+ const uiPaths = getExpoStatePaths({ baseDir: autostart.baseDir, kind: 'ui-dev', projectDir: uiDir, stateFileName: 'ui.state.json' });
149
+ const uiRunning = startUi ? await isStateProcessRunning(uiPaths.statePath) : { running: false, state: null };
150
+ let uiAlreadyRunning = Boolean(uiRunning.running);
151
+
152
+ if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startUi || uiAlreadyRunning)) {
153
+ console.log(`[local] dev: stack already running (server=${internalServerUrl}${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''}${startUi ? ` ui=${uiAlreadyRunning ? 'running' : 'stopped'}` : ''})`);
154
+ return;
155
+ }
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
+
167
+ // Start server (only if not already healthy)
168
+ // NOTE: In stack mode we avoid killing arbitrary port listeners (fail-closed instead).
169
+ if ((!serverAlreadyRunning || restart) && !stackMode) {
170
+ await killPortListeners(serverPort, { label: 'server' });
171
+ }
172
+
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
+
189
+ if (!serverAlreadyRunning || restart) {
190
+ console.log(`[local] server ready at ${internalServerUrl}`);
191
+ } else {
192
+ console.log(`[local] server already running at ${internalServerUrl}`);
193
+ }
118
194
  console.log(
119
195
  `[local] tip: to run 'happy' from your terminal *against this local server* (and have sessions show up in the UI), use:\n` +
120
196
  `export HAPPY_SERVER_URL=\"${internalServerUrl}\"\n` +
@@ -122,26 +198,115 @@ async function main() {
122
198
  `export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
123
199
  );
124
200
 
125
- // Start daemon (detached daemon process managed by happy-cli)
126
- if (startDaemon) {
127
- await startLocalDaemonWithAuth({
128
- cliBin,
129
- cliHomeDir,
130
- internalServerUrl,
131
- publicServerUrl,
132
- isShuttingDown: () => shuttingDown,
133
- });
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
+ );
134
273
  }
135
274
 
136
- // 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
+ });
137
288
  if (startUi) {
138
- await ensureDepsInstalled(uiDir, 'happy');
139
- const uiEnv = { ...baseEnv };
140
- delete uiEnv.CI;
141
- uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = publicServerUrl;
142
- uiEnv.EXPO_PUBLIC_DEBUG = uiEnv.EXPO_PUBLIC_DEBUG ?? '1';
143
- const ui = await pmSpawnScript({ label: 'ui', dir: uiDir, script: 'web', env: uiEnv });
144
- children.push(ui);
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)');
297
+ }
298
+
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}).`);
308
+ }
309
+ }
145
310
  }
146
311
 
147
312
  const shutdown = async () => {
@@ -151,6 +316,14 @@ async function main() {
151
316
  shuttingDown = true;
152
317
  console.log('\n[local] shutting down...');
153
318
 
319
+ for (const w of watchers) {
320
+ try {
321
+ w.close();
322
+ } catch {
323
+ // ignore
324
+ }
325
+ }
326
+
154
327
  if (startDaemon) {
155
328
  await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
156
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
  }