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/README.md CHANGED
@@ -1,9 +1,6 @@
1
1
  # Happy Stacks
2
2
 
3
- Run the **Happy** stack locally (or many stacks in parallel) and access it remotely and securely (using Tailscale).
4
-
5
- `happy-stacks` is a CLI (`happys`) that orchestrate the real upstream repos
6
- cloned under your configured workspace (default: `~/.happy-stacks/workspace/components/*`).
3
+ Run [**Happy**](https://happy.engineering/) locally and access it remotely and securely (using Tailscale).
7
4
 
8
5
  ## What is Happy?
9
6
 
@@ -11,51 +8,31 @@ Happy is an UI/CLI stack (server + web UI + CLI + daemon) who let you monitor an
11
8
 
12
9
  ## What is Happy Stacks?
13
10
 
14
- happy-stacks is a “launcher + workflow toolkit” to:
11
+ happy-stacks is a guided installer + local orchestration CLI for Happy.
15
12
 
16
- - run Happy fully on your own machine (no hosted dependency)
17
- - safely access it remotely (HTTPS secure context) via Tailscale
18
- - manage **worktrees** for clean upstream PRs while keeping a patched fork
19
- - run **multiple isolated stacks** (ports + dirs + component overrides)
20
- - optionally manage autostart (macOS LaunchAgent) and a SwiftBar menu bar control panel
13
+ If you only want to **self-host Happy**, start with the **Self-host** section below.
14
+ If you want to **develop Happy** (worktrees, multiple stacks, upstream PR workflows), see the **Development** section further down.
21
15
 
22
- ## Quickstart
16
+ ## Self-host Happy (install + run)
23
17
 
24
- ### Step 1: Install / bootstrap
18
+ ### Step 1: Setup
25
19
 
26
20
  Recommended:
27
21
 
28
22
  ```bash
29
- npx happy-stacks init
30
- export PATH="$HOME/.happy-stacks/bin:$PATH"
23
+ npx happy-stacks setup --profile=selfhost
31
24
  ```
32
25
 
33
26
  Alternative (global install):
34
27
 
35
28
  ```bash
36
29
  npm install -g happy-stacks
37
- happys init
38
- export PATH="$HOME/.happy-stacks/bin:$PATH"
30
+ happys setup --profile=selfhost
39
31
  ```
40
32
 
41
- (`init` will run `bootstrap` automatically. Use `--no-bootstrap` if you only want to write config and shims.)
42
-
43
- Developer mode (clone this repo):
33
+ `setup` can optionally start Happy and guide you through authentication.
44
34
 
45
- ```bash
46
- git clone https://github.com/leeroybrun/happy-stacks.git
47
- cd happy-stacks
48
-
49
- node ./bin/happys.mjs bootstrap --interactive
50
- # legacy:
51
- # pnpm bootstrap -- --interactive
52
- ```
53
-
54
- Notes:
55
-
56
- - In a cloned repo, `pnpm <script>` still works, but `happys <command>` is now the recommended UX (same underlying scripts).
57
-
58
- ### Step 2: Run the main stack
35
+ ### Step 2: Start Happy
59
36
 
60
37
  Starts the local server, CLI daemon, and serves the pre-built UI.
61
38
 
@@ -63,7 +40,7 @@ Starts the local server, CLI daemon, and serves the pre-built UI.
63
40
  happys start
64
41
  ```
65
42
 
66
- ### Step 2b (first run only): authenticate the daemon
43
+ ### Step 3 (first run only): authenticate
67
44
 
68
45
  On a **fresh machine** (or any new stack), the daemon needs to authenticate once before it can register a “machine”.
69
46
 
@@ -71,34 +48,20 @@ On a **fresh machine** (or any new stack), the daemon needs to authenticate once
71
48
  happys auth login
72
49
  ```
73
50
 
74
- #### Troubleshooting: “no machine” on first run (daemon auth)
75
-
76
- If `.../new` shows “no machine” check whether this is **auth** vs a **daemon/runtime** issue:
51
+ If you want a quick diagnosis:
77
52
 
78
53
  ```bash
79
54
  happys auth status
80
55
  ```
81
56
 
82
- If it says **auth is required**, run:
83
-
84
- ```bash
85
- happys auth login
86
- ```
87
-
88
- If auth is OK but the daemon isn’t running, run:
89
-
90
- ```bash
91
- happys doctor
92
- ```
93
-
94
- ### Step 3: Enable Tailscale Serve (recommended for remote devices)
57
+ ### Step 4: Enable Tailscale Serve (recommended for mobile/remote)
95
58
 
96
59
  ```bash
97
60
  happys tailscale enable
98
61
  happys tailscale url
99
62
  ```
100
63
 
101
- ### Step 4: Mobile access
64
+ ### Step 5: Mobile access
102
65
 
103
66
  Make sure Tailscale is [installed and running]
104
67
  ([https://tailscale.com/kb/1347/installation](https://tailscale.com/kb/1347/installation)) on your
@@ -111,9 +74,41 @@ your local server](docs/remote-access.md).
111
74
 
112
75
  Details (secure context, phone instructions, automation knobs): `[docs/remote-access.md](docs/remote-access.md)`.
113
76
 
114
- ## Why this exists
77
+ ## Development (worktrees, stacks, contributor workflows)
78
+
79
+ ### Setup (guided)
80
+
81
+ ```bash
82
+ npx happy-stacks setup --profile=dev
83
+ ```
84
+
85
+ ### Developing from a cloned repo
86
+
87
+ ```bash
88
+ git clone https://github.com/leeroybrun/happy-stacks.git
89
+ cd happy-stacks
90
+
91
+ node ./bin/happys.mjs setup --profile=dev
92
+ ```
93
+
94
+ Notes:
115
95
 
116
- - **Automated setup**: `happys init` + `happys start` gets the whole stack up and running.
96
+ - In a cloned repo, `pnpm <script>` still works, but `happys <command>` is the recommended UX (same underlying scripts).
97
+ - To make the installed `~/.happy-stacks/bin/happys` shim (LaunchAgents / SwiftBar) run your local checkout without publishing to npm, set:
98
+
99
+ ```bash
100
+ echo 'HAPPY_STACKS_CLI_ROOT_DIR=/path/to/your/happy-stacks-checkout' >> ~/.happy-stacks/.env
101
+ ```
102
+
103
+ Or (recommended) persist it via init:
104
+
105
+ ```bash
106
+ happys init --cli-root-dir=/path/to/your/happy-stacks-checkout
107
+ ```
108
+
109
+ ### Why this exists
110
+
111
+ - **Automated setup**: `happys setup` + `happys start` gets the whole stack up and running.
117
112
  - **No hosted dependency**: run the full stack on your own computer.
118
113
  - **Lower latency**: localhost/LAN is typically much faster than remote hosted servers.
119
114
  - **Custom forks**: easily use forks of the Happy UI + CLI (e.g. `leeroybrun/*`) while still contributing upstream to `slopus/*`.
@@ -121,7 +116,7 @@ Details (secure context, phone instructions, automation knobs): `[docs/remote-ac
121
116
  - **Stacks**: run multiple isolated instances in parallel (ports + dirs + component overrides).
122
117
  - **Remote access**: `happys tailscale ...` helps you get an HTTPS URL for mobile/remote devices.
123
118
 
124
- ## How Happy Stacks wires “local” URLs
119
+ ### How Happy Stacks wires “local” URLs
125
120
 
126
121
  There are two “URLs” to understand:
127
122
 
@@ -143,9 +138,10 @@ Diagram:
143
138
  v
144
139
  local machine (this repo)
145
140
  +--------------------------------+
146
- | happy-server(-light) |
141
+ | happy-server-light OR |
142
+ | happy-server (via UI gateway) |
147
143
  | - listens on :PORT |
148
- | - serves UI (server-light) |
144
+ | - serves UI at / |
149
145
  +--------------------------------+
150
146
  ^
151
147
  | internal loopback
@@ -156,7 +152,7 @@ Diagram:
156
152
 
157
153
  More details + automation: `[docs/remote-access.md](docs/remote-access.md)`.
158
154
 
159
- ## How it’s organized
155
+ ### How it’s organized
160
156
 
161
157
  - **Scripts**: `scripts/*.mjs` (bootstrap/dev/start/build/stacks/worktrees/service/tailscale/mobile)
162
158
  - **Components**: `components/*` (each is its own Git repo)
@@ -169,9 +165,9 @@ Components:
169
165
  - `happy-server-light` (light server, can serve built UI)
170
166
  - `happy-server` (full server)
171
167
 
172
- ## Quickstarts (feature-focused)
168
+ ### Quickstarts (feature-focused)
173
169
 
174
- ### Remote access (Tailscale Serve)
170
+ #### Remote access (Tailscale Serve)
175
171
 
176
172
  ```bash
177
173
  happys tailscale enable
@@ -180,7 +176,7 @@ happys tailscale url
180
176
 
181
177
  Details: `[docs/remote-access.md](docs/remote-access.md)`.
182
178
 
183
- ### Worktrees + forks (clean upstream PRs)
179
+ #### Worktrees + forks (clean upstream PRs)
184
180
 
185
181
  Create a clean upstream PR worktree:
186
182
 
@@ -196,12 +192,44 @@ happys wt pr happy https://github.com/slopus/happy/pull/123 --use
196
192
  happys wt pr happy 123 --update --stash
197
193
  ```
198
194
 
195
+ Create a fully isolated PR stack (creates stack + PR worktrees + optional auth seeding + starts dev):
196
+
197
+ ```bash
198
+ happys stack pr pr123 \
199
+ --happy=https://github.com/slopus/happy/pull/123 \
200
+ --happy-cli=https://github.com/slopus/happy-cli/pull/456 \
201
+ --seed-auth --copy-auth-from=dev-auth --link-auth \
202
+ --dev
203
+ ```
204
+
205
+ One-shot “install + run PR stack” (best for maintainers who don’t have Happy Stacks set up yet):
206
+
207
+ ```bash
208
+ npx happy-stacks setup pr \
209
+ --happy=https://github.com/slopus/happy/pull/123 \
210
+ --happy-cli=https://github.com/slopus/happy-cli/pull/456
211
+ ```
212
+
213
+ You can also run it as:
214
+
215
+ ```bash
216
+ npx happy-stacks setup-pr \
217
+ --happy=https://github.com/slopus/happy/pull/123 \
218
+ --happy-cli=https://github.com/slopus/happy-cli/pull/456
219
+ ```
220
+
221
+ Updating when the PR changes:
222
+
223
+ - Re-run the same command to fast-forward the PR worktrees.
224
+ - If the PR was force-pushed, add `--force`.
225
+
199
226
  Details: `[docs/worktrees-and-forks.md](docs/worktrees-and-forks.md)`.
200
227
 
201
- ### Server flavor (server-light vs full server)
228
+ #### Server flavor (server-light vs full server)
202
229
 
203
230
  - Use `happy-server-light` for a light local stack (no Redis, no Postgres, no Docker), and UI serving via server-light.
204
- - Use `happy-server` when you need some production-ready server (eg. to distribute and host multiple users) or develop server changes for upstream.
231
+ - Use `happy-server` when you need a more production-like server (Postgres + Redis + S3-compatible storage) or want to develop server changes for upstream.
232
+ - Happy Stacks can **manage the required infra automatically per stack** (via Docker Compose) and runs a **UI gateway** so you still get a single `https://...ts.net` URL that serves the UI + proxies API/websockets/files.
205
233
 
206
234
  Switch globally:
207
235
 
@@ -218,7 +246,7 @@ happys stack srv exp1 -- use --interactive
218
246
 
219
247
  Details: `[docs/server-flavors.md](docs/server-flavors.md)`.
220
248
 
221
- ### Stacks (multiple isolated instances)
249
+ #### Stacks (multiple isolated instances)
222
250
 
223
251
  ```bash
224
252
  happys stack new exp1 --interactive
@@ -235,7 +263,15 @@ happys stack dev exp1
235
263
 
236
264
  Details: `[docs/stacks.md](docs/stacks.md)`.
237
265
 
238
- ### Menu bar (SwiftBar)
266
+ #### Dev stacks: browser origin isolation (IMPORTANT)
267
+
268
+ Non-main stacks use a stack-specific localhost hostname (no `/etc/hosts` changes required):
269
+
270
+ - `http://happy-<stack>.localhost:<uiPort>`
271
+
272
+ This avoids browser auth/session collisions between stacks (separate origin per stack).
273
+
274
+ #### Menu bar (SwiftBar)
239
275
 
240
276
  ```bash
241
277
  happys menubar install
@@ -244,7 +280,7 @@ happys menubar open
244
280
 
245
281
  Details: `[docs/menubar.md](docs/menubar.md)`.
246
282
 
247
- ### Mobile iOS dev (optional)
283
+ #### Mobile iOS dev (optional)
248
284
 
249
285
  ```bash
250
286
  happys mobile --help
@@ -253,7 +289,7 @@ happys mobile --json
253
289
 
254
290
  Details: `[docs/mobile-ios.md](docs/mobile-ios.md)`.
255
291
 
256
- ### Tauri desktop app (optional)
292
+ #### Tauri desktop app (optional)
257
293
 
258
294
  ```bash
259
295
  happys build --tauri
@@ -261,13 +297,12 @@ happys build --tauri
261
297
 
262
298
  Details: `[docs/tauri.md](docs/tauri.md)`.
263
299
 
264
- ## Commands (high-signal)
300
+ ### Commands (high-signal)
265
301
 
266
302
  - **Setup**:
267
- - `happys init`
268
- - `happys bootstrap --interactive` (wizard)
269
- - `happys bootstrap --forks|--upstream`
270
- - `happys bootstrap --server=happy-server|happy-server-light|both`
303
+ - `happys setup` (guided; selfhost or dev)
304
+ - (advanced) `happys init` (plumbing: shims/runtime/pointer env)
305
+ - (advanced) `happys bootstrap --interactive` (component installer wizard)
271
306
  - **Run**:
272
307
  - `happys start` (production-like; serves built UI via server-light)
273
308
  - `happys dev` (dev; Expo web dev server for UI)
@@ -288,17 +323,18 @@ Details: `[docs/tauri.md](docs/tauri.md)`.
288
323
  - **Menu bar (SwiftBar)**:
289
324
  - `happys menubar install`
290
325
 
291
- ## Docs (deep dives)
326
+ ### Docs (deep dives)
292
327
 
293
328
  - **Remote access (Tailscale + phone)**: `[docs/remote-access.md](docs/remote-access.md)`
294
329
  - **Server flavors (server-light vs server)**: `[docs/server-flavors.md](docs/server-flavors.md)`
295
330
  - **Worktrees + forks workflow**: `[docs/worktrees-and-forks.md](docs/worktrees-and-forks.md)`
296
331
  - **Stacks (multiple instances)**: `[docs/stacks.md](docs/stacks.md)`
332
+ - **Paths + env precedence (home/workspace/runtime/stacks)**: `[docs/paths-and-env.md](docs/paths-and-env.md)`
297
333
  - **Menu bar (SwiftBar)**: `[docs/menubar.md](docs/menubar.md)`
298
334
  - **Mobile iOS dev**: `[docs/mobile-ios.md](docs/mobile-ios.md)`
299
335
  - **Tauri desktop app**: `[docs/tauri.md](docs/tauri.md)`
300
336
 
301
- ## Configuration
337
+ ### Configuration
302
338
 
303
339
  Where config lives by default:
304
340
 
@@ -310,5 +346,25 @@ Notes:
310
346
 
311
347
  - Canonical env prefix is `HAPPY_STACKS_*` (legacy `HAPPY_LOCAL_*` still works).
312
348
  - Canonical stack storage is `~/.happy/stacks` (legacy `~/.happy/local` is still supported).
349
+ - **Repo env templates**:
350
+ - **Use `.env.example` as the canonical template** (copy it to `.env` if you’re running this repo directly).
351
+ - If an LLM tool refuses to read/edit `.env.example` due to safety restrictions, **do not create an `env.example` workaround**—instead, ask the user to apply the change manually.
352
+
353
+ ### Sandbox / test installs (fully isolated)
354
+
355
+ If you want to test the full setup flow (including PR stacks) without impacting your “real” install, run with:
356
+
357
+ ```bash
358
+ npx happy-stacks --sandbox-dir /tmp/happy-stacks-sandbox setup pr --happy=123 --happy-cli=456
359
+ ```
360
+
361
+ To reset completely, just delete the sandbox folder:
362
+
363
+ ```bash
364
+ rm -rf /tmp/happy-stacks-sandbox
365
+ ```
366
+
367
+ Sandbox mode disables global OS side effects (PATH edits, SwiftBar plugin install, LaunchAgents/systemd services) by default.
368
+ To explicitly allow them for testing, set `HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1`.
313
369
 
314
- For contributor/LLM workflow expectations: `[AGENTS.md](AGENTS.md)`.
370
+ For contributor/LLM workflow expectations: `[AGENTS.md](AGENTS.md)`.
package/bin/happys.mjs CHANGED
@@ -7,18 +7,142 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { dirname, join } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { commandHelpArgs, renderHappysRootHelp, resolveHappysCommand } from '../scripts/utils/cli_registry.mjs';
10
+ import { commandHelpArgs, renderHappysRootHelp, resolveHappysCommand } from '../scripts/utils/cli/cli_registry.mjs';
11
+ import { expandHome, getCanonicalHomeEnvPathFromEnv } from '../scripts/utils/canonical_home.mjs';
11
12
 
12
13
  function getCliRootDir() {
13
14
  return dirname(dirname(fileURLToPath(import.meta.url)));
14
15
  }
15
16
 
17
+ // expandHome is imported from scripts/utils/canonical_home.mjs
18
+
19
+ function dotenvGetQuick(envPath, key) {
20
+ try {
21
+ if (!envPath || !existsSync(envPath)) return '';
22
+ const lines = readFileSync(envPath, 'utf-8').split('\n');
23
+ for (const line of lines) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed || trimmed.startsWith('#')) continue;
26
+ if (!trimmed.startsWith(`${key}=`)) continue;
27
+ let v = trimmed.slice(`${key}=`.length).trim();
28
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
29
+ if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
30
+ return v;
31
+ }
32
+ } catch {
33
+ // ignore
34
+ }
35
+ return '';
36
+ }
37
+
38
+ function resolveCliRootDir() {
39
+ const fromEnv = (
40
+ process.env.HAPPY_STACKS_CLI_ROOT_DIR ??
41
+ process.env.HAPPY_LOCAL_CLI_ROOT_DIR ??
42
+ process.env.HAPPY_STACKS_DEV_CLI_ROOT_DIR ??
43
+ process.env.HAPPY_LOCAL_DEV_CLI_ROOT_DIR ??
44
+ ''
45
+ ).trim();
46
+ if (fromEnv) return expandHome(fromEnv);
47
+
48
+ // Stable pointer file: even if the real home dir is elsewhere, `happys init` writes the pointer here.
49
+ const canonicalEnv = getCanonicalHomeEnvPathFromEnv(process.env);
50
+ const v =
51
+ dotenvGetQuick(canonicalEnv, 'HAPPY_STACKS_CLI_ROOT_DIR') ||
52
+ dotenvGetQuick(canonicalEnv, 'HAPPY_LOCAL_CLI_ROOT_DIR') ||
53
+ dotenvGetQuick(canonicalEnv, 'HAPPY_STACKS_DEV_CLI_ROOT_DIR') ||
54
+ dotenvGetQuick(canonicalEnv, 'HAPPY_LOCAL_DEV_CLI_ROOT_DIR') ||
55
+ '';
56
+ return v ? expandHome(v) : '';
57
+ }
58
+
59
+ function maybeReexecToCliRoot(cliRootDir) {
60
+ if ((process.env.HAPPY_STACKS_CLI_REEXEC ?? process.env.HAPPY_STACKS_DEV_REEXEC ?? '') === '1') return;
61
+ if ((process.env.HAPPY_STACKS_CLI_ROOT_DISABLE ?? process.env.HAPPY_STACKS_DEV_CLI_DISABLE ?? '') === '1') return;
62
+
63
+ const cliRoot = resolveCliRootDir();
64
+ if (!cliRoot) return;
65
+ if (cliRoot === cliRootDir) return;
66
+
67
+ const cliBin = join(cliRoot, 'bin', 'happys.mjs');
68
+ if (!existsSync(cliBin)) return;
69
+
70
+ const argv = process.argv.slice(2);
71
+ const res = spawnSync(process.execPath, [cliBin, ...argv], {
72
+ stdio: 'inherit',
73
+ cwd: cliRoot,
74
+ env: {
75
+ ...process.env,
76
+ HAPPY_STACKS_CLI_REEXEC: '1',
77
+ HAPPY_STACKS_CLI_ROOT_DIR: cliRoot,
78
+ },
79
+ });
80
+ process.exit(res.status ?? 1);
81
+ }
82
+
16
83
  function resolveHomeDir() {
17
- const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
18
- if (fromEnv) {
19
- return fromEnv.replace(/^~(?=\/)/, homedir());
84
+ const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? process.env.HAPPY_LOCAL_HOME_DIR ?? '').trim();
85
+ if (fromEnv) return expandHome(fromEnv);
86
+
87
+ // Stable pointer file: even if the real home dir is elsewhere, `happys init` writes the pointer here.
88
+ const canonicalEnv = getCanonicalHomeEnvPathFromEnv(process.env);
89
+ const v = dotenvGetQuick(canonicalEnv, 'HAPPY_STACKS_HOME_DIR') || dotenvGetQuick(canonicalEnv, 'HAPPY_LOCAL_HOME_DIR') || '';
90
+ return v ? expandHome(v) : join(homedir(), '.happy-stacks');
91
+ }
92
+
93
+ function stripGlobalOpt(argv, { name, aliases = [] }) {
94
+ const names = [name, ...aliases];
95
+ for (const n of names) {
96
+ const eq = `${n}=`;
97
+ const iEq = argv.findIndex((a) => a.startsWith(eq));
98
+ if (iEq >= 0) {
99
+ const value = argv[iEq].slice(eq.length);
100
+ const next = [...argv.slice(0, iEq), ...argv.slice(iEq + 1)];
101
+ return { value, argv: next };
102
+ }
103
+ const i = argv.indexOf(n);
104
+ if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('-')) {
105
+ const value = argv[i + 1];
106
+ const next = [...argv.slice(0, i), ...argv.slice(i + 2)];
107
+ return { value, argv: next };
108
+ }
20
109
  }
21
- return join(homedir(), '.happy-stacks');
110
+ return { value: '', argv };
111
+ }
112
+
113
+ function applySandboxDirIfRequested(argv) {
114
+ const explicit = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
115
+ const { value, argv: nextArgv } = stripGlobalOpt(argv, { name: '--sandbox-dir', aliases: ['--sandbox'] });
116
+ const raw = value || explicit;
117
+ if (!raw) return { argv: nextArgv, enabled: false };
118
+
119
+ const sandboxDir = expandHome(raw);
120
+ // Keep all state under one folder that can be deleted to reset completely.
121
+ const canonicalHomeDir = join(sandboxDir, 'canonical');
122
+ const homeDir = join(sandboxDir, 'home');
123
+ const workspaceDir = join(sandboxDir, 'workspace');
124
+ const runtimeDir = join(sandboxDir, 'runtime');
125
+ const storageDir = join(sandboxDir, 'storage');
126
+
127
+ process.env.HAPPY_STACKS_SANDBOX_DIR = sandboxDir;
128
+ process.env.HAPPY_STACKS_CLI_ROOT_DISABLE = '1'; // never re-exec into a user's "real" install when sandboxing
129
+
130
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = process.env.HAPPY_STACKS_CANONICAL_HOME_DIR ?? canonicalHomeDir;
131
+ process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR = process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR ?? process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
132
+
133
+ process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? homeDir;
134
+ process.env.HAPPY_LOCAL_HOME_DIR = process.env.HAPPY_LOCAL_HOME_DIR ?? process.env.HAPPY_STACKS_HOME_DIR;
135
+
136
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = process.env.HAPPY_STACKS_WORKSPACE_DIR ?? workspaceDir;
137
+ process.env.HAPPY_LOCAL_WORKSPACE_DIR = process.env.HAPPY_LOCAL_WORKSPACE_DIR ?? process.env.HAPPY_STACKS_WORKSPACE_DIR;
138
+
139
+ process.env.HAPPY_STACKS_RUNTIME_DIR = process.env.HAPPY_STACKS_RUNTIME_DIR ?? runtimeDir;
140
+ process.env.HAPPY_LOCAL_RUNTIME_DIR = process.env.HAPPY_LOCAL_RUNTIME_DIR ?? process.env.HAPPY_STACKS_RUNTIME_DIR;
141
+
142
+ process.env.HAPPY_STACKS_STORAGE_DIR = process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDir;
143
+ process.env.HAPPY_LOCAL_STORAGE_DIR = process.env.HAPPY_LOCAL_STORAGE_DIR ?? process.env.HAPPY_STACKS_STORAGE_DIR;
144
+
145
+ return { argv: nextArgv, enabled: true };
22
146
  }
23
147
 
24
148
  function maybeAutoUpdateNotice(cliRootDir, cmd) {
@@ -123,16 +247,23 @@ function runNodeScript(cliRootDir, scriptRelPath, args) {
123
247
 
124
248
  function main() {
125
249
  const cliRootDir = getCliRootDir();
126
- const argv = process.argv.slice(2);
250
+ const initialArgv = process.argv.slice(2);
251
+ const { argv, enabled: sandboxed } = applySandboxDirIfRequested(initialArgv);
252
+ void sandboxed;
253
+ maybeReexecToCliRoot(cliRootDir);
127
254
 
255
+ // If the user passed only flags (common via `npx happy-stacks --help`),
256
+ // treat it as root help rather than `help --help` (which would look like
257
+ // "unknown command: --help").
128
258
  const cmd = argv.find((a) => !a.startsWith('--')) ?? 'help';
129
- const rest = cmd === 'help' ? [] : argv.slice(argv.indexOf(cmd) + 1);
259
+ const cmdIndex = argv.indexOf(cmd);
260
+ const rest = cmdIndex >= 0 ? argv.slice(cmdIndex + 1) : [];
130
261
 
131
262
  maybeAutoUpdateNotice(cliRootDir, cmd);
132
263
 
133
264
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
134
- const target = argv[argv.indexOf(cmd) + 1];
135
- if (!target) {
265
+ const target = rest[0];
266
+ if (!target || target.startsWith('-')) {
136
267
  console.log(usage());
137
268
  return;
138
269
  }