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.
- package/README.md +29 -7
- package/bin/happys.mjs +114 -15
- package/docs/happy-development.md +2 -2
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/package.json +5 -1
- package/scripts/auth.mjs +11 -7
- package/scripts/build.mjs +54 -7
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +181 -46
- package/scripts/edison.mjs +4 -2
- package/scripts/init.mjs +3 -1
- package/scripts/install.mjs +112 -16
- package/scripts/lint.mjs +24 -4
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +217 -0
- package/scripts/review_pr.mjs +368 -0
- package/scripts/run.mjs +83 -9
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +42 -43
- package/scripts/setup_pr.mjs +591 -34
- package/scripts/stack.mjs +503 -45
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +309 -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 +24 -0
- package/scripts/utils/cli/cwd_scope.mjs +82 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/dev/daemon.mjs +47 -3
- package/scripts/utils/dev/expo_dev.mjs +246 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +15 -25
- package/scripts/utils/dev_auth_key.mjs +169 -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 +24 -20
- 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/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 +42 -38
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +69 -12
- package/scripts/utils/proc/proc.mjs +76 -2
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/runners/coderabbit.mjs +19 -0
- package/scripts/utils/review/runners/codex.mjs +51 -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/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/urls.mjs +14 -4
- 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 +2 -2
- 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 +7 -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/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/worktrees.mjs +141 -55
- package/scripts/utils/dev/expo_web.mjs +0 -112
package/scripts/stack.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './utils/env/env.mjs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { chmod, copyFile, mkdir, open, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
// NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
|
|
@@ -9,8 +9,17 @@ import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs
|
|
|
9
9
|
|
|
10
10
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
11
11
|
import { killProcessTree, run, runCapture } from './utils/proc/proc.mjs';
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
componentDirEnvKey,
|
|
14
|
+
getComponentDir,
|
|
15
|
+
getComponentsDir,
|
|
16
|
+
getHappyStacksHomeDir,
|
|
17
|
+
getLegacyStorageRoot,
|
|
18
|
+
getRootDir,
|
|
19
|
+
getStacksStorageRoot,
|
|
20
|
+
resolveStackEnvPath,
|
|
21
|
+
} from './utils/paths/paths.mjs';
|
|
22
|
+
import { isTcpPortFree, listListenPids, pickNextFreeTcpPort } from './utils/net/ports.mjs';
|
|
14
23
|
import {
|
|
15
24
|
createWorktree,
|
|
16
25
|
createWorktreeFromBaseWorktree,
|
|
@@ -27,10 +36,10 @@ import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
|
|
|
27
36
|
import { stopStackWithEnv } from './utils/stack/stop.mjs';
|
|
28
37
|
import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
|
|
29
38
|
import { startDevServer } from './utils/dev/server.mjs';
|
|
30
|
-
import {
|
|
39
|
+
import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
|
|
31
40
|
import { requireDir } from './utils/proc/pm.mjs';
|
|
32
41
|
import { waitForHttpOk } from './utils/server/server.mjs';
|
|
33
|
-
import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
|
|
42
|
+
import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
|
|
34
43
|
import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
35
44
|
import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth/files.mjs';
|
|
36
45
|
import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
|
|
@@ -52,23 +61,30 @@ import {
|
|
|
52
61
|
import { killPid } from './utils/expo/expo.mjs';
|
|
53
62
|
import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
|
|
54
63
|
import { randomToken } from './utils/crypto/tokens.mjs';
|
|
55
|
-
import { killPidOwnedByStack } from './utils/proc/ownership.mjs';
|
|
64
|
+
import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
|
|
56
65
|
import { sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
57
66
|
import { isCursorInstalled, openWorkspaceInEditor, writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
|
|
67
|
+
import { readLastLines } from './utils/fs/tail.mjs';
|
|
68
|
+
import { defaultStackReleaseIdentity } from './utils/mobile/identifiers.mjs';
|
|
58
69
|
|
|
59
70
|
function stackNameFromArg(positionals, idx) {
|
|
60
71
|
const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
|
|
61
72
|
return name;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
function getDefaultPortStart() {
|
|
75
|
+
function getDefaultPortStart(stackName = null) {
|
|
65
76
|
const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
|
|
66
77
|
? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
|
|
67
78
|
: process.env.HAPPY_LOCAL_STACK_PORT_START?.trim()
|
|
68
79
|
? process.env.HAPPY_LOCAL_STACK_PORT_START.trim()
|
|
69
80
|
: '';
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
// Default port strategy:
|
|
82
|
+
// - main historically lives at 3005
|
|
83
|
+
// - non-main stacks should avoid 3005 to reduce accidental collisions/confusion
|
|
84
|
+
const target = (stackName ?? '').toString().trim() || (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
85
|
+
const fallback = target === 'main' ? 3005 : 3009;
|
|
86
|
+
const n = raw ? Number(raw) : fallback;
|
|
87
|
+
return Number.isFinite(n) ? n : fallback;
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
async function isPortFree(port) {
|
|
@@ -231,7 +247,11 @@ function resolveDefaultComponentDirs({ rootDir }) {
|
|
|
231
247
|
for (const name of componentNames) {
|
|
232
248
|
const embedded = join(rootDir, 'components', name);
|
|
233
249
|
const workspace = join(getComponentsDir(rootDir), name);
|
|
234
|
-
|
|
250
|
+
// CRITICAL:
|
|
251
|
+
// In sandbox mode, never point stacks at the repo's embedded `components/*` checkouts.
|
|
252
|
+
// Sandboxes must use the sandbox workspace clones (HAPPY_STACKS_WORKSPACE_DIR/components/*),
|
|
253
|
+
// otherwise worktrees/branches collide with the user's real machine state.
|
|
254
|
+
const dir = !isSandboxed() && existsSync(embedded) ? embedded : workspace;
|
|
235
255
|
out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
|
|
236
256
|
}
|
|
237
257
|
return out;
|
|
@@ -264,9 +284,46 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
|
|
|
264
284
|
// exported in their shell, it would otherwise "win" because utils/env.mjs only sets
|
|
265
285
|
// env vars if they are missing/empty.
|
|
266
286
|
const cleaned = { ...process.env };
|
|
287
|
+
const keepPrefixed = new Set([
|
|
288
|
+
// Stack/env pointers:
|
|
289
|
+
'HAPPY_LOCAL_ENV_FILE',
|
|
290
|
+
'HAPPY_STACKS_ENV_FILE',
|
|
291
|
+
'HAPPY_LOCAL_STACK',
|
|
292
|
+
'HAPPY_STACKS_STACK',
|
|
293
|
+
|
|
294
|
+
// Sandbox detection + policy (must propagate to child processes).
|
|
295
|
+
'HAPPY_STACKS_SANDBOX_DIR',
|
|
296
|
+
'HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL',
|
|
297
|
+
|
|
298
|
+
// Sandbox-enforced dirs (without these, sandbox isolation breaks).
|
|
299
|
+
'HAPPY_STACKS_CLI_ROOT_DISABLE',
|
|
300
|
+
'HAPPY_STACKS_CANONICAL_HOME_DIR',
|
|
301
|
+
'HAPPY_STACKS_HOME_DIR',
|
|
302
|
+
'HAPPY_STACKS_WORKSPACE_DIR',
|
|
303
|
+
'HAPPY_STACKS_RUNTIME_DIR',
|
|
304
|
+
'HAPPY_STACKS_STORAGE_DIR',
|
|
305
|
+
// Legacy prefix mirrors:
|
|
306
|
+
'HAPPY_LOCAL_CANONICAL_HOME_DIR',
|
|
307
|
+
'HAPPY_LOCAL_HOME_DIR',
|
|
308
|
+
'HAPPY_LOCAL_WORKSPACE_DIR',
|
|
309
|
+
'HAPPY_LOCAL_RUNTIME_DIR',
|
|
310
|
+
'HAPPY_LOCAL_STORAGE_DIR',
|
|
311
|
+
|
|
312
|
+
// Sandbox-safe UX knobs (keep consistent through stack wrappers).
|
|
313
|
+
'HAPPY_STACKS_VERBOSE',
|
|
314
|
+
'HAPPY_STACKS_UPDATE_CHECK',
|
|
315
|
+
'HAPPY_STACKS_UPDATE_CHECK_INTERVAL_MS',
|
|
316
|
+
'HAPPY_STACKS_UPDATE_NOTIFY_INTERVAL_MS',
|
|
317
|
+
|
|
318
|
+
// Guided auth flow coordination across wrappers.
|
|
319
|
+
// These are intentionally passed through even though most HAPPY_STACKS_* vars are scrubbed.
|
|
320
|
+
'HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH',
|
|
321
|
+
'HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH',
|
|
322
|
+
'HAPPY_STACKS_AUTH_FLOW',
|
|
323
|
+
'HAPPY_LOCAL_AUTH_FLOW',
|
|
324
|
+
]);
|
|
267
325
|
for (const k of Object.keys(cleaned)) {
|
|
268
|
-
if (k
|
|
269
|
-
if (k === 'HAPPY_LOCAL_STACK' || k === 'HAPPY_STACKS_STACK') continue;
|
|
326
|
+
if (keepPrefixed.has(k)) continue;
|
|
270
327
|
if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
|
|
271
328
|
delete cleaned[k];
|
|
272
329
|
}
|
|
@@ -851,7 +908,7 @@ async function cmdEdit({ rootDir, argv }) {
|
|
|
851
908
|
printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
|
|
852
909
|
}
|
|
853
910
|
|
|
854
|
-
async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
|
|
911
|
+
async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {}, background = false }) {
|
|
855
912
|
await withStackEnv({
|
|
856
913
|
stackName,
|
|
857
914
|
extraEnv,
|
|
@@ -882,6 +939,29 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
882
939
|
// True restart = there was an active runner for this stack. If the stack is not running,
|
|
883
940
|
// `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
|
|
884
941
|
const isTrueRestart = wantsRestart && wasRunning;
|
|
942
|
+
|
|
943
|
+
// Restart semantics (stack mode):
|
|
944
|
+
// - Stop stack-owned processes first (runner, daemon, Expo, etc.)
|
|
945
|
+
// - Never kill arbitrary port listeners
|
|
946
|
+
// - Preserve previous runtime ports in memory so a true restart can reuse them
|
|
947
|
+
if (wantsRestart && !wantsJson) {
|
|
948
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
949
|
+
try {
|
|
950
|
+
await stopStackWithEnv({
|
|
951
|
+
rootDir,
|
|
952
|
+
stackName,
|
|
953
|
+
baseDir,
|
|
954
|
+
env,
|
|
955
|
+
json: false,
|
|
956
|
+
noDocker: false,
|
|
957
|
+
aggressive: false,
|
|
958
|
+
sweepOwned: true,
|
|
959
|
+
});
|
|
960
|
+
} catch {
|
|
961
|
+
// ignore (fail-closed below on port checks)
|
|
962
|
+
}
|
|
963
|
+
await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
|
|
964
|
+
}
|
|
885
965
|
if (wasRunning) {
|
|
886
966
|
if (!wantsRestart) {
|
|
887
967
|
const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
|
|
@@ -913,12 +993,16 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
913
993
|
} else if (scriptPath === 'dev.mjs') {
|
|
914
994
|
console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
|
|
915
995
|
}
|
|
996
|
+
|
|
997
|
+
// Opt-in: allow starting mobile Metro alongside an already-running stack without restarting the runner.
|
|
998
|
+
// This is important for workflows like re-running `setup-pr` with --mobile after the stack is already up.
|
|
999
|
+
const wantsMobile = args.includes('--mobile') || args.includes('--with-mobile');
|
|
1000
|
+
if (wantsMobile) {
|
|
1001
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'mobile.mjs'), '--metro'], { cwd: rootDir, env });
|
|
1002
|
+
}
|
|
916
1003
|
return;
|
|
917
1004
|
}
|
|
918
|
-
// Restart:
|
|
919
|
-
await killPidOwnedByStack(existingOwnerPid, { stackName, envPath, cliHomeDir: (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').toString(), label: 'runner', json: false });
|
|
920
|
-
// Clear runtime state so we don't keep stale process PIDs; we'll re-create it for the new run below.
|
|
921
|
-
await deleteStackRuntimeStateFile(runtimeStatePath);
|
|
1005
|
+
// Restart: already handled above (stopStackWithEnv is ownership-gated).
|
|
922
1006
|
}
|
|
923
1007
|
|
|
924
1008
|
// Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
|
|
@@ -941,7 +1025,7 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
941
1025
|
}
|
|
942
1026
|
}
|
|
943
1027
|
|
|
944
|
-
const startPort = getDefaultPortStart();
|
|
1028
|
+
const startPort = getDefaultPortStart(stackName);
|
|
945
1029
|
const ports = {};
|
|
946
1030
|
|
|
947
1031
|
const parsePortOrNull = (v) => {
|
|
@@ -986,6 +1070,42 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
986
1070
|
for (const p of toCheck) {
|
|
987
1071
|
// eslint-disable-next-line no-await-in-loop
|
|
988
1072
|
if (!(await isTcpPortFree(p))) {
|
|
1073
|
+
if (isTrueRestart && !wantsJson) {
|
|
1074
|
+
// Try one more safe cleanup of stack-owned processes and re-check.
|
|
1075
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1076
|
+
try {
|
|
1077
|
+
await stopStackWithEnv({
|
|
1078
|
+
rootDir,
|
|
1079
|
+
stackName,
|
|
1080
|
+
baseDir,
|
|
1081
|
+
env,
|
|
1082
|
+
json: false,
|
|
1083
|
+
noDocker: false,
|
|
1084
|
+
aggressive: false,
|
|
1085
|
+
sweepOwned: true,
|
|
1086
|
+
});
|
|
1087
|
+
} catch {
|
|
1088
|
+
// ignore
|
|
1089
|
+
}
|
|
1090
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1091
|
+
if (await isTcpPortFree(p)) {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Last resort: if we can prove the listener is stack-owned, kill it.
|
|
1096
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1097
|
+
const pids = await listListenPids(p);
|
|
1098
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1099
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1100
|
+
for (const pid of pids) {
|
|
1101
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1102
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${p}`, json: false });
|
|
1103
|
+
}
|
|
1104
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1105
|
+
if (await isTcpPortFree(p)) {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
989
1109
|
throw new Error(
|
|
990
1110
|
`[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
|
|
991
1111
|
`[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
|
|
@@ -1043,13 +1163,61 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1043
1163
|
: {}),
|
|
1044
1164
|
};
|
|
1045
1165
|
|
|
1166
|
+
// Background dev auth flow (automatic):
|
|
1167
|
+
// If we're starting `dev.mjs` in background and the stack is not authenticated yet,
|
|
1168
|
+
// keep the stack alive for guided login by marking this as an auth-flow so URL resolution
|
|
1169
|
+
// fails closed (never opens server port as "UI").
|
|
1170
|
+
//
|
|
1171
|
+
// IMPORTANT:
|
|
1172
|
+
// We must NOT start the daemon before credentials exist in orchestrated flows (setup-pr/review-pr),
|
|
1173
|
+
// because the daemon can enter its own auth flow and become stranded (lock held, no machine registration).
|
|
1174
|
+
if (background && scriptPath === 'dev.mjs') {
|
|
1175
|
+
const startUi = !args.includes('--no-ui') && (env.HAPPY_LOCAL_UI ?? '1').toString().trim() !== '0';
|
|
1176
|
+
const startDaemon = !args.includes('--no-daemon') && (env.HAPPY_LOCAL_DAEMON ?? '1').toString().trim() !== '0';
|
|
1177
|
+
if (startUi && startDaemon) {
|
|
1178
|
+
try {
|
|
1179
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1180
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1181
|
+
const hasCreds = existsSync(join(cliHomeDir, 'access.key'));
|
|
1182
|
+
if (!hasCreds) {
|
|
1183
|
+
childEnv.HAPPY_STACKS_AUTH_FLOW = '1';
|
|
1184
|
+
childEnv.HAPPY_LOCAL_AUTH_FLOW = '1';
|
|
1185
|
+
}
|
|
1186
|
+
} catch {
|
|
1187
|
+
// If we can't resolve CLI home dir, skip auto auth-flow markers (best-effort).
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Background mode: send runner output to a stack-scoped log file so quiet flows can
|
|
1193
|
+
// remain clean while still providing actionable error logs.
|
|
1194
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1195
|
+
const logsDir = join(stackBaseDir, 'logs');
|
|
1196
|
+
const logPath = join(logsDir, `${scriptPath.replace(/\.mjs$/, '')}.${Date.now()}.log`);
|
|
1197
|
+
if (background) {
|
|
1198
|
+
await ensureDir(logsDir);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
let logHandle = null;
|
|
1202
|
+
let outFd = null;
|
|
1203
|
+
if (background) {
|
|
1204
|
+
logHandle = await open(logPath, 'a');
|
|
1205
|
+
outFd = logHandle.fd;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1046
1208
|
// Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
|
|
1047
1209
|
const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
|
|
1048
1210
|
cwd: rootDir,
|
|
1049
1211
|
env: childEnv,
|
|
1050
|
-
stdio: 'inherit',
|
|
1212
|
+
stdio: background ? ['ignore', outFd ?? 'ignore', outFd ?? 'ignore'] : 'inherit',
|
|
1051
1213
|
shell: false,
|
|
1214
|
+
detached: background && process.platform !== 'win32',
|
|
1052
1215
|
});
|
|
1216
|
+
try {
|
|
1217
|
+
await logHandle?.close();
|
|
1218
|
+
} catch {
|
|
1219
|
+
// ignore
|
|
1220
|
+
}
|
|
1053
1221
|
|
|
1054
1222
|
// Record the chosen ports immediately (before the runner finishes booting), so other stack commands
|
|
1055
1223
|
// can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
|
|
@@ -1059,8 +1227,104 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1059
1227
|
ephemeral: true,
|
|
1060
1228
|
ownerPid: child.pid,
|
|
1061
1229
|
ports,
|
|
1230
|
+
...(background ? { logs: { runner: logPath } } : {}),
|
|
1062
1231
|
}).catch(() => {});
|
|
1063
1232
|
|
|
1233
|
+
if (background) {
|
|
1234
|
+
// Keep stack.runtime.json so stack-scoped stop/restart can manage this runner.
|
|
1235
|
+
// This mode is used by higher-level commands that want to run guided auth steps
|
|
1236
|
+
// without mixing them into server logs.
|
|
1237
|
+
const internalServerUrl = `http://127.0.0.1:${ports.server}`;
|
|
1238
|
+
|
|
1239
|
+
// Fail fast if the runner dies immediately or never exposes HTTP.
|
|
1240
|
+
// IMPORTANT: do not treat "some process answered /health" as success unless our runner
|
|
1241
|
+
// is still alive. Otherwise, if the chosen port is already in use, the runner can exit
|
|
1242
|
+
// and a different stack/process could satisfy the health check (leading to confusing
|
|
1243
|
+
// follow-on behavior like auth using the wrong port).
|
|
1244
|
+
try {
|
|
1245
|
+
let exited = null;
|
|
1246
|
+
const exitPromise = new Promise((resolvePromise) => {
|
|
1247
|
+
child.once('exit', (code, sig) => {
|
|
1248
|
+
exited = { kind: 'exit', code: code ?? 0, sig: sig ?? null };
|
|
1249
|
+
resolvePromise(exited);
|
|
1250
|
+
});
|
|
1251
|
+
child.once('error', (err) => {
|
|
1252
|
+
exited = { kind: 'error', error: err instanceof Error ? err.message : String(err) };
|
|
1253
|
+
resolvePromise(exited);
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
const readyPromise = (async () => {
|
|
1257
|
+
const timeoutMsRaw =
|
|
1258
|
+
(process.env.HAPPY_STACKS_STACK_BACKGROUND_READY_TIMEOUT_MS ??
|
|
1259
|
+
process.env.HAPPY_LOCAL_STACK_BACKGROUND_READY_TIMEOUT_MS ??
|
|
1260
|
+
'180000')
|
|
1261
|
+
.toString()
|
|
1262
|
+
.trim();
|
|
1263
|
+
const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 180_000;
|
|
1264
|
+
await waitForHttpOk(`${internalServerUrl}/health`, {
|
|
1265
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 180_000,
|
|
1266
|
+
intervalMs: 300,
|
|
1267
|
+
});
|
|
1268
|
+
return { kind: 'ready' };
|
|
1269
|
+
})();
|
|
1270
|
+
|
|
1271
|
+
const first = await Promise.race([exitPromise, readyPromise]);
|
|
1272
|
+
if (first.kind !== 'ready') {
|
|
1273
|
+
throw new Error(`[stack] ${stackName}: runner exited before becoming ready. log: ${logPath}`);
|
|
1274
|
+
}
|
|
1275
|
+
// Even if /health responded, ensure our runner is still alive.
|
|
1276
|
+
// (Prevents false positives when another process owns the port.)
|
|
1277
|
+
if (exited && exited.kind !== 'ready') {
|
|
1278
|
+
throw new Error(`[stack] ${stackName}: runner reported ready but exited immediately. log: ${logPath}`);
|
|
1279
|
+
}
|
|
1280
|
+
if (!isPidAlive(child.pid)) {
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`[stack] ${stackName}: runner health check passed, but runner is not running.\n` +
|
|
1283
|
+
`[stack] This usually means the chosen port (${ports.server}) is already in use by another process.\n` +
|
|
1284
|
+
`[stack] log: ${logPath}`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
} catch (e) {
|
|
1288
|
+
// Attach some log context so failures are debuggable even when a higher-level
|
|
1289
|
+
// command cleans up the sandbox directory afterwards.
|
|
1290
|
+
try {
|
|
1291
|
+
const tail = await readLastLines(logPath, 160);
|
|
1292
|
+
if (tail && e instanceof Error) {
|
|
1293
|
+
e.message = `${e.message}\n\n[stack] last runner log lines:\n${tail}`;
|
|
1294
|
+
}
|
|
1295
|
+
} catch {
|
|
1296
|
+
// ignore
|
|
1297
|
+
}
|
|
1298
|
+
// Best-effort cleanup on boot failure.
|
|
1299
|
+
try {
|
|
1300
|
+
// We spawned this runner process, so we can safely terminate it without relying
|
|
1301
|
+
// on ownership heuristics (which can be unreliable on some platforms due to `ps` truncation).
|
|
1302
|
+
if (background && process.platform !== 'win32') {
|
|
1303
|
+
try {
|
|
1304
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1305
|
+
} catch {
|
|
1306
|
+
// ignore
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
child.kill('SIGTERM');
|
|
1311
|
+
} catch {
|
|
1312
|
+
// ignore
|
|
1313
|
+
}
|
|
1314
|
+
} catch {
|
|
1315
|
+
// ignore
|
|
1316
|
+
}
|
|
1317
|
+
await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
|
|
1318
|
+
throw e;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (!wantsJson) {
|
|
1322
|
+
console.log(`[stack] ${stackName}: logs: ${logPath}`);
|
|
1323
|
+
}
|
|
1324
|
+
try { child.unref(); } catch { /* ignore */ }
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1064
1328
|
try {
|
|
1065
1329
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
1066
1330
|
child.on('error', rejectPromise);
|
|
@@ -1079,6 +1343,28 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1079
1343
|
}
|
|
1080
1344
|
|
|
1081
1345
|
// Pinned port stack: run normally under the pinned env.
|
|
1346
|
+
if (background) {
|
|
1347
|
+
throw new Error('[stack] --background is only supported for ephemeral-port stacks');
|
|
1348
|
+
}
|
|
1349
|
+
if (wantsRestart && !wantsJson) {
|
|
1350
|
+
const pinnedPort = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
|
|
1351
|
+
if (pinnedPort && !(await isTcpPortFree(pinnedPort))) {
|
|
1352
|
+
// Last resort: kill listener only if it is stack-owned.
|
|
1353
|
+
const pids = await listListenPids(pinnedPort);
|
|
1354
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1355
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1356
|
+
for (const pid of pids) {
|
|
1357
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1358
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${pinnedPort}`, json: false });
|
|
1359
|
+
}
|
|
1360
|
+
if (!(await isTcpPortFree(pinnedPort))) {
|
|
1361
|
+
throw new Error(
|
|
1362
|
+
`[stack] ${stackName}: server port ${pinnedPort} is not free on restart.\n` +
|
|
1363
|
+
`[stack] Refusing to kill unknown listeners. Stop the process using it, or change the pinned port.`
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1082
1368
|
await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
|
|
1083
1369
|
},
|
|
1084
1370
|
});
|
|
@@ -1122,9 +1408,25 @@ async function cmdService({ rootDir, stackName, svcCmd }) {
|
|
|
1122
1408
|
});
|
|
1123
1409
|
}
|
|
1124
1410
|
|
|
1411
|
+
async function getRuntimePortExtraEnv(stackName) {
|
|
1412
|
+
const runtimeStatePath = getStackRuntimeStatePath(stackName);
|
|
1413
|
+
const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
|
|
1414
|
+
const runtimePort = Number(runtimeState?.ports?.server);
|
|
1415
|
+
return Number.isFinite(runtimePort) && runtimePort > 0
|
|
1416
|
+
? {
|
|
1417
|
+
// Ephemeral stacks (PR stacks) store their chosen ports in stack.runtime.json, not the env file.
|
|
1418
|
+
// Ensure stack-scoped commands that compute URLs don't fall back to 3005 (main default).
|
|
1419
|
+
HAPPY_STACKS_SERVER_PORT: String(runtimePort),
|
|
1420
|
+
HAPPY_LOCAL_SERVER_PORT: String(runtimePort),
|
|
1421
|
+
}
|
|
1422
|
+
: null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1125
1425
|
async function cmdTailscale({ rootDir, stackName, subcmd, args }) {
|
|
1426
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
1126
1427
|
await withStackEnv({
|
|
1127
1428
|
stackName,
|
|
1429
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
1128
1430
|
fn: async ({ env }) => {
|
|
1129
1431
|
await run(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), subcmd, ...args], { cwd: rootDir, env });
|
|
1130
1432
|
},
|
|
@@ -1159,8 +1461,10 @@ async function cmdAuth({ rootDir, stackName, args }) {
|
|
|
1159
1461
|
// Forward to scripts/auth.mjs under the stack env.
|
|
1160
1462
|
// This makes `happys stack auth <name> ...` resolve CLI home/urls for that stack.
|
|
1161
1463
|
const forwarded = args[0] === '--' ? args.slice(1) : args;
|
|
1464
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
1162
1465
|
await withStackEnv({
|
|
1163
1466
|
stackName,
|
|
1467
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
1164
1468
|
fn: async ({ env }) => {
|
|
1165
1469
|
await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...forwarded], { cwd: rootDir, env });
|
|
1166
1470
|
},
|
|
@@ -1795,7 +2099,7 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1795
2099
|
|
|
1796
2100
|
const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
|
|
1797
2101
|
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
1798
|
-
const publicServerUrl = `http://localhost:${serverPort}
|
|
2102
|
+
const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}`, { stackName: name });
|
|
1799
2103
|
|
|
1800
2104
|
const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
|
|
1801
2105
|
const children = [];
|
|
@@ -1815,9 +2119,14 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1815
2119
|
serverComponent === 'happy-server'
|
|
1816
2120
|
? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
|
|
1817
2121
|
: env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT;
|
|
1818
|
-
const resolvedServerDir =
|
|
1819
|
-
|
|
1820
|
-
|
|
2122
|
+
const resolvedServerDir =
|
|
2123
|
+
(serverDir ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT ?? '').toString().trim() ||
|
|
2124
|
+
getComponentDir(rootDir, serverComponent);
|
|
2125
|
+
const resolvedCliDir =
|
|
2126
|
+
(env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() ||
|
|
2127
|
+
getComponentDir(rootDir, 'happy-cli');
|
|
2128
|
+
const resolvedUiDir =
|
|
2129
|
+
(env.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY ?? '').toString().trim() || getComponentDir(rootDir, 'happy');
|
|
1821
2130
|
|
|
1822
2131
|
await requireDir(serverComponent, resolvedServerDir);
|
|
1823
2132
|
await requireDir('happy-cli', resolvedCliDir);
|
|
@@ -1844,9 +2153,10 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1844
2153
|
});
|
|
1845
2154
|
serverProc = started.serverProc;
|
|
1846
2155
|
|
|
1847
|
-
// Start Expo web
|
|
1848
|
-
const uiRes = await
|
|
2156
|
+
// Start Expo (web) so /terminal/connect exists for happy-cli web auth.
|
|
2157
|
+
const uiRes = await ensureDevExpoServer({
|
|
1849
2158
|
startUi: true,
|
|
2159
|
+
startMobile: false,
|
|
1850
2160
|
uiDir: resolvedUiDir,
|
|
1851
2161
|
autostart,
|
|
1852
2162
|
baseEnv: env,
|
|
@@ -1865,10 +2175,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1865
2175
|
}
|
|
1866
2176
|
|
|
1867
2177
|
console.log('');
|
|
1868
|
-
const uiHost = resolveLocalhostHost({ stackMode: true, stackName: name });
|
|
1869
2178
|
const uiPort = uiRes?.port;
|
|
1870
|
-
const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
|
|
1871
2179
|
const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
|
|
2180
|
+
const uiRoot = uiRootLocalhost ? await preferStackLocalhostUrl(uiRootLocalhost, { stackName: name }) : null;
|
|
1872
2181
|
const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
|
|
1873
2182
|
|
|
1874
2183
|
console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
|
|
@@ -2058,14 +2367,14 @@ async function cmdDuplicate({ rootDir, argv }) {
|
|
|
2058
2367
|
if (!rawDir) continue;
|
|
2059
2368
|
|
|
2060
2369
|
let nextDir = rawDir;
|
|
2061
|
-
if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
|
|
2062
|
-
const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir });
|
|
2370
|
+
if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir, env: fromEnv })) {
|
|
2371
|
+
const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir, env: fromEnv });
|
|
2063
2372
|
if (spec) {
|
|
2064
2373
|
const [owner, ...restParts] = spec.split('/').filter(Boolean);
|
|
2065
2374
|
const rest = restParts.join('/');
|
|
2066
2375
|
const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
|
|
2067
2376
|
|
|
2068
|
-
const repoDir = join(getComponentsDir(rootDir), component);
|
|
2377
|
+
const repoDir = join(getComponentsDir(rootDir, fromEnv), component);
|
|
2069
2378
|
const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
|
|
2070
2379
|
// Base on the existing worktree's HEAD/branch so we get the same commit.
|
|
2071
2380
|
nextDir = await createWorktreeFromBaseWorktree({
|
|
@@ -2075,6 +2384,7 @@ async function cmdDuplicate({ rootDir, argv }) {
|
|
|
2075
2384
|
baseWorktreeSpec: spec,
|
|
2076
2385
|
remoteName,
|
|
2077
2386
|
depsMode,
|
|
2387
|
+
env: fromEnv,
|
|
2078
2388
|
});
|
|
2079
2389
|
}
|
|
2080
2390
|
}
|
|
@@ -2160,13 +2470,14 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2160
2470
|
json,
|
|
2161
2471
|
data: {
|
|
2162
2472
|
usage:
|
|
2163
|
-
'happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--server=happy-server|happy-server-light] [--remote=upstream] [--deps=none|link|install|link-or-install] [--seed-auth] [--copy-auth-from=<stack>] [--with-infra] [--auth-force] [--dev|--start] [--json] [-- <stack dev/start args...>]',
|
|
2473
|
+
'happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--server=happy-server|happy-server-light] [--remote=upstream] [--deps=none|link|install|link-or-install] [--seed-auth] [--copy-auth-from=<stack>] [--with-infra] [--auth-force] [--dev|--start] [--background] [--mobile] [--json] [-- <stack dev/start args...>]',
|
|
2164
2474
|
},
|
|
2165
2475
|
text: [
|
|
2166
2476
|
'[stack] usage:',
|
|
2167
2477
|
' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start]',
|
|
2168
|
-
' [--seed-auth] [--copy-auth-from=<stack
|
|
2169
|
-
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force]',
|
|
2478
|
+
' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
|
|
2479
|
+
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
|
|
2480
|
+
' [--mobile] # also start Expo dev-client Metro for mobile',
|
|
2170
2481
|
' [--json] [-- <stack dev/start args...>]',
|
|
2171
2482
|
'',
|
|
2172
2483
|
'examples:',
|
|
@@ -2181,7 +2492,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2181
2492
|
' happys stack pr pr123 --happy=123 --happy-cli=456 --seed-auth --copy-auth-from=dev-auth --dev',
|
|
2182
2493
|
'',
|
|
2183
2494
|
' # Reuse an existing non-stacks Happy install for auth seeding',
|
|
2184
|
-
'
|
|
2495
|
+
' (deprecated) legacy ~/.happy is not supported for reliable seeding',
|
|
2185
2496
|
'',
|
|
2186
2497
|
'notes:',
|
|
2187
2498
|
' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
|
|
@@ -2209,7 +2520,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2209
2520
|
);
|
|
2210
2521
|
}
|
|
2211
2522
|
|
|
2212
|
-
const
|
|
2523
|
+
const remoteNameFromArg = (kv.get('--remote') ?? '').trim();
|
|
2213
2524
|
const depsMode = (kv.get('--deps') ?? '').trim();
|
|
2214
2525
|
|
|
2215
2526
|
const prHappy = (kv.get('--happy') ?? '').trim();
|
|
@@ -2239,6 +2550,9 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2239
2550
|
throw new Error('[stack] pr: choose either --dev or --start (not both)');
|
|
2240
2551
|
}
|
|
2241
2552
|
|
|
2553
|
+
const wantsMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
2554
|
+
const background = flags.has('--background') || flags.has('--bg') || (kv.get('--background') ?? '').trim() === '1';
|
|
2555
|
+
|
|
2242
2556
|
const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
|
|
2243
2557
|
const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
|
|
2244
2558
|
const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
|
|
@@ -2375,6 +2689,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2375
2689
|
].filter((x) => x.pr);
|
|
2376
2690
|
|
|
2377
2691
|
const worktrees = [];
|
|
2692
|
+
const stackEnvPath = resolveStackEnvPath(stackName).envPath;
|
|
2378
2693
|
for (const { component, pr } of prSpecs) {
|
|
2379
2694
|
progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
|
|
2380
2695
|
const out = await withStackEnv({
|
|
@@ -2385,7 +2700,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2385
2700
|
'pr',
|
|
2386
2701
|
component,
|
|
2387
2702
|
pr,
|
|
2388
|
-
`--remote=${
|
|
2703
|
+
...(remoteNameFromArg ? [`--remote=${remoteNameFromArg}`] : []),
|
|
2389
2704
|
'--use',
|
|
2390
2705
|
...(depsMode ? [`--deps=${depsMode}`] : []),
|
|
2391
2706
|
...(doUpdate ? ['--update'] : []),
|
|
@@ -2393,11 +2708,35 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2393
2708
|
'--json',
|
|
2394
2709
|
];
|
|
2395
2710
|
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
|
|
2396
|
-
|
|
2711
|
+
const parsed = stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
2712
|
+
|
|
2713
|
+
// Fail-closed invariant for PR stacks:
|
|
2714
|
+
// If you asked to pin a component to a PR checkout, it MUST be a worktree path under
|
|
2715
|
+
// the active workspace components dir (including sandbox workspace).
|
|
2716
|
+
if (parsed?.path && !isComponentWorktreePath({ rootDir, component, dir: parsed.path, env })) {
|
|
2717
|
+
throw new Error(
|
|
2718
|
+
`[stack] pr: refusing to pin ${component} because the checked out path is not a worktree.\n` +
|
|
2719
|
+
`- expected under: ${join(getComponentsDir(rootDir, env), '.worktrees', component)}/...\n` +
|
|
2720
|
+
`- actual: ${String(parsed.path ?? '').trim()}\n` +
|
|
2721
|
+
`Fix: this is a bug. Please re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
return parsed;
|
|
2397
2726
|
},
|
|
2398
2727
|
});
|
|
2399
|
-
if (
|
|
2728
|
+
if (out) {
|
|
2400
2729
|
worktrees.push(out);
|
|
2730
|
+
// Fail-closed invariant for PR stacks:
|
|
2731
|
+
// - if you asked to pin a component to a PR checkout, the stack env file MUST point at that exact worktree dir
|
|
2732
|
+
// before we start dev/start. Otherwise the stack can accidentally run the base checkout.
|
|
2733
|
+
//
|
|
2734
|
+
// We intentionally do NOT rely solely on `wt pr --use` for this; we make it explicit here.
|
|
2735
|
+
const key = componentDirEnvKey(component);
|
|
2736
|
+
await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key, value: out.path }] });
|
|
2737
|
+
}
|
|
2738
|
+
if (json) {
|
|
2739
|
+
// collected above
|
|
2401
2740
|
} else if (out) {
|
|
2402
2741
|
const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
|
|
2403
2742
|
const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
|
|
@@ -2414,6 +2753,36 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2414
2753
|
}
|
|
2415
2754
|
}
|
|
2416
2755
|
|
|
2756
|
+
// Validate that all PR components are pinned correctly before starting.
|
|
2757
|
+
// This prevents "wrong daemon" / "wrong UI" errors that are otherwise extremely confusing in review-pr.
|
|
2758
|
+
if (prSpecs.length) {
|
|
2759
|
+
const afterRaw = await readExistingEnv(stackEnvPath);
|
|
2760
|
+
const afterEnv = parseEnvToObject(afterRaw);
|
|
2761
|
+
for (const wt of worktrees) {
|
|
2762
|
+
const key = componentDirEnvKey(wt.component);
|
|
2763
|
+
const val = (afterEnv[key] ?? '').toString().trim();
|
|
2764
|
+
const expected = resolve(String(wt.path ?? '').trim());
|
|
2765
|
+
const actual = val ? resolve(val) : '';
|
|
2766
|
+
if (!actual) {
|
|
2767
|
+
throw new Error(
|
|
2768
|
+
`[stack] pr: failed to pin ${wt.component} to the PR checkout.\n` +
|
|
2769
|
+
`- missing env key: ${key}\n` +
|
|
2770
|
+
`- expected: ${expected}\n` +
|
|
2771
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2772
|
+
);
|
|
2773
|
+
}
|
|
2774
|
+
if (expected && actual !== expected) {
|
|
2775
|
+
throw new Error(
|
|
2776
|
+
`[stack] pr: stack is pinned to the wrong checkout for ${wt.component}.\n` +
|
|
2777
|
+
`- env key: ${key}\n` +
|
|
2778
|
+
`- expected: ${expected}\n` +
|
|
2779
|
+
`- actual: ${actual}\n` +
|
|
2780
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2417
2786
|
// 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
|
|
2418
2787
|
let auth = null;
|
|
2419
2788
|
if (seedAuth) {
|
|
@@ -2426,8 +2795,10 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2426
2795
|
...(authLink ? ['--link'] : []),
|
|
2427
2796
|
];
|
|
2428
2797
|
if (json) {
|
|
2798
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
2429
2799
|
auth = await withStackEnv({
|
|
2430
2800
|
stackName,
|
|
2801
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
2431
2802
|
fn: async ({ env }) => {
|
|
2432
2803
|
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
|
|
2433
2804
|
return stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
@@ -2442,12 +2813,18 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2442
2813
|
// 4) Optional: start dev / start.
|
|
2443
2814
|
if (wantsDev) {
|
|
2444
2815
|
progress(`[stack] pr: ${stackName}: starting dev...`);
|
|
2445
|
-
const args =
|
|
2446
|
-
|
|
2816
|
+
const args = [
|
|
2817
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
2818
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
2819
|
+
];
|
|
2820
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
|
|
2447
2821
|
} else if (wantsStart) {
|
|
2448
2822
|
progress(`[stack] pr: ${stackName}: starting...`);
|
|
2449
|
-
const args =
|
|
2450
|
-
|
|
2823
|
+
const args = [
|
|
2824
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
2825
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
2826
|
+
];
|
|
2827
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
|
|
2451
2828
|
}
|
|
2452
2829
|
|
|
2453
2830
|
const info = await cmdInfoInternal({ rootDir, stackName });
|
|
@@ -2500,10 +2877,15 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2500
2877
|
runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
|
|
2501
2878
|
? Number(runtimeState.expo.webPort)
|
|
2502
2879
|
: null;
|
|
2880
|
+
const mobilePort =
|
|
2881
|
+
runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.mobilePort) > 0
|
|
2882
|
+
? Number(runtimeState.expo.mobilePort)
|
|
2883
|
+
: null;
|
|
2503
2884
|
|
|
2504
2885
|
const host = resolveLocalhostHost({ stackMode: true, stackName });
|
|
2505
2886
|
const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
2506
2887
|
const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
|
|
2888
|
+
const mobileUrl = mobilePort ? await preferStackLocalhostUrl(`http://localhost:${mobilePort}`, { stackName }) : null;
|
|
2507
2889
|
|
|
2508
2890
|
const componentSpecs = [
|
|
2509
2891
|
{ component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
|
|
@@ -2546,11 +2928,13 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2546
2928
|
host,
|
|
2547
2929
|
internalServerUrl,
|
|
2548
2930
|
uiUrl,
|
|
2931
|
+
mobileUrl,
|
|
2549
2932
|
},
|
|
2550
2933
|
ports: {
|
|
2551
2934
|
server: serverPort,
|
|
2552
2935
|
backend: backendPort,
|
|
2553
2936
|
ui: uiPort,
|
|
2937
|
+
mobile: mobilePort,
|
|
2554
2938
|
},
|
|
2555
2939
|
components,
|
|
2556
2940
|
};
|
|
@@ -2617,11 +3001,14 @@ async function main() {
|
|
|
2617
3001
|
'dev',
|
|
2618
3002
|
'start',
|
|
2619
3003
|
'build',
|
|
3004
|
+
'review',
|
|
2620
3005
|
'typecheck',
|
|
2621
3006
|
'lint',
|
|
2622
3007
|
'test',
|
|
2623
3008
|
'doctor',
|
|
2624
3009
|
'mobile',
|
|
3010
|
+
'mobile:install',
|
|
3011
|
+
'mobile-dev-client',
|
|
2625
3012
|
'resume',
|
|
2626
3013
|
'stop',
|
|
2627
3014
|
'code',
|
|
@@ -2648,11 +3035,14 @@ async function main() {
|
|
|
2648
3035
|
' happys stack dev <name> [-- ...]',
|
|
2649
3036
|
' happys stack start <name> [-- ...]',
|
|
2650
3037
|
' happys stack build <name> [-- ...]',
|
|
3038
|
+
' happys stack review <name> [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--json]',
|
|
2651
3039
|
' happys stack typecheck <name> [component...] [--json]',
|
|
2652
3040
|
' happys stack lint <name> [component...] [--json]',
|
|
2653
3041
|
' happys stack test <name> [component...] [--json]',
|
|
2654
3042
|
' happys stack doctor <name> [-- ...]',
|
|
2655
3043
|
' happys stack mobile <name> [-- ...]',
|
|
3044
|
+
' happys stack mobile:install <name> [--name="Happy (exp1)"] [--device=...] [--json]',
|
|
3045
|
+
' happys stack mobile-dev-client <name> --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
|
|
2656
3046
|
' happys stack resume <name> <sessionId...> [--json]',
|
|
2657
3047
|
' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
|
|
2658
3048
|
' happys stack code <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
|
|
@@ -2786,11 +3176,15 @@ async function main() {
|
|
|
2786
3176
|
const passthrough = argv.slice(2);
|
|
2787
3177
|
|
|
2788
3178
|
if (cmd === 'dev') {
|
|
2789
|
-
|
|
3179
|
+
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
3180
|
+
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|
|
3181
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
|
|
2790
3182
|
return;
|
|
2791
3183
|
}
|
|
2792
3184
|
if (cmd === 'start') {
|
|
2793
|
-
|
|
3185
|
+
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
3186
|
+
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|
|
3187
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
|
|
2794
3188
|
return;
|
|
2795
3189
|
}
|
|
2796
3190
|
if (cmd === 'build') {
|
|
@@ -2817,6 +3211,12 @@ async function main() {
|
|
|
2817
3211
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
|
|
2818
3212
|
return;
|
|
2819
3213
|
}
|
|
3214
|
+
if (cmd === 'review') {
|
|
3215
|
+
const { kv } = parseArgs(passthrough);
|
|
3216
|
+
const overrides = resolveTransientComponentOverrides({ rootDir, kv });
|
|
3217
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'review.mjs', args: passthrough, extraEnv: overrides });
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
2820
3220
|
if (cmd === 'doctor') {
|
|
2821
3221
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
|
|
2822
3222
|
return;
|
|
@@ -2825,6 +3225,62 @@ async function main() {
|
|
|
2825
3225
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
|
|
2826
3226
|
return;
|
|
2827
3227
|
}
|
|
3228
|
+
if (cmd === 'mobile-dev-client') {
|
|
3229
|
+
// Stack-scoped wrapper so the dev-client can be built from the stack's active happy checkout/worktree.
|
|
3230
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile_dev_client.mjs', args: passthrough });
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
if (cmd === 'mobile:install') {
|
|
3234
|
+
const { flags: mFlags, kv: mKv } = parseArgs(passthrough);
|
|
3235
|
+
const device = (mKv.get('--device') ?? '').toString();
|
|
3236
|
+
const name = (mKv.get('--name') ?? mKv.get('--app-name') ?? '').toString().trim();
|
|
3237
|
+
const jsonOut = wantsJson(passthrough, { flags: mFlags }) || json;
|
|
3238
|
+
|
|
3239
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
3240
|
+
const existingRaw = await readExistingEnv(envPath);
|
|
3241
|
+
const existing = parseEnvToObject(existingRaw);
|
|
3242
|
+
|
|
3243
|
+
const priorName =
|
|
3244
|
+
(existing.HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME ?? existing.HAPPY_LOCAL_MOBILE_RELEASE_IOS_APP_NAME ?? '').toString().trim();
|
|
3245
|
+
const identity = defaultStackReleaseIdentity({
|
|
3246
|
+
stackName,
|
|
3247
|
+
user: process.env.USER ?? process.env.USERNAME ?? 'user',
|
|
3248
|
+
appName: name || priorName || null,
|
|
3249
|
+
});
|
|
3250
|
+
|
|
3251
|
+
// Persist the chosen identity so re-installs are stable and user-friendly.
|
|
3252
|
+
await ensureEnvFileUpdated({
|
|
3253
|
+
envPath,
|
|
3254
|
+
updates: [
|
|
3255
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME', value: identity.iosAppName },
|
|
3256
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_BUNDLE_ID', value: identity.iosBundleId },
|
|
3257
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_SCHEME', value: identity.scheme },
|
|
3258
|
+
],
|
|
3259
|
+
});
|
|
3260
|
+
|
|
3261
|
+
// Install a per-stack release-configured app (isolated container) without starting Metro.
|
|
3262
|
+
const args = [
|
|
3263
|
+
`--app-env=production`,
|
|
3264
|
+
`--ios-app-name=${identity.iosAppName}`,
|
|
3265
|
+
`--ios-bundle-id=${identity.iosBundleId}`,
|
|
3266
|
+
`--scheme=${identity.scheme}`,
|
|
3267
|
+
'--prebuild',
|
|
3268
|
+
'--run-ios',
|
|
3269
|
+
'--configuration=Release',
|
|
3270
|
+
'--no-metro',
|
|
3271
|
+
...(device ? [`--device=${device}`] : []),
|
|
3272
|
+
];
|
|
3273
|
+
|
|
3274
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args });
|
|
3275
|
+
|
|
3276
|
+
if (jsonOut) {
|
|
3277
|
+
printResult({
|
|
3278
|
+
json: true,
|
|
3279
|
+
data: { ok: true, stackName, installed: true, identity },
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
2828
3284
|
if (cmd === 'resume') {
|
|
2829
3285
|
const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
|
|
2830
3286
|
if (sessionIds.length === 0) {
|
|
@@ -2841,7 +3297,9 @@ async function main() {
|
|
|
2841
3297
|
const out = await withStackEnv({
|
|
2842
3298
|
stackName,
|
|
2843
3299
|
fn: async ({ env }) => {
|
|
2844
|
-
|
|
3300
|
+
// IMPORTANT: use the stack's pinned happy-cli checkout if set.
|
|
3301
|
+
// Do not read component dirs from this process's `process.env` (withStackEnv does not mutate it).
|
|
3302
|
+
const cliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() || getComponentDir(rootDir, 'happy-cli');
|
|
2845
3303
|
const happyBin = join(cliDir, 'bin', 'happy.mjs');
|
|
2846
3304
|
// Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
|
|
2847
3305
|
return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });
|