happy-stacks 0.3.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 (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  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 +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
package/scripts/build.mjs CHANGED
@@ -1,12 +1,15 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
3
+ import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
4
4
  import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/proc/pm.mjs';
5
5
  import { resolveServerPortFromEnv } from './utils/server/urls.mjs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { readFile, rm, mkdir, writeFile } from 'node:fs/promises';
8
8
  import { tailscaleServeHttpsUrl } from './tailscale.mjs';
9
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';
10
13
 
11
14
  /**
12
15
  * Build a lightweight static web UI bundle (no Expo dev server).
@@ -29,12 +32,29 @@ async function main() {
29
32
  ' happys build [--tauri] [--json]',
30
33
  ' (legacy in a cloned repo): pnpm build [-- --tauri] [--json]',
31
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.',
32
38
  ].join('\n'),
33
39
  });
34
40
  return;
35
41
  }
36
42
  const rootDir = getRootDir(import.meta.url);
37
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
+
38
58
  // Optional: skip building the web UI bundle.
39
59
  //
40
60
  // This is useful for evidence capture flows that validate non-UI components (e.g. `happy-cli`)
@@ -87,7 +107,17 @@ async function main() {
87
107
  };
88
108
 
89
109
  // Expo CLI is available via node_modules/.bin once dependencies are installed.
90
- 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
+ }
91
121
 
92
122
  if (json) {
93
123
  printResult({ json, data: { ok: true, outDir, tauriBuilt: false } });
@@ -146,13 +176,30 @@ async function main() {
146
176
  };
147
177
  delete tauriEnv.EXPO_PUBLIC_WEB_BASE_URL;
148
178
 
149
- 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({
150
190
  dir: uiDir,
151
- bin: 'expo',
152
- // Important: clear bundler cache so EXPO_PUBLIC_* inlining doesn't reuse
153
- // the previous (web) export's transform results.
154
- 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
+ ],
155
201
  env: tauriEnv,
202
+ ensureDepsLabel: 'happy',
156
203
  });
157
204
 
158
205
  // Build the Tauri app using a generated config that skips upstream beforeBuildCommand (which uses yarn).
@@ -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(