happy-stacks 0.1.2 → 0.3.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 (116) hide show
  1. package/README.md +164 -89
  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 +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -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/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
@@ -0,0 +1,112 @@
1
+ import { ensureDepsInstalled, pmSpawnBin } from '../proc/pm.mjs';
2
+ import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from '../expo/expo.mjs';
3
+ import { pickDevMetroPort, resolveStackUiDevPortStart } from './server.mjs';
4
+ import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
5
+ import { killProcessGroupOwnedByStack } from '../proc/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 '../proc/pm.mjs';
4
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
5
+ import { waitForServerReady } from '../server/server.mjs';
6
+ import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
7
+ import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
8
+ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
9
+ import { watchDebounced } from '../proc/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
+ }
@@ -1,14 +1,18 @@
1
1
  import { join } from 'node:path';
2
- import { homedir } from 'node:os';
3
2
  import { ensureEnvFileUpdated } from './env_file.mjs';
4
- import { getHappyStacksHomeDir, resolveStackEnvPath } from './paths.mjs';
3
+ import { getHappyStacksHomeDir, resolveStackEnvPath } from '../paths/paths.mjs';
4
+ import { getCanonicalHomeDirFromEnv } from '../paths/canonical_home.mjs';
5
5
 
6
6
  export function getHomeEnvPath() {
7
7
  return join(getHappyStacksHomeDir(), '.env');
8
8
  }
9
9
 
10
+ export function getCanonicalHomeDir() {
11
+ return getCanonicalHomeDirFromEnv(process.env);
12
+ }
13
+
10
14
  export function getCanonicalHomeEnvPath() {
11
- return join(homedir(), '.happy-stacks', '.env');
15
+ return join(getCanonicalHomeDir(), '.env');
12
16
  }
13
17
 
14
18
  export function getHomeEnvLocalPath() {
@@ -46,3 +50,4 @@ export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
46
50
  await ensureEnvFileUpdated({ envPath, updates });
47
51
  return envPath;
48
52
  }
53
+
@@ -28,3 +28,6 @@ export function parseDotenv(contents) {
28
28
  return out;
29
29
  }
30
30
 
31
+ export function parseEnvToObject(contents) {
32
+ return Object.fromEntries(parseDotenv(contents));
33
+ }
@@ -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 '../paths/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,30 @@ 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
- // This file lives under scripts/utils/, so repo root is two directories up.
25
- const __utilsDir = dirname(fileURLToPath(import.meta.url));
60
+ // This file lives under scripts/utils/env, so repo root is three directories up.
61
+ const __envDir = dirname(fileURLToPath(import.meta.url));
62
+ const __utilsDir = dirname(__envDir);
26
63
  const __scriptsDir = dirname(__utilsDir);
27
64
  const __cliRootDir = dirname(__scriptsDir);
28
65
 
