happy-stacks 0.2.0 → 0.4.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 (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -8,101 +8,166 @@ see the “Using Happy from your phone” section in the main README.
8
8
  - Xcode installed
9
9
  - CocoaPods installed (`brew install cocoapods`)
10
10
 
11
- ## Step 1: Generate iOS native project + Pods (run when needed)
11
+ ## Two supported modes
12
12
 
13
- Run this after pulling changes that affect native deps/config, or if `ios/` was deleted:
13
+ - **Shared dev-client app** (recommended for development):
14
+ - Install *one* “Happy Stacks Dev” app on your phone.
15
+ - Run any stack with `--mobile`; scan the QR to open that stack inside the dev-client.
16
+ - Per-stack auth/storage is isolated via `EXPO_PUBLIC_HAPPY_STORAGE_SCOPE` (set automatically in stack mode).
14
17
 
15
- ```bash
16
- happys mobile:prebuild
17
- ```
18
+ - **Per-stack “release” app** (recommended for demos / strict isolation):
19
+ - Install a separate iOS app per stack (unique bundle id + scheme).
20
+ - Each stack app is isolated by iOS app container (no token collisions).
21
+
22
+ ## Shared dev-client app (install once)
18
23
 
19
- ## Step 2: Install the iOS dev build
24
+ Install the dedicated Happy Stacks dev-client app on your iPhone (USB).
20
25
 
21
- - **iOS Simulator**:
26
+ This command **runs a prebuild** (generates `ios/` + runs CocoaPods) and then installs a Debug build
27
+ without starting Metro:
22
28
 
23
29
  ```bash
24
- happys mobile --run-ios --device="iPhone 16 Pro"
30
+ happys mobile-dev-client --install
25
31
  ```
26
32
 
27
- - **Real iPhone** (requires code signing in Xcode once):
33
+ If you want to ensure the dev-client is built from a specific stack’s active `happy` worktree
34
+ (e.g. to include upstream changes that aren’t merged into your default checkout yet), run:
28
35
 
29
36
  ```bash
30
- happys mobile --run-ios --device="Your iPhone"
37
+ happys stack mobile-dev-client <stack> --install
31
38
  ```
32
39
 
33
- Tip: you can omit `--device` to auto-pick the first connected iPhone over USB:
40
+ Optional:
34
41
 
35
42
  ```bash
36
- happys mobile --run-ios
43
+ happys mobile-dev-client --install --device="Your iPhone"
44
+ happys mobile-dev-client --install --clean
37
45
  ```
38
46
 
39
- To see the exact device names/IDs you can pass:
47
+ Then run any stack with mobile enabled:
40
48
 
41
49
  ```bash
42
- happys mobile:devices
50
+ happys stack dev <stack> --mobile
51
+ # or:
52
+ happys dev --mobile
43
53
  ```
44
54
 
45
- If you hit a bundle identifier error (e.g. `com.slopus.happy.dev` “not available”), set a unique local bundle id:
55
+ Notes:
56
+
57
+ - **LAN requirement**: for physical iPhones, Metro must be reachable over LAN.
58
+ - Happy Stacks defaults to `lan` for mobile, and will print a QR code + deep link.
59
+ - For simulators you can usually use `localhost` (see `HAPPY_STACKS_MOBILE_HOST` below).
60
+ - **If Expo is already running in web-only mode**: re-run with `--restart` and include `--mobile`.
61
+
62
+ ## Per-stack app install (isolated)
63
+
64
+ Install an isolated app for a specific stack (unique bundle id + scheme, Release config, no Metro):
46
65
 
47
66
  ```bash
48
- HAPPY_STACKS_IOS_BUNDLE_ID="com.yourname.happy.local.dev" happys mobile --run-ios
49
- # legacy: HAPPY_LOCAL_IOS_BUNDLE_ID="com.yourname.happy.local.dev" happys mobile --run-ios
67
+ happys stack mobile:install <stack> --name="Happy (<stack>)"
68
+ happys stack mobile:install <stack> --name="Happy PR 272" --device="Your iPhone"
50
69
  ```
51
70
 
52
- ## Release build (runs without Metro)
71
+ The chosen app name is persisted in the stack env so you can re-run installs without re-typing it.
72
+
73
+ ## Native iOS regeneration / “prebuild” (critical)
74
+
75
+ You’ll need to regenerate the iOS native project + Pods when:
76
+
77
+ - you pull changes that affect native deps / Expo config
78
+ - `components/happy/ios/` was deleted
79
+ - you hit CocoaPods / deployment-target mismatches after a dependency bump
53
80
 
54
- Build + install a Release configuration (no Metro required at runtime):
81
+ Run:
55
82
 
56
83
  ```bash
57
- happys mobile:install
84
+ happys mobile --prebuild
85
+ # (optional) fully regenerate ios/:
86
+ happys mobile --prebuild --clean
58
87
  ```
59
88
 
60
- ## Step 3: Start Metro (dev client)
89
+ What this does today:
61
90
 
62
- - **iOS Simulator**:
91
+ - runs `expo prebuild --no-install` (so we can patch before CocoaPods runs)
92
+ - patches `ios/Podfile.properties.json` to:
93
+ - set `ios.deploymentTarget` to `16.0`
94
+ - set `ios.buildReactNativeFromSource` to `true`
95
+ - patches the generated Xcode project deployment target (where applicable)
96
+ - runs `pod install`
97
+
98
+ Notes:
99
+
100
+ - **You usually don’t need to run this manually** because both:
101
+ - `happys mobile-dev-client --install`
102
+ - `happys stack mobile:install <stack>`
103
+ already include `--prebuild`.
104
+ - Legacy alias: `happys mobile:prebuild` exists (hidden), but prefer `happys mobile --prebuild`.
105
+
106
+ ## Manual `happys mobile` usage (advanced)
107
+
108
+ If you want to work on the embedded Expo app directly (outside `happys dev --mobile`), `happys mobile` supports:
63
109
 
64
110
  ```bash
65
- happys mobile --host=localhost
111
+ # Start Metro (keeps running):
112
+ happys mobile --host=lan
113
+
114
+ # Build + install on iOS (and exit). If you omit --device, it will try to auto-pick a connected iPhone over USB:
115
+ happys mobile --prebuild --run-ios --device="Your iPhone"
116
+ happys mobile --prebuild --run-ios --configuration=Release --no-metro
66
117
  ```
67
118
 
68
- - **Real iPhone** (same Wi‑Fi as your Mac):
119
+ ## Notes / troubleshooting
120
+
121
+ - **QR opens the wrong app**:
122
+ - The dev-client QR uses the `HAPPY_STACKS_DEV_CLIENT_SCHEME` (default: `happystacks-dev`).
123
+ - Per-stack installs use a different per-stack scheme, so they should not intercept dev-client QR scans.
124
+
125
+ - **List connected devices** (for `--device=`):
69
126
 
70
127
  ```bash
71
- happys mobile --host=lan
128
+ happys mobile:devices
72
129
  ```
73
130
 
74
- Open the dev build and tap Reload. Scanning the QR should open the dev build (not the App Store app).
131
+ - **Code signing weirdness on a real iPhone**:
132
+ - Happy Stacks will try to “un-pin” signing fields in the generated `.pbxproj` so Expo/Xcode can reconfigure signing
133
+ (this avoids failures where automatic signing is disabled because `DEVELOPMENT_TEAM`/profiles were pinned).
134
+ - If you want to manage signing manually, pass `--no-signing-fix` to `happys mobile ...` / `happys stack mobile <stack> ...`.
75
135
 
76
136
  ## Bake the default server URL into the app (optional)
77
137
 
78
138
  If you want the built app to default to your happy-stacks server URL, set this **when building**:
79
139
 
80
140
  ```bash
81
- HAPPY_STACKS_SERVER_URL="https://<your-machine>.<tailnet>.ts.net" happys mobile:install
141
+ HAPPY_STACKS_SERVER_URL="https://<your-machine>.<tailnet>.ts.net" happys mobile-dev-client --install
82
142
  ```
83
143
 
84
- Note: changing `HAPPY_STACKS_SERVER_URL` requires rebuilding/reinstalling the Release app (`happys mobile:install`).
144
+ Note: changing `HAPPY_STACKS_SERVER_URL` requires rebuilding/reinstalling the app you care about.
85
145
 
86
- You can also set a custom bundle id (recommended for real devices):
146
+ Important:
87
147
 
88
- ```bash
89
- HAPPY_STACKS_IOS_BUNDLE_ID="com.yourname.happy.local.dev" HAPPY_STACKS_SERVER_URL="https://<your-machine>.<tailnet>.ts.net" happys mobile:install
90
- ```
148
+ - For **non-main stacks**, `HAPPY_STACKS_SERVER_URL` is only respected if it’s set **in that stack’s env file**
149
+ (safety: we ignore “global” URLs for non-main stacks to avoid accidentally repointing other stacks).
150
+
151
+ ## Customizing the app identity (optional / advanced)
152
+
153
+ Happy Stacks uses these identities:
91
154
 
92
- ## Customizing the app identity (optional)
155
+ - **Dev-client**: defaults to `Happy Stacks Dev` + bundle id `com.happystacks.dev.<user>`
156
+ - **Per-stack release**: defaults to `Happy (<stack>)` + bundle id `com.happystacks.stack.<user>.<stack>`
157
+
158
+ If you want to build/install *manually* (instead of `mobile-dev-client` / `stack mobile:install`), you can override:
93
159
 
94
160
  - **Bundle identifier (recommended for real iPhones)**:
95
- - You may *need* this if the default `com.slopus.happy.dev` can’t be registered on your Apple team.
161
+ - You may need this if the bundle id youre using isn’t available/owned by your Apple team.
96
162
 
97
163
  ```bash
98
- HAPPY_STACKS_IOS_BUNDLE_ID="com.yourname.happy.local.dev" happys mobile --run-ios
99
- HAPPY_STACKS_IOS_BUNDLE_ID="com.yourname.happy.local.dev" happys mobile:install
164
+ HAPPY_STACKS_IOS_BUNDLE_ID="com.yourname.happy.local.dev" happys mobile --prebuild --run-ios --no-metro
100
165
  ```
101
166
 
102
167
  - **App name (what shows on the home screen)**:
103
168
 
104
169
  ```bash
105
- HAPPY_STACKS_IOS_APP_NAME="Happy Local" happys mobile:install
170
+ HAPPY_STACKS_IOS_APP_NAME="Happy Local" happys mobile --prebuild --run-ios --no-metro
106
171
  ```
107
172
 
108
173
  ## Suggested env (recommended)
@@ -110,25 +175,18 @@ HAPPY_STACKS_IOS_APP_NAME="Happy Local" happys mobile:install
110
175
  Add these to your main stack env file (`~/.happy/stacks/main/env`) (or `~/.happy-stacks/env.local` for global overrides) so you don’t have to prefix every command:
111
176
 
112
177
  ```bash
113
- # Required if you want the Release app to default to your stack server:
114
- HAPPY_STACKS_SERVER_URL="https://<your-machine>.<tailnet>.ts.net"
178
+ # How the phone reaches Metro:
179
+ # - lan: recommended for real devices
180
+ # - localhost: OK for simulators
181
+ HAPPY_STACKS_MOBILE_HOST="lan"
115
182
 
116
- # Strongly recommended for real devices (needs to be unique + owned by your Apple team):
117
- HAPPY_STACKS_IOS_BUNDLE_ID="com.yourname.happy.local.dev"
183
+ # (optional) default scheme used in the dev-client QR / deep link
184
+ # (must match your installed dev-client app):
185
+ HAPPY_STACKS_DEV_CLIENT_SCHEME="happystacks-dev"
186
+
187
+ # Default public server URL for the stack (baked into the Expo app config):
188
+ HAPPY_STACKS_SERVER_URL="https://<your-machine>.<tailnet>.ts.net"
118
189
 
119
190
  # Optional: home screen name:
120
191
  HAPPY_STACKS_IOS_APP_NAME="Happy Local"
121
192
  ```
122
-
123
- ## Personal build on iPhone (EAS internal distribution)
124
-
125
- ```bash
126
- cd "$HOME/.happy-stacks/workspace/components/happy"
127
- eas build --profile development --platform ios
128
- ```
129
-
130
- Then keep Metro running from `happy-stacks`:
131
-
132
- ```bash
133
- happys mobile --host=lan
134
- ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "happy-stacks",
3
3
  "type": "module",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "packageManager": "pnpm@10.18.3",
6
6
  "bin": {
7
7
  "happys": "./bin/happys.mjs",
@@ -55,5 +55,9 @@
55
55
  "menubar:install": "node ./scripts/menubar.mjs install",
56
56
  "menubar:uninstall": "node ./scripts/menubar.mjs uninstall",
57
57
  "menubar:open": "bash -lc 'DIR=\"$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)\"; if [[ -z \"$DIR\" ]]; then DIR=\"$HOME/Library/Application Support/SwiftBar/Plugins\"; fi; open \"$DIR\"'"
58
+ },
59
+ "dependencies": {
60
+ "qrcode": "^1.5.4",
61
+ "qrcode-terminal": "^0.12.0"
58
62
  }
59
63
  }
package/scripts/auth.mjs CHANGED
@@ -1,170 +1,68 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName, resolveStackEnvPath } from './utils/paths.mjs';
5
- import { listAllStackNames } from './utils/stacks.mjs';
4
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName, resolveStackEnvPath } from './utils/paths/paths.mjs';
5
+ import { listAllStackNames } from './utils/stack/stacks.mjs';
6
6
  import { resolvePublicServerUrl } from './tailscale.mjs';
7
- import { resolveServerPortFromEnv } from './utils/server_urls.mjs';
7
+ import { getInternalServerUrl, getPublicServerUrlEnvOverride, getWebappUrlEnvOverride } from './utils/server/urls.mjs';
8
+ import { fetchHappyHealth } from './utils/server/server.mjs';
8
9
  import { existsSync, readFileSync } from 'node:fs';
9
10
  import { join } from 'node:path';
10
11
  import { homedir } from 'node:os';
11
12
  import { spawn } from 'node:child_process';
12
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
+ import { mkdir, writeFile } from 'node:fs/promises';
13
14
  import { dirname } from 'node:path';
14
15
 
15
- import { parseDotenv } from './utils/dotenv.mjs';
16
- import { ensureDepsInstalled, pmExecBin } from './utils/pm.mjs';
17
- import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/happy_server_infra.mjs';
18
- import { clearDevAuthKey, readDevAuthKey, writeDevAuthKey } from './utils/dev_auth_key.mjs';
19
- import { getExpoStatePaths, isStateProcessRunning } from './utils/expo.mjs';
20
- import { resolveAuthSeedFromEnv } from './utils/stack_startup.mjs';
21
- import { printAuthLoginInstructions } from './utils/auth_login_ux.mjs';
22
- import { copyFileIfMissing, linkFileIfMissing, removeFileOrSymlinkIfExists, writeSecretFileIfMissing } from './utils/auth_files.mjs';
23
- import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth_sources.mjs';
24
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
25
- import { resolveHandyMasterSecretFromStack } from './utils/handy_master_secret.mjs';
26
-
27
- function getInternalServerUrl() {
28
- const n = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
29
- return { port: n, url: `http://127.0.0.1:${n}` };
30
- }
31
-
32
- function resolveEnvPublicUrlForStack({ stackName }) {
33
- const candidate = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
34
-
35
- // For main, allow the user's global/public URL override (commonly a Tailscale Serve URL).
36
- if (stackName === 'main') {
37
- return candidate;
38
- }
39
-
40
- // For non-main stacks, do NOT inherit a global/server URL override from ~/.happy-stacks/env.local
41
- // (which often points at main). Only use a public URL override if it is explicitly present in the
42
- // stack env file itself.
43
- const envPath =
44
- (process.env.HAPPY_STACKS_ENV_FILE ?? '').trim() ||
45
- (process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim() ||
46
- getStackEnvPath(stackName);
47
- try {
48
- if (!envPath || !existsSync(envPath)) return '';
49
- const raw = readFileSync(envPath, 'utf-8');
50
- const env = raw ? parseEnvToObject(raw) : {};
51
- return (env.HAPPY_LOCAL_SERVER_URL ?? env.HAPPY_STACKS_SERVER_URL ?? '').toString().trim();
52
- } catch {
53
- return '';
54
- }
55
- }
56
-
57
- function expandTilde(p) {
58
- return p.replace(/^~(?=\/)/, homedir());
59
- }
60
-
61
- function resolveEnvWebappUrlForStack({ stackName }) {
62
- const candidate = (process.env.HAPPY_WEBAPP_URL ?? '').trim();
63
-
64
- // For main, allow the user's global override.
65
- if (stackName === 'main') {
66
- return candidate;
67
- }
68
-
69
- // For non-main stacks, only respect HAPPY_WEBAPP_URL if it is explicitly present in the stack env file.
70
- const envPath =
71
- (process.env.HAPPY_STACKS_ENV_FILE ?? '').trim() ||
72
- (process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim() ||
73
- getStackEnvPath(stackName);
74
- try {
75
- if (!envPath || !existsSync(envPath)) return '';
76
- const raw = readFileSync(envPath, 'utf-8');
77
- const env = raw ? parseEnvToObject(raw) : {};
78
- return (env.HAPPY_WEBAPP_URL ?? '').toString().trim();
79
- } catch {
80
- return '';
81
- }
82
- }
83
-
84
- function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
85
- const s = String(raw ?? '')
86
- .toLowerCase()
87
- .replace(/[^a-z0-9-]+/g, '-')
88
- .replace(/-+/g, '-')
89
- .replace(/^-+/, '')
90
- .replace(/-+$/, '');
91
- return s || fallback;
16
+ import { parseEnvToObject } from './utils/env/dotenv.mjs';
17
+ import { ensureDepsInstalled, pmExecBin } from './utils/proc/pm.mjs';
18
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/server/infra/happy_server_infra.mjs';
19
+ import { clearDevAuthKey, readDevAuthKey, writeDevAuthKey } from './utils/auth/dev_key.mjs';
20
+ import { getExpoStatePaths, isStateProcessRunning } from './utils/expo/expo.mjs';
21
+ import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
22
+ import { printAuthLoginInstructions } from './utils/auth/login_ux.mjs';
23
+ import { copyFileIfMissing, linkFileIfMissing, removeFileOrSymlinkIfExists, writeSecretFileIfMissing } from './utils/auth/files.mjs';
24
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
25
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
26
+ import { resolveHandyMasterSecretFromStack } from './utils/auth/handy_master_secret.mjs';
27
+ import { ensureDir, readTextIfExists } from './utils/fs/ops.mjs';
28
+ import { stackExistsSync } from './utils/stack/stacks.mjs';
29
+ import { checkDaemonState } from './daemon.mjs';
30
+ import {
31
+ getCliHomeDirFromEnvOrDefault,
32
+ getServerLightDataDirFromEnvOrDefault,
33
+ resolveCliHomeDir,
34
+ } from './utils/stack/dirs.mjs';
35
+ import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
36
+
37
+ function getInternalServerUrlCompat() {
38
+ const { port, internalServerUrl } = getInternalServerUrl({ env: process.env, defaultPort: 3005 });
39
+ return { port, url: internalServerUrl };
92
40
  }
93
41
 
94
42
  async function resolveWebappUrlFromRunningExpo({ rootDir, stackName }) {
95
43
  try {
96
- const baseDir = getStackDir(stackName);
44
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
97
45
  const uiDir = getComponentDir(rootDir, 'happy');
98
46
  const uiPaths = getExpoStatePaths({
99
47
  baseDir,
100
- kind: 'ui-dev',
48
+ kind: 'expo-dev',
101
49
  projectDir: uiDir,
102
- stateFileName: 'ui.state.json',
50
+ stateFileName: 'expo.state.json',
103
51
  });
104
52
  const uiRunning = await isStateProcessRunning(uiPaths.statePath);
105
53
  if (!uiRunning.running) return null;
106
54
  const port = Number(uiRunning.state?.port);
107
55
  if (!Number.isFinite(port) || port <= 0) return null;
108
- const host = stackName && stackName !== 'main' ? `happy-${sanitizeDnsLabel(stackName)}.localhost` : 'localhost';
56
+ const host = resolveLocalhostHost({ stackMode: stackName !== 'main', stackName });
109
57
  return `http://${host}:${port}`;
110
58
  } catch {
111
59
  return null;
112
60
  }
113
61
  }
114
62
 
115
- async function ensureDir(p) {
116
- await mkdir(p, { recursive: true });
117
- }
118
-
119
- async function readTextIfExists(path) {
120
- try {
121
- if (!existsSync(path)) return null;
122
- const raw = await readFile(path, 'utf-8');
123
- const t = raw.trim();
124
- return t ? t : null;
125
- } catch {
126
- return null;
127
- }
128
- }
129
-
130
- // (auth file copy/link helpers live in scripts/utils/auth_files.mjs)
131
-
132
- function parseEnvToObject(raw) {
133
- const parsed = parseDotenv(raw);
134
- return Object.fromEntries(parsed.entries());
135
- }
136
-
137
- function getStackDir(stackName) {
138
- return resolveStackEnvPath(stackName).baseDir;
139
- }
140
-
141
- function getStackEnvPath(stackName) {
142
- return resolveStackEnvPath(stackName).envPath;
143
- }
144
-
145
- function stackExistsSync(stackName) {
146
- if (stackName === 'main') return true;
147
- const envPath = getStackEnvPath(stackName);
148
- return existsSync(envPath);
149
- }
150
-
151
- function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
152
- const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
153
- return fromEnv || join(stackBaseDir, 'cli');
154
- }
63
+ // NOTE: common fs helpers live in scripts/utils/fs/ops.mjs
155
64
 
156
- function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
157
- const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
158
- return fromEnv || join(stackBaseDir, 'server-light');
159
- }
160
-
161
- function resolveCliHomeDir() {
162
- const fromEnv = (process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? process.env.HAPPY_STACKS_CLI_HOME_DIR ?? '').trim();
163
- if (fromEnv) {
164
- return expandTilde(fromEnv);
165
- }
166
- return join(getDefaultAutostartPaths().baseDir, 'cli');
167
- }
65
+ // (auth file copy/link helpers live in scripts/utils/auth/files.mjs)
168
66
 
