happy-stacks 0.3.0 → 0.5.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 (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -1,14 +1,16 @@
1
1
  import { spawnProc, run, runCapture } from './utils/proc/proc.mjs';
2
- import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
2
+ import { resolveAuthSeedFromEnv, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
3
3
  import { getStacksStorageRoot } from './utils/paths/paths.mjs';
4
4
  import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
5
5
  import { runCaptureIfCommandExists } from './utils/proc/commands.mjs';
6
6
  import { readLastLines } from './utils/fs/tail.mjs';
7
+ import { ensureCliBuilt } from './utils/proc/pm.mjs';
7
8
  import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
8
9
  import { chmod, copyFile, mkdir } from 'node:fs/promises';
9
- import { join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
10
11
  import { setTimeout as delay } from 'node:timers/promises';
11
12
  import { homedir } from 'node:os';
13
+ import { getRootDir } from './utils/paths/paths.mjs';
12
14
 
13
15
  /**
14
16
  * Daemon lifecycle helpers for happy-stacks.
@@ -175,6 +177,46 @@ function getLatestDaemonLogPath(homeDir) {
175
177
  }
176
178
  }
177
179
 
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.
185
+ try {
186
+ const binDir = dirname(bin);
187
+ return join(binDir, '..', 'dist', 'index.mjs');
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
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
+
178
220
  function excerptIndicatesMissingAuth(excerpt) {
179
221
  if (!excerpt) return false;
180
222
  return (
@@ -183,6 +225,24 @@ function excerptIndicatesMissingAuth(excerpt) {
183
225
  );
184
226
  }
185
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
+
186
246
  function authLoginHint() {
187
247
  const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
188
248
  return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
@@ -195,6 +255,27 @@ function authCopyFromSeedHint() {
195
255
  return `happys stack auth ${stackName} copy-from ${seed}`;
196
256
  }
197
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
+
198
279
  async function seedCredentialsIfMissing({ cliHomeDir }) {
199
280
  const stacksRoot = getStacksStorageRoot();
200
281
  const allowGlobal = sandboxAllowsGlobalSideEffects();
@@ -373,11 +454,27 @@ export async function startLocalDaemonWithAuth({
373
454
  publicServerUrl,
374
455
  isShuttingDown,
375
456
  forceRestart = false,
457
+ env = process.env,
458
+ stackName = null,
376
459
  }) {
377
- const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
378
- 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 };
379
465
  const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
380
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
+
381
478
  // If this is a migrated/new stack home dir, seed credentials from the user's existing login (best-effort)
382
479
  // to avoid requiring an interactive auth flow under launchd.
383
480
  const migrateCreds = (baseEnv.HAPPY_STACKS_MIGRATE_CREDENTIALS ?? baseEnv.HAPPY_LOCAL_MIGRATE_CREDENTIALS ?? '1').trim() !== '0';
@@ -386,6 +483,20 @@ export async function startLocalDaemonWithAuth({
386
483
  }
387
484
 
388
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
+
389
500
  if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
390
501
  const pid = existing.pid;
391
502
  const matches = await daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl });
@@ -419,7 +530,7 @@ export async function startLocalDaemonWithAuth({
419
530
  }
420
531
 
421
532
  // Best-effort: for the main stack, also stop the legacy global daemon home (~/.happy) to prevent legacy overlap.
422
- if (stackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
533
+ if (resolvedStackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
423
534
  const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
424
535
  try {
425
536
  await new Promise((resolve) => {
@@ -452,6 +563,16 @@ export async function startLocalDaemonWithAuth({
452
563
  return { ok: true, exitCode, excerpt: null, logPath: null };
453
564
  }
454
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
+
455
576
  const logPath =
456
577
  getLatestDaemonLogPath(cliHomeDir) ||
457
578
  ((!isSandboxed() || sandboxAllowsGlobalSideEffects()) ? getLatestDaemonLogPath(join(homedir(), '.happy')) : null);
@@ -468,21 +589,32 @@ export async function startLocalDaemonWithAuth({
468
589
  }
469
590
 
470
591
  if (excerptIndicatesMissingAuth(first.excerpt)) {
592
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || allowDaemonWaitForAuthWithoutTty();
471
593
  const copyHint = authCopyFromSeedHint();
472
- console.error(
594
+ const hint =
473
595
  `[local] daemon is not authenticated yet (expected on first run).\n` +
474
- `[local] Keeping the server running so you can login.\n` +
475
596
  `[local] In another terminal, run:\n` +
476
597
  `${authLoginHint()}\n` +
477
- (copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '') +
478
- `[local] Waiting for credentials at ${credentialsPath}...`
479
- );
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}...`);
480
604
 
481
605
  const ok = await waitForCredentialsFile({ path: credentialsPath, timeoutMs: 10 * 60_000, isShuttingDown });
482
606
  if (!ok) {
483
607
  throw new Error('Timed out waiting for daemon credentials (auth login not completed)');
484
608
  }
485
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
+
486
618
  console.log('[local] credentials detected, retrying daemon start...');
487
619
  const second = await startOnce();
488
620
  if (!second.ok) {
@@ -491,6 +623,30 @@ export async function startLocalDaemonWithAuth({
491
623
  }
492
624
  throw new Error('Failed to start daemon (after credentials were created)');
493
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
+ }
494
650
  } else {
495
651
  const copyHint = authCopyFromSeedHint();
496
652
  console.error(
package/scripts/dev.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { killProcessTree } from './utils/proc/proc.mjs';
4
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
4
+ import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
5
5
  import { killPortListeners } from './utils/net/ports.mjs';
6
6
  import { getServerComponentName, isHappyServerRunning } from './utils/server/server.mjs';
7
7
  import { requireDir } from './utils/proc/pm.mjs';
@@ -17,11 +17,15 @@ import { resolveStackContext } from './utils/stack/context.mjs';
17
17
  import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
18
18
  import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev/daemon.mjs';
19
19
  import { startDevServer, watchDevServerAndRestart } from './utils/dev/server.mjs';
20
- import { startDevExpoWebUi } from './utils/dev/expo_web.mjs';
21
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
20
+ import { ensureDevExpoServer, resolveExpoTailscaleEnabled } from './utils/dev/expo_dev.mjs';
21
+ import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
22
22
  import { openUrlInBrowser } from './utils/ui/browser.mjs';
23
23
  import { waitForHttpOk } from './utils/server/server.mjs';
24
24
  import { sanitizeDnsLabel } from './utils/net/dns.mjs';
25
+ import { getAccountCountForServerComponent, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
26
+ import { maybeRunInteractiveStackAuthSetup } from './utils/auth/interactive_stack_auth.mjs';
27
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
28
+ import { daemonStartGate, formatDaemonAuthRequiredError } from './utils/auth/daemon_gate.mjs';
25
29
 
26
30
  /**
27
31
  * Dev mode stack:
@@ -37,20 +41,42 @@ async function main() {
37
41
  if (wantsHelp(argv, { flags })) {
38
42
  printResult({
39
43
  json,
40
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser'], json: true },
44
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser', '--mobile', '--expo-tailscale'], json: true },
41
45
  text: [
42
46
  '[dev] usage:',
43
47
  ' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
44
- ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
45
- ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
46
- ' happys dev --no-browser # do not open the UI in your browser automatically',
48
+ ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
49
+ ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
50
+ ' happys dev --no-browser # do not open the UI in your browser automatically',
51
+ ' happys dev --mobile # also start Expo dev-client Metro for mobile',
52
+ ' happys dev --expo-tailscale # forward Expo to Tailscale interface for remote access',
47
53
  ' note: --json prints the resolved config (dry-run) and exits.',
54
+ '',
55
+ 'note:',
56
+ ' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
57
+ '',
58
+ 'env:',
59
+ ' HAPPY_STACKS_EXPO_TAILSCALE=1 # enable Expo Tailscale forwarding via env var',
48
60
  ].join('\n'),
49
61
  });
50
62
  return;
51
63
  }
52
64
  const rootDir = getRootDir(import.meta.url);
53
65
 
66
+ const inferred = inferComponentFromCwd({
67
+ rootDir,
68
+ invokedCwd: getInvokedCwd(process.env),
69
+ components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
70
+ });
71
+ if (inferred) {
72
+ const stacksKey = componentDirEnvKey(inferred.component);
73
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
74
+ // Stack env should win. Only infer from CWD when the component dir isn't already configured.
75
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
76
+ process.env[stacksKey] = inferred.repoDir;
77
+ }
78
+ }
79
+
54
80
  const serverComponentName = getServerComponentName({ kv });
55
81
  if (serverComponentName === 'both') {
56
82
  throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
@@ -58,7 +84,9 @@ async function main() {
58
84
 
59
85
  const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
60
86
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
87
+ const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
61
88
  const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
89
+ const expoTailscale = flags.has('--expo-tailscale') || resolveExpoTailscaleEnabled({ env: process.env });
62
90
 
63
91
  const serverDir = getComponentDir(rootDir, serverComponentName);
64
92
  const uiDir = getComponentDir(rootDir, 'happy');
@@ -82,7 +110,10 @@ async function main() {
82
110
  // - Only the main stack should ever auto-enable (or prefer) Tailscale Serve by default.
83
111
  // - Non-main stacks should default to localhost URLs unless the user explicitly configured a public URL
84
112
  // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
85
- const allowEnableTailscale = !stackMode || stackName === 'main';
113
+ const allowEnableTailscale =
114
+ !stackMode ||
115
+ stackName === 'main' ||
116
+ (baseEnv.HAPPY_STACKS_TAILSCALE_SERVE ?? baseEnv.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
86
117
  const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
87
118
  const internalServerUrl = resolvedUrls.internalServerUrl;
88
119
  let publicServerUrl = resolvedUrls.publicServerUrl;
@@ -93,6 +124,8 @@ async function main() {
93
124
  publicServerUrl = resolvedUrls.defaultPublicUrl;
94
125
  }
95
126
  }
127
+ // Expo app config: this is what both web + native app use to reach the Happy server.
128
+ // LAN rewrite (for dev-client) is centralized in ensureDevExpoServer.
96
129
  const uiApiUrl = resolvedUrls.defaultPublicUrl;
97
130
  const restart = flags.has('--restart');
98
131
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
@@ -112,6 +145,7 @@ async function main() {
112
145
  internalServerUrl,
113
146
  publicServerUrl,
114
147
  startUi,
148
+ startMobile,
115
149
  startDaemon,
116
150
  cliHomeDir,
117
151
  },
@@ -135,13 +169,25 @@ async function main() {
135
169
  const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
136
170
  const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
137
171
 
138
- // UI dev server state (worktree-scoped)
139
- const uiPaths = getExpoStatePaths({ baseDir: autostart.baseDir, kind: 'ui-dev', projectDir: uiDir, stateFileName: 'ui.state.json' });
140
- const uiRunning = startUi ? await isStateProcessRunning(uiPaths.statePath) : { running: false, state: null };
141
- let uiAlreadyRunning = Boolean(uiRunning.running);
142
-
143
- if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startUi || uiAlreadyRunning)) {
144
- console.log(`[local] dev: stack already running (server=${internalServerUrl}${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''}${startUi ? ` ui=${uiAlreadyRunning ? 'running' : 'stopped'}` : ''})`);
172
+ // Expo dev server state (worktree-scoped): single Expo process per stack/worktree.
173
+ const startExpo = startUi || startMobile;
174
+ const expoPaths = getExpoStatePaths({
175
+ baseDir: autostart.baseDir,
176
+ kind: 'expo-dev',
177
+ projectDir: uiDir,
178
+ stateFileName: 'expo.state.json',
179
+ });
180
+ const expoRunning = startExpo ? await isStateProcessRunning(expoPaths.statePath) : { running: false, state: null };
181
+ let expoAlreadyRunning = Boolean(expoRunning.running);
182
+
183
+ if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startExpo || expoAlreadyRunning)) {
184
+ console.log(
185
+ `[local] dev: stack already running (server=${internalServerUrl}` +
186
+ `${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
187
+ `${startUi ? ` ui=${expoAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
188
+ `${startMobile ? ` mobile=${expoAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
189
+ `)`
190
+ );
145
191
  return;
146
192
  }
147
193
 
@@ -190,9 +236,80 @@ async function main() {
190
236
  );
191
237
 
192
238
  // Reliability before daemon start:
193
- // - Ensure schema exists (server-light: db push; happy-server: migrate deploy if tables missing)
239
+ // - Ensure schema exists (server-light: prisma migrate deploy; happy-server: migrate deploy if tables missing)
194
240
  // - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
195
241
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
242
+ const accountProbe = await getAccountCountForServerComponent({
243
+ serverComponentName,
244
+ serverDir,
245
+ env: serverEnv,
246
+ bestEffort: true,
247
+ });
248
+ const accountCount = typeof accountProbe.accountCount === 'number' ? accountProbe.accountCount : null;
249
+ const autoSeedEnabled = resolveAutoCopyFromMainEnabled({ env: baseEnv, stackName, isInteractive });
250
+
251
+ let expoResEarly = null;
252
+ const wantsAuthFlow =
253
+ (baseEnv.HAPPY_STACKS_AUTH_FLOW ?? baseEnv.HAPPY_LOCAL_AUTH_FLOW ?? '').toString().trim() === '1' ||
254
+ (baseEnv.HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH ?? baseEnv.HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH ?? '').toString().trim() === '1';
255
+
256
+ // CRITICAL (review-pr / setup-pr guided login):
257
+ // In background/non-interactive runs, the daemon may block on auth. If we wait to start Expo web
258
+ // until after the daemon is authenticated, guided login will have no UI origin and will fall back
259
+ // to the server port (wrong). Start Expo web UI early when running an auth flow.
260
+ if (wantsAuthFlow && startUi && !expoResEarly) {
261
+ expoResEarly = await ensureDevExpoServer({
262
+ startUi,
263
+ startMobile,
264
+ uiDir,
265
+ autostart,
266
+ baseEnv,
267
+ apiServerUrl: uiApiUrl,
268
+ restart,
269
+ stackMode,
270
+ runtimeStatePath,
271
+ stackName,
272
+ envPath,
273
+ children,
274
+ spawnOptions: { stdio: ['ignore', 'ignore', 'ignore'] },
275
+ expoTailscale,
276
+ });
277
+ }
278
+ await maybeRunInteractiveStackAuthSetup({
279
+ rootDir,
280
+ // In dev mode, guided login must target the Expo web UI origin (not the server port).
281
+ // Mark this as an auth-flow so URL resolution fails closed if Expo isn't ready.
282
+ env: startUi ? { ...baseEnv, HAPPY_STACKS_AUTH_FLOW: '1', HAPPY_LOCAL_AUTH_FLOW: '1' } : baseEnv,
283
+ stackName,
284
+ cliHomeDir,
285
+ accountCount,
286
+ isInteractive,
287
+ autoSeedEnabled,
288
+ beforeLogin: async () => {
289
+ if (!startUi) {
290
+ throw new Error(
291
+ `[local] auth: interactive login requires the web UI.\n` +
292
+ `Re-run without --no-ui, or set HAPPY_WEBAPP_URL to a reachable Happy UI for this stack.`
293
+ );
294
+ }
295
+ if (expoResEarly) return;
296
+ expoResEarly = await ensureDevExpoServer({
297
+ startUi,
298
+ startMobile,
299
+ uiDir,
300
+ autostart,
301
+ baseEnv,
302
+ apiServerUrl: uiApiUrl,
303
+ restart,
304
+ stackMode,
305
+ runtimeStatePath,
306
+ stackName,
307
+ envPath,
308
+ children,
309
+ expoTailscale,
310
+ });
311
+ },
312
+ });
196
313
  await prepareDaemonAuthSeed({
197
314
  rootDir,
198
315
  env: baseEnv,
@@ -206,19 +323,35 @@ async function main() {
206
323
  quiet: false,
207
324
  });
208
325
 
209
- await startDevDaemon({
210
- startDaemon,
211
- cliBin,
212
- cliHomeDir,
213
- internalServerUrl,
214
- publicServerUrl,
215
- restart,
216
- isShuttingDown: () => shuttingDown,
217
- });
326
+ if (startDaemon) {
327
+ const gate = daemonStartGate({ env: baseEnv, cliHomeDir });
328
+ if (!gate.ok) {
329
+ // In orchestrated auth flows (setup-pr/review-pr), we intentionally keep server/UI up
330
+ // for guided login and start daemon post-auth from the orchestrator.
331
+ if (gate.reason === 'auth_flow_missing_credentials') {
332
+ console.log('[local] auth flow: skipping daemon start until credentials exist');
333
+ } else {
334
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
335
+ if (!isInteractive) {
336
+ throw new Error(formatDaemonAuthRequiredError({ stackName, cliHomeDir }));
337
+ }
338
+ }
339
+ } else {
340
+ await startDevDaemon({
341
+ startDaemon,
342
+ cliBin,
343
+ cliHomeDir,
344
+ internalServerUrl,
345
+ publicServerUrl,
346
+ restart,
347
+ isShuttingDown: () => shuttingDown,
348
+ });
349
+ }
350
+ }
218
351
 
219
352
  const cliWatcher = watchHappyCliAndRestartDaemon({
220
353
  enabled: watchEnabled,
221
- startDaemon,
354
+ startDaemon: startDaemon && daemonStartGate({ env: baseEnv, cliHomeDir }).ok,
222
355
  buildCli,
223
356
  cliDir,
224
357
  cliBin,
@@ -263,43 +396,58 @@ async function main() {
263
396
  );
264
397
  }
265
398
 
266
- const uiRes = await startDevExpoWebUi({
267
- startUi,
268
- uiDir,
269
- autostart,
270
- baseEnv,
271
- apiServerUrl: uiApiUrl,
272
- restart,
273
- stackMode,
274
- runtimeStatePath,
275
- stackName,
276
- envPath,
277
- children,
278
- });
399
+ const expoRes =
400
+ expoResEarly ??
401
+ (await ensureDevExpoServer({
402
+ startUi,
403
+ startMobile,
404
+ uiDir,
405
+ autostart,
406
+ baseEnv,
407
+ apiServerUrl: uiApiUrl,
408
+ restart,
409
+ stackMode,
410
+ runtimeStatePath,
411
+ stackName,
412
+ envPath,
413
+ children,
414
+ expoTailscale,
415
+ }));
279
416
  if (startUi) {
280
- const host = resolveLocalhostHost({ stackMode, stackName });
281
- if (uiRes?.reason === 'already_running' && uiRes.port) {
282
- console.log(`[local] ui already running (pid=${uiRes.pid}, port=${uiRes.port})`);
283
- console.log(`[local] ui: open http://${host}:${uiRes.port}`);
284
- } else if (uiRes?.skipped === false && uiRes.port) {
285
- console.log(`[local] ui: open http://${host}:${uiRes.port}`);
286
- } else if (uiRes?.skipped && uiRes?.reason === 'already_running') {
417
+ const uiPort = expoRes?.port;
418
+ const uiUrlRaw = uiPort ? `http://localhost:${uiPort}` : '';
419
+ const uiUrl = uiUrlRaw ? await preferStackLocalhostUrl(uiUrlRaw, { stackName }) : '';
420
+ if (expoRes?.reason === 'already_running' && expoRes.port) {
421
+ console.log(`[local] ui already running (pid=${expoRes.pid}, port=${expoRes.port})`);
422
+ if (uiUrl) console.log(`[local] ui: open ${uiUrl}`);
423
+ } else if (expoRes?.skipped === false && expoRes.port) {
424
+ if (uiUrl) console.log(`[local] ui: open ${uiUrl}`);
425
+ } else if (expoRes?.skipped && expoRes?.reason === 'already_running') {
287
426
  console.log('[local] ui already running (skipping Expo start)');
288
427
  }
289
428
 
290
429
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
291
- const shouldOpen = isInteractive && !noBrowser && Boolean(uiRes?.port);
430
+ const shouldOpen = isInteractive && !noBrowser && Boolean(expoRes?.port);
292
431
  if (shouldOpen) {
293
- const url = `http://${host}:${uiRes.port}`;
294
432
  // Prefer localhost for readiness checks (faster/more reliable), but open the stack-scoped hostname.
295
- await waitForHttpOk(`http://localhost:${uiRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
296
- const res = await openUrlInBrowser(url);
433
+ await waitForHttpOk(`http://localhost:${expoRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
434
+ const res = await openUrlInBrowser(uiUrl);
297
435
  if (!res.ok) {
298
436
  console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
299
437
  }
300
438
  }
301
439
  }
302
440
 
441
+ if (startMobile && expoRes?.port) {
442
+ const metroUrl = await preferStackLocalhostUrl(`http://localhost:${expoRes.port}`, { stackName });
443
+ console.log(`[local] mobile: metro ${metroUrl}`);
444
+ }
445
+
446
+ // Show Tailscale URL if forwarder is running
447
+ if (expoRes?.tailscale?.ok && expoRes.tailscale.tailscaleIp && expoRes.port) {
448
+ console.log(`[local] expo tailscale: http://${expoRes.tailscale.tailscaleIp}:${expoRes.port}`);
449
+ }
450
+
303
451
  const shutdown = async () => {
304
452
  if (shuttingDown) {
305
453
  return;
@@ -202,10 +202,6 @@ async function main() {
202
202
 
203
203
  // UI build dir check
204
204
  if (serveUi) {
205
- if (serverComponentName !== 'happy-server-light') {
206
- report.checks.uiServing = { ok: false, reason: `requires happy-server-light (current: ${serverComponentName})` };
207
- if (!json) console.log(`ℹ️ ui serving requires happy-server-light (current: ${serverComponentName})`);
208
- }
209
205
  if (await pathExists(uiBuildDir)) {
210
206
  report.checks.uiBuildDir = { ok: true, path: uiBuildDir };
211
207
  if (!json) console.log('✅ ui build dir present');
@@ -8,12 +8,13 @@ import { readTextOrEmpty } from './utils/fs/ops.mjs';
8
8
  import { readJsonIfExists } from './utils/fs/json.mjs';
9
9
  import { isPidAlive } from './utils/proc/pids.mjs';
10
10
  import { run, runCapture } from './utils/proc/proc.mjs';
11
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
11
+ import { preferStackLocalhostHost, resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
12
12
  import { sanitizeStackName } from './utils/stack/names.mjs';
13
13
  import { join } from 'node:path';
14
14
  import { spawn } from 'node:child_process';
15
15
  import { mkdir, lstat, rename, symlink, writeFile, readdir, chmod } from 'node:fs/promises';
16
16
  import os from 'node:os';
17
+ import { normalizeGitRoots } from './utils/edison/git_roots.mjs';
17
18
 
18
19
  const COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
19
20
 
@@ -1343,7 +1344,7 @@ async function resolveFingerprintGitRoots({ rootDir, stackEnv, edisonArgs }) {
1343
1344
  const d = resolveComponentDirFromStackEnv({ rootDir, stackEnv, component: c });
1344
1345
  if (d) dirs.push(d);
1345
1346
  }
1346
- return dirs.length ? dirs : fallback;
1347
+ return normalizeGitRoots(dirs.length ? dirs : fallback);
1347
1348
  }
1348
1349
 
1349
1350
  async function cmdTrackCoherence({ rootDir, argv, json }) {
@@ -1781,7 +1782,9 @@ async function main() {
1781
1782
  env.HAPPY_STACKS_EDISON_WRAPPER = '1';
1782
1783
  // Provide a stack-scoped localhost hostname for validators and browser flows.
1783
1784
  // This ensures origin isolation even if ports are reused later (common with ephemeral ports).
1784
- const localhostHost = resolveLocalhostHost({ stackMode: Boolean(stackName), stackName: stackName || 'main' });
1785
+ const localhostHost = Boolean(stackName)
1786
+ ? await preferStackLocalhostHost({ stackName })
1787
+ : resolveLocalhostHost({ stackMode: false, stackName: 'main' });
1785
1788
  env.HAPPY_STACKS_LOCALHOST_HOST = localhostHost;
1786
1789
  env.HAPPY_LOCAL_LOCALHOST_HOST = localhostHost;
1787
1790
 
@@ -1850,4 +1853,3 @@ main().catch((err) => {
1850
1853
  console.error('[edison] failed:', err);
1851
1854
  process.exit(1);
1852
1855
  });
1853
-