29
- function expandHome(p) {
30
- return p.replace(/^~(?=\/)/, homedir());
31
- }
32
-
33
66
  function resolveHomeDir() {
34
67
  const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
35
68
  if (fromEnv) {
@@ -65,11 +98,11 @@ function applyStacksPrefixMapping() {
65
98
  }
66
99
  }
67
100
 
68
- // If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at ~/.happy-stacks/.env first.
101
+ // If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at <canonicalHomeDir>/.env first.
69
102
  //
70
103
  // This allows installs where the "real" home/workspace/runtime are elsewhere, while still
71
104
  // giving us a stable discovery location for launchd/SwiftBar/minimal shells.
72
- const canonicalEnvPath = join(homedir(), '.happy-stacks', '.env');
105
+ const canonicalEnvPath = getCanonicalHomeEnvPathFromEnv(process.env);
73
106
  if (!(process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() && existsSync(canonicalEnvPath)) {
74
107
  await loadEnvFile(canonicalEnvPath, { override: false });
75
108
  await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
@@ -83,12 +116,15 @@ process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? __homeD
83
116
  // ~/.happy-stacks/.env
84
117
  // ~/.happy-stacks/env.local
85
118
  //
86
- // Backwards compatible fallback for cloned-repo usage (when home config doesn't exist yet):
87
- // <repo>/.env
88
- // <repo>/env.local
119
+ // Additionally: when running from a cloned repo, load <repo>/.env as a *fallback* even if home config exists.
120
+ // This helps keep repo-local dev settings (e.g. custom Codex binaries) working without requiring users to
121
+ // duplicate them into ~/.happy-stacks/env.local.
89
122
  const homeEnv = join(__homeDir, '.env');
90
123
  const homeLocal = join(__homeDir, 'env.local');
91
- const hasHomeConfig = existsSync(homeEnv) || existsSync(homeLocal);
124
+ // In sandbox mode, never load repo env.local (it can contain "real" machine paths/URLs).
125
+ // Treat sandbox runs as having home config even if the sandbox home env files don't exist yet.
126
+ const hasHomeConfig = isSandboxed() || existsSync(homeEnv) || existsSync(homeLocal);
127
+ const repoEnv = join(__cliRootDir, '.env');
92
128
 
93
129
  // 1) Load defaults first (lowest precedence)
94
130
  if (hasHomeConfig) {
@@ -101,6 +137,19 @@ if (hasHomeConfig) {
101
137
  await loadEnvFile(join(__cliRootDir, 'env.local'), { override: true, overridePrefix: 'HAPPY_STACKS_' });
102
138
  }
103
139
 
140
+ // Repo-local fallback (dev convenience):
141
+ // If the repo has a .env, load it without overriding anything already set by the environment or home config.
142
+ // Note: we intentionally do NOT load repo env.local here, because env.local is treated as higher-precedence
143
+ // overrides and could unexpectedly fight with stack/home configuration when present.
144
+ if (hasHomeConfig) {
145
+ // IMPORTANT:
146
+ // When home config exists, do not let repo-local .env set HAPPY_STACKS_* / HAPPY_LOCAL_* keys.
147
+ // Otherwise a cloned repo's .env can accidentally leak global URLs/ports into every stack.
148
+ await loadEnvFileIgnoringPrefixes(repoEnv, { ignorePrefixes: ['HAPPY_STACKS_', 'HAPPY_LOCAL_'] });
149
+ } else {
150
+ await loadEnvFile(repoEnv, { override: false });
151
+ }
152
+
104
153
  // If no explicit env file is set, and we're on the default "main" stack, prefer the stack-scoped env file
105
154
  // if it exists: ~/.happy/stacks/main/env
106
155
  (() => {
@@ -112,7 +161,8 @@ if (hasHomeConfig) {
112
161
  const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
113
162
  const stacksStorageRootRaw = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
114
163
  const stacksStorageRoot = stacksStorageRootRaw ? expandHome(stacksStorageRootRaw) : join(homedir(), '.happy', 'stacks');
115
- const legacyStacksRoot = join(homedir(), '.happy', 'local', 'stacks');
164
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
165
+ const legacyStacksRoot = allowLegacy ? join(homedir(), '.happy', 'local', 'stacks') : join(stacksStorageRoot, '__legacy_disabled__');
116
166
 
117
167
  const candidates = [
118
168
  join(stacksStorageRoot, stackName, 'env'),
@@ -158,3 +208,4 @@ process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_M
158
208
  const next = [...want.filter((p) => p && !current.includes(p)), ...current];
159
209
  process.env.PATH = next.join(delimiter);
160
210
  })();
211
+
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { pathExists } from './fs.mjs';
3
+ import { pathExists } from '../fs/fs.mjs';
4
4
 
5
5
  export async function ensureEnvFileUpdated({ envPath, updates }) {
6
6
  if (!updates.length) {
@@ -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' : '');
@@ -57,3 +93,4 @@ async function writeFileIfChanged(existingContent, nextContent, path) {
57
93
  }
58
94
  await writeFile(path, normalizedNext, 'utf-8');
59
95
  }
96
+
@@ -23,3 +23,4 @@ export async function ensureEnvLocalUpdated({ rootDir, updates }) {
23
23
 
24
24
  await ensureEnvFileUpdated({ envPath: join(rootDir, 'env.local'), updates });
25
25
  }
26
+
@@ -0,0 +1,30 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ import { parseDotenv } from './dotenv.mjs';
5
+
6
+ export async function readEnvValueFromFile(envPath, key, { defaultValue = '' } = {}) {
7
+ try {
8
+ const p = String(envPath ?? '').trim();
9
+ const k = String(key ?? '').trim();
10
+ if (!p || !k) return defaultValue;
11
+ if (!existsSync(p)) return defaultValue;
12
+ const raw = await readFile(p, 'utf-8');
13
+ const parsed = parseDotenv(raw ?? '');
14
+ return String(parsed.get(k) ?? '').trim();
15
+ } catch {
16
+ return defaultValue;
17
+ }
18
+ }
19
+
20
+ export async function readEnvObjectFromFile(envPath) {
21
+ try {
22
+ const p = String(envPath ?? '').trim();
23
+ if (!p || !existsSync(p)) return {};
24
+ const raw = await readFile(p, 'utf-8');
25
+ return Object.fromEntries(parseDotenv(raw ?? '').entries());
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
@@ -0,0 +1,14 @@
1
+ export function getSandboxDir() {
2
+ const v = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
3
+ return v || '';
4
+ }
5
+
6
+ export function isSandboxed() {
7
+ return Boolean(getSandboxDir());
8
+ }
9
+
10
+ export function sandboxAllowsGlobalSideEffects() {
11
+ const raw = (process.env.HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL ?? '').trim().toLowerCase();
12
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y';
13
+ }
14
+
@@ -0,0 +1,13 @@
1
+ export function getEnvValue(obj, key) {
2
+ const v = (obj?.[key] ?? '').toString().trim();
3
+ return v || '';
4
+ }
5
+
6
+ export function getEnvValueAny(obj, keys) {
7
+ for (const k of keys) {
8
+ const v = getEnvValue(obj, k);
9
+ if (v) return v;
10
+ }
11
+ return '';
12
+ }
13
+
@@ -3,6 +3,9 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
+ import { isPidAlive } from '../proc/pids.mjs';
7
+
8
+ export { isPidAlive };
6
9
 
7
10
  function hashDir(dir) {
8
11
  return createHash('sha1').update(String(dir ?? '')).digest('hex').slice(0, 12);
@@ -26,10 +29,12 @@ export async function ensureExpoIsolationEnv({ env, stateDir, expoHomeDir, tmpDi
26
29
  await mkdir(tmpDir, { recursive: true });
27
30
 
28
31
  // Expo CLI uses this to override ~/.expo.
29
- env.__UNSAFE_EXPO_HOME_DIRECTORY = env.__UNSAFE_EXPO_HOME_DIRECTORY ?? expoHomeDir;
32
+ // Always override: stack/worktree isolation must not fall back to the user's global ~/.expo.
33
+ env.__UNSAFE_EXPO_HOME_DIRECTORY = expoHomeDir;
30
34
 
31
35
  // Metro default cache root is `path.join(os.tmpdir(), 'metro-cache')`, so TMPDIR isolates it.
32
- env.TMPDIR = env.TMPDIR ?? tmpDir;
36
+ // Always override: macOS sets TMPDIR by default, so a "set-if-missing" guard would not isolate Metro.
37
+ env.TMPDIR = tmpDir;
33
38
  }
34
39
 
35
40
  export function wantsExpoClearCache({ env }) {
@@ -54,15 +59,6 @@ export async function readPidState(statePath) {
54
59
  }
55
60
  }
56
61
 
57
- export function isPidAlive(pid) {
58
- try {
59
- process.kill(pid, 0);
60
- return true;
61
- } catch {
62
- return false;
63
- }
64
- }
65
-
66
62
  export async function isStateProcessRunning(statePath) {
67
63
  const state = await readPidState(statePath);
68
64
  if (!state) return { running: false, state: null };
@@ -0,0 +1,25 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+
5
+ export async function readJsonIfExists(path, { defaultValue = null } = {}) {
6
+ try {
7
+ const p = String(path ?? '').trim();
8
+ if (!p || !existsSync(p)) return defaultValue;
9
+ const raw = await readFile(p, 'utf-8');
10
+ return JSON.parse(raw);
11
+ } catch {
12
+ return defaultValue;
13
+ }
14
+ }
15
+
16
+ export async function writeJsonAtomic(path, value) {
17
+ const p = String(path ?? '').trim();
18
+ if (!p) throw new Error('writeJsonAtomic: path is required');
19
+ const dir = dirname(p);
20
+ await mkdir(dir, { recursive: true }).catch(() => {});
21
+ const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
22
+ await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
23
+ await rename(tmp, p);
24
+ }
25
+