happy-stacks 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,106 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join, resolve } from 'node:path';
5
+
6
+ export const REVIEW_PR_MARKER_FILENAME = '.happy-stacks-sandbox-marker';
7
+ export const REVIEW_PR_META_FILENAME = '.happy-stacks-review-pr.json';
8
+
9
+ export function reviewPrSandboxPrefixBase(baseStackName) {
10
+ const base = String(baseStackName ?? '').trim() || 'pr';
11
+ // Keep prefix stable for listing/reuse; mkdtemp adds a random suffix.
12
+ return `happy-stacks-review-pr-${base}-`;
13
+ }
14
+
15
+ export function reviewPrSandboxPrefixPath(baseStackName) {
16
+ return join(tmpdir(), reviewPrSandboxPrefixBase(baseStackName));
17
+ }
18
+
19
+ async function readJsonBestEffort(path) {
20
+ try {
21
+ const raw = await readFile(path, 'utf-8');
22
+ return JSON.parse(raw);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function listReviewPrSandboxes({ baseStackName }) {
29
+ const prefixBase = reviewPrSandboxPrefixBase(baseStackName);
30
+ const root = tmpdir();
31
+ let entries = [];
32
+ try {
33
+ entries = await readdir(root, { withFileTypes: true });
34
+ } catch {
35
+ entries = [];
36
+ }
37
+
38
+ const out = [];
39
+ for (const e of entries) {
40
+ if (!e.isDirectory()) continue;
41
+ if (!e.name.startsWith(prefixBase)) continue;
42
+ const dir = resolve(join(root, e.name));
43
+ const markerPath = join(dir, REVIEW_PR_MARKER_FILENAME);
44
+ if (!existsSync(markerPath)) continue;
45
+
46
+ let markerOk = false;
47
+ try {
48
+ const marker = await readFile(markerPath, 'utf-8');
49
+ markerOk = marker.trim().startsWith('review-pr');
50
+ } catch {
51
+ markerOk = false;
52
+ }
53
+ if (!markerOk) continue;
54
+
55
+ const metaPath = join(dir, REVIEW_PR_META_FILENAME);
56
+ const meta = await readJsonBestEffort(metaPath);
57
+
58
+ let mtimeMs = 0;
59
+ try {
60
+ mtimeMs = (await stat(dir)).mtimeMs;
61
+ } catch {
62
+ mtimeMs = 0;
63
+ }
64
+
65
+ out.push({
66
+ dir,
67
+ name: basename(dir),
68
+ markerPath,
69
+ metaPath,
70
+ baseStackName: String(meta?.baseStackName ?? '').trim() || String(baseStackName ?? '').trim() || null,
71
+ stackName: String(meta?.stackName ?? '').trim() || null,
72
+ createdAtMs: typeof meta?.createdAtMs === 'number' ? meta.createdAtMs : null,
73
+ lastTouchedAtMs: Number.isFinite(mtimeMs) ? mtimeMs : null,
74
+ });
75
+ }
76
+
77
+ out.sort((a, b) => (b.lastTouchedAtMs ?? 0) - (a.lastTouchedAtMs ?? 0));
78
+ return out;
79
+ }
80
+
81
+ export async function writeReviewPrSandboxMeta({ sandboxDir, baseStackName, stackName, argv }) {
82
+ const dir = resolve(String(sandboxDir ?? '').trim());
83
+ const markerPath = join(dir, REVIEW_PR_MARKER_FILENAME);
84
+ const metaPath = join(dir, REVIEW_PR_META_FILENAME);
85
+
86
+ // Marker (for safe deletion) + meta (for reuse menu).
87
+ await writeFile(markerPath, 'review-pr\n', 'utf-8');
88
+ await writeFile(
89
+ metaPath,
90
+ JSON.stringify(
91
+ {
92
+ kind: 'review-pr',
93
+ createdAtMs: Date.now(),
94
+ baseStackName: String(baseStackName ?? '').trim() || null,
95
+ stackName: String(stackName ?? '').trim() || null,
96
+ argv: Array.isArray(argv) ? argv : null,
97
+ },
98
+ null,
99
+ 2
100
+ ) + '\n',
101
+ 'utf-8'
102
+ );
103
+
104
+ return { markerPath, metaPath };
105
+ }
106
+
@@ -0,0 +1,61 @@
1
+ import { pickLanIpv4 } from '../net/lan_ip.mjs';
2
+ import { normalizeUrlNoTrailingSlash } from '../net/url.mjs';
3
+
4
+ function resolveLanIp({ env = process.env } = {}) {
5
+ const raw = (env.HAPPY_STACKS_LAN_IP ?? env.HAPPY_LOCAL_LAN_IP ?? '').toString().trim();
6
+ return raw || pickLanIpv4() || '';
7
+ }
8
+
9
+ function isLocalHostName(hostname) {
10
+ const h = String(hostname ?? '').trim().toLowerCase();
11
+ if (!h) return false;
12
+ if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') return true;
13
+ if (h.endsWith('.localhost')) return true;
14
+ return false;
15
+ }
16
+
17
+ /**
18
+ * For mobile devices, `localhost` and `*.localhost` are not reachable.
19
+ *
20
+ * This helper rewrites any local server URL to a LAN-reachable URL using the machine's LAN IPv4.
21
+ * It preserves protocol, port, and path/query.
22
+ *
23
+ * Notes:
24
+ * - If the URL is already non-local (e.g. Tailscale HTTPS), it is returned unchanged.
25
+ * - If LAN IP cannot be determined, it returns the original URL unchanged.
26
+ */
27
+ export function resolveMobileReachableServerUrl({
28
+ env = process.env,
29
+ serverUrl,
30
+ serverPort,
31
+ } = {}) {
32
+ const raw = String(serverUrl ?? '').trim();
33
+ const fallbackPort = Number(serverPort);
34
+ const fallback = Number.isFinite(fallbackPort) && fallbackPort > 0 ? `http://localhost:${fallbackPort}` : '';
35
+ const base = raw || fallback;
36
+ if (!base) return '';
37
+
38
+ let parsed;
39
+ try {
40
+ parsed = new URL(base);
41
+ } catch {
42
+ return base;
43
+ }
44
+
45
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
46
+ return base;
47
+ }
48
+
49
+ if (!isLocalHostName(parsed.hostname)) {
50
+ return normalizeUrlNoTrailingSlash(parsed.toString());
51
+ }
52
+
53
+ const lanIp = resolveLanIp({ env });
54
+ if (!lanIp) {
55
+ return normalizeUrlNoTrailingSlash(parsed.toString());
56
+ }
57
+
58
+ parsed.hostname = lanIp;
59
+ return normalizeUrlNoTrailingSlash(parsed.toString());
60
+ }
61
+
@@ -0,0 +1,41 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { resolveMobileReachableServerUrl } from './mobile_api_url.mjs';
5
+
6
+ test('resolveMobileReachableServerUrl rewrites localhost to LAN IP (env override)', () => {
7
+ const out = resolveMobileReachableServerUrl({
8
+ env: { HAPPY_STACKS_LAN_IP: '192.168.0.50' },
9
+ serverUrl: 'http://localhost:3005',
10
+ serverPort: 3005,
11
+ });
12
+ assert.equal(out, 'http://192.168.0.50:3005');
13
+ });
14
+
15
+ test('resolveMobileReachableServerUrl rewrites *.localhost to LAN IP (env override)', () => {
16
+ const out = resolveMobileReachableServerUrl({
17
+ env: { HAPPY_STACKS_LAN_IP: '10.0.0.12' },
18
+ serverUrl: 'http://happy-exp1.localhost:3009/',
19
+ serverPort: 3009,
20
+ });
21
+ assert.equal(out, 'http://10.0.0.12:3009');
22
+ });
23
+
24
+ test('resolveMobileReachableServerUrl preserves path and query', () => {
25
+ const out = resolveMobileReachableServerUrl({
26
+ env: { HAPPY_STACKS_LAN_IP: '10.0.0.12' },
27
+ serverUrl: 'http://127.0.0.1:3005/api?x=1',
28
+ serverPort: 3005,
29
+ });
30
+ assert.equal(out, 'http://10.0.0.12:3005/api?x=1');
31
+ });
32
+
33
+ test('resolveMobileReachableServerUrl does not rewrite non-local URLs', () => {
34
+ const out = resolveMobileReachableServerUrl({
35
+ env: { HAPPY_STACKS_LAN_IP: '192.168.0.50' },
36
+ serverUrl: 'https://my-machine.tailnet.ts.net',
37
+ serverPort: 3005,
38
+ });
39
+ assert.equal(out, 'https://my-machine.tailnet.ts.net');
40
+ });
41
+
@@ -1,8 +1,10 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
 
3
3
  import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
4
+ import { preferStackLocalhostUrl } from '../paths/localhost_host.mjs';
4
5
  import { resolvePublicServerUrl } from '../../tailscale.mjs';
5
6
  import { resolveServerPortFromEnv } from './port.mjs';
7
+ import { normalizeUrlNoTrailingSlash } from '../net/url.mjs';
6
8
 
7
9
  function stackEnvExplicitlySetsPublicUrl({ env, stackName }) {
8
10
  try {
@@ -31,14 +33,15 @@ function stackEnvExplicitlySetsWebappUrl({ env, stackName }) {
31
33
  }
32
34
 
33
35
  export function getPublicServerUrlEnvOverride({ env = process.env, serverPort, stackName = null } = {}) {
34
- const defaultPublicUrl = `http://localhost:${serverPort}`;
35
36
  const name =
36
37
  (stackName ?? '').toString().trim() ||
37
38
  (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
38
- getStackName();
39
+ getStackName(env);
40
+ const defaultPublicUrl = `http://localhost:${serverPort}`;
39
41
 
40
42
  let envPublicUrl =
41
43
  (env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
44
+ envPublicUrl = normalizeUrlNoTrailingSlash(envPublicUrl);
42
45
 
43
46
  // Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
44
47
  if (name !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName: name })) {
@@ -52,7 +55,7 @@ export function getWebappUrlEnvOverride({ env = process.env, stackName = null }
52
55
  const name =
53
56
  (stackName ?? '').toString().trim() ||
54
57
  (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
55
- getStackName();
58
+ getStackName(env);
56
59
 
57
60
  let envWebappUrl = (env.HAPPY_WEBAPP_URL ?? '').toString().trim() || '';
58
61
 
@@ -66,18 +69,25 @@ export function getWebappUrlEnvOverride({ env = process.env, stackName = null }
66
69
 
67
70
  export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
68
71
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
72
+ const stackName =
73
+ (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
74
+ getStackName(env);
69
75
  const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
70
76
  const resolved = await resolvePublicServerUrl({
71
77
  internalServerUrl,
72
78
  defaultPublicUrl,
73
79
  envPublicUrl,
74
80
  allowEnable,
81
+ stackName,
75
82
  });
83
+ const publicServerUrl = normalizeUrlNoTrailingSlash(
84
+ await preferStackLocalhostUrl(resolved.publicServerUrl, { stackName })
85
+ );
76
86
  return {
77
87
  internalServerUrl,
78
88
  defaultPublicUrl,
79
89
  envPublicUrl,
80
- publicServerUrl: resolved.publicServerUrl,
90
+ publicServerUrl,
81
91
  publicServerUrlSource: resolved.source,
82
92
  };
83
93
  }
@@ -1,15 +1,54 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { mkdir, rename, writeFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
4
5
 
5
6
  import { runCapture } from '../proc/proc.mjs';
6
7
  import { getDefaultAutostartPaths } from '../paths/paths.mjs';
7
8
  import { resolveInstalledCliRoot, resolveInstalledPath } from '../paths/runtime.mjs';
9
+ import { getCanonicalHomeDir } from '../env/config.mjs';
8
10
 
9
11
  function plistPathForLabel(label) {
10
12
  return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
11
13
  }
12
14
 
15
+ function splitPath(p) {
16
+ return String(p ?? '')
17
+ .split(':')
18
+ .map((s) => s.trim())
19
+ .filter(Boolean);
20
+ }
21
+
22
+ export function buildLaunchdPath({ execPath = process.execPath, basePath = process.env.PATH } = {}) {
23
+ // launchd starts with a minimal environment; ensure common tool paths exist,
24
+ // and include the current Node binary directory so shell shims that exec `node`
25
+ // still work (e.g. nvm-managed installs).
26
+ const nodeDir = execPath ? dirname(execPath) : '';
27
+ const defaults = splitPath('/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin');
28
+ const fromNode = nodeDir ? [nodeDir] : [];
29
+ const fromEnv = splitPath(basePath);
30
+
31
+ const seen = new Set();
32
+ const out = [];
33
+ for (const part of [...fromNode, ...fromEnv, ...defaults]) {
34
+ if (seen.has(part)) continue;
35
+ seen.add(part);
36
+ out.push(part);
37
+ }
38
+ return out.join(':') || '/usr/bin:/bin:/usr/sbin:/sbin';
39
+ }
40
+
41
+ export function pickLaunchdProgramArgs({ rootDir, execPath = process.execPath } = {}) {
42
+ // Prefer the stable shim under the canonical home dir (used by selfhost installs).
43
+ // This keeps the LaunchAgent pointing at a stable path while allowing runtime updates.
44
+ const happysShim = join(getCanonicalHomeDir(), 'bin', 'happys');
45
+ if (existsSync(happysShim)) {
46
+ return [happysShim, 'start'];
47
+ }
48
+ // Fallback: call the Node entry directly (works in repo-only installs).
49
+ return [execPath, resolveInstalledPath(rootDir, 'bin/happys.mjs'), 'start'];
50
+ }
51
+
13
52
  function xmlEscape(s) {
14
53
  return String(s ?? '')
15
54
  .replaceAll('&', '&')
@@ -72,11 +111,12 @@ export async function ensureMacAutostartEnabled({ rootDir, label, env }) {
72
111
  await mkdir(dirname(stdoutPath), { recursive: true }).catch(() => {});
73
112
  await mkdir(dirname(stderrPath), { recursive: true }).catch(() => {});
74
113
 
75
- const programArgs = [process.execPath, resolveInstalledPath(rootDir, 'bin/happys.mjs'), 'start'];
114
+ const programArgs = pickLaunchdProgramArgs({ rootDir, execPath: process.execPath });
76
115
  const mergedEnv = {
77
116
  ...(env ?? {}),
78
117
  // Ensure a reasonable PATH for subprocesses (git/docker/etc) in launchd’s minimal environment.
79
- PATH: (process.env.PATH ?? '').trim() || '/usr/bin:/bin:/usr/sbin:/sbin',
118
+ // Also ensure Node is on PATH for shell shims that exec `node` (common with nvm installs).
119
+ PATH: buildLaunchdPath({ execPath: process.execPath, basePath: process.env.PATH }),
80
120
  };
81
121
 
82
122
  const xml = plistXml({
@@ -0,0 +1,50 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ import { buildLaunchdPath, pickLaunchdProgramArgs } from './autostart_darwin.mjs';
8
+
9
+ test('buildLaunchdPath includes node dir and common tool paths', () => {
10
+ const execPath = '/Users/me/.nvm/versions/node/v22.14.0/bin/node';
11
+ const p = buildLaunchdPath({ execPath, basePath: '' });
12
+
13
+ assert.ok(p.includes('/Users/me/.nvm/versions/node/v22.14.0/bin'), 'includes node dir');
14
+ assert.ok(p.includes('/usr/bin'), 'includes /usr/bin');
15
+ assert.ok(p.includes('/bin'), 'includes /bin');
16
+ });
17
+
18
+ test('pickLaunchdProgramArgs uses stable happys shim when present', async () => {
19
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-home-'));
20
+ const shim = join(dir, 'bin', 'happys');
21
+ await mkdir(join(dir, 'bin'), { recursive: true });
22
+ await writeFile(shim, '#!/bin/sh\necho ok\n', { encoding: 'utf-8' });
23
+
24
+ // Temporarily point canonical home at our temp dir via env var used by getCanonicalHomeDirFromEnv().
25
+ const prev = process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
26
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = dir;
27
+ try {
28
+ const args = pickLaunchdProgramArgs({ rootDir: '/fake/root' });
29
+ assert.deepEqual(args, [shim, 'start']);
30
+ } finally {
31
+ if (prev == null) delete process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
32
+ else process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = prev;
33
+ }
34
+ });
35
+
36
+ test('pickLaunchdProgramArgs falls back to node + happys.mjs when shim missing', () => {
37
+ const prev = process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
38
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = '/definitely-not-a-real-path';
39
+ try {
40
+ const execPath = '/usr/local/bin/node';
41
+ const args = pickLaunchdProgramArgs({ rootDir: '/cli/root', execPath });
42
+ assert.equal(args[0], execPath);
43
+ assert.ok(String(args[1]).endsWith('/bin/happys.mjs'));
44
+ assert.equal(args[2], 'start');
45
+ } finally {
46
+ if (prev == null) delete process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
47
+ else process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = prev;
48
+ }
49
+ });
50
+
@@ -3,12 +3,12 @@ import { getStackRuntimeStatePath } from './runtime_state.mjs';
3
3
 
4
4
  export function resolveStackContext({ env = process.env, autostart = null } = {}) {
5
5
  const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
6
- const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName();
6
+ const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName(env);
7
7
  const stackMode = Boolean(explicitStack);
8
8
 
9
9
  const envPath =
10
10
  (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
- resolveStackEnvPath(stackName).envPath;
11
+ resolveStackEnvPath(stackName, env).envPath;
12
12
 
13
13
  const runtimeStatePath =
14
14
  (env.HAPPY_STACKS_RUNTIME_STATE_PATH ?? env.HAPPY_LOCAL_RUNTIME_STATE_PATH ?? '').toString().trim() ||
@@ -13,7 +13,7 @@ import { getCliHomeDirFromEnvOrDefault } from './dirs.mjs';
13
13
  function resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv }) {
14
14
  const raw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_WORKSPACE_DIR', 'HAPPY_LOCAL_WORKSPACE_DIR']);
15
15
  if (!raw) {
16
- return getWorkspaceDir(rootDir);
16
+ return getWorkspaceDir(rootDir, stackEnv);
17
17
  }
18
18
  const expanded = expandHome(raw);
19
19
  return expanded.startsWith('/') ? expanded : resolve(rootDir, expanded);
@@ -21,7 +21,7 @@ function resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv }) {
21
21
 
22
22
  function resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component }) {
23
23
  const raw = getEnvValueAny(stackEnv, keys);
24
- if (!raw) return getComponentDir(rootDir, component);
24
+ if (!raw) return getComponentDir(rootDir, component, stackEnv);
25
25
  const expanded = expandHome(raw);
26
26
  if (expanded.startsWith('/')) return expanded;
27
27
  const workspaceDir = resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv });
@@ -0,0 +1,16 @@
1
+ import { parseGithubPullRequest } from '../git/refs.mjs';
2
+ import { sanitizeStackName } from './names.mjs';
3
+
4
+ export function inferPrStackBaseName({ happy, happyCli, server, serverLight, fallback = 'pr' }) {
5
+ const parts = [];
6
+ const hn = parseGithubPullRequest(happy)?.number ?? null;
7
+ const cn = parseGithubPullRequest(happyCli)?.number ?? null;
8
+ const sn = parseGithubPullRequest(server)?.number ?? null;
9
+ const sln = parseGithubPullRequest(serverLight)?.number ?? null;
10
+ if (hn) parts.push(`happy${hn}`);
11
+ if (cn) parts.push(`cli${cn}`);
12
+ if (sn) parts.push(`server${sn}`);
13
+ if (sln) parts.push(`light${sln}`);
14
+ return sanitizeStackName(parts.length ? `pr-${parts.join('-')}` : fallback, { fallback, maxLen: 64 });
15
+ }
16
+
@@ -51,7 +51,7 @@ export async function updateStackRuntimeStateFile(statePath, patch) {
51
51
  return next;
52
52
  }
53
53
 
54
- export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports } = {}) {
54
+ export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports, ...rest } = {}) {
55
55
  const now = new Date().toISOString();
56
56
  const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
57
57
  const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
@@ -64,6 +64,7 @@ export async function recordStackRuntimeStart(statePath, { stackName, script, ep
64
64
  ports: ports ?? {},
65
65
  startedAt,
66
66
  updatedAt: now,
67
+ ...(rest ?? {}),
67
68
  });
68
69
  await writeStackRuntimeStateFile(statePath, next);
69
70
  return next;
@@ -1,5 +1,6 @@
1
1
  import { runCapture } from '../proc/proc.mjs';
2
2
  import { ensureDepsInstalled, pmExecBin } from '../proc/pm.mjs';
3
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
3
4
  import { existsSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
 
@@ -47,6 +48,12 @@ async function probeAccountCount({ serverDir, env }) {
47
48
  }
48
49
 
49
50
  export function resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive }) {
51
+ // Sandboxes should be isolated by default.
52
+ // Auto auth seeding can copy credentials/account rows from another stack (global state),
53
+ // which breaks isolation and can confuse guided auth flows (setup-pr/review-pr).
54
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
55
+ return false;
56
+ }
50
57
  const raw = (env.HAPPY_STACKS_AUTO_AUTH_SEED ?? env.HAPPY_LOCAL_AUTO_AUTH_SEED ?? '').toString().trim();
51
58
  if (raw) return raw !== '0';
52
59
 
@@ -126,6 +126,7 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
126
126
  daemonSessionsStopped: null,
127
127
  daemonStopped: false,
128
128
  killedPorts: [],
129
+ expoDev: [],
129
130
  uiDev: [],
130
131
  mobile: [],
131
132
  infra: null,
@@ -136,7 +137,13 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
136
137
  const port = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
137
138
  const backendPort = coercePort(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
138
139
  const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
139
- const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
140
+ // IMPORTANT:
141
+ // When stopping a stack, always prefer the stack's pinned happy-cli checkout/worktree.
142
+ // Otherwise, PR stacks can accidentally run the base checkout's CLI bin, which may not be built
143
+ // (we intentionally skip building base checkouts in some sandbox PR flows).
144
+ const pinnedCliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim();
145
+ const cliDir = pinnedCliDir || getComponentDir(rootDir, 'happy-cli');
146
+ const cliBin = join(cliDir, 'bin', 'happy.mjs');
140
147
  const envPath = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
141
148
 
142
149
  // Preferred: stop stack-started processes (by PID) recorded in stack.runtime.json.
@@ -194,12 +201,16 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
194
201
  }
195
202
 
196
203
  try {
197
- actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
204
+ actions.expoDev = await stopExpoStateDir({ stackName, baseDir, kind: 'expo-dev', stateFileName: 'expo.state.json', envPath, json });
198
205
  } catch (e) {
199
- actions.errors.push({ step: 'expo-ui', error: e instanceof Error ? e.message : String(e) });
206
+ actions.errors.push({ step: 'expo-dev', error: e instanceof Error ? e.message : String(e) });
200
207
  }
201
208
  try {
202
- actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
209
+ // Legacy cleanups (best-effort): older runs used separate state dirs.
210
+ actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
211
+ const killedDev = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile-dev', stateFileName: 'mobile.state.json', envPath, json });
212
+ const killedLegacy = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
213
+ actions.mobile = [...killedDev, ...killedLegacy];
203
214
  } catch (e) {
204
215
  actions.errors.push({ step: 'expo-mobile', error: e instanceof Error ? e.message : String(e) });
205
216
  }
@@ -0,0 +1,23 @@
1
+ import { getStackName, resolveStackEnvPath } from './paths/paths.mjs';
2
+ import { getStackRuntimeStatePath } from './stack_runtime_state.mjs';
3
+
4
+ export function resolveStackContext({ env = process.env, autostart = null } = {}) {
5
+ const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
6
+ const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName(env);
7
+ const stackMode = Boolean(explicitStack);
8
+
9
+ const envPath =
10
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
+ resolveStackEnvPath(stackName, env).envPath;
12
+
13
+ const runtimeStatePath =
14
+ (env.HAPPY_STACKS_RUNTIME_STATE_PATH ?? env.HAPPY_LOCAL_RUNTIME_STATE_PATH ?? '').toString().trim() ||
15
+ getStackRuntimeStatePath(stackName);
16
+
17
+ const explicitEphemeral =
18
+ (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
19
+ const ephemeral = explicitEphemeral || (stackMode && stackName !== 'main');
20
+
21
+ return { stackMode, stackName, envPath, runtimeStatePath, ephemeral };
22
+ }
23
+
@@ -0,0 +1,104 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import { resolveStackEnvPath } from './paths/paths.mjs';
6
+
7
+ export function getStackRuntimeStatePath(stackName) {
8
+ const { baseDir } = resolveStackEnvPath(stackName);
9
+ return join(baseDir, 'stack.runtime.json');
10
+ }
11
+
12
+ export function isPidAlive(pid) {
13
+ const n = Number(pid);
14
+ if (!Number.isFinite(n) || n <= 1) return false;
15
+ try {
16
+ process.kill(n, 0);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export async function readStackRuntimeStateFile(statePath) {
24
+ try {
25
+ if (!statePath || !existsSync(statePath)) return null;
26
+ const raw = await readFile(statePath, 'utf-8');
27
+ const parsed = JSON.parse(raw);
28
+ return parsed && typeof parsed === 'object' ? parsed : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export async function writeStackRuntimeStateFile(statePath, state) {
35
+ if (!statePath) {
36
+ throw new Error('[stack] missing runtime state path');
37
+ }
38
+ const dir = dirname(statePath);
39
+ await mkdir(dir, { recursive: true }).catch(() => {});
40
+ const tmp = join(dir, `.stack.runtime.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
41
+ await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
42
+ await rename(tmp, statePath);
43
+ }
44
+
45
+ function isPlainObject(v) {
46
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
47
+ }
48
+
49
+ function deepMerge(a, b) {
50
+ if (!isPlainObject(a) || !isPlainObject(b)) {
51
+ return b;
52
+ }
53
+ const out = { ...a };
54
+ for (const [k, v] of Object.entries(b)) {
55
+ if (isPlainObject(out[k]) && isPlainObject(v)) {
56
+ out[k] = deepMerge(out[k], v);
57
+ } else {
58
+ out[k] = v;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export async function updateStackRuntimeStateFile(statePath, patch) {
65
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
66
+ const next = deepMerge(existing, patch ?? {});
67
+ await writeStackRuntimeStateFile(statePath, next);
68
+ return next;
69
+ }
70
+
71
+ export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports } = {}) {
72
+ const now = new Date().toISOString();
73
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
74
+ const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
75
+ const next = deepMerge(existing, {
76
+ version: 1,
77
+ stackName,
78
+ script,
79
+ ephemeral: Boolean(ephemeral),
80
+ ownerPid,
81
+ ports: ports ?? {},
82
+ startedAt,
83
+ updatedAt: now,
84
+ });
85
+ await writeStackRuntimeStateFile(statePath, next);
86
+ return next;
87
+ }
88
+
89
+ export async function recordStackRuntimeUpdate(statePath, patch = {}) {
90
+ return await updateStackRuntimeStateFile(statePath, {
91
+ ...(patch ?? {}),
92
+ updatedAt: new Date().toISOString(),
93
+ });
94
+ }
95
+
96
+ export async function deleteStackRuntimeStateFile(statePath) {
97
+ try {
98
+ if (!statePath || !existsSync(statePath)) return;
99
+ await unlink(statePath);
100
+ } catch {
101
+ // ignore
102
+ }
103
+ }
104
+