happy-stacks 0.1.2 → 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 (91) hide show
  1. package/README.md +121 -83
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -0,0 +1,112 @@
1
+ import { ensureDepsInstalled, pmSpawnBin } from './pm.mjs';
2
+ import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from './expo.mjs';
3
+ import { pickDevMetroPort, resolveStackUiDevPortStart } from './dev_server.mjs';
4
+ import { recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
5
+ import { killProcessGroupOwnedByStack } from './ownership.mjs';
6
+
7
+ export async function startDevExpoWebUi({
8
+ startUi,
9
+ uiDir,
10
+ autostart,
11
+ baseEnv,
12
+ apiServerUrl,
13
+ restart,
14
+ stackMode,
15
+ runtimeStatePath,
16
+ stackName,
17
+ envPath,
18
+ children,
19
+ spawnOptions = {},
20
+ }) {
21
+ if (!startUi) return { ok: true, skipped: true, reason: 'disabled' };
22
+
23
+ await ensureDepsInstalled(uiDir, 'happy');
24
+ const uiEnv = { ...baseEnv };
25
+ delete uiEnv.CI;
26
+ uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = apiServerUrl;
27
+ uiEnv.EXPO_PUBLIC_DEBUG = uiEnv.EXPO_PUBLIC_DEBUG ?? '1';
28
+
29
+ // We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
30
+ uiEnv.EXPO_NO_BROWSER = '1';
31
+ uiEnv.BROWSER = 'none';
32
+
33
+ const uiPaths = getExpoStatePaths({
34
+ baseDir: autostart.baseDir,
35
+ kind: 'ui-dev',
36
+ projectDir: uiDir,
37
+ stateFileName: 'ui.state.json',
38
+ });
39
+
40
+ await ensureExpoIsolationEnv({
41
+ env: uiEnv,
42
+ stateDir: uiPaths.stateDir,
43
+ expoHomeDir: uiPaths.expoHomeDir,
44
+ tmpDir: uiPaths.tmpDir,
45
+ });
46
+
47
+ const uiRunning = await isStateProcessRunning(uiPaths.statePath);
48
+ const uiAlreadyRunning = Boolean(uiRunning.running);
49
+
50
+ if (uiAlreadyRunning && !restart) {
51
+ const pid = Number(uiRunning.state?.pid);
52
+ const port = Number(uiRunning.state?.port);
53
+ if (stackMode && runtimeStatePath && Number.isFinite(pid) && pid > 1) {
54
+ await recordStackRuntimeUpdate(runtimeStatePath, {
55
+ processes: { expoWebPid: pid },
56
+ expo: { webPort: Number.isFinite(port) && port > 0 ? port : null },
57
+ }).catch(() => {});
58
+ }
59
+ return {
60
+ ok: true,
61
+ skipped: true,
62
+ reason: 'already_running',
63
+ pid: Number.isFinite(pid) ? pid : null,
64
+ port: Number.isFinite(port) ? port : null,
65
+ };
66
+ }
67
+
68
+ const strategy =
69
+ (baseEnv.HAPPY_STACKS_UI_DEV_PORT_STRATEGY ?? baseEnv.HAPPY_LOCAL_UI_DEV_PORT_STRATEGY ?? 'ephemeral').toString().trim() ||
70
+ 'ephemeral';
71
+ const stable = strategy === 'stable';
72
+ const startPort = stackMode && stable ? resolveStackUiDevPortStart({ env: baseEnv, stackName }) : 8081;
73
+ const metroPort = await pickDevMetroPort({ startPort });
74
+ uiEnv.RCT_METRO_PORT = String(metroPort);
75
+
76
+ const uiArgs = ['start', '--web', '--port', String(metroPort)];
77
+ if (wantsExpoClearCache({ env: baseEnv })) {
78
+ uiArgs.push('--clear');
79
+ }
80
+
81
+ if (restart && uiRunning.state?.pid) {
82
+ const prevPid = Number(uiRunning.state.pid);
83
+ const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-web', json: true });
84
+ if (!res.killed) {
85
+ // eslint-disable-next-line no-console
86
+ console.warn(
87
+ `[local] ui: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
88
+ `[local] ui: continuing by starting a new Expo process on a free port.`
89
+ );
90
+ }
91
+ }
92
+
93
+ // eslint-disable-next-line no-console
94
+ console.log(`[local] ui: starting Expo web (metro port=${metroPort})`);
95
+ const ui = await pmSpawnBin({ label: 'ui', dir: uiDir, bin: 'expo', args: uiArgs, env: uiEnv, options: spawnOptions });
96
+ children.push(ui);
97
+
98
+ if (stackMode && runtimeStatePath) {
99
+ await recordStackRuntimeUpdate(runtimeStatePath, {
100
+ processes: { expoWebPid: ui.pid },
101
+ expo: { webPort: metroPort },
102
+ }).catch(() => {});
103
+ }
104
+
105
+ try {
106
+ await writePidState(uiPaths.statePath, { pid: ui.pid, port: metroPort, uiDir, startedAt: new Date().toISOString() });
107
+ } catch {
108
+ // ignore
109
+ }
110
+
111
+ return { ok: true, skipped: false, pid: ui.pid, port: metroPort, proc: ui };
112
+ }
@@ -0,0 +1,183 @@
1
+ import { join, resolve } from 'node:path';
2
+
3
+ import { ensureDepsInstalled, pmSpawnScript } from './pm.mjs';
4
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './happy_server_infra.mjs';
5
+ import { waitForServerReady } from './server.mjs';
6
+ import { isTcpPortFree, pickNextFreeTcpPort } from './ports.mjs';
7
+ import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
8
+ import { killProcessGroupOwnedByStack } from './ownership.mjs';
9
+ import { watchDebounced } from './watch.mjs';
10
+
11
+ function hashStringToInt(s) {
12
+ let h = 0;
13
+ const str = String(s ?? '');
14
+ for (let i = 0; i < str.length; i++) {
15
+ h = (h * 31 + str.charCodeAt(i)) >>> 0;
16
+ }
17
+ return h >>> 0;
18
+ }
19
+
20
+ export function resolveStackUiDevPortStart({ env = process.env, stackName }) {
21
+ const baseRaw = (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString().trim();
22
+ const rangeRaw = (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString().trim();
23
+ const base = Number(baseRaw);
24
+ const range = Number(rangeRaw);
25
+ const b = Number.isFinite(base) ? base : 8081;
26
+ const r = Number.isFinite(range) && range > 0 ? range : 1000;
27
+ return b + (hashStringToInt(stackName) % r);
28
+ }
29
+
30
+ export async function pickDevMetroPort({ startPort, reservedPorts = new Set(), host = '127.0.0.1' } = {}) {
31
+ const forcedRaw = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
32
+ if (forcedRaw) {
33
+ const forced = Number(forcedRaw);
34
+ if (Number.isFinite(forced) && forced > 0) {
35
+ const ok = await isTcpPortFree(forced, { host });
36
+ if (ok) return forced;
37
+ }
38
+ }
39
+ return await pickNextFreeTcpPort(startPort, { reservedPorts, host });
40
+ }
41
+
42
+ export async function startDevServer({
43
+ serverComponentName,
44
+ serverDir,
45
+ autostart,
46
+ baseEnv,
47
+ serverPort,
48
+ internalServerUrl,
49
+ publicServerUrl,
50
+ envPath,
51
+ stackMode,
52
+ runtimeStatePath,
53
+ serverAlreadyRunning,
54
+ restart,
55
+ children,
56
+ }) {
57
+ const serverEnv = {
58
+ ...baseEnv,
59
+ PORT: String(serverPort),
60
+ PUBLIC_URL: publicServerUrl,
61
+ // Avoid noisy failures if a previous run left the metrics port busy.
62
+ METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
63
+ };
64
+
65
+ if (serverComponentName === 'happy-server-light') {
66
+ const dataDir = baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR?.trim()
67
+ ? baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR.trim()
68
+ : join(autostart.baseDir, 'server-light');
69
+ serverEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
70
+ serverEnv.HAPPY_SERVER_LIGHT_FILES_DIR = baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR?.trim()
71
+ ? baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR.trim()
72
+ : join(dataDir, 'files');
73
+ serverEnv.DATABASE_URL = baseEnv.DATABASE_URL?.trim() ? baseEnv.DATABASE_URL.trim() : `file:${join(dataDir, 'happy-server-light.sqlite')}`;
74
+ }
75
+
76
+ if (serverComponentName === 'happy-server') {
77
+ const managed = (baseEnv.HAPPY_STACKS_MANAGED_INFRA ?? baseEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1') !== '0';
78
+ if (managed) {
79
+ const infra = await ensureHappyServerManagedInfra({
80
+ stackName: autostart.stackName,
81
+ baseDir: autostart.baseDir,
82
+ serverPort,
83
+ publicServerUrl,
84
+ envPath,
85
+ env: baseEnv,
86
+ });
87
+ Object.assign(serverEnv, infra.env);
88
+ }
89
+
90
+ const autoMigrate = (baseEnv.HAPPY_STACKS_PRISMA_MIGRATE ?? baseEnv.HAPPY_LOCAL_PRISMA_MIGRATE ?? '1') !== '0';
91
+ if (autoMigrate) {
92
+ await applyHappyServerMigrations({ serverDir, env: serverEnv });
93
+ }
94
+ }
95
+
96
+ // Ensure server deps exist before any Prisma/docker work.
97
+ await ensureDepsInstalled(serverDir, serverComponentName);
98
+
99
+ const prismaPush = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '1').toString().trim() !== '0';
100
+ const serverScript =
101
+ serverComponentName === 'happy-server'
102
+ ? 'start'
103
+ : serverComponentName === 'happy-server-light' && !prismaPush
104
+ ? 'start'
105
+ : 'dev';
106
+
107
+ // Restart behavior (stack-safe): only kill when we can prove ownership via runtime state.
108
+ if (restart && stackMode && runtimeStatePath && serverAlreadyRunning) {
109
+ const st = await readStackRuntimeStateFile(runtimeStatePath);
110
+ const pid = Number(st?.processes?.serverPid);
111
+ if (pid > 1) {
112
+ const res = await killProcessGroupOwnedByStack(pid, { stackName: autostart.stackName, envPath, label: 'server', json: true });
113
+ if (!res.killed) {
114
+ // Fail-closed if the port is still occupied.
115
+ const free = await isTcpPortFree(serverPort, { host: '127.0.0.1' });
116
+ if (!free) {
117
+ throw new Error(
118
+ `[local] restart refused: server port ${serverPort} is occupied and the PID is not provably stack-owned.\n` +
119
+ `[local] Fix: run 'happys stack stop ${autostart.stackName}' then re-run, or re-run without --restart.`
120
+ );
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ if (serverAlreadyRunning && !restart) {
127
+ return { serverEnv, serverScript, serverProc: null };
128
+ }
129
+
130
+ const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
131
+ children.push(server);
132
+ if (stackMode && runtimeStatePath) {
133
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: server.pid } }).catch(() => {});
134
+ }
135
+ await waitForServerReady(internalServerUrl);
136
+ return { serverEnv, serverScript, serverProc: server };
137
+ }
138
+
139
+ export function watchDevServerAndRestart({
140
+ enabled,
141
+ stackMode,
142
+ serverComponentName,
143
+ serverDir,
144
+ serverPort,
145
+ internalServerUrl,
146
+ serverScript,
147
+ serverEnv,
148
+ runtimeStatePath,
149
+ stackName,
150
+ envPath,
151
+ children,
152
+ serverProcRef,
153
+ isShuttingDown,
154
+ }) {
155
+ if (!enabled) return null;
156
+
157
+ // Only watch full server by default; happy-server-light already has a good upstream dev loop.
158
+ if (serverComponentName !== 'happy-server') return null;
159
+
160
+ return watchDebounced({
161
+ paths: [resolve(serverDir)],
162
+ debounceMs: 600,
163
+ onChange: async () => {
164
+ if (isShuttingDown?.()) return;
165
+ const pid = Number(serverProcRef?.current?.pid);
166
+ if (!Number.isFinite(pid) || pid <= 1) return;
167
+
168
+ // eslint-disable-next-line no-console
169
+ console.log('[local] watch: server changed → restarting...');
170
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: 'server', json: false });
171
+
172
+ const next = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
173
+ children.push(next);
174
+ serverProcRef.current = next;
175
+ if (stackMode && runtimeStatePath) {
176
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: next.pid } }).catch(() => {});
177
+ }
178
+ await waitForServerReady(internalServerUrl);
179
+ // eslint-disable-next-line no-console
180
+ console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
181
+ },
182
+ });
183
+ }
@@ -4,13 +4,32 @@ import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseDotenv } from './dotenv.mjs';
7
+ import { expandHome, getCanonicalHomeEnvPathFromEnv } from './canonical_home.mjs';
8
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
7
9
 
8
10
  async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
9
11
  try {
10
12
  const contents = await readFile(path, 'utf-8');
11
13
  const parsed = parseDotenv(contents);
14
+ const allowTransientComponentDirOverrides =
15
+ !overridePrefix &&
16
+ override &&
17
+ ((process.env.HAPPY_STACKS_TRANSIENT_COMPONENT_OVERRIDES ?? '').trim() === '1' ||
18
+ (process.env.HAPPY_LOCAL_TRANSIENT_COMPONENT_OVERRIDES ?? '').trim() === '1');
12
19
  for (const [k, v] of parsed.entries()) {
13
20
  const allowOverride = override && (!overridePrefix || k.startsWith(overridePrefix));
21
+ // Special-case: allow one-shot CLI overrides (e.g. `happys stack typecheck <stack> --happy-cli=...`)
22
+ // to win over stack env files for component directories.
23
+ //
24
+ // This keeps stack env files authoritative by default (we also scrub HAPPY_STACKS_* from the parent
25
+ // environment in `withStackEnv()`), but lets the stack wrappers inject a temporary override when explicitly requested.
26
+ if (
27
+ allowTransientComponentDirOverrides &&
28
+ (k.startsWith('HAPPY_STACKS_COMPONENT_DIR_') || k.startsWith('HAPPY_LOCAL_COMPONENT_DIR_')) &&
29
+ (process.env[k] ?? '').trim()
30
+ ) {
31
+ continue;
32
+ }
14
33
  if (allowOverride || process.env[k] == null || process.env[k] === '') {
15
34
  process.env[k] = v;
16
35
  }
@@ -20,16 +39,29 @@ async function loadEnvFile(path, { override = false, overridePrefix = null } = {
20
39
  }
21
40
  }
22
41
 
42
+ async function loadEnvFileIgnoringPrefixes(path, { ignorePrefixes = [] } = {}) {
43
+ try {
44
+ const contents = await readFile(path, 'utf-8');
45
+ const parsed = parseDotenv(contents);
46
+ for (const [k, v] of parsed.entries()) {
47
+ if (ignorePrefixes.some((p) => k.startsWith(p))) {
48
+ continue;
49
+ }
50
+ if (process.env[k] == null || process.env[k] === '') {
51
+ process.env[k] = v;
52
+ }
53
+ }
54
+ } catch {
55
+ // ignore missing/invalid env file
56
+ }
57
+ }
58
+
23
59
  // Load happy-stacks env (optional). This is intentionally lightweight and does not require extra deps.
24
60
  // This file lives under scripts/utils/, so repo root is two directories up.
25
61
  const __utilsDir = dirname(fileURLToPath(import.meta.url));
26
62
  const __scriptsDir = dirname(__utilsDir);
27
63
  const __cliRootDir = dirname(__scriptsDir);
28
64
 
29
- function expandHome(p) {
30
- return p.replace(/^~(?=\/)/, homedir());
31
- }
32
-
33
65
  function resolveHomeDir() {
34
66
  const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
35
67
  if (fromEnv) {
@@ -65,11 +97,11 @@ function applyStacksPrefixMapping() {
65
97
  }
66
98
  }
67
99
 
68
- // If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at ~/.happy-stacks/.env first.
100
+ // If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at <canonicalHomeDir>/.env first.
69
101
  //
70
102
  // This allows installs where the "real" home/workspace/runtime are elsewhere, while still
71
103
  // giving us a stable discovery location for launchd/SwiftBar/minimal shells.
72
- const canonicalEnvPath = join(homedir(), '.happy-stacks', '.env');
104
+ const canonicalEnvPath = getCanonicalHomeEnvPathFromEnv(process.env);
73
105
  if (!(process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() && existsSync(canonicalEnvPath)) {
74
106
  await loadEnvFile(canonicalEnvPath, { override: false });
75
107
  await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
@@ -83,12 +115,15 @@ process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? __homeD
83
115
  // ~/.happy-stacks/.env
84
116
  // ~/.happy-stacks/env.local
85
117
  //
86
- // Backwards compatible fallback for cloned-repo usage (when home config doesn't exist yet):
87
- // <repo>/.env
88
- // <repo>/env.local
118
+ // Additionally: when running from a cloned repo, load <repo>/.env as a *fallback* even if home config exists.
119
+ // This helps keep repo-local dev settings (e.g. custom Codex binaries) working without requiring users to
120
+ // duplicate them into ~/.happy-stacks/env.local.
89
121
  const homeEnv = join(__homeDir, '.env');
90
122
  const homeLocal = join(__homeDir, 'env.local');
91
- const hasHomeConfig = existsSync(homeEnv) || existsSync(homeLocal);
123
+ // In sandbox mode, never load repo env.local (it can contain "real" machine paths/URLs).
124
+ // Treat sandbox runs as having home config even if the sandbox home env files don't exist yet.
125
+ const hasHomeConfig = isSandboxed() || existsSync(homeEnv) || existsSync(homeLocal);
126
+ const repoEnv = join(__cliRootDir, '.env');
92
127
 
93
128
  // 1) Load defaults first (lowest precedence)
94
129
  if (hasHomeConfig) {
@@ -101,6 +136,19 @@ if (hasHomeConfig) {
101
136
  await loadEnvFile(join(__cliRootDir, 'env.local'), { override: true, overridePrefix: 'HAPPY_STACKS_' });
102
137
  }
103
138
 
139
+ // Repo-local fallback (dev convenience):
140
+ // If the repo has a .env, load it without overriding anything already set by the environment or home config.
141
+ // Note: we intentionally do NOT load repo env.local here, because env.local is treated as higher-precedence
142
+ // overrides and could unexpectedly fight with stack/home configuration when present.
143
+ if (hasHomeConfig) {
144
+ // IMPORTANT:
145
+ // When home config exists, do not let repo-local .env set HAPPY_STACKS_* / HAPPY_LOCAL_* keys.
146
+ // Otherwise a cloned repo's .env can accidentally leak global URLs/ports into every stack.
147
+ await loadEnvFileIgnoringPrefixes(repoEnv, { ignorePrefixes: ['HAPPY_STACKS_', 'HAPPY_LOCAL_'] });
148
+ } else {
149
+ await loadEnvFile(repoEnv, { override: false });
150
+ }
151
+
104
152
  // If no explicit env file is set, and we're on the default "main" stack, prefer the stack-scoped env file
105
153
  // if it exists: ~/.happy/stacks/main/env
106
154
  (() => {
@@ -112,7 +160,8 @@ if (hasHomeConfig) {
112
160
  const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
113
161
  const stacksStorageRootRaw = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
114
162
  const stacksStorageRoot = stacksStorageRootRaw ? expandHome(stacksStorageRootRaw) : join(homedir(), '.happy', 'stacks');
115
- const legacyStacksRoot = join(homedir(), '.happy', 'local', 'stacks');
163
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
164
+ const legacyStacksRoot = allowLegacy ? join(homedir(), '.happy', 'local', 'stacks') : join(stacksStorageRoot, '__legacy_disabled__');
116
165
 
117
166
  const candidates = [
118
167
  join(stacksStorageRoot, stackName, 'env'),
@@ -10,6 +10,17 @@ export async function ensureEnvFileUpdated({ envPath, updates }) {
10
10
  await writeFileIfChanged(envPath, applyEnvUpdates(await readText(envPath), updates), envPath);
11
11
  }
12
12
 
13
+ export async function ensureEnvFilePruned({ envPath, removeKeys }) {
14
+ const keys = Array.from(new Set((removeKeys ?? []).map((k) => String(k).trim()).filter(Boolean)));
15
+ if (!keys.length) {
16
+ return;
17
+ }
18
+ await mkdir(dirname(envPath), { recursive: true });
19
+ const existing = await readText(envPath);
20
+ const next = pruneEnvKeys(existing, keys);
21
+ await writeFileIfChanged(existing, next, envPath);
22
+ }
23
+
13
24
  async function readText(path) {
14
25
  try {
15
26
  return (await pathExists(path)) ? await readFile(path, 'utf-8') : '';
@@ -42,6 +53,31 @@ function applyEnvUpdates(existing, updates) {
42
53
  return next.join('\n');
43
54
  }
44
55
 
56
+ function pruneEnvKeys(existing, removeKeys) {
57
+ const keys = new Set(removeKeys);
58
+ const lines = (existing ?? '').split('\n');
59
+ const kept = [];
60
+ for (const line of lines) {
61
+ const trimmed = line.trim();
62
+ if (!trimmed || trimmed.startsWith('#')) {
63
+ kept.push(line);
64
+ continue;
65
+ }
66
+ // Remove any "KEY=..." line for keys in removeKeys.
67
+ const eq = trimmed.indexOf('=');
68
+ if (eq <= 0) {
69
+ kept.push(line);
70
+ continue;
71
+ }
72
+ const key = trimmed.slice(0, eq).trim();
73
+ if (keys.has(key)) {
74
+ continue;
75
+ }
76
+ kept.push(line);
77
+ }
78
+ return kept.join('\n');
79
+ }
80
+
45
81
  async function writeFileIfChanged(existingContent, nextContent, path) {
46
82
  const normalizedNext = nextContent.endsWith('\n') ? nextContent : nextContent + '\n';
47
83
  const normalizedExisting = existingContent.endsWith('\n') ? existingContent : existingContent + (existingContent ? '\n' : '');
@@ -26,10 +26,12 @@ export async function ensureExpoIsolationEnv({ env, stateDir, expoHomeDir, tmpDi
26
26
  await mkdir(tmpDir, { recursive: true });
27
27
 
28
28
  // Expo CLI uses this to override ~/.expo.
29
- env.__UNSAFE_EXPO_HOME_DIRECTORY = env.__UNSAFE_EXPO_HOME_DIRECTORY ?? expoHomeDir;
29
+ // Always override: stack/worktree isolation must not fall back to the user's global ~/.expo.
30
+ env.__UNSAFE_EXPO_HOME_DIRECTORY = expoHomeDir;
30
31
 
31
32
  // Metro default cache root is `path.join(os.tmpdir(), 'metro-cache')`, so TMPDIR isolates it.
32
- env.TMPDIR = env.TMPDIR ?? tmpDir;
33
+ // Always override: macOS sets TMPDIR by default, so a "set-if-missing" guard would not isolate Metro.
34
+ env.TMPDIR = tmpDir;
33
35
  }
34
36
 
35
37
  export function wantsExpoClearCache({ env }) {
@@ -0,0 +1,94 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { parseDotenv } from './dotenv.mjs';
7
+ import { resolveStackEnvPath } from './paths.mjs';
8
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './auth_sources.mjs';
9
+
10
+ async function readTextIfExists(path) {
11
+ try {
12
+ if (!path || !existsSync(path)) return null;
13
+ const raw = await readFile(path, 'utf-8');
14
+ const t = raw.trim();
15
+ return t ? t : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function parseEnvToObject(raw) {
22
+ const parsed = parseDotenv(raw ?? '');
23
+ return Object.fromEntries(parsed.entries());
24
+ }
25
+
26
+ function getEnvValue(env, key) {
27
+ const v = (env?.[key] ?? '').toString().trim();
28
+ return v || '';
29
+ }
30
+
31
+ function stackExistsSync(stackName) {
32
+ if (stackName === 'main') return true;
33
+ const envPath = resolveStackEnvPath(stackName).envPath;
34
+ return existsSync(envPath);
35
+ }
36
+
37
+ export async function resolveHandyMasterSecretFromStack({
38
+ stackName,
39
+ requireStackExists = false,
40
+ allowLegacyAuthSource = true,
41
+ allowLegacyMainFallback = true,
42
+ } = {}) {
43
+ const name = String(stackName ?? '').trim() || 'main';
44
+
45
+ if (isLegacyAuthSourceName(name)) {
46
+ if (!allowLegacyAuthSource) {
47
+ throw new Error(
48
+ '[auth] legacy auth source is disabled in sandbox mode.\n' +
49
+ 'Reason: it reads from ~/.happy (global user state).\n' +
50
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
51
+ );
52
+ }
53
+ const baseDir = getLegacyHappyBaseDir();
54
+ const legacySecretPath = join(baseDir, 'server-light', 'handy-master-secret.txt');
55
+ const secret = await readTextIfExists(legacySecretPath);
56
+ return secret ? { secret, source: legacySecretPath } : { secret: null, source: null };
57
+ }
58
+
59
+ if (requireStackExists && !stackExistsSync(name)) {
60
+ throw new Error(`[auth] cannot copy auth: source stack "${name}" does not exist`);
61
+ }
62
+
63
+ const resolved = resolveStackEnvPath(name);
64
+ const sourceBaseDir = resolved.baseDir;
65
+ const sourceEnvPath = resolved.envPath;
66
+ const raw = await readTextIfExists(sourceEnvPath);
67
+ const env = raw ? parseEnvToObject(raw) : {};
68
+
69
+ const inline = getEnvValue(env, 'HANDY_MASTER_SECRET');
70
+ if (inline) {
71
+ return { secret: inline, source: `${sourceEnvPath} (HANDY_MASTER_SECRET)` };
72
+ }
73
+
74
+ const secretFile = getEnvValue(env, 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE');
75
+ if (secretFile) {
76
+ const secret = await readTextIfExists(secretFile);
77
+ if (secret) return { secret, source: secretFile };
78
+ }
79
+
80
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR') || join(sourceBaseDir, 'server-light');
81
+ const secretPath = join(dataDir, 'handy-master-secret.txt');
82
+ const secret = await readTextIfExists(secretPath);
83
+ if (secret) return { secret, source: secretPath };
84
+
85
+ // Last-resort legacy: if main has never been migrated to stack dirs.
86
+ if (name === 'main' && allowLegacyMainFallback) {
87
+ const legacy = join(homedir(), '.happy', 'server-light', 'handy-master-secret.txt');
88
+ const legacySecret = await readTextIfExists(legacy);
89
+ if (legacySecret) return { secret: legacySecret, source: legacy };
90
+ }
91
+
92
+ return { secret: null, source: null };
93
+ }
94
+