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
package/scripts/lint.mjs CHANGED
@@ -1,36 +1,16 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { getComponentDir, getRootDir } from './utils/paths.mjs';
5
- import { ensureDepsInstalled, requirePnpm } from './utils/pm.mjs';
6
- import { pathExists } from './utils/fs.mjs';
7
- import { run } from './utils/proc.mjs';
8
- import { join } from 'node:path';
9
- import { readFile } from 'node:fs/promises';
4
+ import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
+ import { pathExists } from './utils/fs/fs.mjs';
7
+ import { run } from './utils/proc/proc.mjs';
8
+ import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
9
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
10
10
 
11
11
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
12
12
 
13
- async function detectPackageManagerCmd(dir) {
14
- if (await pathExists(join(dir, 'yarn.lock'))) {
15
- return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
16
- }
17
- await requirePnpm();
18
- return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
19
- }
20
-
21
- async function readScripts(dir) {
22
- try {
23
- const raw = await readFile(join(dir, 'package.json'), 'utf-8');
24
- const pkg = JSON.parse(raw);
25
- const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
26
- return scripts;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
13
  function pickLintScript(scripts) {
33
- if (!scripts) return null;
34
14
  const candidates = [
35
15
  'lint',
36
16
  'lint:ci',
@@ -39,7 +19,7 @@ function pickLintScript(scripts) {
39
19
  'eslint',
40
20
  'eslint:check',
41
21
  ];
42
- return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
22
+ return pickFirstScript(scripts, candidates);
43
23
  }
44
24
 
45
25
  async function main() {
@@ -61,18 +41,37 @@ async function main() {
61
41
  'examples:',
62
42
  ' happys lint',
63
43
  ' happys lint happy happy-cli',
44
+ '',
45
+ 'note:',
46
+ ' If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
64
47
  ].join('\n'),
65
48
  });
66
49
  return;
67
50
  }
68
51
 
52
+ const rootDir = getRootDir(import.meta.url);
53
+
69
54
  const positionals = argv.filter((a) => !a.startsWith('--'));
70
- const requested = positionals.length ? positionals : ['all'];
55
+ const inferred =
56
+ positionals.length === 0
57
+ ? inferComponentFromCwd({
58
+ rootDir,
59
+ invokedCwd: getInvokedCwd(process.env),
60
+ components: DEFAULT_COMPONENTS,
61
+ })
62
+ : null;
63
+ if (inferred) {
64
+ const stacksKey = componentDirEnvKey(inferred.component);
65
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
66
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
67
+ process.env[stacksKey] = inferred.repoDir;
68
+ }
69
+ }
70
+
71
+ const requested = positionals.length ? positionals : inferred ? [inferred.component] : ['all'];
71
72
  const wantAll = requested.includes('all');
72
73
  const components = wantAll ? DEFAULT_COMPONENTS : requested;
73
74
 
74
- const rootDir = getRootDir(import.meta.url);
75
-
76
75
  const results = [];
