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,94 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { parseDotenv } from './env/dotenv.mjs';
7
+ import { resolveStackEnvPath } from './paths/paths.mjs';
8
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './auth_sources.mjs';
9
+
10
+ async function readTextIfExists(path) {
11
+ try {
12
+ if (!path || !existsSync(path)) return null;
13
+ const raw = await readFile(path, 'utf-8');
14
+ const t = raw.trim();
15
+ return t ? t : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function parseEnvToObject(raw) {
22
+ const parsed = parseDotenv(raw ?? '');
23
+ return Object.fromEntries(parsed.entries());
24
+ }
25
+
26
+ function getEnvValue(env, key) {
27
+ const v = (env?.[key] ?? '').toString().trim();
28
+ return v || '';
29
+ }
30
+
31
+ function stackExistsSync(stackName) {
32
+ if (stackName === 'main') return true;
33
+ const envPath = resolveStackEnvPath(stackName).envPath;
34
+ return existsSync(envPath);
35
+ }
36
+
37
+ export async function resolveHandyMasterSecretFromStack({
38
+ stackName,
39
+ requireStackExists = false,
40
+ allowLegacyAuthSource = true,
41
+ allowLegacyMainFallback = true,
42
+ } = {}) {
43
+ const name = String(stackName ?? '').trim() || 'main';
44
+
45
+ if (isLegacyAuthSourceName(name)) {
46
+ if (!allowLegacyAuthSource) {
47
+ throw new Error(
48
+ '[auth] legacy auth source is disabled in sandbox mode.\n' +
49
+ 'Reason: it reads from ~/.happy (global user state).\n' +
50
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
51
+ );
52
+ }
53
+ const baseDir = getLegacyHappyBaseDir();
54
+ const legacySecretPath = join(baseDir, 'server-light', 'handy-master-secret.txt');
55
+ const secret = await readTextIfExists(legacySecretPath);
56
+ return secret ? { secret, source: legacySecretPath } : { secret: null, source: null };
57
+ }
58
+
59
+ if (requireStackExists && !stackExistsSync(name)) {
60
+ throw new Error(`[auth] cannot copy auth: source stack "${name}" does not exist`);
61
+ }
62
+
63
+ const resolved = resolveStackEnvPath(name);
64
+ const sourceBaseDir = resolved.baseDir;
65
+ const sourceEnvPath = resolved.envPath;
66
+ const raw = await readTextIfExists(sourceEnvPath);
67
+ const env = raw ? parseEnvToObject(raw) : {};
68
+
69
+ const inline = getEnvValue(env, 'HANDY_MASTER_SECRET');
70
+ if (inline) {
71
+ return { secret: inline, source: `${sourceEnvPath} (HANDY_MASTER_SECRET)` };
72
+ }
73
+
74
+ const secretFile = getEnvValue(env, 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE');
75
+ if (secretFile) {
76
+ const secret = await readTextIfExists(secretFile);
77
+ if (secret) return { secret, source: secretFile };
78
+ }
79
+
80
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR') || join(sourceBaseDir, 'server-light');
81
+ const secretPath = join(dataDir, 'handy-master-secret.txt');
82
+ const secret = await readTextIfExists(secretPath);
83
+ if (secret) return { secret, source: secretPath };
84
+
85
+ // Last-resort legacy: if main has never been migrated to stack dirs.
86
+ if (name === 'main' && allowLegacyMainFallback) {
87
+ const legacy = join(homedir(), '.happy', 'server-light', 'handy-master-secret.txt');
88
+ const legacySecret = await readTextIfExists(legacy);
89
+ if (legacySecret) return { secret: legacySecret, source: legacy };
90
+ }
91
+
92
+ return { secret: null, source: null };
93
+ }
94
+
@@ -0,0 +1,31 @@
1
+ import { sanitizeBundleIdSegment, sanitizeUrlScheme } from './identifiers.mjs';
2
+
3
+ export function resolveMobileExpoConfig({ env = process.env } = {}) {
4
+ const user = sanitizeBundleIdSegment(env.USER ?? env.USERNAME ?? 'user');
5
+ const defaultLocalBundleId = `com.happy.local.${user}.dev`;
6
+
7
+ const appEnv = env.APP_ENV ?? env.HAPPY_STACKS_APP_ENV ?? env.HAPPY_LOCAL_APP_ENV ?? 'development';
8
+ const iosAppName = (env.HAPPY_STACKS_IOS_APP_NAME ?? env.HAPPY_LOCAL_IOS_APP_NAME ?? '').toString();
9
+ const iosBundleId = (env.HAPPY_STACKS_IOS_BUNDLE_ID ?? env.HAPPY_LOCAL_IOS_BUNDLE_ID ?? defaultLocalBundleId).toString();
10
+ // Happy Stacks convention:
11
+ // - dev-client QR should open a dedicated "Happy Stacks Dev" app (not a per-stack release build)
12
+ // - so default to a stable happy-stacks-specific scheme unless explicitly overridden.
13
+ const scheme = sanitizeUrlScheme(
14
+ (env.HAPPY_STACKS_MOBILE_SCHEME ??
15
+ env.HAPPY_LOCAL_MOBILE_SCHEME ??
16
+ env.HAPPY_STACKS_DEV_CLIENT_SCHEME ??
17
+ env.HAPPY_LOCAL_DEV_CLIENT_SCHEME ??
18
+ 'happystacks-dev')
19
+ .toString()
20
+ );
21
+ const host = (env.HAPPY_STACKS_MOBILE_HOST ?? env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan').toString();
22
+
23
+ return {
24
+ appEnv,
25
+ iosAppName,
26
+ iosBundleId,
27
+ scheme,
28
+ host,
29
+ };
30
+ }
31
+
@@ -0,0 +1,60 @@
1
+ import { getEnvValueAny } from '../env/values.mjs';
2
+ import { pickLanIpv4 } from '../net/lan_ip.mjs';
3
+ import { resolveMobileExpoConfig } from './config.mjs';
4
+
5
+ function normalizeHostMode(raw) {
6
+ const v = String(raw ?? '').trim().toLowerCase();
7
+ if (v === 'localhost' || v === 'local') return 'localhost';
8
+ if (v === 'lan' || v === 'ip') return 'lan';
9
+ if (v === 'tunnel') return 'tunnel';
10
+ return v || 'lan';
11
+ }
12
+
13
+ export function resolveMobileHostMode(env = process.env) {
14
+ // Prefer explicit host vars (so TUI/setup-pr match the same knobs Expo uses).
15
+ const raw =
16
+ getEnvValueAny(env, ['HAPPY_STACKS_MOBILE_HOST', 'HAPPY_LOCAL_MOBILE_HOST']) ||
17
+ resolveMobileExpoConfig({ env }).host ||
18
+ 'lan';
19
+ return normalizeHostMode(raw);
20
+ }
21
+
22
+ export function resolveMobileScheme(env = process.env) {
23
+ return String(resolveMobileExpoConfig({ env }).scheme || '').trim();
24
+ }
25
+
26
+ export function resolveMetroUrlForMobile({ env = process.env, port }) {
27
+ const p = Number(port);
28
+ if (!Number.isFinite(p) || p <= 0) return '';
29
+
30
+ const mode = resolveMobileHostMode(env);
31
+ if (mode === 'localhost') {
32
+ return `http://localhost:${p}`;
33
+ }
34
+ if (mode === 'lan') {
35
+ const ip = pickLanIpv4();
36
+ return `http://${ip || 'localhost'}:${p}`;
37
+ }
38
+ // Tunnel URLs are controlled by Expo; we can't reliably derive them locally.
39
+ // Fall back to localhost so the URL is at least correct for the host machine.
40
+ return `http://localhost:${p}`;
41
+ }
42
+
43
+ export function resolveDevClientDeepLink({ scheme, metroUrl }) {
44
+ const s = String(scheme ?? '').trim();
45
+ const url = String(metroUrl ?? '').trim();
46
+ if (!url) return '';
47
+ if (!s) return url;
48
+ return `${s}://expo-development-client/?url=${encodeURIComponent(url)}`;
49
+ }
50
+
51
+ export function resolveMobileQrPayload({ env = process.env, port }) {
52
+ const metroUrl = resolveMetroUrlForMobile({ env, port });
53
+ const scheme = resolveMobileScheme(env);
54
+ const deepLink = resolveDevClientDeepLink({ scheme, metroUrl });
55
+ // Match Expo CLI / @expo/cli UrlCreator: QR encodes the dev-client deep link.
56
+ // Note: iOS Camera will still offer to open custom schemes when the app is installed.
57
+ const payload = deepLink || metroUrl;
58
+ return { scheme, metroUrl, deepLink, payload };
59
+ }
60
+
@@ -0,0 +1,47 @@
1
+ function sanitizeToken(raw, { allowDots = false } = {}) {
2
+ const s = (raw ?? '').toString().trim().toLowerCase();
3
+ const re = allowDots ? /[^a-z0-9.-]+/g : /[^a-z0-9-]+/g;
4
+ const out = s.replace(re, '-').replace(/^-+|-+$/g, '').replace(/-+/g, '-');
5
+ return out;
6
+ }
7
+
8
+ export function sanitizeBundleIdSegment(s) {
9
+ const seg = sanitizeToken(s, { allowDots: false });
10
+ if (!seg) return 'app';
11
+ // Bundle id segments should not start with a digit; prefix if needed.
12
+ return /^[a-z]/.test(seg) ? seg : `s${seg}`;
13
+ }
14
+
15
+ export function sanitizeUrlScheme(s) {
16
+ // iOS URL schemes must start with a letter and may contain letters/digits/+.-.
17
+ const raw = (s ?? '').toString().trim().toLowerCase();
18
+ const cleaned = raw.replace(/[^a-z0-9+.-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
19
+ if (!cleaned) return 'happystacks-dev';
20
+ return /^[a-z]/.test(cleaned) ? cleaned : `h${cleaned}`;
21
+ }
22
+
23
+ export function stackSlugForMobileIds(stackName) {
24
+ const raw = (stackName ?? '').toString().trim();
25
+ return sanitizeBundleIdSegment(raw || 'stack');
26
+ }
27
+
28
+ export function defaultDevClientIdentity({ user = null } = {}) {
29
+ const u = sanitizeBundleIdSegment(user ?? 'user');
30
+ return {
31
+ iosAppName: 'Happy Stacks Dev',
32
+ iosBundleId: `com.happystacks.dev.${u}`,
33
+ scheme: 'happystacks-dev',
34
+ };
35
+ }
36
+
37
+ export function defaultStackReleaseIdentity({ stackName, user = null, appName = null } = {}) {
38
+ const slug = stackSlugForMobileIds(stackName);
39
+ const u = sanitizeBundleIdSegment(user ?? 'user');
40
+ const label = (appName ?? '').toString().trim();
41
+ return {
42
+ iosAppName: label || `Happy (${stackName})`,
43
+ iosBundleId: `com.happystacks.stack.${u}.${slug}`,
44
+ scheme: sanitizeUrlScheme(`happystacks-${slug}`),
45
+ };
46
+ }
47
+
@@ -0,0 +1,42 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ defaultDevClientIdentity,
6
+ defaultStackReleaseIdentity,
7
+ sanitizeBundleIdSegment,
8
+ sanitizeUrlScheme,
9
+ stackSlugForMobileIds,
10
+ } from './identifiers.mjs';
11
+
12
+ test('sanitizeBundleIdSegment produces a safe segment', () => {
13
+ assert.equal(sanitizeBundleIdSegment(' PR272-107 '), 'pr272-107');
14
+ assert.equal(sanitizeBundleIdSegment('---'), 'app');
15
+ assert.equal(sanitizeBundleIdSegment('123'), 's123');
16
+ });
17
+
18
+ test('sanitizeUrlScheme produces a safe scheme', () => {
19
+ assert.equal(sanitizeUrlScheme('HappyStacks-Dev'), 'happystacks-dev');
20
+ assert.equal(sanitizeUrlScheme('123bad'), 'h123bad');
21
+ assert.equal(sanitizeUrlScheme(''), 'happystacks-dev');
22
+ });
23
+
24
+ test('stackSlugForMobileIds derives a stable slug', () => {
25
+ assert.equal(stackSlugForMobileIds('pr272-107-fixes-2026-01-15'), 'pr272-107-fixes-2026-01-15');
26
+ assert.equal(stackSlugForMobileIds(' Weird Name '), 'weird-name');
27
+ });
28
+
29
+ test('defaultDevClientIdentity is stable and safe', () => {
30
+ const id = defaultDevClientIdentity({ user: 'Leeroy' });
31
+ assert.equal(id.iosAppName, 'Happy Stacks Dev');
32
+ assert.equal(id.scheme, 'happystacks-dev');
33
+ assert.equal(id.iosBundleId, 'com.happystacks.dev.leeroy');
34
+ });
35
+
36
+ test('defaultStackReleaseIdentity is per-stack', () => {
37
+ const id = defaultStackReleaseIdentity({ stackName: 'pr272-107', user: 'Leeroy' });
38
+ assert.equal(id.iosBundleId, 'com.happystacks.stack.leeroy.pr272-107');
39
+ assert.equal(id.scheme, 'happystacks-pr272-107');
40
+ assert.equal(id.iosAppName, 'Happy (pr272-107)');
41
+ });
42
+
@@ -0,0 +1,128 @@
1
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { pathExists } from '../fs/fs.mjs';
5
+
6
+ function sanitizeXcodeProductName(name) {
7
+ const raw = (name ?? '').toString().trim();
8
+ const out = raw
9
+ .replace(/[^A-Za-z0-9_-]+/g, '-')
10
+ .replace(/-+/g, '-')
11
+ .replace(/^[-_]+|[-_]+$/g, '');
12
+ return out || 'Happy';
13
+ }
14
+
15
+ async function listIosAppXcodeprojNames({ iosDir }) {
16
+ let entries = [];
17
+ try {
18
+ entries = await readdir(iosDir, { withFileTypes: true });
19
+ } catch {
20
+ return [];
21
+ }
22
+
23
+ const names = entries
24
+ .filter((e) => e.isDirectory() && e.name.endsWith('.xcodeproj') && e.name.startsWith('Happy'))
25
+ .map((e) => e.name);
26
+
27
+ // Prefer the common names first to keep behavior stable if multiple projects exist.
28
+ const score = (name) => {
29
+ if (name === 'Happydev.xcodeproj') return 0;
30
+ if (name === 'Happy.xcodeproj') return 1;
31
+ return 2;
32
+ };
33
+ names.sort((a, b) => score(a) - score(b) || a.localeCompare(b));
34
+ return names;
35
+ }
36
+
37
+ export async function resolveIosAppXcodeProjects({ uiDir }) {
38
+ const iosDir = join(uiDir, 'ios');
39
+ const projectNames = await listIosAppXcodeprojNames({ iosDir });
40
+
41
+ const projects = [];
42
+ for (const projectName of projectNames) {
43
+ const pbxprojPath = join(iosDir, projectName, 'project.pbxproj');
44
+ if (!(await pathExists(pbxprojPath))) {
45
+ continue;
46
+ }
47
+
48
+ const appDirName = projectName.replace(/\.xcodeproj$/, '');
49
+ const infoPlistPath = join(iosDir, appDirName, 'Info.plist');
50
+
51
+ projects.push({
52
+ name: appDirName,
53
+ pbxprojPath,
54
+ infoPlistPath: (await pathExists(infoPlistPath)) ? infoPlistPath : null,
55
+ });
56
+ }
57
+
58
+ return projects;
59
+ }
60
+
61
+ export async function patchIosXcodeProjectsForSigningAndIdentity({
62
+ uiDir,
63
+ iosBundleId,
64
+ iosAppName = '',
65
+ } = {}) {
66
+ const bundleId = (iosBundleId ?? '').toString().trim();
67
+ const appName = (iosAppName ?? '').toString().trim();
68
+ const productName = sanitizeXcodeProductName(appName);
69
+
70
+ if (!uiDir || !bundleId) {
71
+ return;
72
+ }
73
+
74
+ const projects = await resolveIosAppXcodeProjects({ uiDir });
75
+ if (projects.length === 0) {
76
+ return;
77
+ }
78
+
79
+ for (const project of projects) {
80
+ // Patch pbxproj: clear pinned signing fields so Expo can reconfigure and include provisioning update flags,
81
+ // and force a per-stack bundle id + optional PRODUCT_NAME.
82
+ try {
83
+ const raw = await readFile(project.pbxprojPath, 'utf-8');
84
+ let next = raw;
85
+
86
+ // Clear team identifiers (both TargetAttributes and build settings variants).
87
+ next = next.replaceAll(/^\s*DevelopmentTeam\s*=\s*[^;]+;\s*$/gm, '');
88
+ next = next.replaceAll(/^\s*DEVELOPMENT_TEAM\s*=\s*[^;]+;\s*$/gm, '');
89
+ // Clear any pinned provisioning profiles/specifiers (manual signing).
90
+ next = next.replaceAll(/^\s*PROVISIONING_PROFILE\s*=\s*[^;]+;\s*$/gm, '');
91
+ next = next.replaceAll(/^\s*PROVISIONING_PROFILE_SPECIFIER\s*=\s*[^;]+;\s*$/gm, '');
92
+ // Some projects pin code signing identity; remove to let Xcode resolve based on the selected team.
93
+ next = next.replaceAll(/^\s*CODE_SIGN_IDENTITY\s*=\s*[^;]+;\s*$/gm, '');
94
+ next = next.replaceAll(/^\s*"CODE_SIGN_IDENTITY\\[sdk=iphoneos\\*\\]"\s*=\s*[^;]+;\s*$/gm, '');
95
+
96
+ next = next.replaceAll(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${bundleId};`);
97
+
98
+ if (appName) {
99
+ // Expo CLI appears to treat some escaped build paths as literal (e.g. "Happy\\ (stack).app"),
100
+ // so keep PRODUCT_NAME free of spaces to avoid breaking post-build Info.plist parsing.
101
+ next = next.replaceAll(/PRODUCT_NAME = [^;]+;/g, `PRODUCT_NAME = ${productName};`);
102
+ }
103
+
104
+ if (next !== raw) {
105
+ await writeFile(project.pbxprojPath, next, 'utf-8');
106
+ }
107
+ } catch {
108
+ // ignore project patch errors; Expo will surface actionable failures if needed
109
+ }
110
+
111
+ // Patch Info.plist display name when possible (home screen label).
112
+ if (appName && project.infoPlistPath) {
113
+ try {
114
+ const plistRaw = await readFile(project.infoPlistPath, 'utf-8');
115
+ const escaped = appName.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
116
+ const replaced = plistRaw.replace(
117
+ /(<key>CFBundleDisplayName<\/key>\s*<string>)([\s\S]*?)(<\/string>)/m,
118
+ `$1${escaped}$3`
119
+ );
120
+ if (replaced !== plistRaw) {
121
+ await writeFile(project.infoPlistPath, replaced, 'utf-8');
122
+ }
123
+ } catch {
124
+ // ignore
125
+ }
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,98 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { patchIosXcodeProjectsForSigningAndIdentity } from './ios_xcodeproj_patch.mjs';
8
+
9
+ async function makeTempUiDir() {
10
+ return await mkdtemp(join(tmpdir(), 'happy-stacks-mobile-'));
11
+ }
12
+
13
+ test('patchIosXcodeProjectsForSigningAndIdentity patches legacy ios/Happy.xcodeproj + ios/Happy/Info.plist', async () => {
14
+ const uiDir = await makeTempUiDir();
15
+ try {
16
+ const iosDir = join(uiDir, 'ios');
17
+ await mkdir(join(iosDir, 'Happy.xcodeproj'), { recursive: true });
18
+ await mkdir(join(iosDir, 'Happy'), { recursive: true });
19
+
20
+ const pbxprojPath = join(iosDir, 'Happy.xcodeproj', 'project.pbxproj');
21
+ await writeFile(
22
+ pbxprojPath,
23
+ [
24
+ 'ProvisioningStyle = Automatic;',
25
+ 'DEVELOPMENT_TEAM = 3RSYVV66F6;',
26
+ 'CODE_SIGN_IDENTITY = "Apple Development";',
27
+ '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";',
28
+ 'PROVISIONING_PROFILE_SPECIFIER = some-profile;',
29
+ 'PRODUCT_BUNDLE_IDENTIFIER = com.ex3ndr.happy;',
30
+ 'PRODUCT_NAME = Happy;',
31
+ '',
32
+ ].join('\n'),
33
+ 'utf-8'
34
+ );
35
+
36
+ const infoPlistPath = join(iosDir, 'Happy', 'Info.plist');
37
+ await writeFile(
38
+ infoPlistPath,
39
+ [
40
+ '<?xml version="1.0" encoding="UTF-8"?>',
41
+ '<plist version="1.0"><dict>',
42
+ '<key>CFBundleDisplayName</key><string>Happy</string>',
43
+ '</dict></plist>',
44
+ '',
45
+ ].join('\n'),
46
+ 'utf-8'
47
+ );
48
+
49
+ await patchIosXcodeProjectsForSigningAndIdentity({
50
+ uiDir,
51
+ iosBundleId: 'com.happystacks.stack.user.pre-pr272',
52
+ iosAppName: 'HAPPY LEGACY',
53
+ });
54
+
55
+ const pbxproj = await readFile(pbxprojPath, 'utf-8');
56
+ assert.match(pbxproj, /PRODUCT_BUNDLE_IDENTIFIER = com\.happystacks\.stack\.user\.pre-pr272;/);
57
+ assert.ok(!pbxproj.includes('DEVELOPMENT_TEAM ='));
58
+ assert.ok(!pbxproj.includes('PROVISIONING_PROFILE_SPECIFIER ='));
59
+ assert.ok(!pbxproj.includes('CODE_SIGN_IDENTITY ='));
60
+ assert.match(pbxproj, /PRODUCT_NAME = HAPPY-LEGACY;/);
61
+
62
+ const plist = await readFile(infoPlistPath, 'utf-8');
63
+ assert.match(plist, /<key>CFBundleDisplayName<\/key><string>HAPPY LEGACY<\/string>/);
64
+ } finally {
65
+ await rm(uiDir, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test('patchIosXcodeProjectsForSigningAndIdentity patches both Happydev + Happy projects when present', async () => {
70
+ const uiDir = await makeTempUiDir();
71
+ try {
72
+ const iosDir = join(uiDir, 'ios');
73
+
74
+ await mkdir(join(iosDir, 'Happy.xcodeproj'), { recursive: true });
75
+ await mkdir(join(iosDir, 'Happy'), { recursive: true });
76
+ await writeFile(join(iosDir, 'Happy.xcodeproj', 'project.pbxproj'), 'PRODUCT_BUNDLE_IDENTIFIER = com.ex3ndr.happy;\n', 'utf-8');
77
+ await writeFile(join(iosDir, 'Happy', 'Info.plist'), '<key>CFBundleDisplayName</key><string>Happy</string>\n', 'utf-8');
78
+
79
+ await mkdir(join(iosDir, 'Happydev.xcodeproj'), { recursive: true });
80
+ await mkdir(join(iosDir, 'Happydev'), { recursive: true });
81
+ await writeFile(join(iosDir, 'Happydev.xcodeproj', 'project.pbxproj'), 'PRODUCT_BUNDLE_IDENTIFIER = com.slopus.happy.dev;\n', 'utf-8');
82
+ await writeFile(join(iosDir, 'Happydev', 'Info.plist'), '<key>CFBundleDisplayName</key><string>Happy (dev)</string>\n', 'utf-8');
83
+
84
+ await patchIosXcodeProjectsForSigningAndIdentity({
85
+ uiDir,
86
+ iosBundleId: 'com.happystacks.stack.user.pre-pr272',
87
+ iosAppName: 'HAPPY LEGACY',
88
+ });
89
+
90
+ const pbxprojRelease = await readFile(join(iosDir, 'Happy.xcodeproj', 'project.pbxproj'), 'utf-8');
91
+ assert.match(pbxprojRelease, /PRODUCT_BUNDLE_IDENTIFIER = com\.happystacks\.stack\.user\.pre-pr272;/);
92
+
93
+ const pbxprojDev = await readFile(join(iosDir, 'Happydev.xcodeproj', 'project.pbxproj'), 'utf-8');
94
+ assert.match(pbxprojDev, /PRODUCT_BUNDLE_IDENTIFIER = com\.happystacks\.stack\.user\.pre-pr272;/);
95
+ } finally {
96
+ await rm(uiDir, { recursive: true, force: true });
97
+ }
98
+ });
@@ -0,0 +1,24 @@
1
+ import { networkInterfaces } from 'node:os';
2
+
3
+ export function pickLanIpv4() {
4
+ try {
5
+ const ifaces = networkInterfaces();
6
+ // Prefer en0 (typical Wi-Fi on macOS), then any non-internal IPv4.
7
+ const preferred = ['en0', 'en1', 'eth0', 'wlan0'];
8
+ for (const name of preferred) {
9
+ const list = ifaces[name] ?? [];
10
+ for (const i of list) {
11
+ if (i && i.family === 'IPv4' && !i.internal && i.address) return i.address;
12
+ }
13
+ }
14
+ for (const list of Object.values(ifaces)) {
15
+ for (const i of list ?? []) {
16
+ if (i && i.family === 'IPv4' && !i.internal && i.address) return i.address;
17
+ }
18
+ }
19
+ } catch {
20
+ // ignore
21
+ }
22
+ return '';
23
+ }
24
+
@@ -2,7 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises';
2
2
  import net from 'node:net';
3
3
  import { runCaptureIfCommandExists } from '../proc/commands.mjs';
4
4
 
5
- async function listListenPids(port) {
5
+ export async function listListenPids(port) {
6
6
  if (!Number.isFinite(port) || port <= 0) return [];
7
7
  if (process.platform === 'win32') return [];
8
8
 
@@ -10,6 +10,14 @@ async function listListenPids(port) {
10
10
  try {
11
11
  // `lsof` exits non-zero if no matches; normalize to empty output.
12
12
  raw = await runCaptureIfCommandExists('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
13
+ if (!raw && process.platform === 'darwin') {
14
+ // Some non-interactive shells (launchd/GUI apps) have a PATH that omits /usr/sbin,
15
+ // which makes `command -v lsof` fail even though lsof exists. Fall back to absolute paths.
16
+ raw =
17
+ (await runCaptureIfCommandExists('/usr/sbin/lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'])) ||
18
+ (await runCaptureIfCommandExists('/usr/bin/lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'])) ||
19
+ '';
20
+ }
13
21
  } catch {
14
22
  raw = '';
15
23
  }
@@ -0,0 +1,30 @@
1
+ export function normalizeUrlNoTrailingSlash(raw) {
2
+ const s = String(raw ?? '').trim();
3
+ if (!s) return '';
4
+
5
+ let u;
6
+ try {
7
+ u = new URL(s);
8
+ } catch {
9
+ // Best-effort: if it's a plain string with trailing slash, trim it.
10
+ return s.endsWith('/') ? s.replace(/\/+$/, '') : s;
11
+ }
12
+
13
+ // Only normalize "base" URLs without search/hash.
14
+ // If search/hash is present, removing slashes can change semantics.
15
+ if (u.search || u.hash) {
16
+ return u.toString();
17
+ }
18
+
19
+ // Normalize multiple trailing slashes down to none (root) or one-less (non-root).
20
+ const path = u.pathname || '/';
21
+ if (path === '/' || path === '') {
22
+ return u.origin;
23
+ }
24
+ if (path.endsWith('/')) {
25
+ const nextPath = path.replace(/\/+$/, '');
26
+ return `${u.origin}${nextPath}`;
27
+ }
28
+ return u.toString();
29
+ }
30
+
@@ -0,0 +1,20 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { normalizeUrlNoTrailingSlash } from './url.mjs';
5
+
6
+ test('normalizeUrlNoTrailingSlash removes trailing slash from origins', () => {
7
+ assert.equal(normalizeUrlNoTrailingSlash('https://example.com/'), 'https://example.com');
8
+ assert.equal(normalizeUrlNoTrailingSlash('http://localhost:3005/'), 'http://localhost:3005');
9
+ });
10
+
11
+ test('normalizeUrlNoTrailingSlash removes trailing slash from path-only base URLs', () => {
12
+ assert.equal(normalizeUrlNoTrailingSlash('https://example.com/api/'), 'https://example.com/api');
13
+ assert.equal(normalizeUrlNoTrailingSlash('https://example.com/api///'), 'https://example.com/api');
14
+ });
15
+
16
+ test('normalizeUrlNoTrailingSlash preserves query/hash URLs', () => {
17
+ assert.equal(normalizeUrlNoTrailingSlash('https://example.com/?q=1'), 'https://example.com/?q=1');
18
+ assert.equal(normalizeUrlNoTrailingSlash('https://example.com/api/?q=1'), 'https://example.com/api/?q=1');
19
+ });
20
+
@@ -1,9 +1,56 @@
1
1
  import { getStackName } from './paths.mjs';
2
2
  import { sanitizeDnsLabel } from '../net/dns.mjs';
3
3
 
4
- export function resolveLocalhostHost({ stackMode, stackName = getStackName() } = {}) {
4
+ export function resolveLocalhostHost({ stackMode, stackName = null, env = process.env } = {}) {
5
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
5
6
  if (!stackMode) return 'localhost';
6
- if (!stackName || stackName === 'main') return 'localhost';
7
- return `happy-${sanitizeDnsLabel(stackName)}.localhost`;
7
+ if (!name || name === 'main') return 'localhost';
8
+ return `happy-${sanitizeDnsLabel(name)}.localhost`;
9
+ }
10
+
11
+ export async function preferStackLocalhostHost({ stackName = null, env = process.env } = {}) {
12
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
13
+ if (!name || name === 'main') return 'localhost';
14
+ // IMPORTANT:
15
+ // We intentionally do NOT gate on `dns.lookup()` here.
16
+ //
17
+ // On some systems (notably macOS), Node's DNS resolver may return ENOTFOUND for `*.localhost`
18
+ // even though browsers treat `*.localhost` as loopback and will load it fine.
19
+ //
20
+ // Since this hostname is primarily used for browser-facing URLs and origin isolation, we
21
+ // prefer the stable `happy-<stack>.localhost` form by default and allow opting out via env.
22
+ const modeRaw = (env.HAPPY_STACKS_LOCALHOST_SUBDOMAINS ?? env.HAPPY_LOCAL_LOCALHOST_SUBDOMAINS ?? '')
23
+ .toString()
24
+ .trim()
25
+ .toLowerCase();
26
+ const disabled = modeRaw === '0' || modeRaw === 'false' || modeRaw === 'no' || modeRaw === 'off';
27
+ if (disabled) return 'localhost';
28
+
29
+ const preferredHost = resolveLocalhostHost({ stackMode: true, stackName: name, env });
30
+ return preferredHost || 'localhost';
31
+ }
32
+
33
+ // Best-effort: for stacks, prefer `happy-<stack>.localhost` over `localhost` when it's reachable.
34
+ // This keeps URLs stable and stack-scoped while still failing closed to plain localhost.
35
+ export async function preferStackLocalhostUrl(url, { stackName = null, env = process.env } = {}) {
36
+ const raw = String(url ?? '').trim();
37
+ if (!raw) return '';
38
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
39
+ if (!name || name === 'main') return raw;
40
+
41
+ let u = null;
42
+ try {
43
+ u = new URL(raw);
44
+ } catch {
45
+ return raw;
46
+ }
47
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return raw;
48
+
49
+ const isLoopbackHost = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
50
+ if (!isLoopbackHost) return raw;
51
+
52
+ const preferredHost = await preferStackLocalhostHost({ stackName: name, env });
53
+ if (!preferredHost || preferredHost === 'localhost') return raw;
54
+ return raw.replace(`://${u.hostname}`, `://${preferredHost}`);
8
55
  }
9
56