happy-stacks 0.2.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 (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  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 +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -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,10 @@
1
+ export function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
2
+ const s = String(raw ?? '')
3
+ .toLowerCase()
4
+ .replace(/[^a-z0-9-]+/g, '-')
5
+ .replace(/-+/g, '-')
6
+ .replace(/^-+/, '')
7
+ .replace(/-+$/, '');
8
+ return s || fallback;
9
+ }
10
+
@@ -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
+
@@ -1,18 +1,23 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import net from 'node:net';
3
- import { runCapture } from './proc.mjs';
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
 
9
9
  let raw = '';
10
10
  try {
11
11
  // `lsof` exits non-zero if no matches; normalize to empty output.
12
- raw = await runCapture('sh', [
13
- '-lc',
14
- `command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`,
15
- ]);
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
+ }
16
21
  } catch {
17
22
  raw = '';
18
23
  }
@@ -102,3 +107,4 @@ export async function pickNextFreeTcpPort(startPort, { reservedPorts = new Set()
102
107
  }
103
108
  throw new Error(`[local] unable to find a free TCP port starting at ${startPort}`);
104
109
  }
110
+
@@ -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
+
@@ -0,0 +1,56 @@
1
+ import { getStackName } from './paths.mjs';
2
+ import { sanitizeDnsLabel } from '../net/dns.mjs';
3
+
4
+ export function resolveLocalhostHost({ stackMode, stackName = null, env = process.env } = {}) {
5
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
6
+ if (!stackMode) return '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}`);
55
+ }
56
+
@@ -2,7 +2,9 @@ import { homedir } from 'node:os';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { existsSync } from 'node:fs';
5
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
5
+
6
+ import { expandHome } from './canonical_home.mjs';
7
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
6
8
 
7
9
  const PRIMARY_APP_SLUG = 'happy-stacks';
8
10
  const LEGACY_APP_SLUG = 'happy-local';
@@ -16,18 +18,18 @@ export function getRootDir(importMetaUrl) {
16
18
  return dirname(dirname(fileURLToPath(importMetaUrl)));
17
19
  }
18
20
 
