happy-stacks 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -1,13 +1,17 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { pickNextFreeTcpPort } from './utils/net/ports.mjs';
4
3
  import { run, runCapture, spawnProc } from './utils/proc/proc.mjs';
5
4
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
6
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/expo.mjs';
9
- import { killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
10
- import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv } from './utils/server/urls.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';
11
15
 
12
16
  /**
13
17
  * Mobile dev helper for the embedded `components/happy` Expo app.
@@ -70,19 +74,6 @@ async function main() {
70
74
  await requireDir('happy', uiDir);
71
75
  await ensureDepsInstalled(uiDir, 'happy');
72
76
 
73
- const sanitizeBundleIdSegment = (s) =>
74
- (s ?? '')
75
- .toString()
76
- .trim()
77
- .toLowerCase()
78
- .replace(/[^a-z0-9-]+/g, '-')
79
- .replace(/^-+|-+$/g, '') || 'user';
80
-
81
- const defaultLocalBundleId = (() => {
82
- const user = sanitizeBundleIdSegment(process.env.USER ?? process.env.USERNAME ?? 'user');
83
- return `com.happy.local.${user}.dev`;
84
- })();
85
-
86
77
  async function readXcdeviceList() {
87
78
  if (process.platform !== 'darwin') {
88
79
  return [];
@@ -97,21 +88,6 @@ async function main() {
97
88
  // Default to the existing dev bundle identifier, which is also registered as a URL scheme
98
89
  // (Info.plist includes `com.slopus.happy.dev`), so iOS will open the dev build instead of the App Store app.
99
90
  const appEnv = process.env.APP_ENV ?? kv.get('--app-env') ?? 'development';
100
- const iosAppName =
101
- kv.get('--ios-app-name') ??
102
- process.env.HAPPY_STACKS_IOS_APP_NAME ??
103
- process.env.HAPPY_LOCAL_IOS_APP_NAME ??
104
- '';
105
- const iosBundleId =
106
- kv.get('--ios-bundle-id') ??
107
- process.env.HAPPY_STACKS_IOS_BUNDLE_ID ??
108
- process.env.HAPPY_LOCAL_IOS_BUNDLE_ID ??
109
- defaultLocalBundleId;
110
- const scheme =
111
- kv.get('--scheme') ??
112
- process.env.HAPPY_STACKS_MOBILE_SCHEME ??
113
- process.env.HAPPY_LOCAL_MOBILE_SCHEME ??
114
- iosBundleId;
115
91
  const host = kv.get('--host') ?? process.env.HAPPY_STACKS_MOBILE_HOST ?? process.env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan';
116
92
  const portRaw = kv.get('--port') ?? process.env.HAPPY_STACKS_MOBILE_PORT ?? process.env.HAPPY_LOCAL_MOBILE_PORT ?? '8081';
117
93
  // Default behavior:
@@ -126,26 +102,56 @@ async function main() {
126
102
  APP_ENV: appEnv,
127
103
  };
128
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
+
129
110
  const autostart = getDefaultAutostartPaths();
130
- 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({
131
128
  baseDir: autostart.baseDir,
132
- kind: 'mobile-dev',
129
+ kind: 'expo-dev',
133
130
  projectDir: uiDir,
134
- stateFileName: 'mobile.state.json',
131
+ stateFileName: 'expo.state.json',
135
132
  });
136
133
  await ensureExpoIsolationEnv({
137
134
  env,
138
- stateDir: mobilePaths.stateDir,
139
- expoHomeDir: mobilePaths.expoHomeDir,
140
- tmpDir: mobilePaths.tmpDir,
135
+ stateDir: expoPaths.stateDir,
136
+ expoHomeDir: expoPaths.expoHomeDir,
137
+ tmpDir: expoPaths.tmpDir,
141
138
  });
142
139
 
143
140
  // Allow happy-stacks to define the default server URL baked into the app bundle.
144
141
  // This is read by the app via `process.env.EXPO_PUBLIC_HAPPY_SERVER_URL`.
145
- const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
146
- const { envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort });
147
- if (envPublicUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
148
- env.EXPO_PUBLIC_HAPPY_SERVER_URL = envPublicUrl;
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
+ });
149
155
  }
150
156
 
151
157
  if (json) {
@@ -179,13 +185,12 @@ async function main() {
179
185
  if (shouldClean) {
180
186
  prebuildArgs.push('--clean');
181
187
  }
182
- await pmExecBin({ dir: uiDir, bin: 'expo', args: prebuildArgs, env });
188
+ await expoExec({ dir: uiDir, args: prebuildArgs, env, ensureDepsLabel: 'happy' });
183
189
 
184
190
  // Always patch iOS props if iOS was generated.
185
191
  if (platform === 'ios' || platform === 'all') {
186
192
  const fs = await import('node:fs/promises');
187
193
  const podPropsPath = `${uiDir}/ios/Podfile.properties.json`;
188
- const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
189
194
  try {
190
195
  const raw = await fs.readFile(podPropsPath, 'utf-8');
191
196
  const json = JSON.parse(raw);
@@ -196,14 +201,17 @@ async function main() {
196
201
  // ignore if path missing (platform != ios)
197
202
  }
198
203
 
199
- try {
200
- const raw = await fs.readFile(pbxprojPath, 'utf-8');
201
- const next = raw.replaceAll('IPHONEOS_DEPLOYMENT_TARGET = 15.1;', 'IPHONEOS_DEPLOYMENT_TARGET = 16.0;');
202
- if (next !== raw) {
203
- 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
204
214
  }
205
- } catch {
206
- // ignore missing pbxproj (unexpected)
207
215
  }
208
216
 
209
217
  // Ensure CocoaPods doesn't crash due to locale issues.
@@ -264,25 +272,9 @@ async function main() {
264
272
  // xcodebuild fails with:
265
273
  // "Automatic signing is disabled ... pass -allowProvisioningUpdates"
266
274
  //
267
- // We force Expo CLI to go through its signing configuration path by clearing DEVELOPMENT_TEAM,
268
- // so it will re-set the team and include the provisioning flags.
269
- try {
270
- const fs = await import('node:fs/promises');
271
- const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
272
- const raw = await fs.readFile(pbxprojPath, 'utf-8');
273
- let next = raw.replaceAll(/^\s*DEVELOPMENT_TEAM = ".*";\s*$/gm, '');
274
- next = next.replaceAll(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${iosBundleId};`);
275
- if (iosAppName && iosAppName.trim()) {
276
- const name = iosAppName.trim();
277
- const quoted = name.includes(' ') || name.includes('"') ? `"${name.replaceAll('"', '\\"')}"` : name;
278
- next = next.replaceAll(/PRODUCT_NAME = [^;]+;/g, `PRODUCT_NAME = ${quoted};`);
279
- }
280
- if (next !== raw) {
281
- await fs.writeFile(pbxprojPath, next, 'utf-8');
282
- }
283
- } catch {
284
- // ignore
285
- }
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 });
286
278
  }
287
279
 
288
280
  const configuration = kv.get('--configuration') ?? 'Debug';
@@ -293,47 +285,39 @@ async function main() {
293
285
  // Ensure CocoaPods doesn't crash due to locale issues.
294
286
  env.LANG = env.LANG ?? 'en_US.UTF-8';
295
287
  env.LC_ALL = env.LC_ALL ?? 'en_US.UTF-8';
296
- await pmExecBin({ dir: uiDir, bin: 'expo', args, env });
288
+ await expoExec({ dir: uiDir, args, env, ensureDepsLabel: 'happy' });
297
289
  }
298
290
 
299
291
  if (!shouldStartMetro) {
300
292
  return;
301
293
  }
302
294
 
303
- const running = await isStateProcessRunning(mobilePaths.statePath);
304
- if (!restart && running.running) {
305
- // eslint-disable-next-line no-console
306
- console.log(`[mobile] Metro already running for this stack/worktree (pid=${running.state.pid}, port=${running.state.port})`);
307
- return;
308
- }
309
- if (restart && running.state?.pid) {
310
- const prevPid = Number(running.state.pid);
311
- const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || autostart.stackName;
312
- const envPath = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
313
- const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-mobile', json: true });
314
- if (!res.killed) {
315
- // eslint-disable-next-line no-console
316
- console.warn(
317
- `[mobile] not stopping existing Metro pid=${prevPid} because it does not look stack-owned.\n` +
318
- `[mobile] continuing by starting a new Metro on a free port.`
319
- );
320
- }
321
- }
322
-
323
- const requestedPort = Number.parseInt(String(portRaw), 10);
324
- const startPort = Number.isFinite(requestedPort) && requestedPort > 0 ? requestedPort : 8081;
325
- const portNumber = await pickNextFreeTcpPort(startPort);
326
- 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);
327
305
 
328
- // Start Metro for a dev client.
329
- // The critical part is --scheme: without it, Expo defaults to `exp+<slug>` (here `exp+happy`)
330
- // which the App Store app also registers, so iOS can open the wrong app.
331
- const args = ['start', '--dev-client', '--host', host, '--port', String(portNumber), '--scheme', scheme];
332
- if (wantsExpoClearCache({ env })) {
333
- args.push('--clear');
334
- }
335
- const child = await pmSpawnBin({ label: 'mobile', dir: uiDir, bin: 'expo', args, env });
336
- 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
+ });
337
321
 
338
322
  await new Promise(() => {});
339
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
+
@@ -0,0 +1,217 @@
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 { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
6
+ import { assertCliPrereqs } from './utils/cli/prereqs.mjs';
7
+ import { resolveBaseRef } from './utils/review/base_ref.mjs';
8
+ import { isStackMode, resolveDefaultStackReviewComponents } from './utils/review/targets.mjs';
9
+ import { runWithConcurrencyLimit } from './utils/proc/parallel.mjs';
10
+ import { runCodeRabbitReview } from './utils/review/runners/coderabbit.mjs';
11
+ import { extractCodexReviewFromJsonl, runCodexReview } from './utils/review/runners/codex.mjs';
12
+
13
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
14
+ const VALID_COMPONENTS = DEFAULT_COMPONENTS;
15
+ const VALID_REVIEWERS = ['coderabbit', 'codex'];
16
+
17
+ function parseCsv(raw) {
18
+ return String(raw ?? '')
19
+ .split(',')
20
+ .map((s) => s.trim())
21
+ .filter(Boolean);
22
+ }
23
+
24
+ function normalizeReviewers(list) {
25
+ const raw = Array.isArray(list) ? list : [];
26
+ const lower = raw.map((r) => String(r).trim().toLowerCase()).filter(Boolean);
27
+ const uniq = Array.from(new Set(lower));
28
+ return uniq.length ? uniq : ['coderabbit'];
29
+ }
30
+
31
+ function usage() {
32
+ return [
33
+ '[review] usage:',
34
+ ' happys review [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--concurrency=N] [--json]',
35
+ '',
36
+ 'components:',
37
+ ` ${VALID_COMPONENTS.join(' | ')}`,
38
+ '',
39
+ 'reviewers:',
40
+ ` ${VALID_REVIEWERS.join(' | ')}`,
41
+ '',
42
+ 'notes:',
43
+ '- If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
44
+ '- In stack mode (invoked via `happys stack review <stack>`), if no components are provided, defaults to stack-pinned non-default components only.',
45
+ '',
46
+ 'examples:',
47
+ ' happys review',
48
+ ' happys review happy-cli --reviewers=coderabbit,codex',
49
+ ' happys stack review exp1 --reviewers=codex',
50
+ ' happys review happy --base-remote=upstream --base-branch=main',
51
+ ].join('\n');
52
+ }
53
+
54
+ function resolveComponentFromCwdOrNull({ rootDir, invokedCwd }) {
55
+ return inferComponentFromCwd({ rootDir, invokedCwd, components: DEFAULT_COMPONENTS });
56
+ }
57
+
58
+ function stackRemoteFallbackFromEnv(env) {
59
+ return String(env.HAPPY_STACKS_STACK_REMOTE ?? env.HAPPY_LOCAL_STACK_REMOTE ?? '').trim();
60
+ }
61
+
62
+ async function main() {
63
+ const argv = process.argv.slice(2);
64
+ const { flags, kv } = parseArgs(argv);
65
+ const json = wantsJson(argv, { flags });
66
+
67
+ if (wantsHelp(argv, { flags })) {
68
+ printResult({ json, data: { usage: usage() }, text: usage() });
69
+ return;
70
+ }
71
+
72
+ const rootDir = getRootDir(import.meta.url);
73
+ const invokedCwd = getInvokedCwd(process.env);
74
+ const positionals = argv.filter((a) => !a.startsWith('--'));
75
+
76
+ const reviewers = normalizeReviewers(parseCsv(kv.get('--reviewers') ?? ''));
77
+ for (const r of reviewers) {
78
+ if (!VALID_REVIEWERS.includes(r)) {
79
+ throw new Error(`[review] unknown reviewer: ${r} (expected one of: ${VALID_REVIEWERS.join(', ')})`);
80
+ }
81
+ }
82
+
83
+ await assertCliPrereqs({
84
+ git: true,
85
+ coderabbit: reviewers.includes('coderabbit'),
86
+ codex: reviewers.includes('codex'),
87
+ });
88
+
89
+ const inferred = positionals.length === 0 ? resolveComponentFromCwdOrNull({ rootDir, invokedCwd }) : null;
90
+ if (inferred) {
91
+ // Make downstream getComponentDir() resolve to the inferred repo dir for this run.
92
+ process.env[`HAPPY_STACKS_COMPONENT_DIR_${inferred.component.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = inferred.repoDir;
93
+ }
94
+
95
+ const inStackMode = isStackMode(process.env);
96
+ const requestedComponents = positionals.length ? positionals : inferred ? [inferred.component] : ['all'];
97
+ const wantAll = requestedComponents.includes('all');
98
+
99
+ let components = wantAll ? DEFAULT_COMPONENTS : requestedComponents;
100
+ if (!positionals.length && !inferred && inStackMode) {
101
+ const pinned = resolveDefaultStackReviewComponents({ rootDir, components: DEFAULT_COMPONENTS });
102
+ components = pinned.length ? pinned : [];
103
+ }
104
+
105
+ for (const c of components) {
106
+ if (!VALID_COMPONENTS.includes(c)) {
107
+ throw new Error(`[review] unknown component: ${c} (expected one of: ${VALID_COMPONENTS.join(', ')})`);
108
+ }
109
+ }
110
+
111
+ if (!components.length) {
112
+ const msg = inStackMode ? '[review] no non-default stack-pinned components to review' : '[review] no components selected';
113
+ printResult({ json, data: { ok: true, skipped: true, reason: msg }, text: msg });
114
+ return;
115
+ }
116
+
117
+ const baseRefOverride = (kv.get('--base-ref') ?? '').trim();
118
+ const baseRemoteOverride = (kv.get('--base-remote') ?? '').trim();
119
+ const baseBranchOverride = (kv.get('--base-branch') ?? '').trim();
120
+ const stackRemoteFallback = stackRemoteFallbackFromEnv(process.env);
121
+ const concurrency = (kv.get('--concurrency') ?? '').trim();
122
+ const limit = concurrency ? Number(concurrency) : 4;
123
+
124
+ const jobs = [];
125
+ for (const component of components) {
126
+ const repoDir = getComponentDir(rootDir, component);
127
+ jobs.push({ component, repoDir });
128
+ }
129
+
130
+ const jobResults = await runWithConcurrencyLimit({
131
+ items: jobs,
132
+ limit,
133
+ fn: async (job) => {
134
+ const { component, repoDir } = job;
135
+ const base = await resolveBaseRef({
136
+ cwd: repoDir,
137
+ baseRefOverride,
138
+ baseRemoteOverride,
139
+ baseBranchOverride,
140
+ stackRemoteFallback,
141
+ });
142
+
143
+ const perReviewer = await Promise.all(
144
+ reviewers.map(async (reviewer) => {
145
+ if (reviewer === 'coderabbit') {
146
+ const res = await runCodeRabbitReview({ repoDir, baseRef: base.baseRef, env: process.env });
147
+ return {
148
+ reviewer,
149
+ ok: Boolean(res.ok),
150
+ exitCode: res.exitCode,
151
+ signal: res.signal,
152
+ durationMs: res.durationMs,
153
+ stdout: res.stdout ?? '',
154
+ stderr: res.stderr ?? '',
155
+ };
156
+ }
157
+ if (reviewer === 'codex') {
158
+ const res = await runCodexReview({ repoDir, baseRef: base.baseRef, env: process.env, jsonMode: true });
159
+ const extracted = extractCodexReviewFromJsonl(res.stdout ?? '');
160
+ return {
161
+ reviewer,
162
+ ok: Boolean(res.ok),
163
+ exitCode: res.exitCode,
164
+ signal: res.signal,
165
+ durationMs: res.durationMs,
166
+ stdout: res.stdout ?? '',
167
+ stderr: res.stderr ?? '',
168
+ review_output: extracted,
169
+ };
170
+ }
171
+ return { reviewer, ok: false, exitCode: null, signal: null, durationMs: 0, stdout: '', stderr: 'unknown reviewer\n' };
172
+ })
173
+ );
174
+
175
+ return { component, repoDir, base, results: perReviewer };
176
+ },
177
+ });
178
+
179
+ const ok = jobResults.every((r) => r.results.every((x) => x.ok));
180
+ if (json) {
181
+ printResult({ json, data: { ok, reviewers, components, results: jobResults } });
182
+ if (!ok) process.exit(1);
183
+ return;
184
+ }
185
+
186
+ const lines = [];
187
+ lines.push('[review] results:');
188
+ for (const r of jobResults) {
189
+ lines.push('============================================================================');
190
+ lines.push(`component: ${r.component}`);
191
+ lines.push(`dir: ${r.repoDir}`);
192
+ lines.push(`baseRef: ${r.base.baseRef}`);
193
+ for (const rr of r.results) {
194
+ lines.push('');
195
+ const status = rr.ok ? '✅ ok' : '❌ failed';
196
+ lines.push(`[${rr.reviewer}] ${status} (exit=${rr.exitCode ?? 'null'} durMs=${rr.durationMs ?? '?'})`);
197
+ if (rr.stderr) {
198
+ lines.push('--- stderr ---');
199
+ lines.push(String(rr.stderr).trimEnd());
200
+ }
201
+ if (rr.stdout) {
202
+ lines.push('--- stdout ---');
203
+ lines.push(String(rr.stdout).trimEnd());
204
+ }
205
+ }
206
+ lines.push('');
207
+ }
208
+ lines.push(ok ? '[review] ok' : '[review] failed');
209
+ printResult({ json: false, text: lines.join('\n') });
210
+ if (!ok) process.exit(1);
211
+ }
212
+
213
+ main().catch((err) => {
214
+ console.error('[review] failed:', err);
215
+ process.exit(1);
216
+ });
217
+