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
@@ -1,11 +1,13 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { killPortListeners, pickNextFreeTcpPort } from './utils/ports.mjs';
4
- import { run, runCapture, spawnProc } from './utils/proc.mjs';
5
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
- import { ensureDepsInstalled, pmExecBin, pmSpawnBin, requireDir } from './utils/pm.mjs';
7
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
- import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { pickNextFreeTcpPort } from './utils/net/ports.mjs';
4
+ import { run, runCapture, spawnProc } from './utils/proc/proc.mjs';
5
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
6
+ import { ensureDepsInstalled, pmExecBin, pmSpawnBin, requireDir } from './utils/proc/pm.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
+ import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo/expo.mjs';
9
+ import { killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
10
+ import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv } from './utils/server/urls.mjs';
9
11
 
10
12
  /**
11
13
  * Mobile dev helper for the embedded `components/happy` Expo app.
@@ -57,7 +59,7 @@ async function main() {
57
59
  '',
58
60
  'Notes:',
59
61
  '- This script is designed to avoid editing upstream `components/happy` config in-place.',
60
- '- It sets EXPO_PUBLIC_HAPPY_SERVER_URL from HAPPY_STACKS_SERVER_URL (legacy: HAPPY_LOCAL_SERVER_URL) if provided.',
62
+ '- If you explicitly set HAPPY_STACKS_SERVER_URL (legacy: HAPPY_LOCAL_SERVER_URL), it bakes that URL into the app via EXPO_PUBLIC_HAPPY_SERVER_URL.',
61
63
  ].join('\n'),
62
64
  });
63
65
  return;
@@ -140,10 +142,10 @@ async function main() {
140
142
 
141
143
  // Allow happy-stacks to define the default server URL baked into the app bundle.
142
144
  // This is read by the app via `process.env.EXPO_PUBLIC_HAPPY_SERVER_URL`.
143
- const stacksServerUrl =
144
- process.env.HAPPY_STACKS_SERVER_URL?.trim() || process.env.HAPPY_LOCAL_SERVER_URL?.trim() || '';
145
- if (stacksServerUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
146
- env.EXPO_PUBLIC_HAPPY_SERVER_URL = stacksServerUrl;
145
+ const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
146
+ const { envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort });
147
+ if (envPublicUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
148
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = envPublicUrl;
147
149
  }
148
150
 
149
151
  if (json) {
@@ -306,11 +308,16 @@ async function main() {
306
308
  }
307
309
  if (restart && running.state?.pid) {
308
310
  const prevPid = Number(running.state.pid);
309
- const prevPort = Number(running.state.port);
310
- if (Number.isFinite(prevPort) && prevPort > 0) {
311
- await killPortListeners(prevPort, { label: 'expo' });
311
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || autostart.stackName;
312
+ const envPath = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
313
+ const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-mobile', json: true });
314
+ if (!res.killed) {
315
+ // eslint-disable-next-line no-console
316
+ console.warn(
317
+ `[mobile] not stopping existing Metro pid=${prevPid} because it does not look stack-owned.\n` +
318
+ `[mobile] continuing by starting a new Metro on a free port.`
319
+ );
312
320
  }
313
- await killPid(prevPid);
314
321
  }
315
322
 
316
323
  const requestedPort = Number.parseInt(String(portRaw), 10);
package/scripts/run.mjs CHANGED
@@ -1,19 +1,25 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { pathExists } from './utils/fs.mjs';
4
- import { killProcessTree, runCapture, spawnProc } from './utils/proc.mjs';
5
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
- import { killPortListeners } from './utils/ports.mjs';
7
- import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server.mjs';
8
- import { ensureDepsInstalled, pmExecBin, pmSpawnScript, requireDir } from './utils/pm.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 { killProcessTree, runCapture, spawnProc } from './utils/proc/proc.mjs';
5
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
6
+ import { killPortListeners } from './utils/net/ports.mjs';
7
+ import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server/server.mjs';
8
+ import { ensureCliBuilt, ensureDepsInstalled, pmExecBin, pmSpawnScript, requireDir } from './utils/proc/pm.mjs';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { setTimeout as delay } from 'node:timers/promises';
12
- import { maybeResetTailscaleServe, resolvePublicServerUrl } from './tailscale.mjs';
12
+ import { maybeResetTailscaleServe } from './tailscale.mjs';
13
13
  import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
14
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
15
- import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/validate.mjs';
16
- import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/happy_server_infra.mjs';
14
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
+ import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/server/validate.mjs';
16
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/server/infra/happy_server_infra.mjs';
17
+ import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './utils/stack/startup.mjs';
18
+ import { recordStackRuntimeStart, recordStackRuntimeUpdate } from './utils/stack/runtime_state.mjs';
19
+ import { resolveStackContext } from './utils/stack/context.mjs';
20
+ import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
21
+ import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
22
+ import { openUrlInBrowser } from './utils/ui/browser.mjs';
17
23
 
18
24
  /**
19
25
  * Run the local stack in "production-like" mode:
@@ -31,7 +37,7 @@ async function main() {
31
37
  if (wantsHelp(argv, { flags })) {
32
38
  printResult({
33
39
  json,
34
- 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', '--no-browser'], json: true },
35
41
  text: [
36
42
  '[start] usage:',
37
43
  ' happys start [--server=happy-server|happy-server-light] [--restart] [--json]',
@@ -44,17 +50,14 @@ async function main() {
44
50
 
45
51
  const rootDir = getRootDir(import.meta.url);
46
52
 
47
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
48
- ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
49
- : 3005;
53
+ const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
50
54
 
51
55
  // Internal URL used by local processes on this machine.
52
56
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
53
57
  // Public URL is what you might share/open (e.g. https://<machine>.<tailnet>.ts.net).
54
58
  // We auto-prefer the Tailscale HTTPS URL when available, unless explicitly overridden.
55
- const defaultPublicUrl = `http://localhost:${serverPort}`;
56
- const envPublicUrl = process.env.HAPPY_LOCAL_SERVER_URL?.trim() ? process.env.HAPPY_LOCAL_SERVER_URL.trim() : '';
57
- let publicServerUrl = envPublicUrl || defaultPublicUrl;
59
+ const { defaultPublicUrl, envPublicUrl, publicServerUrl: publicServerUrlPreview } = getPublicServerUrlEnvOverride({ serverPort });
60
+ let publicServerUrl = publicServerUrlPreview;
58
61
 
59
62
  const serverComponentName = getServerComponentName({ kv });
60
63
  if (serverComponentName === 'both') {
@@ -64,6 +67,7 @@ async function main() {
64
67
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
65
68
  const serveUiWanted = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
66
69
  const serveUi = serveUiWanted;
70
+ const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
67
71
  const uiPrefix = process.env.HAPPY_LOCAL_UI_PREFIX?.trim() ? process.env.HAPPY_LOCAL_UI_PREFIX.trim() : '/';
68
72
  const autostart = getDefaultAutostartPaths();
69
73
  const uiBuildDir = process.env.HAPPY_LOCAL_UI_BUILD_DIR?.trim()
@@ -120,18 +124,29 @@ async function main() {
120
124
  const children = [];
121
125
  let shuttingDown = false;
122
126
  const baseEnv = { ...process.env };
127
+ const stackCtx = resolveStackContext({ env: baseEnv, autostart });
128
+ const { stackMode, runtimeStatePath, stackName, ephemeral } = stackCtx;
129
+
130
+ // Ensure happy-cli is install+build ready before starting the daemon.
131
+ const buildCli = (baseEnv.HAPPY_STACKS_CLI_BUILD ?? baseEnv.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
132
+ await ensureCliBuilt(cliDir, { buildCli });
123
133
 
124
134
  // Ensure server deps exist before any Prisma/docker work.
125
135
  await ensureDepsInstalled(serverDir, serverComponentName);
126
136
 
127
- // Public URL automation: auto-prefer https://*.ts.net on every start.
128
- const resolved = await resolvePublicServerUrl({
129
- internalServerUrl,
130
- defaultPublicUrl,
131
- envPublicUrl,
132
- allowEnable: true,
133
- });
134
- publicServerUrl = resolved.publicServerUrl;
137
+ // Public URL automation:
138
+ // - Only the main stack should ever auto-enable Tailscale Serve by default.
139
+ // - Non-main stacks default to localhost unless the user explicitly configured a public URL
140
+ // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
141
+ const allowEnableTailscale = !stackMode || stackName === 'main';
142
+ const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
143
+ if (stackMode && stackName !== 'main' && !resolvedUrls.envPublicUrl) {
144
+ const src = String(resolvedUrls.publicServerUrlSource ?? '');
145
+ const hasStackScopedTailscale = src.startsWith('tailscale-');
146
+ publicServerUrl = hasStackScopedTailscale ? resolvedUrls.publicServerUrl : resolvedUrls.defaultPublicUrl;
147
+ } else {
148
+ publicServerUrl = resolvedUrls.publicServerUrl;
149
+ }
135
150
 
136
151
  const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
137
152
  const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
@@ -140,9 +155,21 @@ async function main() {
140
155
  return;
141
156
  }
142
157
 
158
+ // Stack runtime state (stack-scoped commands only): record the runner PID + chosen ports so stop/restart never kills other stacks.
159
+ if (stackMode && runtimeStatePath) {
160
+ await recordStackRuntimeStart(runtimeStatePath, {
161
+ stackName,
162
+ script: 'run.mjs',
163
+ ephemeral,
164
+ ownerPid: process.pid,
165
+ ports: { server: serverPort },
166
+ }).catch(() => {});
167
+ }
168
+
143
169
  // Server
144
170
  // If a previous run left a server behind, free the port first (prevents false "ready" checks).
145
- if (!serverAlreadyRunning || restart) {
171
+ // NOTE: In stack mode we avoid killing arbitrary port listeners (fail-closed instead).
172
+ if ((!serverAlreadyRunning || restart) && !stackMode) {
146
173
  await killPortListeners(serverPort, { label: 'server' });
147
174
  }
148
175
 
@@ -161,6 +188,8 @@ async function main() {
161
188
  }
162
189
  : {}),
163
190
  };
191
+ let serverLightAccountCount = null;
192
+ let happyServerAccountCount = null;
164
193
  if (serverComponentName === 'happy-server-light') {
165
194
  const dataDir = baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR?.trim()
166
195
  ? baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR.trim()
@@ -173,12 +202,14 @@ async function main() {
173
202
  ? baseEnv.DATABASE_URL.trim()
174
203
  : `file:${join(dataDir, 'happy-server-light.sqlite')}`;
175
204
 
176
- // Optional: update SQLite schema on interactive start only.
177
- // We intentionally do NOT run this under launchd KeepAlive (no TTY) to avoid restart loops.
178
- const prismaPushOnStart = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '').trim() === '1';
179
- if (prismaPushOnStart && process.stdout.isTTY) {
180
- await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['db', 'push'], env: serverEnv });
181
- }
205
+ // Reliability: ensure DB schema exists before daemon hits /v1/machines (health checks don't cover DB readiness).
206
+ const acct = await getAccountCountForServerComponent({
207
+ serverComponentName,
208
+ serverDir,
209
+ env: serverEnv,
210
+ bestEffort: false,
211
+ });
212
+ serverLightAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
182
213
  }
183
214
  let effectiveInternalServerUrl = internalServerUrl;
184
215
  if (serverComponentName === 'happy-server') {
@@ -198,16 +229,32 @@ async function main() {
198
229
  const backendPortRaw = (baseEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? baseEnv.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT ?? '').trim();
199
230
  const backendPort = backendPortRaw ? Number(backendPortRaw) : serverPort + 10;
200
231
  const backendUrl = `http://127.0.0.1:${backendPort}`;
201
- await killPortListeners(backendPort, { label: 'happy-server-backend' });
232
+ if (!stackMode) {
233
+ await killPortListeners(backendPort, { label: 'happy-server-backend' });
234
+ }
202
235
 
203
236
  const backendEnv = { ...serverEnv, ...infra.env, PORT: String(backendPort) };
204
237
  const autoMigrate = (baseEnv.HAPPY_STACKS_PRISMA_MIGRATE ?? baseEnv.HAPPY_LOCAL_PRISMA_MIGRATE ?? '1') !== '0';
205
238
  if (autoMigrate) {
206
239
  await applyHappyServerMigrations({ serverDir, env: backendEnv });
207
240
  }
241
+ // Account probe should use the *actual* DATABASE_URL/infra env (ephemeral stacks do not persist it in env files).
242
+ const acct = await getAccountCountForServerComponent({
243
+ serverComponentName,
244
+ serverDir,
245
+ env: backendEnv,
246
+ bestEffort: true,
247
+ });
248
+ happyServerAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
208
249
 
209
250
  const backend = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'start', env: backendEnv });
210
251
  children.push(backend);
252
+ if (stackMode && runtimeStatePath) {
253
+ await recordStackRuntimeUpdate(runtimeStatePath, {
254
+ ports: { server: serverPort, backend: backendPort },
255
+ processes: { happyServerBackendPid: backend.pid },
256
+ }).catch(() => {});
257
+ }
211
258
  await waitForServerReady(backendUrl);
212
259
 
213
260
  const gatewayArgs = [
@@ -225,6 +272,9 @@ async function main() {
225
272
 
226
273
  const gateway = spawnProc('ui', process.execPath, gatewayArgs, { ...backendEnv, PORT: String(serverPort) }, { cwd: rootDir });
227
274
  children.push(gateway);
275
+ if (stackMode && runtimeStatePath) {
276
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { uiGatewayPid: gateway.pid } }).catch(() => {});
277
+ }
228
278
  await waitForServerReady(internalServerUrl);
229
279
  effectiveInternalServerUrl = internalServerUrl;
230
280
 
@@ -237,6 +287,9 @@ async function main() {
237
287
  if (!serverAlreadyRunning || restart) {
238
288
  const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'start', env: serverEnv });
239
289
  children.push(server);
290
+ if (stackMode && runtimeStatePath) {
291
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: server.pid } }).catch(() => {});
292
+ }
240
293
  await waitForServerReady(internalServerUrl);
241
294
  } else {
242
295
  console.log(`[local] server already running at ${internalServerUrl}`);
@@ -274,10 +327,44 @@ async function main() {
274
327
  `export HAPPY_HOME_DIR=\"${cliHomeDir}\"\n` +
275
328
  `export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
276
329
  );
330
+
331
+ // Auto-open UI (interactive only) using the stack-scoped hostname when applicable.
332
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
333
+ if (isInteractive && !noBrowser) {
334
+ const host = resolveLocalhostHost({ stackMode, stackName: autostart.stackName });
335
+ const prefix = uiPrefix.startsWith('/') ? uiPrefix : `/${uiPrefix}`;
336
+ const openUrl = `http://${host}:${serverPort}${prefix}`;
337
+ const res = await openUrlInBrowser(openUrl);
338
+ if (!res.ok) {
339
+ console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
340
+ }
341
+ }
277
342
  }
278
343
 
279
344
  // Daemon
280
345
  if (startDaemon) {
346
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
347
+ if (serverComponentName === 'happy-server' && happyServerAccountCount == null) {
348
+ const acct = await getAccountCountForServerComponent({
349
+ serverComponentName,
350
+ serverDir,
351
+ env: serverEnv,
352
+ bestEffort: true,
353
+ });
354
+ happyServerAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
355
+ }
356
+ const accountCount =
357
+ serverComponentName === 'happy-server-light' ? serverLightAccountCount : happyServerAccountCount;
358
+ await prepareDaemonAuthSeedIfNeeded({
359
+ rootDir,
360
+ env: baseEnv,
361
+ stackName: autostart.stackName,
362
+ cliHomeDir,
363
+ startDaemon,
364
+ isInteractive,
365
+ accountCount,
366
+ quiet: false,
367
+ });
281
368
  await startLocalDaemonWithAuth({
282
369
  cliBin,
283
370
  cliHomeDir,
package/scripts/self.mjs CHANGED
@@ -1,20 +1,18 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
 
3
3
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
5
  import { join } from 'node:path';
7
6
 
8
- import { parseArgs } from './utils/args.mjs';
9
- import { pathExists } from './utils/fs.mjs';
10
- import { run, runCapture } from './utils/proc.mjs';
11
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
12
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
13
- import { getRuntimeDir } from './utils/runtime.mjs';
14
-
15
- function expandHome(p) {
16
- return p.replace(/^~(?=\/)/, homedir());
17
- }
7
+ import { parseArgs } from './utils/cli/args.mjs';
8
+ import { pathExists } from './utils/fs/fs.mjs';
9
+ import { run, runCapture } from './utils/proc/proc.mjs';
10
+ import { expandHome } from './utils/paths/canonical_home.mjs';
11
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths/paths.mjs';
12
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
13
+ import { getRuntimeDir } from './utils/paths/runtime.mjs';
14
+ import { readJsonIfExists } from './utils/fs/json.mjs';
15
+ import { readPackageJsonVersion } from './utils/fs/package_json.mjs';
18
16
 
19
17
  function cachePaths() {
20
18
  const home = getHappyStacksHomeDir();
@@ -25,15 +23,6 @@ function cachePaths() {
25
23
  };
26
24
  }
27
25
 
28
- async function readJsonSafe(path) {
29
- try {
30
- const raw = await readFile(path, 'utf-8');
31
- return JSON.parse(raw);
32
- } catch {
33
- return null;
34
- }
35
- }
36
-
37
26
  async function writeJsonSafe(path, obj) {
38
27
  try {
39
28
  await mkdir(join(path, '..'), { recursive: true });
@@ -47,25 +36,14 @@ async function writeJsonSafe(path, obj) {
47
36
  }
48
37
  }
49
38
 
50
- async function readPkgVersion(pkgJsonPath) {
51
- try {
52
- const raw = await readFile(pkgJsonPath, 'utf-8');
53
- const pkg = JSON.parse(raw);
54
- const v = String(pkg.version ?? '').trim();
55
- return v || null;
56
- } catch {
57
- return null;
58
- }
59
- }
60
-
61
39
  async function getRuntimeInstalledVersion() {
62
40
  const runtimeDir = getRuntimeDir();
63
41
  const pkgJson = join(runtimeDir, 'node_modules', 'happy-stacks', 'package.json');
64
- return await readPkgVersion(pkgJson);
42
+ return await readPackageJsonVersion(pkgJson);
65
43
  }
66
44
 
67
45
  async function getInvokerVersion({ rootDir }) {
68
- return await readPkgVersion(join(rootDir, 'package.json'));
46
+ return await readPackageJsonVersion(join(rootDir, 'package.json'));
69
47
  }
70
48
 
71
49
  async function fetchLatestVersion() {
@@ -106,7 +84,7 @@ async function cmdStatus({ rootDir, argv }) {
106
84
  const runtimeDir = getRuntimeDir();
107
85
  const runtimeVersion = await getRuntimeInstalledVersion();
108
86
 
109
- const cached = await readJsonSafe(updateJson);
87
+ const cached = await readJsonIfExists(updateJson, { defaultValue: null });
110
88
 
111
89
  let latest = cached?.latest ?? null;
112
90
  let checkedAt = cached?.checkedAt ?? null;
@@ -1,10 +1,10 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { getRootDir } from './utils/paths.mjs';
4
- import { ensureEnvFileUpdated } from './utils/env_file.mjs';
5
- import { resolveUserConfigEnvPath } from './utils/config.mjs';
6
- import { isTty, promptSelect, withRl } from './utils/wizard.mjs';
7
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { getRootDir } from './utils/paths/paths.mjs';
4
+ import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
5
+ import { resolveUserConfigEnvPath } from './utils/env/config.mjs';
6
+ import { isTty, promptSelect, withRl } from './utils/cli/wizard.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
8
 
9
9
  const FLAVORS = [
10
10
  { label: 'happy-server-light (recommended default, serves UI)', value: 'happy-server-light' },
@@ -1,15 +1,19 @@
1
- import './utils/env.mjs';
2
- import { run, runCapture } from './utils/proc.mjs';
3
- import { getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
4
- import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/pm.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { run, runCapture } from './utils/proc/proc.mjs';
3
+ import { getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
4
+ import { getInternalServerUrl, getPublicServerUrlEnvOverride } from './utils/server/urls.mjs';
5
+ import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/service/autostart_darwin.mjs';
6
+ import { getCanonicalHomeDir } from './utils/env/config.mjs';
7
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
5
8
  import { spawn } from 'node:child_process';
6
9
  import { homedir } from 'node:os';
7
10
  import { existsSync } from 'node:fs';
8
11
  import { rm } from 'node:fs/promises';
9
12
  import { dirname, join, resolve } from 'node:path';
10
13
  import { fileURLToPath } from 'node:url';
11
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
14
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
12
15
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
+ import { readLastLines } from './utils/fs/tail.mjs';
13
17
 
14
18
  /**
15
19
  * Manage the autostart service installed by `happys bootstrap -- --autostart`.
@@ -33,11 +37,6 @@ function getUid() {
33
37
  return Number.isFinite(n) ? n : null;
34
38
  }
35
39
 
36
- function getInternalUrl() {
37
- const port = process.env.HAPPY_LOCAL_SERVER_PORT?.trim() ? Number(process.env.HAPPY_LOCAL_SERVER_PORT) : 3005;
38
- return `http://127.0.0.1:${port}`;
39
- }
40
-
41
40
  function getAutostartEnv({ rootDir }) {
42
41
  // IMPORTANT:
43
42
  // LaunchAgents should NOT bake the entire config into the plist, because that would require
@@ -63,6 +62,13 @@ function getAutostartEnv({ rootDir }) {
63
62
  }
64
63
 
65
64
  export async function installService() {
65
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
66
+ throw new Error(
67
+ '[local] service install is disabled in sandbox mode.\n' +
68
+ 'Reason: services are global OS state (launchd/systemd) and can affect your real installation.\n' +
69
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
70
+ );
71
+ }
66
72
  if (process.platform !== 'darwin' && process.platform !== 'linux') {
67
73
  throw new Error('[local] service install is only supported on macOS (launchd) and Linux (systemd user).');
68
74
  }
@@ -89,6 +95,10 @@ export async function installService() {
89
95
  }
90
96
 
91
97
  export async function uninstallService() {
98
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
99
+ // Sandbox cleanups should be safe and should not touch global services by default.
100
+ return;
101
+ }
92
102
  if (process.platform !== 'darwin' && process.platform !== 'linux') return;
93
103
 
94
104
  if (process.platform === 'linux') {
@@ -132,7 +142,7 @@ function systemdEnvLines(env) {
132
142
  async function ensureSystemdUserServiceEnabled({ rootDir, label, env }) {
133
143
  const unitPath = systemdUnitPath();
134
144
  await mkdir(dirname(unitPath), { recursive: true });
135
- const happysShim = join(homedir(), '.happy-stacks', 'bin', 'happys');
145
+ const happysShim = join(getCanonicalHomeDir(), 'bin', 'happys');
136
146
  const entry = existsSync(happysShim) ? happysShim : join(rootDir, 'bin', 'happys.mjs');
137
147
  const exec = existsSync(happysShim) ? entry : `${process.execPath} ${entry}`;
138
148
 
@@ -254,16 +264,19 @@ async function startLaunchAgent({ persistent }) {
254
264
 
255
265
  async function postStartDiagnostics() {
256
266
  const rootDir = getRootDir(import.meta.url);
257
- const internalUrl = getInternalUrl();
267
+ const internalUrl = getInternalServerUrl({ env: process.env, defaultPort: 3005 }).internalServerUrl;
258
268
 
259
269
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
260
270
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
261
271
  : join(getDefaultAutostartPaths().baseDir, 'cli');
262
272
 
263
- const publicUrl =
264
- process.env.HAPPY_LOCAL_SERVER_URL?.trim()
265
- ? process.env.HAPPY_LOCAL_SERVER_URL.trim()
266
- : internalUrl.replace('127.0.0.1', 'localhost');
273
+ let port = 3005;
274
+ try {
275
+ port = Number(new URL(internalUrl).port || 0) || 3005;
276
+ } catch {
277
+ port = 3005;
278
+ }
279
+ const { publicServerUrl: publicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port });
267
280
 
268
281
  const cliDir = join(rootDir, 'components', 'happy-cli');
269
282
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
@@ -273,16 +286,6 @@ async function postStartDiagnostics() {
273
286
  const lockFile = join(cliHomeDir, 'daemon.state.json.lock');
274
287
  const logsDir = join(cliHomeDir, 'logs');
275
288
 
276
- const readLastLines = async (path, lines = 60) => {
277
- try {
278
- const raw = await readFile(path, 'utf-8');
279
- const parts = raw.split('\n');
280
- return parts.slice(Math.max(0, parts.length - lines)).join('\n');
281
- } catch {
282
- return null;
283
- }
284
- };
285
-
286
289
  const latestDaemonLog = async () => {
287
290
  try {
288
291
  const ls = await runCapture('bash', ['-lc', `ls -1t "${logsDir}"/*-daemon.log 2>/dev/null | head -1 || true`]);
@@ -448,7 +451,7 @@ async function waitForLaunchAgentStopped({ timeoutMs = 8000 } = {}) {
448
451
 
449
452
  async function showStatus() {
450
453
  const { plistPath, stdoutPath, stderrPath, label } = getDefaultAutostartPaths();
451
- const internalUrl = getInternalUrl();
454
+ const internalUrl = getInternalServerUrl({ env: process.env, defaultPort: 3005 }).internalServerUrl;
452
455
 
453
456
  console.log(`label: ${label}`);
454
457
  console.log(`plist: ${plistPath} ${existsSync(plistPath) ? '(present)' : '(missing)'}`);
@@ -537,7 +540,7 @@ async function main() {
537
540
  return;
538
541
  case 'status':
539
542
  if (json) {
540
- const internalUrl = getInternalUrl();
543
+ const internalUrl = getInternalServerUrl({ env: process.env, defaultPort: 3005 }).internalServerUrl;
541
544
  let health = null;
542
545
  try {
543
546
  const res = await fetch(`${internalUrl}/health`, { method: 'GET' });