19
- export function getHappyStacksHomeDir() {
20
- const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
21
+ export function getHappyStacksHomeDir(env = process.env) {
22
+ const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').trim();
21
23
  if (fromEnv) {
22
- return fromEnv.replace(/^~(?=\/)/, homedir());
24
+ return expandHome(fromEnv);
23
25
  }
24
26
  return PRIMARY_HOME_DIR;
25
27
  }
26
28
 
27
- export function getWorkspaceDir(cliRootDir = null) {
28
- const fromEnv = (process.env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
29
+ export function getWorkspaceDir(cliRootDir = null, env = process.env) {
30
+ const fromEnv = (env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
29
31
  if (fromEnv) {
30
- return fromEnv.replace(/^~(?=\/)/, homedir());
32
+ return expandHome(fromEnv);
31
33
  }
32
34
  const homeDir = getHappyStacksHomeDir();
33
35
  const defaultWorkspace = join(homeDir, 'workspace');
@@ -39,8 +41,8 @@ export function getWorkspaceDir(cliRootDir = null) {
39
41
  return cliRootDir ? cliRootDir : defaultWorkspace;
40
42
  }
41
43
 
42
- export function getComponentsDir(rootDir) {
43
- const workspaceDir = getWorkspaceDir(rootDir);
44
+ export function getComponentsDir(rootDir, env = process.env) {
45
+ const workspaceDir = getWorkspaceDir(rootDir, env);
44
46
  return join(workspaceDir, 'components');
45
47
  }
46
48
 
@@ -48,48 +50,50 @@ export function componentDirEnvKey(name) {
48
50
  return `HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
49
51
  }
50
52
 
51
- function normalizePathForEnv(rootDir, raw) {
53
+ function normalizePathForEnv(rootDir, raw, env = process.env) {
52
54
  const trimmed = (raw ?? '').trim();
53
55
  if (!trimmed) {
54
56
  return '';
55
57
  }
56
- const expanded = trimmed.replace(/^~(?=\/)/, homedir());
58
+ const expanded = expandHome(trimmed);
57
59
  // If the path is relative, treat it as relative to the workspace root (default: repo root).
58
- const workspaceDir = getWorkspaceDir(rootDir);
60
+ const workspaceDir = getWorkspaceDir(rootDir, env);
59
61
  return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
60
62
  }
61
63
 
62
- export function getComponentDir(rootDir, name) {
64
+ export function getComponentDir(rootDir, name, env = process.env) {
63
65
  const stacksKey = componentDirEnvKey(name);
64
66
  const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
65
- const fromEnv = normalizePathForEnv(rootDir, process.env[stacksKey] ?? process.env[legacyKey]);
67
+ const fromEnv = normalizePathForEnv(rootDir, env[stacksKey] ?? env[legacyKey], env);
66
68
  if (fromEnv) {
67
69
  return fromEnv;
68
70
  }
69
- return join(getComponentsDir(rootDir), name);
71
+ return join(getComponentsDir(rootDir, env), name);
70
72
  }
71
73
 
72
- export function getStackName() {
73
- const raw = process.env.HAPPY_STACKS_STACK?.trim()
74
- ? process.env.HAPPY_STACKS_STACK.trim()
75
- : process.env.HAPPY_LOCAL_STACK?.trim()
76
- ? process.env.HAPPY_LOCAL_STACK.trim()
74
+ export function getStackName(env = process.env) {
75
+ const raw = env.HAPPY_STACKS_STACK?.trim()
76
+ ? env.HAPPY_STACKS_STACK.trim()
77
+ : env.HAPPY_LOCAL_STACK?.trim()
78
+ ? env.HAPPY_LOCAL_STACK.trim()
77
79
  : '';
78
80
  return raw || 'main';
79
81
  }
80
82
 
81
- export function getStackLabel(stackName = getStackName()) {
82
- return stackName === 'main' ? PRIMARY_LABEL_BASE : `${PRIMARY_LABEL_BASE}.${stackName}`;
83
+ export function getStackLabel(stackName = null, env = process.env) {
84
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
85
+ return name === 'main' ? PRIMARY_LABEL_BASE : `${PRIMARY_LABEL_BASE}.${name}`;
83
86
  }
84
87
 
85
- export function getLegacyStackLabel(stackName = getStackName()) {
86
- return stackName === 'main' ? LEGACY_LABEL_BASE : `${LEGACY_LABEL_BASE}.${stackName}`;
88
+ export function getLegacyStackLabel(stackName = null, env = process.env) {
89
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
90
+ return name === 'main' ? LEGACY_LABEL_BASE : `${LEGACY_LABEL_BASE}.${name}`;
87
91
  }
88
92
 
89
- export function getStacksStorageRoot() {
90
- const fromEnv = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
93
+ export function getStacksStorageRoot(env = process.env) {
94
+ const fromEnv = (env.HAPPY_STACKS_STORAGE_DIR ?? env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
91
95
  if (fromEnv) {
92
- return fromEnv.replace(/^~(?=\/)/, homedir());
96
+ return expandHome(fromEnv);
93
97
  }
94
98
  return PRIMARY_STORAGE_ROOT;
95
99
  }
@@ -98,19 +102,20 @@ export function getLegacyStorageRoot() {
98
102
  return LEGACY_STORAGE_ROOT;
99
103
  }
100
104
 
101
- export function resolveStackBaseDir(stackName = getStackName()) {
102
- const preferredRoot = getStacksStorageRoot();
103
- const newBase = join(preferredRoot, stackName);
104
- const legacyBase = stackName === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', stackName);
105
+ export function resolveStackBaseDir(stackName = null, env = process.env) {
106
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
107
+ const preferredRoot = getStacksStorageRoot(env);
108
+ const newBase = join(preferredRoot, name);
109
+ const legacyBase = name === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', name);
105
110
  const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
106
111
 
107
112
  // Prefer the new layout by default.
108
113
  //
109
114
  // For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
110
115
  // This avoids breaking existing stacks until `happys stack migrate` is run.
111
- if (allowLegacy && stackName !== 'main') {
112
- const newEnv = join(preferredRoot, stackName, 'env');
113
- const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
116
+ if (allowLegacy && name !== 'main') {
117
+ const newEnv = join(preferredRoot, name, 'env');
118
+ const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
114
119
  if (!existsSync(newEnv) && existsSync(legacyEnv)) {
115
120
  return { baseDir: legacyBase, isLegacy: true };
116
121
  }
@@ -119,30 +124,31 @@ export function resolveStackBaseDir(stackName = getStackName()) {
119
124
  return { baseDir: newBase, isLegacy: false };
120
125
  }
121
126
 
122
- export function resolveStackEnvPath(stackName = getStackName()) {
123
- const { baseDir: activeBase, isLegacy } = resolveStackBaseDir(stackName);
127
+ export function resolveStackEnvPath(stackName = null, env = process.env) {
128
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
129
+ const { baseDir: activeBase, isLegacy } = resolveStackBaseDir(name, env);
124
130
  // New layout: ~/.happy/stacks/<name>/env
125
- const newEnv = join(getStacksStorageRoot(), stackName, 'env');
131
+ const newEnv = join(getStacksStorageRoot(env), name, 'env');
126
132
  // Legacy layout: ~/.happy/local/stacks/<name>/env
127
- const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
133
+ const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
128
134
  const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
129
135
 
130
136
  if (existsSync(newEnv)) {
131
- return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(), stackName) };
137
+ return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(env), name) };
132
138
  }
133
139
  if (allowLegacy && existsSync(legacyEnv)) {
134
- return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', stackName) };
140
+ return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', name) };
135
141
  }
136
142
  return { envPath: newEnv, isLegacy, baseDir: activeBase };
137
143
  }
138
144
 
139
- export function getDefaultAutostartPaths() {
140
- const stackName = getStackName();
141
- const { baseDir, isLegacy } = resolveStackBaseDir(stackName);
145
+ export function getDefaultAutostartPaths(env = process.env) {
146
+ const stackName = getStackName(env);
147
+ const { baseDir, isLegacy } = resolveStackBaseDir(stackName, env);
142
148
  const logsDir = join(baseDir, 'logs');
143
149
 
144
- const primaryLabel = getStackLabel(stackName);
145
- const legacyLabel = getLegacyStackLabel(stackName);
150
+ const primaryLabel = getStackLabel(stackName, env);
151
+ const legacyLabel = getLegacyStackLabel(stackName, env);
146
152
  const primaryPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${primaryLabel}.plist`);
147
153
  const legacyPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${legacyLabel}.plist`);
148
154
 
@@ -185,3 +191,4 @@ export function getDefaultAutostartPaths() {
185
191
  legacyStderrPath,
186
192
  };
187
193
  }
194
+
@@ -9,7 +9,9 @@ export function getRuntimeDir() {
9
9
  if (fromEnv) {
10
10
  return expandHome(fromEnv);
11
11
  }
12
- const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() ? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim()) : join(homedir(), '.happy-stacks');
12
+ const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim()
13
+ ? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim())
14
+ : join(homedir(), '.happy-stacks');
13
15
  return join(homeDir, 'runtime');
14
16
  }
15
17
 
@@ -0,0 +1,34 @@
1
+ import { runCapture } from './proc.mjs';
2
+
3
+ export async function resolveCommandPath(cmd, { cwd, env, timeoutMs } = {}) {
4
+ const c = String(cmd ?? '').trim();
5
+ if (!c) return '';
6
+
7
+ try {
8
+ if (process.platform === 'win32') {
9
+ const out = (await runCapture('where', [c], { cwd, env, timeoutMs })).trim();
10
+ const first = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
11
+ return first;
12
+ }
13
+ return (
14
+ await runCapture('sh', ['-lc', `command -v "${c}" 2>/dev/null || true`], { cwd, env, timeoutMs })
15
+ ).trim();
16
+ } catch {
17
+ return '';
18
+ }
19
+ }
20
+
21
+ export async function runCaptureIfCommandExists(cmd, args, { cwd, env, timeoutMs } = {}) {
22
+ const resolved = await resolveCommandPath(cmd, { cwd, env, timeoutMs });
23
+ if (!resolved) return '';
24
+ try {
25
+ return await runCapture(resolved, args, { cwd, env, timeoutMs });
26
+ } catch {
27
+ return '';
28
+ }
29
+ }
30
+
31
+ export async function commandExists(cmd, { cwd } = {}) {
32
+ return Boolean(await resolveCommandPath(cmd, { cwd }));
33
+ }
34
+
@@ -1,5 +1,5 @@
1
1
  import { runCapture } from './proc.mjs';
2
- import { killPid } from './expo.mjs';
2
+ import { killPid } from '../expo/expo.mjs';
3
3
 
4
4
  export async function getPsEnvLine(pid) {
5
5
  const n = Number(pid);