happy-stacks 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
package/scripts/build.mjs CHANGED
@@ -1,11 +1,15 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
4
- import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/pm.mjs';
3
+ import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
4
+ import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/proc/pm.mjs';
5
+ import { resolveServerPortFromEnv } from './utils/server/urls.mjs';
5
6
  import { dirname, join } from 'node:path';
6
7
  import { readFile, rm, mkdir, writeFile } from 'node:fs/promises';
7
8
  import { tailscaleServeHttpsUrl } from './tailscale.mjs';
8
9
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
+ import { ensureExpoIsolationEnv, getExpoStatePaths, wantsExpoClearCache } from './utils/expo/expo.mjs';
11
+ import { expoExec } from './utils/expo/command.mjs';
12
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
9
13
 
10
14
  /**
11
15
  * Build a lightweight static web UI bundle (no Expo dev server).
@@ -28,12 +32,29 @@ async function main() {
28
32
  ' happys build [--tauri] [--json]',
29
33
  ' (legacy in a cloned repo): pnpm build [-- --tauri] [--json]',
30
34
  ' node scripts/build.mjs [--tauri|--no-tauri] [--no-ui] [--json]',
35
+ '',
36
+ 'note:',
37
+ ' If run from inside the Happy UI checkout/worktree, the build uses that checkout.',
31
38
  ].join('\n'),
32
39
  });
33
40
  return;
34
41
  }
35
42
  const rootDir = getRootDir(import.meta.url);
36
43
 
44
+ // If invoked from inside the Happy UI checkout/worktree, prefer that directory without requiring `happys wt use ...`.
45
+ const inferred = inferComponentFromCwd({
46
+ rootDir,
47
+ invokedCwd: getInvokedCwd(process.env),
48
+ components: ['happy'],
49
+ });
50
+ if (inferred?.component === 'happy') {
51
+ const stacksKey = componentDirEnvKey('happy');
52
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
53
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
54
+ process.env[stacksKey] = inferred.repoDir;
55
+ }
56
+ }
57
+
37
58
  // Optional: skip building the web UI bundle.
38
59
  //
39
60
  // This is useful for evidence capture flows that validate non-UI components (e.g. `happy-cli`)
@@ -43,9 +64,7 @@ async function main() {
43
64
  const uiDir = getComponentDir(rootDir, 'happy');
44
65
  await requireDir('happy', uiDir);
45
66
 
46
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
47
- ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
48
- : 3005;
67
+ const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
49
68
 
50
69
  // For Tauri builds we embed an explicit API base URL (tauri:// origins cannot use window.location.origin).
51
70
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
@@ -88,7 +107,17 @@ async function main() {
88
107
  };
89
108
 
90
109
  // Expo CLI is available via node_modules/.bin once dependencies are installed.
91
- await pmExecBin({ dir: uiDir, bin: 'expo', args: ['export', '--platform', 'web', '--output-dir', outDir], env });
110
+ {
111
+ const paths = getExpoStatePaths({
112
+ baseDir: getDefaultAutostartPaths().baseDir,
113
+ kind: 'ui-export',
114
+ projectDir: uiDir,
115
+ stateFileName: 'ui.export.state.json',
116
+ });
117
+ await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
118
+ const args = ['export', '--platform', 'web', '--output-dir', outDir, ...(wantsExpoClearCache({ env }) ? ['-c'] : [])];
119
+ await expoExec({ dir: uiDir, args, env, ensureDepsLabel: 'happy' });
120
+ }
92
121
 
93
122
  if (json) {
94
123
  printResult({ json, data: { ok: true, outDir, tauriBuilt: false } });
@@ -147,13 +176,30 @@ async function main() {
147
176
  };
148
177
  delete tauriEnv.EXPO_PUBLIC_WEB_BASE_URL;
149
178
 
150
- await pmExecBin({
179
+ {
180
+ const paths = getExpoStatePaths({
181
+ baseDir: getDefaultAutostartPaths().baseDir,
182
+ kind: 'ui-export-tauri',
183
+ projectDir: uiDir,
184
+ stateFileName: 'ui.export.tauri.state.json',
185
+ });
186
+ await ensureExpoIsolationEnv({ env: tauriEnv, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
187
+ }
188
+
189
+ await expoExec({
151
190
  dir: uiDir,
152
- bin: 'expo',
153
- // Important: clear bundler cache so EXPO_PUBLIC_* inlining doesn't reuse
154
- // the previous (web) export's transform results.
155
- args: ['export', '--platform', 'web', '--output-dir', tauriDistDir, '-c'],
191
+ args: [
192
+ 'export',
193
+ '--platform',
194
+ 'web',
195
+ '--output-dir',
196
+ tauriDistDir,
197
+ // Important: clear bundler cache so EXPO_PUBLIC_* inlining doesn't reuse
198
+ // the previous (web) export's transform results.
199
+ '-c',
200
+ ],
156
201
  env: tauriEnv,
202
+ ensureDepsLabel: 'happy',
157
203
  });
158
204
 
159
205
  // Build the Tauri app using a generated config that skips upstream beforeBuildCommand (which uses yarn).
@@ -1,7 +1,7 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { getComponentDir, getRootDir } from './utils/paths.mjs';
4
- import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
3
+ import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
4
+ import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/proc/pm.mjs';
5
5
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
6
6
 
7
7
  /**
@@ -1,4 +1,4 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
 
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
@@ -7,11 +7,11 @@ import { join } from 'node:path';
7
7
 
8
8
  import { parseArgs } from './utils/cli/args.mjs';
9
9
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
- import { runCapture } from './utils/proc.mjs';
10
+ import { runCapture } from './utils/proc/proc.mjs';
11
11
  import { getHappysRegistry } from './utils/cli/cli_registry.mjs';
12
- import { expandHome } from './utils/canonical_home.mjs';
13
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
14
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
12
+ import { expandHome } from './utils/paths/canonical_home.mjs';
13
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths/paths.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
15
15
 
16
16
  function detectShell() {
17
17
  const raw = (process.env.SHELL ?? '').toLowerCase();
@@ -1,12 +1,16 @@
1
- import { spawnProc, run, runCapture } from './utils/proc.mjs';
2
- import { resolveAuthSeedFromEnv } from './utils/stack_startup.mjs';
3
- import { getStacksStorageRoot } from './utils/paths.mjs';
4
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
1
+ import { spawnProc, run, runCapture } from './utils/proc/proc.mjs';
2
+ import { resolveAuthSeedFromEnv, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
3
+ import { getStacksStorageRoot } from './utils/paths/paths.mjs';
4
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
5
+ import { runCaptureIfCommandExists } from './utils/proc/commands.mjs';
6
+ import { readLastLines } from './utils/fs/tail.mjs';
7
+ import { ensureCliBuilt } from './utils/proc/pm.mjs';
5
8
  import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
6
9
  import { chmod, copyFile, mkdir } from 'node:fs/promises';
7
- import { join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
8
11
  import { setTimeout as delay } from 'node:timers/promises';
9
12
  import { homedir } from 'node:os';
13
+ import { getRootDir } from './utils/paths/paths.mjs';
10
14
 
11
15
  /**
12
16
  * Daemon lifecycle helpers for happy-stacks.
@@ -28,7 +32,7 @@ export async function cleanupStaleDaemonState(homeDir) {
28
32
 
29
33
  const lsofHasPath = async (pid, pathNeedle) => {
30
34
  try {
31
- const out = await runCapture('sh', ['-lc', `command -v lsof >/dev/null 2>&1 && lsof -nP -p ${pid} 2>/dev/null || true`]);
35
+ const out = await runCaptureIfCommandExists('lsof', ['-nP', '-p', String(pid)]);
32
36
  return out.includes(pathNeedle);
33
37
  } catch {
34
38
  return false;
@@ -173,16 +177,46 @@ function getLatestDaemonLogPath(homeDir) {
173
177
  }
174
178
  }
175
179
 
176
- function readLastLines(path, lines = 60) {
180
+ function resolveHappyCliDistEntrypoint(cliBin) {
181
+ const bin = String(cliBin ?? '').trim();
182
+ if (!bin) return null;
183
+ // In component checkouts/worktrees we launch via <cliDir>/bin/happy.mjs, which expects dist output.
184
+ // Use this to protect restarts from bricking the running daemon if dist disappears mid-build.
177
185
  try {
178
- const content = readFileSync(path, 'utf-8');
179
- const parts = content.split('\n');
180
- return parts.slice(Math.max(0, parts.length - lines)).join('\n');
186
+ const binDir = dirname(bin);
187
+ return join(binDir, '..', 'dist', 'index.mjs');
181
188
  } catch {
182
189
  return null;
183
190
  }
184
191
  }
185
192
 
193
+ async function ensureHappyCliDistExists({ cliBin }) {
194
+ const distEntrypoint = resolveHappyCliDistEntrypoint(cliBin);
195
+ if (!distEntrypoint) return { ok: false, distEntrypoint: null, built: false, reason: 'unknown_cli_bin' };
196
+ if (existsSync(distEntrypoint)) return { ok: true, distEntrypoint, built: false, reason: 'exists' };
197
+
198
+ // Try to recover automatically: missing dist is a common first-run worktree issue.
199
+ // We build in-place using the cliDir that owns this cliBin (../ from bin/).
200
+ const cliDir = join(dirname(cliBin), '..');
201
+ const buildCli =
202
+ (process.env.HAPPY_STACKS_CLI_BUILD ?? process.env.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
203
+ if (!buildCli) {
204
+ return { ok: false, distEntrypoint, built: false, reason: 'build_disabled' };
205
+ }
206
+
207
+ try {
208
+ // eslint-disable-next-line no-console
209
+ console.warn(`[local] happy-cli build output missing; rebuilding (${cliDir})...`);
210
+ await ensureCliBuilt(cliDir, { buildCli: true });
211
+ } catch (e) {
212
+ return { ok: false, distEntrypoint, built: false, reason: String(e?.message ?? e) };
213
+ }
214
+
215
+ return existsSync(distEntrypoint)
216
+ ? { ok: true, distEntrypoint, built: true, reason: 'rebuilt' }
217
+ : { ok: false, distEntrypoint, built: true, reason: 'rebuilt_but_missing' };
218
+ }
219
+
186
220
  function excerptIndicatesMissingAuth(excerpt) {
187
221
  if (!excerpt) return false;
188
222
  return (
@@ -191,6 +225,24 @@ function excerptIndicatesMissingAuth(excerpt) {
191
225
  );
192
226
  }
193
227
 
228
+ function excerptIndicatesInvalidAuth(excerpt) {
229
+ if (!excerpt) return false;
230
+ return (
231
+ excerpt.includes('Auth failed - invalid token') ||
232
+ excerpt.includes('Request failed with status code 401') ||
233
+ excerpt.includes('"status":401') ||
234
+ excerpt.includes('[DAEMON RUN][FATAL]') && excerpt.includes('status code 401')
235
+ );
236
+ }
237
+
238
+ function allowDaemonWaitForAuthWithoutTty() {
239
+ const raw = (process.env.HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH ?? process.env.HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH ?? '')
240
+ .toString()
241
+ .trim()
242
+ .toLowerCase();
243
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y';
244
+ }
245
+
194
246
  function authLoginHint() {
195
247
  const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
196
248
  return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
@@ -203,6 +255,27 @@ function authCopyFromSeedHint() {
203
255
  return `happys stack auth ${stackName} copy-from ${seed}`;
204
256
  }
205
257
 
258
+ async function maybeAutoReseedInvalidAuth({ stackName, quiet = false }) {
259
+ if (stackName === 'main') return { ok: false, skipped: true, reason: 'main' };
260
+ const env = process.env;
261
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
262
+ const enabled = resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive });
263
+ if (!enabled) return { ok: false, skipped: true, reason: 'disabled' };
264
+
265
+ const seed = resolveAuthSeedFromEnv(env);
266
+ if (!quiet) {
267
+ console.log(`[local] auth: invalid token detected; re-seeding ${stackName} from ${seed}...`);
268
+ }
269
+ const rootDir = getRootDir(import.meta.url);
270
+
271
+ // Use stack-scoped auth copy so env/database resolution is correct for the target stack.
272
+ await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'auth', stackName, '--', 'copy-from', seed], {
273
+ cwd: rootDir,
274
+ env,
275
+ });
276
+ return { ok: true, skipped: false, seed };
277
+ }
278
+
206
279
  async function seedCredentialsIfMissing({ cliHomeDir }) {
207
280
  const stacksRoot = getStacksStorageRoot();
208
281
  const allowGlobal = sandboxAllowsGlobalSideEffects();
@@ -297,7 +370,7 @@ async function killDaemonFromLockFile({ cliHomeDir }) {
297
370
  // We do this by checking that `lsof -p <pid>` includes the lock path (or state file path).
298
371
  let ownsLock = false;
299
372
  try {
300
- const out = await runCapture('sh', ['-lc', `command -v lsof >/dev/null 2>&1 && lsof -nP -p ${pid} 2>/dev/null || true`]);
373
+ const out = await runCaptureIfCommandExists('lsof', ['-nP', '-p', String(pid)]);
301
374
  ownsLock = out.includes(lockPath) || out.includes(join(cliHomeDir, 'daemon.state.json')) || out.includes(join(cliHomeDir, 'logs'));
302
375
  } catch {
303
376
  ownsLock = false;
@@ -381,11 +454,27 @@ export async function startLocalDaemonWithAuth({
381
454
  publicServerUrl,
382
455
  isShuttingDown,
383
456
  forceRestart = false,
457
+ env = process.env,
458
+ stackName = null,
384
459
  }) {
385
- const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
386
- const baseEnv = { ...process.env };
460
+ const resolvedStackName =
461
+ (stackName ?? '').toString().trim() ||
462
+ (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
463
+ 'main';
464
+ const baseEnv = { ...env };
387
465
  const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
388
466
 
467
+ const distEntrypoint = resolveHappyCliDistEntrypoint(cliBin);
468
+ const distCheck = await ensureHappyCliDistExists({ cliBin });
469
+ if (!distCheck.ok) {
470
+ throw new Error(
471
+ `[local] happy-cli dist entrypoint is missing (${distEntrypoint}).\n` +
472
+ `[local] Refusing to start/restart daemon because it would crash with MODULE_NOT_FOUND.\n` +
473
+ `[local] Fix: rebuild happy-cli in the active checkout/worktree.\n` +
474
+ (distCheck.reason ? `[local] Detail: ${distCheck.reason}\n` : '')
475
+ );
476
+ }
477
+
389
478
  // If this is a migrated/new stack home dir, seed credentials from the user's existing login (best-effort)
390
479
  // to avoid requiring an interactive auth flow under launchd.
391
480
  const migrateCreds = (baseEnv.HAPPY_STACKS_MIGRATE_CREDENTIALS ?? baseEnv.HAPPY_LOCAL_MIGRATE_CREDENTIALS ?? '1').trim() !== '0';
@@ -394,6 +483,20 @@ export async function startLocalDaemonWithAuth({
394
483
  }
395
484
 
396
485
  const existing = checkDaemonState(cliHomeDir);
486
+ // If the daemon is already running and we're restarting it, refuse to stop it unless the
487
+ // happy-cli dist entrypoint exists. Otherwise a rebuild (rm -rf dist) can brick the stack.
488
+ if (
489
+ distEntrypoint &&
490
+ !existsSync(distEntrypoint) &&
491
+ (existing.status === 'running' || existing.status === 'starting')
492
+ ) {
493
+ console.warn(
494
+ `[local] happy-cli dist entrypoint is missing (${distEntrypoint}).\n` +
495
+ `[local] Refusing to restart daemon to avoid downtime. Rebuild happy-cli first.`
496
+ );
497
+ return;
498
+ }
499
+
397
500
  if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
398
501
  const pid = existing.pid;
399
502
  const matches = await daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl });
@@ -427,7 +530,7 @@ export async function startLocalDaemonWithAuth({
427
530
  }
428
531
 
429
532
  // Best-effort: for the main stack, also stop the legacy global daemon home (~/.happy) to prevent legacy overlap.
430
- if (stackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
533
+ if (resolvedStackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
431
534
  const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
432
535
  try {
433
536
  await new Promise((resolve) => {
@@ -460,10 +563,20 @@ export async function startLocalDaemonWithAuth({
460
563
  return { ok: true, exitCode, excerpt: null, logPath: null };
461
564
  }
462
565
 
566
+ // Some daemon versions (or transient races) can return non-zero even if the daemon
567
+ // is already running / starting for this stack home dir (e.g. "lock already held").
568
+ // In those cases, fail-open and keep the stack running; callers can still surface
569
+ // daemon status separately.
570
+ await delay(500);
571
+ const stateAfter = checkDaemonState(cliHomeDir);
572
+ if (stateAfter.status === 'running' || stateAfter.status === 'starting') {
573
+ return { ok: true, exitCode, excerpt: null, logPath: null };
574
+ }
575
+
463
576
  const logPath =
464
577
  getLatestDaemonLogPath(cliHomeDir) ||
465
578
  ((!isSandboxed() || sandboxAllowsGlobalSideEffects()) ? getLatestDaemonLogPath(join(homedir(), '.happy')) : null);
466
- const excerpt = logPath ? readLastLines(logPath, 120) : null;
579
+ const excerpt = logPath ? await readLastLines(logPath, 120) : null;
467
580
  return { ok: false, exitCode, excerpt, logPath };
468
581
  };
469
582
 
@@ -476,21 +589,32 @@ export async function startLocalDaemonWithAuth({
476
589
  }
477
590
 
478
591
  if (excerptIndicatesMissingAuth(first.excerpt)) {
592
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || allowDaemonWaitForAuthWithoutTty();
479
593
  const copyHint = authCopyFromSeedHint();
480
- console.error(
594
+ const hint =
481
595
  `[local] daemon is not authenticated yet (expected on first run).\n` +
482
- `[local] Keeping the server running so you can login.\n` +
483
596
  `[local] In another terminal, run:\n` +
484
597
  `${authLoginHint()}\n` +
485
- (copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '') +
486
- `[local] Waiting for credentials at ${credentialsPath}...`
487
- );
598
+ (copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '');
599
+ if (!isInteractive) {
600
+ throw new Error(`${hint}[local] Non-interactive mode: refusing to wait for credentials.`);
601
+ }
602
+
603
+ console.error(`${hint}[local] Keeping the server running so you can login.\n[local] Waiting for credentials at ${credentialsPath}...`);
488
604
 
489
605
  const ok = await waitForCredentialsFile({ path: credentialsPath, timeoutMs: 10 * 60_000, isShuttingDown });
490
606
  if (!ok) {
491
607
  throw new Error('Timed out waiting for daemon credentials (auth login not completed)');
492
608
  }
493
609
 
610
+ // If a daemon start attempt was already in-flight (or a previous daemon is already running),
611
+ // avoid a second concurrent start and treat it as success.
612
+ await delay(500);
613
+ const stateAfterCreds = checkDaemonState(cliHomeDir);
614
+ if (stateAfterCreds.status === 'running' || stateAfterCreds.status === 'starting') {
615
+ return;
616
+ }
617
+
494
618
  console.log('[local] credentials detected, retrying daemon start...');
495
619
  const second = await startOnce();
496
620
  if (!second.ok) {
@@ -499,6 +623,30 @@ export async function startLocalDaemonWithAuth({
499
623
  }
500
624
  throw new Error('Failed to start daemon (after credentials were created)');
501
625
  }
626
+ } else if (excerptIndicatesInvalidAuth(first.excerpt)) {
627
+ // Credentials exist but are rejected by this server (common when a stack's env/DB was reset,
628
+ // or credentials were copied from a different stack identity).
629
+ try {
630
+ await maybeAutoReseedInvalidAuth({ stackName });
631
+ } catch (e) {
632
+ const copyHint = authCopyFromSeedHint();
633
+ console.error(
634
+ `[local] daemon credentials were rejected by the server (401).\n` +
635
+ `[local] Fix:\n` +
636
+ (copyHint ? `- ${copyHint}\n` : '') +
637
+ `- ${authLoginHint()}`
638
+ );
639
+ throw e;
640
+ }
641
+
642
+ console.log('[local] auth re-seeded, retrying daemon start...');
643
+ const second = await startOnce();
644
+ if (!second.ok) {
645
+ if (second.excerpt) {
646
+ console.error(`[local] daemon still failed to start; last daemon log (${second.logPath}):\n${second.excerpt}`);
647
+ }
648
+ throw new Error('Failed to start daemon (after auth re-seed)');
649
+ }
502
650
  } else {
503
651
  const copyHint = authCopyFromSeedHint();
504
652
  console.error(