happy-stacks 0.0.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 (67) hide show
  1. package/README.md +314 -0
  2. package/bin/happys.mjs +168 -0
  3. package/docs/menubar.md +186 -0
  4. package/docs/mobile-ios.md +134 -0
  5. package/docs/remote-access.md +43 -0
  6. package/docs/server-flavors.md +79 -0
  7. package/docs/stacks.md +218 -0
  8. package/docs/tauri.md +62 -0
  9. package/docs/worktrees-and-forks.md +395 -0
  10. package/extras/swiftbar/auth-login.sh +31 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +218 -0
  12. package/extras/swiftbar/icons/happy-green.png +0 -0
  13. package/extras/swiftbar/icons/happy-orange.png +0 -0
  14. package/extras/swiftbar/icons/happy-red.png +0 -0
  15. package/extras/swiftbar/icons/logo-white.png +0 -0
  16. package/extras/swiftbar/install.sh +191 -0
  17. package/extras/swiftbar/lib/git.sh +330 -0
  18. package/extras/swiftbar/lib/icons.sh +105 -0
  19. package/extras/swiftbar/lib/render.sh +774 -0
  20. package/extras/swiftbar/lib/system.sh +190 -0
  21. package/extras/swiftbar/lib/utils.sh +205 -0
  22. package/extras/swiftbar/pnpm-term.sh +125 -0
  23. package/extras/swiftbar/pnpm.sh +21 -0
  24. package/extras/swiftbar/set-interval.sh +62 -0
  25. package/extras/swiftbar/set-server-flavor.sh +57 -0
  26. package/extras/swiftbar/wt-pr.sh +95 -0
  27. package/package.json +58 -0
  28. package/scripts/auth.mjs +272 -0
  29. package/scripts/build.mjs +204 -0
  30. package/scripts/cli-link.mjs +58 -0
  31. package/scripts/completion.mjs +364 -0
  32. package/scripts/daemon.mjs +349 -0
  33. package/scripts/dev.mjs +181 -0
  34. package/scripts/doctor.mjs +342 -0
  35. package/scripts/happy.mjs +79 -0
  36. package/scripts/init.mjs +232 -0
  37. package/scripts/install.mjs +379 -0
  38. package/scripts/menubar.mjs +107 -0
  39. package/scripts/mobile.mjs +305 -0
  40. package/scripts/run.mjs +236 -0
  41. package/scripts/self.mjs +298 -0
  42. package/scripts/server_flavor.mjs +125 -0
  43. package/scripts/service.mjs +526 -0
  44. package/scripts/stack.mjs +815 -0
  45. package/scripts/tailscale.mjs +278 -0
  46. package/scripts/uninstall.mjs +190 -0
  47. package/scripts/utils/args.mjs +17 -0
  48. package/scripts/utils/cli.mjs +24 -0
  49. package/scripts/utils/cli_registry.mjs +262 -0
  50. package/scripts/utils/config.mjs +40 -0
  51. package/scripts/utils/dotenv.mjs +30 -0
  52. package/scripts/utils/env.mjs +138 -0
  53. package/scripts/utils/env_file.mjs +59 -0
  54. package/scripts/utils/env_local.mjs +25 -0
  55. package/scripts/utils/fs.mjs +11 -0
  56. package/scripts/utils/paths.mjs +184 -0
  57. package/scripts/utils/pm.mjs +294 -0
  58. package/scripts/utils/ports.mjs +66 -0
  59. package/scripts/utils/proc.mjs +66 -0
  60. package/scripts/utils/runtime.mjs +30 -0
  61. package/scripts/utils/server.mjs +41 -0
  62. package/scripts/utils/smoke_help.mjs +45 -0
  63. package/scripts/utils/validate.mjs +47 -0
  64. package/scripts/utils/wizard.mjs +69 -0
  65. package/scripts/utils/worktrees.mjs +78 -0
  66. package/scripts/where.mjs +105 -0
  67. package/scripts/worktrees.mjs +1721 -0
