happy-stacks 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -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,162 @@
1
+ /**
2
+ * Lightweight TCP port forwarder using Node.js built-in `net` module.
3
+ *
4
+ * Used to expose Expo dev server (which binds to LAN IP) on the Tailscale interface,
5
+ * enabling remote device access over Tailscale without modifying Expo's binding behavior.
6
+ *
7
+ * Can be run standalone:
8
+ * node tcp_forward.mjs --listen-host=100.x.y.z --listen-port=8081 --target-host=192.168.1.50 --target-port=8081
9
+ *
10
+ * Or imported and spawned as a managed child process.
11
+ */
12
+
13
+ import net from 'node:net';
14
+
15
+ /**
16
+ * Create a TCP forwarding server.
17
+ *
18
+ * @param {Object} options
19
+ * @param {string} options.listenHost - Host/IP to listen on (e.g., Tailscale IP)
20
+ * @param {number} options.listenPort - Port to listen on
21
+ * @param {string} options.targetHost - Host/IP to forward to (e.g., LAN IP or 127.0.0.1)
22
+ * @param {number} options.targetPort - Port to forward to
23
+ * @param {string} [options.label] - Label for logging (default: 'tcp-forward')
24
+ * @returns {net.Server}
25
+ */
26
+ export function createTcpForwarder({ listenHost, listenPort, targetHost, targetPort, label = 'tcp-forward' }) {
27
+ const server = net.createServer((clientSocket) => {
28
+ const targetSocket = net.createConnection({ host: targetHost, port: targetPort }, () => {
29
+ // Connection established, pipe data both ways
30
+ clientSocket.pipe(targetSocket);
31
+ targetSocket.pipe(clientSocket);
32
+ });
33
+
34
+ // Handle errors on both sockets
35
+ clientSocket.on('error', (err) => {
36
+ if (err.code !== 'ECONNRESET') {
37
+ process.stderr.write(`[${label}] client error: ${err.message}\n`);
38
+ }
39
+ targetSocket.destroy();
40
+ });
41
+
42
+ targetSocket.on('error', (err) => {
43
+ if (err.code !== 'ECONNRESET' && err.code !== 'ECONNREFUSED') {
44
+ process.stderr.write(`[${label}] target error: ${err.message}\n`);
45
+ }
46
+ clientSocket.destroy();
47
+ });
48
+
49
+ // Clean up on close
50
+ clientSocket.on('close', () => targetSocket.destroy());
51
+ targetSocket.on('close', () => clientSocket.destroy());
52
+ });
53
+
54
+ server.on('error', (err) => {
55
+ process.stderr.write(`[${label}] server error: ${err.message}\n`);
56
+ });
57
+
58
+ return server;
59
+ }
60
+
61
+ /**
62
+ * Start a TCP forwarder and return a promise that resolves when listening.
63
+ *
64
+ * @param {Object} options - Same as createTcpForwarder
65
+ * @returns {Promise<{ server: net.Server, address: string, port: number }>}
66
+ */
67
+ export async function startTcpForwarder(options) {
68
+ const { listenHost, listenPort, label = 'tcp-forward' } = options;
69
+ const server = createTcpForwarder(options);
70
+
71
+ return new Promise((resolve, reject) => {
72
+ server.once('error', reject);
73
+ server.listen(listenPort, listenHost, () => {
74
+ server.removeListener('error', reject);
75
+ const addr = server.address();
76
+ const address = typeof addr === 'object' ? addr.address : listenHost;
77
+ const port = typeof addr === 'object' ? addr.port : listenPort;
78
+ process.stdout.write(`[${label}] forwarding ${address}:${port} -> ${options.targetHost}:${options.targetPort}\n`);
79
+ resolve({ server, address, port });
80
+ });
81
+ });
82
+ }
83
+
84
+ function trySendIpc(msg) {
85
+ try {
86
+ if (typeof process.send === 'function') {
87
+ process.send(msg);
88
+ }
89
+ } catch {
90
+ // ignore
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Gracefully stop a TCP forwarder server.
96
+ *
97
+ * @param {net.Server} server
98
+ * @param {string} [label]
99
+ * @returns {Promise<void>}
100
+ */
101
+ export async function stopTcpForwarder(server, label = 'tcp-forward') {
102
+ if (!server) return;
103
+ return new Promise((resolve) => {
104
+ server.close(() => {
105
+ process.stdout.write(`[${label}] stopped\n`);
106
+ resolve();
107
+ });
108
+ // Force-close after timeout
109
+ setTimeout(() => {
110
+ resolve();
111
+ }, 2000);
112
+ });
113
+ }
114
+
115
+ // Standalone CLI mode
116
+ if (import.meta.url === `file://${process.argv[1]}`) {
117
+ const args = process.argv.slice(2);
118
+ const kv = new Map();
119
+ for (const arg of args) {
120
+ const m = arg.match(/^--([^=]+)=(.*)$/);
121
+ if (m) kv.set(m[1], m[2]);
122
+ }
123
+
124
+ const listenHost = kv.get('listen-host') || kv.get('listenHost');
125
+ const listenPort = Number(kv.get('listen-port') || kv.get('listenPort'));
126
+ const targetHost = kv.get('target-host') || kv.get('targetHost') || '127.0.0.1';
127
+ const targetPort = Number(kv.get('target-port') || kv.get('targetPort'));
128
+ const label = kv.get('label') || 'tcp-forward';
129
+
130
+ if (!listenHost || !listenPort || !targetPort) {
131
+ console.error('Usage: node tcp_forward.mjs --listen-host=<ip> --listen-port=<port> --target-host=<ip> --target-port=<port> [--label=<label>]');
132
+ process.exit(1);
133
+ }
134
+
135
+ const shutdown = () => {
136
+ process.stdout.write(`\n[${label}] shutting down...\n`);
137
+ process.exit(0);
138
+ };
139
+
140
+ process.on('SIGINT', shutdown);
141
+ process.on('SIGTERM', shutdown);
142
+
143
+ startTcpForwarder({ listenHost, listenPort, targetHost, targetPort, label })
144
+ .then(() => {
145
+ trySendIpc({ type: 'ready', listenHost, listenPort, targetHost, targetPort, label });
146
+ // Keep running until signal
147
+ })
148
+ .catch((err) => {
149
+ trySendIpc({
150
+ type: 'error',
151
+ code: err && typeof err === 'object' ? err.code : null,
152
+ message: err instanceof Error ? err.message : String(err ?? 'unknown error'),
153
+ listenHost,
154
+ listenPort,
155
+ targetHost,
156
+ targetPort,
157
+ label,
158
+ });
159
+ console.error(`[${label}] failed to start: ${err.message}`);
160
+ process.exit(1);
161
+ });
162
+ }
@@ -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