169
67
  function fileHasContent(path) {
170
68
  try {
@@ -175,60 +73,6 @@ function fileHasContent(path) {
175
73
  }
176
74
  }
177
75
 
178
- function checkDaemonState(cliHomeDir) {
179
- const statePath = join(cliHomeDir, 'daemon.state.json');
180
- const lockPath = join(cliHomeDir, 'daemon.state.json.lock');
181
-
182
- const alive = (pid) => {
183
- try {
184
- process.kill(pid, 0);
185
- return true;
186
- } catch {
187
- return false;
188
- }
189
- };
190
-
191
- if (existsSync(statePath)) {
192
- try {
193
- const state = JSON.parse(readFileSync(statePath, 'utf-8'));
194
- const pid = Number(state?.pid);
195
- if (Number.isFinite(pid) && pid > 0) {
196
- return alive(pid) ? { status: 'running', pid } : { status: 'stale_state', pid };
197
- }
198
- return { status: 'bad_state' };
199
- } catch {
200
- return { status: 'bad_state' };
201
- }
202
- }
203
-
204
- if (existsSync(lockPath)) {
205
- try {
206
- const pid = Number(readFileSync(lockPath, 'utf-8').trim());
207
- if (Number.isFinite(pid) && pid > 0) {
208
- return alive(pid) ? { status: 'starting', pid } : { status: 'stale_lock', pid };
209
- }
210
- } catch {
211
- // ignore
212
- }
213
- }
214
-
215
- return { status: 'stopped' };
216
- }
217
-
218
- async function fetchHealth(internalServerUrl) {
219
- const ctl = new AbortController();
220
- const t = setTimeout(() => ctl.abort(), 1500);
221
- try {
222
- const res = await fetch(`${internalServerUrl}/health`, { method: 'GET', signal: ctl.signal });
223
- const body = (await res.text()).trim();
224
- return { ok: res.ok, status: res.status, body };
225
- } catch {
226
- return { ok: false, status: null, body: null };
227
- } finally {
228
- clearTimeout(t);
229
- }
230
- }
231
-
232
76
  function authLoginSuggestion(stackName) {
233
77
  return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
234
78
  }
@@ -678,8 +522,8 @@ async function cmdCopyFrom({ argv, json }) {
678
522
  }
679
523
  }