@@ -0,0 +1,305 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/args.mjs';
3
+ import { killPortListeners } from './utils/ports.mjs';
4
+ import { run, runCapture, spawnProc } from './utils/proc.mjs';
5
+ import { getComponentDir, getRootDir } from './utils/paths.mjs';
6
+ import { ensureDepsInstalled, requireDir } from './utils/pm.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
+
9
+ /**
10
+ * Mobile dev helper for the embedded `components/happy` Expo app.
11
+ *
12
+ * Goals:
13
+ * - Avoid editing upstream config files in-place.
14
+ * - Ensure the QR/deeplink opens the *dev build* even if the App Store app is installed.
15
+ *
16
+ * Usage:
17
+ * happys mobile
18
+ * happys mobile --host=lan
19
+ * happys mobile --scheme=com.slopus.happy.dev
20
+ * happys mobile --no-metro
21
+ * happys mobile --run-ios --device="Your iPhone"
22
+ */
23
+
24
+ async function main() {
25
+ const argv = process.argv.slice(2);
26
+ const { flags, kv } = parseArgs(argv);
27
+ const json = wantsJson(argv, { flags });
28
+
29
+ if (wantsHelp(argv, { flags })) {
30
+ printResult({
31
+ json,
32
+ data: {
33
+ flags: [
34
+ '--host=lan|localhost|tunnel',
35
+ '--port=8081',
36
+ '--scheme=<url-scheme>',
37
+ '--ios-bundle-id=<bundle-id>',
38
+ '--ios-app-name=<name>',
39
+ '--app-env=development|production',
40
+ '--prebuild [--platform=ios|all] [--clean]',
41
+ '--run-ios [--device=<id-or-name>] [--configuration=Debug|Release]',
42
+ '--metro / --no-metro',
43
+ '--no-signing-fix',
44
+ ],
45
+ json: true,
46
+ },
47
+ text: [
48
+ '[mobile] usage:',
49
+ ' happys mobile [--host=lan|localhost|tunnel] [--port=8081] [--scheme=...] [--json]',
50
+ ' happys mobile --run-ios [--device=...] [--configuration=Debug|Release]',
51
+ ' happys mobile --prebuild [--platform=ios|all] [--clean]',
52
+ ' happys mobile --no-metro # just build/install (if --run-ios) without starting Metro',
53
+ '',
54
+ 'Notes:',
55
+ '- This script is designed to avoid editing upstream `components/happy` config in-place.',
56
+ '- It sets EXPO_PUBLIC_HAPPY_SERVER_URL from HAPPY_STACKS_SERVER_URL (legacy: HAPPY_LOCAL_SERVER_URL) if provided.',
57
+ ].join('\n'),
58
+ });
59
+ return;
60
+ }
61
+
62
+ const rootDir = getRootDir(import.meta.url);
63
+ const uiDir = getComponentDir(rootDir, 'happy');
64
+ await requireDir('happy', uiDir);
65
+ await ensureDepsInstalled(uiDir, 'happy');
66
+
67
+ const sanitizeBundleIdSegment = (s) =>
68
+ (s ?? '')
69
+ .toString()
70
+ .trim()
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9-]+/g, '-')
73
+ .replace(/^-+|-+$/g, '') || 'user';
74
+
75
+ const defaultLocalBundleId = (() => {
76
+ const user = sanitizeBundleIdSegment(process.env.USER ?? process.env.USERNAME ?? 'user');
77
+ return `com.happy.local.${user}.dev`;
78
+ })();
79
+
80
+ async function readXcdeviceList() {
81
+ if (process.platform !== 'darwin') {
82
+ return [];
83
+ }
84
+ const raw = await runCapture('xcrun', ['xcdevice', 'list'], { cwd: uiDir, env: process.env });
85
+ const start = raw.indexOf('[');
86
+ const jsonText = start >= 0 ? raw.slice(start) : raw;
87
+ const parsed = JSON.parse(jsonText);
88
+ return Array.isArray(parsed) ? parsed : [];
89
+ }
90
+
91
+ // Default to the existing dev bundle identifier, which is also registered as a URL scheme
92
+ // (Info.plist includes `com.slopus.happy.dev`), so iOS will open the dev build instead of the App Store app.
93
+ const appEnv = process.env.APP_ENV ?? kv.get('--app-env') ?? 'development';
94
+ const iosAppName =
95
+ kv.get('--ios-app-name') ??
96
+ process.env.HAPPY_STACKS_IOS_APP_NAME ??
97
+ process.env.HAPPY_LOCAL_IOS_APP_NAME ??
98
+ '';
99
+ const iosBundleId =
100
+ kv.get('--ios-bundle-id') ??
101
+ process.env.HAPPY_STACKS_IOS_BUNDLE_ID ??
102
+ process.env.HAPPY_LOCAL_IOS_BUNDLE_ID ??
103
+ defaultLocalBundleId;
104
+ const scheme =
105
+ kv.get('--scheme') ??
106
+ process.env.HAPPY_STACKS_MOBILE_SCHEME ??
107
+ process.env.HAPPY_LOCAL_MOBILE_SCHEME ??
108
+ iosBundleId;
109
+ const host = kv.get('--host') ?? process.env.HAPPY_STACKS_MOBILE_HOST ?? process.env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan';
110
+ const port = kv.get('--port') ?? process.env.HAPPY_STACKS_MOBILE_PORT ?? process.env.HAPPY_LOCAL_MOBILE_PORT ?? '8081';
111
+ // Default behavior:
112
+ // - `happys mobile` starts Metro and keeps running.
113
+ // - `happys mobile --run-ios` / `happys mobile:ios` just builds/installs and exits (unless --metro is provided).
114
+ const shouldStartMetro =
115
+ flags.has('--metro') ||
116
+ (!flags.has('--no-metro') && !flags.has('--run-ios') && !flags.has('--prebuild'));
117
+
118
+ const env = {
119
+ ...process.env,
120
+ APP_ENV: appEnv,
121
+ };
122
+
123
+ // Allow happy-stacks to define the default server URL baked into the app bundle.
124
+ // This is read by the app via `process.env.EXPO_PUBLIC_HAPPY_SERVER_URL`.
125
+ const stacksServerUrl =
126
+ process.env.HAPPY_STACKS_SERVER_URL?.trim() || process.env.HAPPY_LOCAL_SERVER_URL?.trim() || '';
127
+ if (stacksServerUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
128
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = stacksServerUrl;
129
+ }
130
+
131
+ if (json) {
132
+ printResult({
133
+ json,
134
+ data: {
135
+ ok: true,
136
+ uiDir,
137
+ appEnv,
138
+ iosAppName,
139
+ iosBundleId,
140
+ scheme,
141
+ host,
142
+ port,
143
+ shouldPrebuild: flags.has('--prebuild'),
144
+ shouldRunIos: flags.has('--run-ios'),
145
+ shouldStartMetro,
146
+ expoPublicHappyServerUrl: env.EXPO_PUBLIC_HAPPY_SERVER_URL ?? '',
147
+ },
148
+ });
149
+ return;
150
+ }
151
+
152
+ const shouldPrebuild = flags.has('--prebuild');
153
+ if (shouldPrebuild) {
154
+ const platform = kv.get('--platform') ?? 'ios';
155
+ const shouldClean = flags.has('--clean');
156
+ // Prebuild can fail during `pod install` if deployment target mismatches.
157
+ // We skip installs, patch deployment target + RN build mode, then run `pod install` ourselves.
158
+ const prebuildArgs = ['expo', 'prebuild', '--no-install', '--platform', platform];
159
+ if (shouldClean) {
160
+ prebuildArgs.push('--clean');
161
+ }
162
+ await run('npx', prebuildArgs, { cwd: uiDir, env });
163
+
164
+ // Always patch iOS props if iOS was generated.
165
+ if (platform === 'ios' || platform === 'all') {
166
+ const fs = await import('node:fs/promises');
167
+ const podPropsPath = `${uiDir}/ios/Podfile.properties.json`;
168
+ const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
169
+ try {
170
+ const raw = await fs.readFile(podPropsPath, 'utf-8');
171
+ const json = JSON.parse(raw);
172
+ json['ios.deploymentTarget'] = '16.0';
173
+ json['ios.buildReactNativeFromSource'] = 'true';
174
+ await fs.writeFile(podPropsPath, JSON.stringify(json, null, 2) + '\n', 'utf-8');
175
+ } catch {
176
+ // ignore if path missing (platform != ios)
177
+ }
178
+
179
+ try {
180
+ const raw = await fs.readFile(pbxprojPath, 'utf-8');
181
+ const next = raw.replaceAll('IPHONEOS_DEPLOYMENT_TARGET = 15.1;', 'IPHONEOS_DEPLOYMENT_TARGET = 16.0;');
182
+ if (next !== raw) {
183
+ await fs.writeFile(pbxprojPath, next, 'utf-8');
184
+ }
185
+ } catch {
186
+ // ignore missing pbxproj (unexpected)
187
+ }
188
+
189
+ // Ensure CocoaPods doesn't crash due to locale issues.
190
+ env.LANG = env.LANG ?? 'en_US.UTF-8';
191
+ env.LC_ALL = env.LC_ALL ?? 'en_US.UTF-8';
192
+ await run('sh', ['-lc', 'cd ios && pod install'], { cwd: uiDir, env });
193
+ }
194
+ }
195
+
196
+ if (flags.has('--run-ios')) {
197
+ let device = kv.get('--device') ?? '';
198
+ let resolvedDevice = null;
199
+ if (process.platform === 'darwin') {
200
+ try {
201
+ const list = await readXcdeviceList();
202
+ resolvedDevice = device
203
+ ? list.find((d) => d && (d.identifier === device || d.name === device)) ?? null
204
+ : null;
205
+ } catch {
206
+ resolvedDevice = null;
207
+ }
208
+ }
209
+
210
+ if (!device && process.platform === 'darwin') {
211
+ // Auto-pick a connected physical iPhone/iPad if available.
212
+ // This avoids needing to know the exact "Your iPhone" string.
213
+ try {
214
+ const list = await readXcdeviceList();
215
+ const firstConnectedIosDevice = Array.isArray(list)
216
+ ? list.find(
217
+ (d) =>
218
+ d &&
219
+ d.platform === 'com.apple.platform.iphoneos' &&
220
+ d.interface === 'usb' &&
221
+ (d.available === true || d.available === 'YES') &&
222
+ typeof d.identifier === 'string' &&
223
+ d.identifier.length > 0
224
+ )
225
+ : null;
226
+ if (firstConnectedIosDevice?.identifier) {
227
+ device = firstConnectedIosDevice.identifier;
228
+ resolvedDevice = firstConnectedIosDevice;
229
+ // eslint-disable-next-line no-console
230
+ console.log(`[mobile] using connected device: ${firstConnectedIosDevice.name} (${device})`);
231
+ }
232
+ } catch {
233
+ // ignore and let Expo choose
234
+ }
235
+ }
236
+
237
+ const isPhysicalIosDevice =
238
+ resolvedDevice?.platform === 'com.apple.platform.iphoneos' && resolvedDevice?.simulator === false;
239
+
240
+ const shouldPatchXcodeProject = isPhysicalIosDevice || !!iosAppName;
241
+ if (shouldPatchXcodeProject && !flags.has('--no-signing-fix')) {
242
+ // Expo CLI only passes `-allowProvisioningUpdates` when it *needs* to configure signing.
243
+ // If the pbxproj already has a DEVELOPMENT_TEAM set but no local provisioning profile exists yet,
244
+ // xcodebuild fails with:
245
+ // "Automatic signing is disabled ... pass -allowProvisioningUpdates"
246
+ //
247
+ // We force Expo CLI to go through its signing configuration path by clearing DEVELOPMENT_TEAM,
248
+ // so it will re-set the team and include the provisioning flags.
249
+ try {
250
+ const fs = await import('node:fs/promises');
251
+ const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
252
+ const raw = await fs.readFile(pbxprojPath, 'utf-8');
253
+ let next = raw.replaceAll(/^\s*DEVELOPMENT_TEAM = ".*";\s*$/gm, '');
254
+ next = next.replaceAll(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${iosBundleId};`);
255
+ if (iosAppName && iosAppName.trim()) {
256
+ const name = iosAppName.trim();
257
+ const quoted = name.includes(' ') || name.includes('"') ? `"${name.replaceAll('"', '\\"')}"` : name;
258
+ next = next.replaceAll(/PRODUCT_NAME = [^;]+;/g, `PRODUCT_NAME = ${quoted};`);
259
+ }
260
+ if (next !== raw) {
261
+ await fs.writeFile(pbxprojPath, next, 'utf-8');
262
+ }
263
+ } catch {
264
+ // ignore
265
+ }
266
+ }
267
+
268
+ const configuration = kv.get('--configuration') ?? 'Debug';
269
+ const args = ['expo', 'run:ios', '--no-bundler', '--no-build-cache', '--configuration', configuration];
270
+ if (device) {
271
+ args.push('-d', device);
272
+ }
273
+ // Ensure CocoaPods doesn't crash due to locale issues.
274
+ env.LANG = env.LANG ?? 'en_US.UTF-8';
275
+ env.LC_ALL = env.LC_ALL ?? 'en_US.UTF-8';
276
+ await run('npx', args, { cwd: uiDir, env });
277
+ }
278
+
279
+ if (!shouldStartMetro) {
280
+ return;
281
+ }
282
+
283
+ const portNumber = Number.parseInt(port, 10);
284
+ if (Number.isFinite(portNumber) && portNumber > 0) {
285
+ await killPortListeners(portNumber, { label: 'expo' });
286
+ }
287
+
288
+ // Start Metro for a dev client.
289
+ // The critical part is --scheme: without it, Expo defaults to `exp+<slug>` (here `exp+happy`)
290
+ // which the App Store app also registers, so iOS can open the wrong app.
291
+ spawnProc(
292
+ 'mobile',
293
+ 'npx',
294
+ ['expo', 'start', '--dev-client', '--host', host, '--port', port, '--scheme', scheme],
295
+ env,
296
+ { cwd: uiDir }
297
+ );
298
+
299
+ await new Promise(() => {});
300
+ }
301
+
302
+ main().catch((err) => {
303
+ console.error('[mobile] failed:', err);
304
+ process.exit(1);
305
+ });
@@ -0,0 +1,236 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/args.mjs';
3
+ import { pathExists } from './utils/fs.mjs';
4
+ import { killProcessTree, runCapture } from './utils/proc.mjs';
5
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
+ import { killPortListeners } from './utils/ports.mjs';
7
+ import { getServerComponentName, waitForServerReady } from './utils/server.mjs';
8
+ import { pmSpawnScript, requireDir } from './utils/pm.mjs';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
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';
16
+
17
+ /**
18
+ * Run the local stack in "production-like" mode:
19
+ * - happy-server-light
20
+ * - happy-cli daemon
21
+ * - serve prebuilt UI via happy-server-light (/)
22
+ *
23
+ * No Expo dev server.
24
+ */
25
+
26
+ async function main() {
27
+ const argv = process.argv.slice(2);
28
+ const { flags, kv } = parseArgs(argv);
29
+ const json = wantsJson(argv, { flags });
30
+ if (wantsHelp(argv, { flags })) {
31
+ printResult({
32
+ json,
33
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon'], json: true },
34
+ text: [
35
+ '[start] usage:',
36
+ ' happys start [--server=happy-server|happy-server-light] [--json]',
37
+ ' (legacy in a cloned repo): pnpm start [-- --server=happy-server|happy-server-light] [--json]',
38
+ ' note: --json prints the resolved config (dry-run) and exits.',
39
+ ].join('\n'),
40
+ });
41
+ return;
42
+ }
43
+
44
+ const rootDir = getRootDir(import.meta.url);
45
+
46
+ const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
47
+ ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
48
+ : 3005;
49
+
50
+ // Internal URL used by local processes on this machine.
51
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
52
+ // Public URL is what you might share/open (e.g. https://<machine>.<tailnet>.ts.net).
53
+ // 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;
57
+
58
+ const serverComponentName = getServerComponentName({ kv });
59
+ if (serverComponentName === 'both') {
60
+ throw new Error(`[local] --server=both is not supported for run (pick one: happy-server-light or happy-server)`);
61
+ }
62
+
63
+ const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
64
+ const serveUiWanted = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
65
+ const serveUi = serveUiWanted && serverComponentName === 'happy-server-light';
66
+ const uiPrefix = process.env.HAPPY_LOCAL_UI_PREFIX?.trim() ? process.env.HAPPY_LOCAL_UI_PREFIX.trim() : '/';
67
+ const uiBuildDir = process.env.HAPPY_LOCAL_UI_BUILD_DIR?.trim()
68
+ ? process.env.HAPPY_LOCAL_UI_BUILD_DIR.trim()
69
+ : join(getDefaultAutostartPaths().baseDir, 'ui');
70
+
71
+ const enableTailscaleServe = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
72
+
73
+ const serverDir = getComponentDir(rootDir, serverComponentName);
74
+ const cliDir = getComponentDir(rootDir, 'happy-cli');
75
+
76
+ assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
77
+
78
+ await requireDir(serverComponentName, serverDir);
79
+ await requireDir('happy-cli', cliDir);
80
+
81
+ const cliBin = join(cliDir, 'bin', 'happy.mjs');
82
+
83
+ const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
84
+ ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
85
+ : join(getDefaultAutostartPaths().baseDir, 'cli');
86
+
87
+ if (json) {
88
+ printResult({
89
+ json,
90
+ data: {
91
+ mode: 'start',
92
+ serverComponentName,
93
+ serverDir,
94
+ cliDir,
95
+ serverPort,
96
+ internalServerUrl,
97
+ publicServerUrl,
98
+ startDaemon,
99
+ serveUi,
100
+ uiPrefix,
101
+ uiBuildDir,
102
+ cliHomeDir,
103
+ },
104
+ });
105
+ return;
106
+ }
107
+
108
+ if (serveUiWanted && !serveUi) {
109
+ console.log(`[local] ui serving disabled (requires happy-server-light; you are using ${serverComponentName})`);
110
+ }
111
+
112
+ 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)`);
114
+ }
115
+
116
+ const children = [];
117
+ let shuttingDown = false;
118
+ const baseEnv = { ...process.env };
119
+
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;
128
+
129
+ // Server
130
+ // If a previous run left a server behind, free the port first (prevents false "ready" checks).
131
+ await killPortListeners(serverPort, { label: 'server' });
132
+
133
+ const serverEnv = {
134
+ ...baseEnv,
135
+ PORT: String(serverPort),
136
+ // Used by server-light for generating public file URLs.
137
+ PUBLIC_URL: publicServerUrl,
138
+ // Avoid noisy failures if a previous run left the metrics port busy.
139
+ // You can override with METRICS_ENABLED=true if you want it.
140
+ METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
141
+ ...(serveUi
142
+ ? {
143
+ HAPPY_SERVER_LIGHT_UI_DIR: uiBuildDir,
144
+ HAPPY_SERVER_LIGHT_UI_PREFIX: uiPrefix,
145
+ }
146
+ : {}),
147
+ };
148
+
149
+ const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'dev', env: serverEnv });
150
+ children.push(server);
151
+
152
+ await waitForServerReady(internalServerUrl);
153
+ console.log(`[local] server ready at ${internalServerUrl}`);
154
+
155
+ if (enableTailscaleServe) {
156
+ try {
157
+ const status = await runCapture(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), 'status']);
158
+ const line = status.split('\n').find((l) => l.toLowerCase().includes('https://'))?.trim();
159
+ if (line) {
160
+ console.log(`[local] tailscale serve: ${line}`);
161
+ } else {
162
+ console.log('[local] tailscale serve enabled');
163
+ }
164
+ } catch {
165
+ console.log('[local] tailscale serve enabled');
166
+ }
167
+ }
168
+
169
+ if (serveUi) {
170
+ const localUi = internalServerUrl.replace(/\/+$/, '') + '/';
171
+ console.log(`[local] ui served locally at ${localUi}`);
172
+ if (publicServerUrl && publicServerUrl !== internalServerUrl && publicServerUrl !== localUi && publicServerUrl !== defaultPublicUrl) {
173
+ const pubUi = publicServerUrl.replace(/\/+$/, '') + '/';
174
+ console.log(`[local] public url: ${pubUi}`);
175
+ }
176
+ if (enableTailscaleServe) {
177
+ console.log('[local] tip: use the HTTPS *.ts.net URL for remote access');
178
+ }
179
+
180
+ console.log(
181
+ `[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` +
183
+ `export HAPPY_HOME_DIR=\"${cliHomeDir}\"\n` +
184
+ `export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
185
+ );
186
+ }
187
+
188
+ // Daemon
189
+ if (startDaemon) {
190
+ await startLocalDaemonWithAuth({
191
+ cliBin,
192
+ cliHomeDir,
193
+ internalServerUrl,
194
+ publicServerUrl,
195
+ isShuttingDown: () => shuttingDown,
196
+ });
197
+ }
198
+
199
+ const shutdown = async () => {
200
+ if (shuttingDown) {
201
+ return;
202
+ }
203
+ shuttingDown = true;
204
+ console.log('\n[local] shutting down...');
205
+
206
+ if (startDaemon) {
207
+ await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
208
+ }
209
+
210
+ for (const child of children) {
211
+ if (child.exitCode == null) {
212
+ killProcessTree(child, 'SIGINT');
213
+ }
214
+ }
215
+
216
+ await delay(1500);
217
+ for (const child of children) {
218
+ if (child.exitCode == null) {
219
+ killProcessTree(child, 'SIGKILL');
220
+ }
221
+ }
222
+
223
+ await maybeResetTailscaleServe();
224
+ };
225
+
226
+ process.on('SIGINT', () => shutdown().then(() => process.exit(0)));
227
+ process.on('SIGTERM', () => shutdown().then(() => process.exit(0)));
228
+
229
+ // Keep running
230
+ await new Promise(() => {});
231
+ }
232
+
233
+ main().catch((err) => {
234
+ console.error('[local] failed:', err);
235
+ process.exit(1);
236
+ });