77
76
  for (const component of components) {
78
77
  if (!DEFAULT_COMPONENTS.includes(component)) {
@@ -86,7 +85,7 @@ async function main() {
86
85
  continue;
87
86
  }
88
87
 
89
- const scripts = await readScripts(dir);
88
+ const scripts = await readPackageJsonScripts(dir);
90
89
  if (!scripts) {
91
90
  results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
92
91
  continue;
@@ -1,14 +1,15 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { cp, mkdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { createHash } from 'node:crypto';
7
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
7
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths/paths.mjs';
8
8
  import { parseArgs } from './utils/cli/args.mjs';
9
9
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
- import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
11
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
10
+ import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
11
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
12
+ import { normalizeProfile } from './utils/cli/normalize.mjs';
12
13
 
13
14
  async function ensureSwiftbarAssets({ cliRootDir }) {
14
15
  const homeDir = getHappyStacksHomeDir();
@@ -59,14 +60,6 @@ function removeSwiftbarPlugins({ patterns }) {
59
60
  return out || null;
60
61
  }
61
62
 
62
- function normalizeMenubarMode(raw) {
63
- const v = String(raw ?? '').trim().toLowerCase();
64
- if (!v) return '';
65
- if (v === 'selfhost' || v === 'self-host' || v === 'self_host' || v === 'host') return 'selfhost';
66
- if (v === 'dev' || v === 'developer') return 'dev';
67
- return '';
68
- }
69
-
70
63
  async function main() {
71
64
  const rawArgv = process.argv.slice(2);
72
65
  const argv = rawArgv[0] === 'menubar' ? rawArgv.slice(1) : rawArgv;
@@ -128,7 +121,7 @@ async function main() {
128
121
  if (cmd === 'mode') {
129
122
  const positionals = argv.filter((a) => !a.startsWith('--'));
130
123
  const raw = positionals[1] ?? '';
131
- const mode = normalizeMenubarMode(raw);
124
+ const mode = normalizeProfile(raw);
132
125
  if (!mode) {
133
126
  throw new Error('[menubar] usage: happys menubar mode <selfhost|dev> [--json]');
134
127
  }
@@ -1,17 +1,18 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { copyFile, mkdir, readFile } from 'node:fs/promises';
3
3
  import { basename, join } from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
5
 
6
6
  import { parseArgs } from './utils/cli/args.mjs';
7
7
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
- import { parseDotenv } from './utils/dotenv.mjs';
9
- import { ensureEnvFileUpdated } from './utils/env_file.mjs';
10
- import { resolveStackEnvPath } from './utils/paths.mjs';
11
- import { ensureDepsInstalled } from './utils/pm.mjs';
12
- import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/happy_server_infra.mjs';
13
- import { runCapture } from './utils/proc.mjs';
14
- import { pickNextFreeTcpPort } from './utils/ports.mjs';
8
+ import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
9
+ import { readEnvObjectFromFile } from './utils/env/read.mjs';
10
+ import { resolveStackEnvPath } from './utils/paths/paths.mjs';
11
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
12
+ import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/server/infra/happy_server_infra.mjs';
13
+ import { runCapture } from './utils/proc/proc.mjs';
14
+ import { pickNextFreeTcpPort } from './utils/net/ports.mjs';
15
+ import { getEnvValue } from './utils/env/values.mjs';
15
16
 
16
17
  function usage() {
17
18
  return [
@@ -25,18 +26,7 @@ function usage() {
25
26
  ].join('\n');
26
27
  }
27
28
 
28
- async function readEnvObject(envPath) {
29
- try {
30
- const raw = await readFile(envPath, 'utf-8');
31
- return Object.fromEntries(parseDotenv(raw).entries());
32
- } catch {
33
- return {};
34
- }
35
- }
36
-
37
- function getEnvValue(env, key) {
38
- return (env?.[key] ?? '').toString().trim();
39
- }
29
+ const readEnvObject = readEnvObjectFromFile;
40
30
 
41
31
  function parseFileDatabaseUrl(url) {
42
32
  const raw = String(url ?? '').trim();
@@ -290,7 +280,7 @@ async function main() {
290
280
  throw new Error('[migrate] --to-stack is required');
291
281
  }
292
282
 
293
- const rootDir = (await import('./utils/paths.mjs')).getRootDir(import.meta.url);
283
+ const rootDir = (await import('./utils/paths/paths.mjs')).getRootDir(import.meta.url);
294
284
  await migrateLightToServer({ rootDir, fromStack, toStack, includeFiles, force, json });
295
285
  }
296
286
 
@@ -1,12 +1,17 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { pickNextFreeTcpPort } from './utils/ports.mjs';
4
- import { run, runCapture, spawnProc } from './utils/proc.mjs';
5
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
- import { ensureDepsInstalled, pmExecBin, pmSpawnBin, requireDir } from './utils/pm.mjs';
3
+ import { run, runCapture, spawnProc } from './utils/proc/proc.mjs';
4
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
5
+ import { ensureDepsInstalled, pmExecBin, pmSpawnBin, requireDir } from './utils/proc/pm.mjs';
7
6
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
- import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo.mjs';
9
- import { killProcessGroupOwnedByStack } from './utils/ownership.mjs';
7
+ import { ensureExpoIsolationEnv, getExpoStatePaths } from './utils/expo/expo.mjs';
8
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
9
+ import { resolveMobileExpoConfig } from './utils/mobile/config.mjs';
10
+ import { resolveStackContext } from './utils/stack/context.mjs';
11
+ import { expoExec } from './utils/expo/command.mjs';
12
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
13
+ import { resolveMobileReachableServerUrl } from './utils/server/mobile_api_url.mjs';
14
+ import { patchIosXcodeProjectsForSigningAndIdentity, resolveIosAppXcodeProjects } from './utils/mobile/ios_xcodeproj_patch.mjs';
10
15
 
11
16
  /**
12
17
  * Mobile dev helper for the embedded `components/happy` Expo app.
@@ -58,7 +63,7 @@ async function main() {
58
63
  '',
59
64
  'Notes:',
60
65
  '- This script is designed to avoid editing upstream `components/happy` config in-place.',
61
- '- It sets EXPO_PUBLIC_HAPPY_SERVER_URL from HAPPY_STACKS_SERVER_URL (legacy: HAPPY_LOCAL_SERVER_URL) if provided.',
66
+ '- If you explicitly set HAPPY_STACKS_SERVER_URL (legacy: HAPPY_LOCAL_SERVER_URL), it bakes that URL into the app via EXPO_PUBLIC_HAPPY_SERVER_URL.',
62
67
  ].join('\n'),
63
68
  });
64
69
  return;
@@ -69,19 +74,6 @@ async function main() {
69
74
  await requireDir('happy', uiDir);
70
75
  await ensureDepsInstalled(uiDir, 'happy');
71
76
 
72
- const sanitizeBundleIdSegment = (s) =>
73
- (s ?? '')
74
- .toString()
75
- .trim()
76
- .toLowerCase()
77
- .replace(/[^a-z0-9-]+/g, '-')
78
- .replace(/^-+|-+$/g, '') || 'user';
79
-
80
- const defaultLocalBundleId = (() => {
81
- const user = sanitizeBundleIdSegment(process.env.USER ?? process.env.USERNAME ?? 'user');
82
- return `com.happy.local.${user}.dev`;
83
- })();
84
-
85
77
  async function readXcdeviceList() {
86
78
  if (process.platform !== 'darwin') {
87
79
  return [];
@@ -96,21 +88,6 @@ async function main() {
96
88
  // Default to the existing dev bundle identifier, which is also registered as a URL scheme
97
89
  // (Info.plist includes `com.slopus.happy.dev`), so iOS will open the dev build instead of the App Store app.
98
90
  const appEnv = process.env.APP_ENV ?? kv.get('--app-env') ?? 'development';
99
- const iosAppName =
100
- kv.get('--ios-app-name') ??
101
- process.env.HAPPY_STACKS_IOS_APP_NAME ??
102
- process.env.HAPPY_LOCAL_IOS_APP_NAME ??
103
- '';
104
- const iosBundleId =
105
- kv.get('--ios-bundle-id') ??
106
- process.env.HAPPY_STACKS_IOS_BUNDLE_ID ??
107
- process.env.HAPPY_LOCAL_IOS_BUNDLE_ID ??
108
- defaultLocalBundleId;
109
- const scheme =
110
- kv.get('--scheme') ??
111
- process.env.HAPPY_STACKS_MOBILE_SCHEME ??
112
- process.env.HAPPY_LOCAL_MOBILE_SCHEME ??
113
- iosBundleId;
114
91
  const host = kv.get('--host') ?? process.env.HAPPY_STACKS_MOBILE_HOST ?? process.env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan';
115
92
  const portRaw = kv.get('--port') ?? process.env.HAPPY_STACKS_MOBILE_PORT ?? process.env.HAPPY_LOCAL_MOBILE_PORT ?? '8081';
116
93
  // Default behavior:
@@ -125,26 +102,56 @@ async function main() {
125
102
  APP_ENV: appEnv,
126
103
  };
127
104
 
105
+ const cfgBase = resolveMobileExpoConfig({ env });
106
+ const iosAppName = (kv.get('--ios-app-name') ?? cfgBase.iosAppName ?? '').toString();
107
+ const iosBundleId = (kv.get('--ios-bundle-id') ?? cfgBase.iosBundleId ?? '').toString();
108
+ const scheme = (kv.get('--scheme') ?? cfgBase.scheme ?? iosBundleId).toString();
109
+
128
110
  const autostart = getDefaultAutostartPaths();
129
- const mobilePaths = getExpoStatePaths({
111
+ const stackCtx = resolveStackContext({ env, autostart });
112
+ const { stackMode, runtimeStatePath, stackName, envPath } = stackCtx;
113
+
114
+ // Ensure the built iOS app registers the same scheme we use for dev-client QR links.
115
+ // (Happy app reads EXPO_APP_SCHEME in app.config.js; default remains unchanged when unset.)
116
+ env.EXPO_APP_SCHEME = scheme;
117
+ // Ensure the app display name + bundle id are consistent with what we install.
118
+ // (app.config.js keeps upstream defaults unless these are explicitly set.)
119
+ if (iosAppName && iosAppName.trim()) {
120
+ env.EXPO_APP_NAME = iosAppName.trim();
121
+ }
122
+ if (iosBundleId && iosBundleId.trim()) {
123
+ env.EXPO_APP_BUNDLE_ID = iosBundleId.trim();
124
+ }
125
+
126
+ // Always isolate Expo home + TMPDIR to avoid cross-worktree cache pollution (and to keep sandbox runs contained).
127
+ const expoPaths = getExpoStatePaths({
130
128
  baseDir: autostart.baseDir,
131
- kind: 'mobile-dev',
129
+ kind: 'expo-dev',
132
130
  projectDir: uiDir,
133
- stateFileName: 'mobile.state.json',
131
+ stateFileName: 'expo.state.json',
134
132
  });
135
133
  await ensureExpoIsolationEnv({
136
134
  env,
137
- stateDir: mobilePaths.stateDir,
138
- expoHomeDir: mobilePaths.expoHomeDir,
139
- tmpDir: mobilePaths.tmpDir,
135
+ stateDir: expoPaths.stateDir,
136
+ expoHomeDir: expoPaths.expoHomeDir,
137
+ tmpDir: expoPaths.tmpDir,
140
138
  });
141
139
 
142
140
  // Allow happy-stacks to define the default server URL baked into the app bundle.
143
141
  // This is read by the app via `process.env.EXPO_PUBLIC_HAPPY_SERVER_URL`.
144
- const stacksServerUrl =
145
- process.env.HAPPY_STACKS_SERVER_URL?.trim() || process.env.HAPPY_LOCAL_SERVER_URL?.trim() || '';
146
- if (stacksServerUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
147
- env.EXPO_PUBLIC_HAPPY_SERVER_URL = stacksServerUrl;
142
+ const serverPort = resolveServerPortFromEnv({ env, defaultPort: 3005 });
143
+ const allowEnableTailscale =
144
+ !stackMode || stackName === 'main' || (env.HAPPY_STACKS_TAILSCALE_SERVE ?? env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
145
+ const resolvedUrls = await resolveServerUrls({ env, serverPort, allowEnable: allowEnableTailscale });
146
+ if (resolvedUrls.publicServerUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
147
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = resolvedUrls.publicServerUrl;
148
+ }
149
+ if (env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
150
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = resolveMobileReachableServerUrl({
151
+ env,
152
+ serverUrl: env.EXPO_PUBLIC_HAPPY_SERVER_URL,
153
+ serverPort,
154
+ });
148
155
  }
149
156
 
150
157
  if (json) {
@@ -178,13 +185,12 @@ async function main() {
178
185
  if (shouldClean) {
179
186
  prebuildArgs.push('--clean');
180
187
  }
181
- await pmExecBin({ dir: uiDir, bin: 'expo', args: prebuildArgs, env });
188
+ await expoExec({ dir: uiDir, args: prebuildArgs, env, ensureDepsLabel: 'happy' });
182
189
 
183
190
  // Always patch iOS props if iOS was generated.
184
191
  if (platform === 'ios' || platform === 'all') {
185
192
  const fs = await import('node:fs/promises');
186
193
  const podPropsPath = `${uiDir}/ios/Podfile.properties.json`;
187
- const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
188
194
  try {
189
195
  const raw = await fs.readFile(podPropsPath, 'utf-8');
190
196
  const json = JSON.parse(raw);
@@ -195,14 +201,17 @@ async function main() {
195
201
  // ignore if path missing (platform != ios)
196
202
  }
197
203
 
198
- try {
199
- const raw = await fs.readFile(pbxprojPath, 'utf-8');
200
- const next = raw.replaceAll('IPHONEOS_DEPLOYMENT_TARGET = 15.1;', 'IPHONEOS_DEPLOYMENT_TARGET = 16.0;');
201
- if (next !== raw) {
202
- await fs.writeFile(pbxprojPath, next, 'utf-8');
204
+ const iosProjects = await resolveIosAppXcodeProjects({ uiDir });
205
+ for (const project of iosProjects) {
206
+ try {
207
+ const raw = await fs.readFile(project.pbxprojPath, 'utf-8');
208
+ const next = raw.replaceAll('IPHONEOS_DEPLOYMENT_TARGET = 15.1;', 'IPHONEOS_DEPLOYMENT_TARGET = 16.0;');
209
+ if (next !== raw) {
210
+ await fs.writeFile(project.pbxprojPath, next, 'utf-8');
211
+ }
212
+ } catch {
213
+ // ignore missing/invalid pbxproj; Expo will surface actionable errors if needed
203
214
  }
204
- } catch {
205
- // ignore missing pbxproj (unexpected)
206
215
  }
207
216
 
208
217
  // Ensure CocoaPods doesn't crash due to locale issues.
@@ -263,25 +272,9 @@ async function main() {
263
272
  // xcodebuild fails with:
264
273
  // "Automatic signing is disabled ... pass -allowProvisioningUpdates"
265
274
  //
266
- // We force Expo CLI to go through its signing configuration path by clearing DEVELOPMENT_TEAM,
267
- // so it will re-set the team and include the provisioning flags.
268
- try {
269
- const fs = await import('node:fs/promises');
270
- const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
271
- const raw = await fs.readFile(pbxprojPath, 'utf-8');
272
- let next = raw.replaceAll(/^\s*DEVELOPMENT_TEAM = ".*";\s*$/gm, '');
273
- next = next.replaceAll(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${iosBundleId};`);
274
- if (iosAppName && iosAppName.trim()) {
275
- const name = iosAppName.trim();
276
- const quoted = name.includes(' ') || name.includes('"') ? `"${name.replaceAll('"', '\\"')}"` : name;
277
- next = next.replaceAll(/PRODUCT_NAME = [^;]+;/g, `PRODUCT_NAME = ${quoted};`);
278
- }
279
- if (next !== raw) {
280
- await fs.writeFile(pbxprojPath, next, 'utf-8');
281
- }
282
- } catch {
283
- // ignore
284
- }
275
+ // We force Expo CLI to go through its signing configuration path by clearing any pre-existing
276
+ // team/profile identifiers, so it will re-set the team and include the provisioning flags.
277
+ await patchIosXcodeProjectsForSigningAndIdentity({ uiDir, iosBundleId, iosAppName });
285
278
  }
286
279
 
287
280
  const configuration = kv.get('--configuration') ?? 'Debug';
@@ -292,47 +285,39 @@ async function main() {
292
285
  // Ensure CocoaPods doesn't crash due to locale issues.
293
286
  env.LANG = env.LANG ?? 'en_US.UTF-8';
294
287
  env.LC_ALL = env.LC_ALL ?? 'en_US.UTF-8';
295
- await pmExecBin({ dir: uiDir, bin: 'expo', args, env });
288
+ await expoExec({ dir: uiDir, args, env, ensureDepsLabel: 'happy' });
296
289
  }
297
290
 
298
291
  if (!shouldStartMetro) {
299
292
  return;
300
293
  }
301
294
 
302
- const running = await isStateProcessRunning(mobilePaths.statePath);
303
- if (!restart && running.running) {
304
- // eslint-disable-next-line no-console
305
- console.log(`[mobile] Metro already running for this stack/worktree (pid=${running.state.pid}, port=${running.state.port})`);
306
- return;
307
- }
308
- if (restart && running.state?.pid) {
309
- const prevPid = Number(running.state.pid);
310
- const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || autostart.stackName;
311
- const envPath = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
312
- const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-mobile', json: true });
313
- if (!res.killed) {
314
- // eslint-disable-next-line no-console
315
- console.warn(
316
- `[mobile] not stopping existing Metro pid=${prevPid} because it does not look stack-owned.\n` +
317
- `[mobile] continuing by starting a new Metro on a free port.`
318
- );
319
- }
320
- }
321
-
322
- const requestedPort = Number.parseInt(String(portRaw), 10);
323
- const startPort = Number.isFinite(requestedPort) && requestedPort > 0 ? requestedPort : 8081;
324
- const portNumber = await pickNextFreeTcpPort(startPort);
325
- env.RCT_METRO_PORT = String(portNumber);
295
+ // Unify Expo: one Expo dev server per stack/worktree. If dev mode already started Expo, we reuse it.
296
+ // If Expo is already running without dev-client enabled, we fail closed (no second Expo).
297
+ env.HAPPY_STACKS_EXPO_HOST = host;
298
+ env.HAPPY_LOCAL_EXPO_HOST = host;
299
+ env.HAPPY_STACKS_MOBILE_HOST = host;
300
+ env.HAPPY_LOCAL_MOBILE_HOST = host;
301
+ env.HAPPY_STACKS_MOBILE_SCHEME = scheme;
302
+ env.HAPPY_LOCAL_MOBILE_SCHEME = scheme;
303
+ env.HAPPY_STACKS_EXPO_DEV_PORT = String(portRaw);
304
+ env.HAPPY_LOCAL_EXPO_DEV_PORT = String(portRaw);
326
305
 
327
- // Start Metro for a dev client.
328
- // The critical part is --scheme: without it, Expo defaults to `exp+<slug>` (here `exp+happy`)
329
- // which the App Store app also registers, so iOS can open the wrong app.
330
- const args = ['start', '--dev-client', '--host', host, '--port', String(portNumber), '--scheme', scheme];
331
- if (wantsExpoClearCache({ env })) {
332
- args.push('--clear');
333
- }
334
- const child = await pmSpawnBin({ label: 'mobile', dir: uiDir, bin: 'expo', args, env });
335
- await writePidState(mobilePaths.statePath, { pid: child.pid, port: portNumber, uiDir, startedAt: new Date().toISOString() });
306
+ const children = [];
307
+ await ensureDevExpoServer({
308
+ startUi: false,
309
+ startMobile: true,
310
+ uiDir,
311
+ autostart,
312
+ baseEnv: env,
313
+ apiServerUrl: env.EXPO_PUBLIC_HAPPY_SERVER_URL ?? '',
314
+ restart,
315
+ stackMode,
316
+ runtimeStatePath,
317
+ stackName,
318
+ envPath,
319
+ children,
320
+ });
336
321
 
337
322
  await new Promise(() => {});
338
323
  }
@@ -0,0 +1,83 @@
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
+ import { run } from './utils/proc/proc.mjs';
5
+ import { getRootDir } from './utils/paths/paths.mjs';
6
+ import { join } from 'node:path';
7
+
8
+ import { defaultDevClientIdentity } from './utils/mobile/identifiers.mjs';
9
+
10
+ async function main() {
11
+ const argv = process.argv.slice(2);
12
+ const { flags, kv } = parseArgs(argv);
13
+ const json = wantsJson(argv, { flags });
14
+
15
+ if (wantsHelp(argv, { flags }) || flags.has('--help') || argv.length === 0) {
16
+ printResult({
17
+ json,
18
+ data: {
19
+ flags: ['--device=<id-or-name>', '--clean', '--configuration=Debug|Release', '--json'],
20
+ },
21
+ text: [
22
+ '[mobile-dev-client] usage:',
23
+ ' happys mobile-dev-client --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
24
+ '',
25
+ 'Notes:',
26
+ '- Installs a dedicated "Happy Stacks Dev" Expo dev-client app on your iPhone.',
27
+ '- This app is intended to be reused across stacks (no per-stack installs for dev-client).',
28
+ ].join('\n'),
29
+ });
30
+ return;
31
+ }
32
+
33
+ if (!flags.has('--install')) {
34
+ printResult({
35
+ json,
36
+ data: { ok: false, error: 'missing_install_flag' },
37
+ text: '[mobile-dev-client] missing --install. Run: happys mobile-dev-client --help',
38
+ });
39
+ process.exit(1);
40
+ }
41
+
42
+ const rootDir = getRootDir(import.meta.url);
43
+ const mobileScript = join(rootDir, 'scripts', 'mobile.mjs');
44
+
45
+ const device = kv.get('--device') ?? '';
46
+ const clean = flags.has('--clean');
47
+ const configuration = kv.get('--configuration') ?? 'Debug';
48
+
49
+ const id = defaultDevClientIdentity({ user: process.env.USER ?? process.env.USERNAME ?? 'user' });
50
+
51
+ const args = [
52
+ mobileScript,
53
+ '--app-env=development',
54
+ `--ios-app-name=${id.iosAppName}`,
55
+ `--ios-bundle-id=${id.iosBundleId}`,
56
+ `--scheme=${id.scheme}`,
57
+ '--prebuild',
58
+ ...(clean ? ['--clean'] : []),
59
+ '--run-ios',
60
+ `--configuration=${configuration}`,
61
+ '--no-metro',
62
+ ...(device ? [`--device=${device}`] : []),
63
+ ];
64
+
65
+ const env = {
66
+ ...process.env,
67
+ // Ensure Expo app config uses the dev-client scheme.
68
+ EXPO_APP_SCHEME: id.scheme,
69
+ // Ensure per-stack storage isolation is available during dev-client usage.
70
+ EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: process.env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ?? '',
71
+ };
72
+
73
+ const out = await run(process.execPath, args, { cwd: rootDir, env });
74
+ if (json) {
75
+ printResult({ json, data: { ok: true, installed: true, identity: id, out } });
76
+ }
77
+ }
78
+
79
+ main().catch((err) => {
80
+ console.error('[mobile-dev-client] failed:', err);
81
+ process.exit(1);
82
+ });
83
+
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Provision a fresh Ubuntu VM for running happy-local's `review-pr` end-to-end.
5
+ # Intended for Apple Silicon users running Ubuntu ARM64 via Lima/UTM.
6
+ #
7
+ # This installs:
8
+ # - Node (via nvm)
9
+ # - corepack (yarn/pnpm shims)
10
+ # - basic build tooling for native deps used by Expo/React Native ecosystem
11
+
12
+ if [[ "$(uname -s)" != "Linux" ]]; then
13
+ echo "[provision] expected Linux; got: $(uname -s)" >&2
14
+ exit 1
15
+ fi
16
+
17
+ export DEBIAN_FRONTEND=noninteractive
18
+
19
+ echo "[provision] installing apt dependencies..."
20
+ sudo apt-get update -y
21
+ sudo apt-get install -y \
22
+ ca-certificates \
23
+ curl \
24
+ git \
25
+ build-essential \
26
+ python3 \
27
+ pkg-config
28
+
29
+ echo "[provision] installing nvm + Node..."
30
+ export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
31
+ if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then
32
+ mkdir -p "$NVM_DIR"
33
+ curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
34
+ fi
35
+
36
+ # shellcheck disable=SC1090
37
+ source "$NVM_DIR/nvm.sh"
38
+
39
+ # Use a modern Node; match the repo's expectations if it ever adds .nvmrc.
40
+ NODE_VERSION="${NODE_VERSION:-22}"
41
+ nvm install "$NODE_VERSION"
42
+ nvm use "$NODE_VERSION"
43
+
44
+ echo "[provision] enabling corepack..."
45
+ corepack enable >/dev/null 2>&1 || true
46
+
47
+ echo "[provision] done."
48
+ echo "[provision] Node: $(node --version)"
49
+ echo "[provision] npm: $(npm --version)"
50
+ echo "[provision] git: $(git --version)"
51
+