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.
- package/README.md +93 -40
- package/bin/happys.mjs +158 -16
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
- package/scripts/utils/dev/expo_web.mjs +0 -112
package/scripts/daemon.mjs
CHANGED
|
@@ -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
|
|
378
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
|
45
|
-
' happys dev --no-watch
|
|
46
|
-
' happys dev --no-browser
|
|
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 =
|
|
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
|
-
//
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
console.log(`[local] ui: open
|
|
286
|
-
} else if (
|
|
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(
|
|
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:${
|
|
296
|
-
const res = await openUrlInBrowser(
|
|
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;
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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');
|
package/scripts/edison.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|