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/run.mjs CHANGED
@@ -1,18 +1,25 @@
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
- import { killProcessTree, runCapture } from './utils/proc.mjs';
4
+ import { killProcessTree, runCapture, spawnProc } from './utils/proc.mjs';
5
5
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
6
  import { killPortListeners } from './utils/ports.mjs';
7
- import { getServerComponentName, waitForServerReady } from './utils/server.mjs';
8
- import { pmSpawnScript, requireDir } from './utils/pm.mjs';
7
+ import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server.mjs';
8
+ import { ensureCliBuilt, ensureDepsInstalled, pmExecBin, pmSpawnScript, requireDir } from './utils/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';
13
- import { startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
14
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
15
- import { assertServerComponentDirMatches } from './utils/validate.mjs';
12
+ import { maybeResetTailscaleServe } from './tailscale.mjs';
13
+ import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
14
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
+ import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/validate.mjs';
16
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/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/localhost_host.mjs';
22
+ import { openUrlInBrowser } from './utils/browser.mjs';
16
23
 
17
24
  /**
18
25
  * Run the local stack in "production-like" mode:
@@ -30,10 +37,10 @@ async function main() {
30
37
  if (wantsHelp(argv, { flags })) {
31
38
  printResult({
32
39
  json,
33
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon'], json: true },
40
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser'], json: true },
34
41
  text: [
35
42
  '[start] usage:',
36
- ' happys start [--server=happy-server|happy-server-light] [--json]',
43
+ ' happys start [--server=happy-server|happy-server-light] [--restart] [--json]',
37
44
  ' (legacy in a cloned repo): pnpm start [-- --server=happy-server|happy-server-light] [--json]',
38
45
  ' note: --json prints the resolved config (dry-run) and exits.',
39
46
  ].join('\n'),
@@ -43,17 +50,14 @@ async function main() {
43
50
 
44
51
  const rootDir = getRootDir(import.meta.url);
45
52
 
46
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
47
- ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
48
- : 3005;
53
+ const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
49
54
 
50
55
  // Internal URL used by local processes on this machine.
51
56
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
52
57
  // Public URL is what you might share/open (e.g. https://<machine>.<tailnet>.ts.net).
53
58
  // We auto-prefer the Tailscale HTTPS URL when available, unless explicitly overridden.
54
- const defaultPublicUrl = `http://localhost:${serverPort}`;
55
- const envPublicUrl = process.env.HAPPY_LOCAL_SERVER_URL?.trim() ? process.env.HAPPY_LOCAL_SERVER_URL.trim() : '';
56
- let publicServerUrl = envPublicUrl || defaultPublicUrl;
59
+ const { defaultPublicUrl, envPublicUrl, publicServerUrl: publicServerUrlPreview } = getPublicServerUrlEnvOverride({ serverPort });
60
+ let publicServerUrl = publicServerUrlPreview;
57
61
 
58
62
  const serverComponentName = getServerComponentName({ kv });
59
63
  if (serverComponentName === 'both') {
@@ -62,11 +66,13 @@ async function main() {
62
66
 
63
67
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
64
68
  const serveUiWanted = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
65
- const serveUi = serveUiWanted && serverComponentName === 'happy-server-light';
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';
66
71
  const uiPrefix = process.env.HAPPY_LOCAL_UI_PREFIX?.trim() ? process.env.HAPPY_LOCAL_UI_PREFIX.trim() : '/';
72
+ const autostart = getDefaultAutostartPaths();
67
73
  const uiBuildDir = process.env.HAPPY_LOCAL_UI_BUILD_DIR?.trim()
68
74
  ? process.env.HAPPY_LOCAL_UI_BUILD_DIR.trim()
69
- : join(getDefaultAutostartPaths().baseDir, 'ui');
75
+ : join(autostart.baseDir, 'ui');
70
76
 
71
77
  const enableTailscaleServe = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
72
78
 
@@ -74,6 +80,7 @@ async function main() {
74
80
  const cliDir = getComponentDir(rootDir, 'happy-cli');
75
81
 
76
82
  assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
83
+ assertServerPrismaProviderMatches({ serverComponentName, serverDir });
77
84
 
78
85
  await requireDir(serverComponentName, serverDir);
79
86
  await requireDir('happy-cli', cliDir);
@@ -82,7 +89,8 @@ async function main() {
82
89
 
83
90
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
84
91
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
85
- : join(getDefaultAutostartPaths().baseDir, 'cli');
92
+ : join(autostart.baseDir, 'cli');
93
+ const restart = flags.has('--restart');
86
94
 
87
95
  if (json) {
88
96
  printResult({
@@ -105,30 +113,65 @@ async function main() {
105
113
  return;
106
114
  }
107
115
 
108
- if (serveUiWanted && !serveUi) {
109
- console.log(`[local] ui serving disabled (requires happy-server-light; you are using ${serverComponentName})`);
110
- }
111
-
112
116
  if (serveUi && !(await pathExists(uiBuildDir))) {
113
- throw new Error(`[local] UI build directory not found at ${uiBuildDir}. Run: happys build (legacy in a cloned repo: pnpm build)`);
117
+ if (serverComponentName === 'happy-server-light') {
118
+ throw new Error(`[local] UI build directory not found at ${uiBuildDir}. Run: happys build (legacy in a cloned repo: pnpm build)`);
119
+ }
120
+ // For happy-server, UI serving is optional via the UI gateway.
121
+ console.log(`[local] UI build directory not found at ${uiBuildDir}; UI gateway will be disabled`);
114
122
  }
115
123
 
116
124
  const children = [];
117
125
  let shuttingDown = false;
118
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 });
133
+
134
+ // Ensure server deps exist before any Prisma/docker work.
135
+ await ensureDepsInstalled(serverDir, serverComponentName);
136
+
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
+ }
119
150
 
120
- // Public URL automation: auto-prefer https://*.ts.net on every start.
121
- const resolved = await resolvePublicServerUrl({
122
- internalServerUrl,
123
- defaultPublicUrl,
124
- envPublicUrl,
125
- allowEnable: true,
126
- });
127
- publicServerUrl = resolved.publicServerUrl;
151
+ const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
152
+ const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
153
+ if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning)) {
154
+ console.log(`[local] start: stack already running (server=${internalServerUrl}${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''})`);
155
+ return;
156
+ }
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
+ }
128
168
 
129
169
  // Server
130
170
  // If a previous run left a server behind, free the port first (prevents false "ready" checks).
131
- await killPortListeners(serverPort, { label: 'server' });
171
+ // NOTE: In stack mode we avoid killing arbitrary port listeners (fail-closed instead).
172
+ if ((!serverAlreadyRunning || restart) && !stackMode) {
173
+ await killPortListeners(serverPort, { label: 'server' });
174
+ }
132
175
 
133
176
  const serverEnv = {
134
177
  ...baseEnv,
@@ -138,19 +181,120 @@ async function main() {
138
181
  // Avoid noisy failures if a previous run left the metrics port busy.
139
182
  // You can override with METRICS_ENABLED=true if you want it.
140
183
  METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
141
- ...(serveUi
184
+ ...(serveUi && serverComponentName === 'happy-server-light'
142
185
  ? {
143
186
  HAPPY_SERVER_LIGHT_UI_DIR: uiBuildDir,
144
187
  HAPPY_SERVER_LIGHT_UI_PREFIX: uiPrefix,
145
188
  }
146
189
  : {}),
147
190
  };
191
+ let serverLightAccountCount = null;
192
+ let happyServerAccountCount = null;
193
+ if (serverComponentName === 'happy-server-light') {
194
+ const dataDir = baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR?.trim()
195
+ ? baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR.trim()
196
+ : join(autostart.baseDir, 'server-light');
197
+ serverEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
198
+ serverEnv.HAPPY_SERVER_LIGHT_FILES_DIR = baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR?.trim()
199
+ ? baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR.trim()
200
+ : join(dataDir, 'files');
201
+ serverEnv.DATABASE_URL = baseEnv.DATABASE_URL?.trim()
202
+ ? baseEnv.DATABASE_URL.trim()
203
+ : `file:${join(dataDir, 'happy-server-light.sqlite')}`;
204
+
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;
213
+ }
214
+ let effectiveInternalServerUrl = internalServerUrl;
215
+ if (serverComponentName === 'happy-server') {
216
+ const managed = (baseEnv.HAPPY_STACKS_MANAGED_INFRA ?? baseEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1') !== '0';
217
+ if (managed) {
218
+ const envPath = baseEnv.HAPPY_STACKS_ENV_FILE ?? baseEnv.HAPPY_LOCAL_ENV_FILE ?? '';
219
+ const infra = await ensureHappyServerManagedInfra({
220
+ stackName: autostart.stackName,
221
+ baseDir: autostart.baseDir,
222
+ serverPort,
223
+ publicServerUrl,
224
+ envPath,
225
+ env: baseEnv,
226
+ });
227
+
228
+ // Backend runs on a separate port; gateway owns the public port.
229
+ const backendPortRaw = (baseEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? baseEnv.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT ?? '').trim();
230
+ const backendPort = backendPortRaw ? Number(backendPortRaw) : serverPort + 10;
231
+ const backendUrl = `http://127.0.0.1:${backendPort}`;
232
+ if (!stackMode) {
233
+ await killPortListeners(backendPort, { label: 'happy-server-backend' });
234
+ }
235
+
236
+ const backendEnv = { ...serverEnv, ...infra.env, PORT: String(backendPort) };
237
+ const autoMigrate = (baseEnv.HAPPY_STACKS_PRISMA_MIGRATE ?? baseEnv.HAPPY_LOCAL_PRISMA_MIGRATE ?? '1') !== '0';
238
+ if (autoMigrate) {
239
+ await applyHappyServerMigrations({ serverDir, env: backendEnv });
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;
249
+
250
+ const backend = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'start', env: backendEnv });
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
+ }
258
+ await waitForServerReady(backendUrl);
259
+
260
+ const gatewayArgs = [
261
+ join(rootDir, 'scripts', 'ui_gateway.mjs'),
262
+ `--port=${serverPort}`,
263
+ `--backend-url=${backendUrl}`,
264
+ `--minio-port=${infra.env.S3_PORT}`,
265
+ `--bucket=${infra.env.S3_BUCKET}`,
266
+ ];
267
+ if (serveUi && (await pathExists(uiBuildDir))) {
268
+ gatewayArgs.push(`--ui-dir=${uiBuildDir}`);
269
+ } else {
270
+ gatewayArgs.push('--no-ui');
271
+ }
148
272
 
149
- const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'dev', env: serverEnv });
150
- children.push(server);
273
+ const gateway = spawnProc('ui', process.execPath, gatewayArgs, { ...backendEnv, PORT: String(serverPort) }, { cwd: rootDir });
274
+ children.push(gateway);
275
+ if (stackMode && runtimeStatePath) {
276
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { uiGatewayPid: gateway.pid } }).catch(() => {});
277
+ }
278
+ await waitForServerReady(internalServerUrl);
279
+ effectiveInternalServerUrl = internalServerUrl;
151
280
 
152
- await waitForServerReady(internalServerUrl);
153
- console.log(`[local] server ready at ${internalServerUrl}`);
281
+ // Skip default server spawn below
282
+ }
283
+ }
284
+
285
+ // Default server start (happy-server-light, or happy-server without managed infra).
286
+ if (!(serverComponentName === 'happy-server' && (baseEnv.HAPPY_STACKS_MANAGED_INFRA ?? baseEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1') !== '0')) {
287
+ if (!serverAlreadyRunning || restart) {
288
+ const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'start', env: serverEnv });
289
+ children.push(server);
290
+ if (stackMode && runtimeStatePath) {
291
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: server.pid } }).catch(() => {});
292
+ }
293
+ await waitForServerReady(internalServerUrl);
294
+ } else {
295
+ console.log(`[local] server already running at ${internalServerUrl}`);
296
+ }
297
+ }
154
298
 
155
299
  if (enableTailscaleServe) {
156
300
  try {
@@ -167,9 +311,9 @@ async function main() {
167
311
  }
168
312
 
169
313
  if (serveUi) {
170
- const localUi = internalServerUrl.replace(/\/+$/, '') + '/';
314
+ const localUi = effectiveInternalServerUrl.replace(/\/+$/, '') + '/';
171
315
  console.log(`[local] ui served locally at ${localUi}`);
172
- if (publicServerUrl && publicServerUrl !== internalServerUrl && publicServerUrl !== localUi && publicServerUrl !== defaultPublicUrl) {
316
+ if (publicServerUrl && publicServerUrl !== effectiveInternalServerUrl && publicServerUrl !== localUi && publicServerUrl !== defaultPublicUrl) {
173
317
  const pubUi = publicServerUrl.replace(/\/+$/, '') + '/';
174
318
  console.log(`[local] public url: ${pubUi}`);
175
319
  }
@@ -179,20 +323,55 @@ async function main() {
179
323
 
180
324
  console.log(
181
325
  `[local] tip: to run 'happy' from your terminal *against this local server* (and have sessions show up in the UI), use:\n` +
182
- `export HAPPY_SERVER_URL=\"${internalServerUrl}\"\n` +
326
+ `export HAPPY_SERVER_URL=\"${effectiveInternalServerUrl}\"\n` +
183
327
  `export HAPPY_HOME_DIR=\"${cliHomeDir}\"\n` +
184
328
  `export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
185
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
+ }
186
342
  }
187
343
 
188
344
  // Daemon
189
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
+ });
190
368
  await startLocalDaemonWithAuth({
191
369
  cliBin,
192
370
  cliHomeDir,
193
- internalServerUrl,
371
+ internalServerUrl: effectiveInternalServerUrl,
194
372
  publicServerUrl,
195
373
  isShuttingDown: () => shuttingDown,
374
+ forceRestart: restart,
196
375
  });
197
376
  }
198
377
 
@@ -204,7 +383,7 @@ async function main() {
204
383
  console.log('\n[local] shutting down...');
205
384
 
206
385
  if (startDaemon) {
207
- await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
386
+ await stopLocalDaemon({ cliBin, internalServerUrl: effectiveInternalServerUrl, cliHomeDir });
208
387
  }
209
388
 
210
389
  for (const child of children) {
package/scripts/self.mjs CHANGED
@@ -2,20 +2,16 @@ import './utils/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';
7
+ import { parseArgs } from './utils/cli/args.mjs';
9
8
  import { pathExists } from './utils/fs.mjs';
10
9
  import { run, runCapture } from './utils/proc.mjs';
10
+ import { expandHome } from './utils/canonical_home.mjs';
11
11
  import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
12
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
12
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
13
13
  import { getRuntimeDir } from './utils/runtime.mjs';
14
14
 
15
- function expandHome(p) {
16
- return p.replace(/^~(?=\/)/, homedir());
17
- }
18
-
19
15
  function cachePaths() {
20
16
  const home = getHappyStacksHomeDir();
21
17
  return {
@@ -1,10 +1,10 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { getRootDir } from './utils/paths.mjs';
4
4
  import { ensureEnvFileUpdated } from './utils/env_file.mjs';
5
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';
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' },