680
524
 
681
- const sourceBaseDir = isLegacySource ? getLegacyHappyBaseDir() : getStackDir(fromStackName);
682
- const sourceEnvRaw = isLegacySource ? '' : await readTextIfExists(getStackEnvPath(fromStackName));
525
+ const sourceBaseDir = isLegacySource ? getLegacyHappyBaseDir() : resolveStackEnvPath(fromStackName).baseDir;
526
+ const sourceEnvRaw = isLegacySource ? '' : await readTextIfExists(resolveStackEnvPath(fromStackName).envPath);
683
527
  const sourceEnv = sourceEnvRaw ? parseEnvToObject(sourceEnvRaw) : {};
684
528
  const sourceCli = isLegacySource
685
529
  ? join(sourceBaseDir, 'cli')
@@ -735,9 +579,9 @@ async function cmdCopyFrom({ argv, json }) {
735
579
  // so we can seed DB accounts reliably.
736
580
  const managed = (targetEnv.HAPPY_STACKS_MANAGED_INFRA ?? targetEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0';
737
581
  if (targetServerComponent === 'happy-server' && withInfra && managed) {
738
- const { port } = getInternalServerUrl();
739
- const publicServerUrl = `http://localhost:${port}`;
740
- const envPath = getStackEnvPath(stackName);
582
+ const { port } = getInternalServerUrlCompat();
583
+ const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${port}`, { stackName });
584
+ const envPath = resolveStackEnvPath(stackName).envPath;
741
585
  const infra = await ensureHappyServerManagedInfra({
742
586
  stackName,
743
587
  baseDir: targetBaseDir,
@@ -838,14 +682,14 @@ async function cmdStatus({ json }) {
838
682
  const rootDir = getRootDir(import.meta.url);
839
683
  const stackName = getStackName();
840
684
 
841
- const { port, url: internalServerUrl } = getInternalServerUrl();
842
- const defaultPublicUrl = `http://localhost:${port}`;
843
- const envPublicUrl = resolveEnvPublicUrlForStack({ stackName });
685
+ const { port, url: internalServerUrl } = getInternalServerUrlCompat();
686
+ const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port, stackName });
844
687
  const { publicServerUrl } = await resolvePublicServerUrl({
845
688
  internalServerUrl,
846
689
  defaultPublicUrl,
847
690
  envPublicUrl,
848
691
  allowEnable: false,
692
+ stackName,
849
693
  });
850
694
 
851
695
  const cliHomeDir = resolveCliHomeDir();
@@ -861,7 +705,12 @@ async function cmdStatus({ json }) {
861
705
  };
862
706
 
863
707
  const daemon = checkDaemonState(cliHomeDir);
864
- const health = await fetchHealth(internalServerUrl);
708
+ const healthRaw = await fetchHappyHealth(internalServerUrl);
709
+ const health = {
710
+ ok: Boolean(healthRaw.ok),
711
+ status: healthRaw.status,
712
+ body: healthRaw.text ? healthRaw.text.trim() : null,
713
+ };
865
714
 
866
715
  const out = {
867
716
  stackName,
@@ -922,20 +771,21 @@ async function cmdStatus({ json }) {
922
771
  async function cmdLogin({ argv, json }) {
923
772
  const rootDir = getRootDir(import.meta.url);
924
773
  const stackName = getStackName();
925
- const { kv } = parseArgs(argv);
774
+ const { flags, kv } = parseArgs(argv);
926
775
 
927
- const { port, url: internalServerUrl } = getInternalServerUrl();
928
- const defaultPublicUrl = `http://localhost:${port}`;
929
- const envPublicUrl = resolveEnvPublicUrlForStack({ stackName });
776
+ const { port, url: internalServerUrl } = getInternalServerUrlCompat();
777
+ const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port, stackName });
930
778
  const { publicServerUrl } = await resolvePublicServerUrl({
931
779
  internalServerUrl,
932
780
  defaultPublicUrl,
933
781
  envPublicUrl,
934
782
  allowEnable: false,
783
+ stackName,
935
784
  });
936
- const envWebappUrl = resolveEnvWebappUrlForStack({ stackName });
785
+ const { envWebappUrl } = getWebappUrlEnvOverride({ env: process.env, stackName });
937
786
  const expoWebappUrl = await resolveWebappUrlFromRunningExpo({ rootDir, stackName });
938
- const webappUrl = envWebappUrl || expoWebappUrl || publicServerUrl;
787
+ const webappUrlRaw = envWebappUrl || expoWebappUrl || publicServerUrl;
788
+ const webappUrl = await preferStackLocalhostUrl(webappUrlRaw, { stackName });
939
789
  const webappUrlSource = expoWebappUrl ? 'expo' : envWebappUrl ? 'stack env override' : 'server';
940
790
 
941
791
  const cliHomeDir = resolveCliHomeDir();
@@ -971,7 +821,8 @@ async function cmdLogin({ argv, json }) {
971
821
  return;
972
822
  }
973
823
 
974
- if (!json) {
824
+ const quietUx = flags.has('--quiet') || flags.has('--no-ux');
825
+ if (!json && !quietUx) {
975
826
  printAuthLoginInstructions({
976
827
  stackName,
977
828
  context,