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
@@ -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,6 +97,17 @@ function applyStacksPrefixMapping() {
65
97
  }
66
98
  }
67
99
 
100
+ // If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at <canonicalHomeDir>/.env first.
101
+ //
102
+ // This allows installs where the "real" home/workspace/runtime are elsewhere, while still
103
+ // giving us a stable discovery location for launchd/SwiftBar/minimal shells.
104
+ const canonicalEnvPath = getCanonicalHomeEnvPathFromEnv(process.env);
105
+ if (!(process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() && existsSync(canonicalEnvPath)) {
106
+ await loadEnvFile(canonicalEnvPath, { override: false });
107
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
108
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_LOCAL_' });
109
+ }
110
+
68
111
  const __homeDir = resolveHomeDir();
69
112
  process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? __homeDir;
70
113
 
@@ -72,12 +115,15 @@ process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? __homeD
72
115
  // ~/.happy-stacks/.env
73
116
  // ~/.happy-stacks/env.local
74
117
  //
75
- // Backwards compatible fallback for cloned-repo usage (when home config doesn't exist yet):
76
- // <repo>/.env
77
- // <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.
78
121
  const homeEnv = join(__homeDir, '.env');
79
122
  const homeLocal = join(__homeDir, 'env.local');
80
- 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');
81
127
 
82
128
  // 1) Load defaults first (lowest precedence)
83
129
  if (hasHomeConfig) {
@@ -90,6 +136,19 @@ if (hasHomeConfig) {
90
136
  await loadEnvFile(join(__cliRootDir, 'env.local'), { override: true, overridePrefix: 'HAPPY_STACKS_' });
91
137
  }
92
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
+
93
152
  // If no explicit env file is set, and we're on the default "main" stack, prefer the stack-scoped env file
94
153
  // if it exists: ~/.happy/stacks/main/env
95
154
  (() => {
@@ -99,22 +158,34 @@ if (hasHomeConfig) {
99
158
  return;
100
159
  }
101
160
  const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
102
- if (stackName !== 'main') {
103
- return;
104
- }
105
- const mainEnv = join(homedir(), '.happy', 'stacks', 'main', 'env');
106
- if (!existsSync(mainEnv)) {
107
- return;
108
- }
109
- process.env.HAPPY_STACKS_ENV_FILE = mainEnv;
110
- process.env.HAPPY_LOCAL_ENV_FILE = mainEnv;
161
+ const stacksStorageRootRaw = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
162
+ const stacksStorageRoot = stacksStorageRootRaw ? expandHome(stacksStorageRootRaw) : join(homedir(), '.happy', 'stacks');
163
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
164
+ const legacyStacksRoot = allowLegacy ? join(homedir(), '.happy', 'local', 'stacks') : join(stacksStorageRoot, '__legacy_disabled__');
165
+
166
+ const candidates = [
167
+ join(stacksStorageRoot, stackName, 'env'),
168
+ join(legacyStacksRoot, stackName, 'env'),
169
+ ];
170
+ const envPath = candidates.find((p) => existsSync(p));
171
+ if (!envPath) return;
172
+
173
+ process.env.HAPPY_STACKS_ENV_FILE = envPath;
174
+ process.env.HAPPY_LOCAL_ENV_FILE = envPath;
111
175
  })();
112
- // 3) Load explicit env file overlay (stack env, or any caller-provided env file) last (highest precedence)
113
- if (process.env.HAPPY_STACKS_ENV_FILE?.trim()) {
114
- await loadEnvFile(process.env.HAPPY_STACKS_ENV_FILE.trim(), { override: true, overridePrefix: 'HAPPY_STACKS_' });
115
- }
116
- if (process.env.HAPPY_LOCAL_ENV_FILE?.trim()) {
117
- await loadEnvFile(process.env.HAPPY_LOCAL_ENV_FILE.trim(), { override: true, overridePrefix: 'HAPPY_LOCAL_' });
176
+ // 3) Load explicit env file overlay (stack env, or any caller-provided env file) last (highest precedence).
177
+ //
178
+ // IMPORTANT:
179
+ // Stack env files intentionally include some non-prefixed keys (e.g. DATABASE_URL, HAPPY_SERVER_LIGHT_DATA_DIR)
180
+ // that must apply for true per-stack isolation. Do not filter by prefix here.
181
+ {
182
+ const stacksEnv = process.env.HAPPY_STACKS_ENV_FILE?.trim() ? process.env.HAPPY_STACKS_ENV_FILE.trim() : '';
183
+ const localEnv = process.env.HAPPY_LOCAL_ENV_FILE?.trim() ? process.env.HAPPY_LOCAL_ENV_FILE.trim() : '';
184
+ const unique = Array.from(new Set([stacksEnv, localEnv].filter(Boolean)));
185
+ for (const p of unique) {
186
+ // eslint-disable-next-line no-await-in-loop
187
+ await loadEnvFile(p, { override: true });
188
+ }
118
189
  }
119
190
 
120
191
  // Make both prefixes available to the rest of the codebase.
@@ -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' : '');
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+
7
+ function hashDir(dir) {
8
+ return createHash('sha1').update(String(dir ?? '')).digest('hex').slice(0, 12);
9
+ }
10
+
11
+ export function getExpoStatePaths({ baseDir, kind, projectDir, stateFileName = 'expo.state.json' }) {
12
+ const key = hashDir(projectDir);
13
+ const stateDir = join(baseDir, kind, key);
14
+ return {
15
+ key,
16
+ stateDir,
17
+ statePath: join(stateDir, stateFileName),
18
+ expoHomeDir: join(stateDir, 'expo-home'),
19
+ tmpDir: join(stateDir, 'tmp'),
20
+ };
21
+ }
22
+
23
+ export async function ensureExpoIsolationEnv({ env, stateDir, expoHomeDir, tmpDir }) {
24
+ await mkdir(stateDir, { recursive: true });
25
+ await mkdir(expoHomeDir, { recursive: true });
26
+ await mkdir(tmpDir, { recursive: true });
27
+
28
+ // Expo CLI uses this to override ~/.expo.
29
+ // Always override: stack/worktree isolation must not fall back to the user's global ~/.expo.
30
+ env.__UNSAFE_EXPO_HOME_DIRECTORY = expoHomeDir;
31
+
32
+ // Metro default cache root is `path.join(os.tmpdir(), 'metro-cache')`, so TMPDIR isolates it.
33
+ // Always override: macOS sets TMPDIR by default, so a "set-if-missing" guard would not isolate Metro.
34
+ env.TMPDIR = tmpDir;
35
+ }
36
+
37
+ export function wantsExpoClearCache({ env }) {
38
+ const raw = (env.HAPPY_STACKS_EXPO_CLEAR_CACHE ?? env.HAPPY_LOCAL_EXPO_CLEAR_CACHE ?? '').trim();
39
+ if (raw) {
40
+ return raw !== '0';
41
+ }
42
+ // Default: clear cache when non-interactive (LLMs/services), keep fast iteration in TTY shells.
43
+ return !(process.stdin.isTTY && process.stdout.isTTY);
44
+ }
45
+
46
+ export async function readPidState(statePath) {
47
+ try {
48
+ if (!existsSync(statePath)) return null;
49
+ const raw = await readFile(statePath, 'utf-8');
50
+ const state = JSON.parse(raw);
51
+ const pid = Number(state?.pid);
52
+ if (!Number.isFinite(pid) || pid <= 0) return null;
53
+ return state;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function isPidAlive(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export async function isStateProcessRunning(statePath) {
69
+ const state = await readPidState(statePath);
70
+ if (!state) return { running: false, state: null };
71
+ const pid = Number(state.pid);
72
+ return { running: isPidAlive(pid), state };
73
+ }
74
+
75
+ export async function writePidState(statePath, state) {
76
+ await mkdir(dirname(statePath), { recursive: true }).catch(() => {});
77
+ await writeFile(statePath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
78
+ }
79
+
80
+ export async function killPid(pid) {
81
+ const n = Number(pid);
82
+ if (!Number.isFinite(n) || n <= 1) return;
83
+ try {
84
+ process.kill(n, 'SIGTERM');
85
+ } catch {
86
+ return;
87
+ }
88
+ await delay(500);
89
+ try {
90
+ process.kill(n, 0);
91
+ process.kill(n, 'SIGKILL');
92
+ } catch {
93
+ // exited
94
+ }
95
+ }
96
+
@@ -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
+