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
@@ -26,8 +26,8 @@ export function getHappyStacksHomeDir(env = process.env) {
26
26
  return PRIMARY_HOME_DIR;
27
27
  }
28
28
 
29
- export function getWorkspaceDir(cliRootDir = null) {
30
- 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();
31
31
  if (fromEnv) {
32
32
  return expandHome(fromEnv);
33
33
  }
@@ -41,8 +41,8 @@ export function getWorkspaceDir(cliRootDir = null) {
41
41
  return cliRootDir ? cliRootDir : defaultWorkspace;
42
42
  }
43
43
 
44
- export function getComponentsDir(rootDir) {
45
- const workspaceDir = getWorkspaceDir(rootDir);
44
+ export function getComponentsDir(rootDir, env = process.env) {
45
+ const workspaceDir = getWorkspaceDir(rootDir, env);
46
46
  return join(workspaceDir, 'components');
47
47
  }
48
48
 
@@ -50,46 +50,48 @@ export function componentDirEnvKey(name) {
50
50
  return `HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
51
51
  }
52
52
 
53
- function normalizePathForEnv(rootDir, raw) {
53
+ function normalizePathForEnv(rootDir, raw, env = process.env) {
54
54
  const trimmed = (raw ?? '').trim();
55
55
  if (!trimmed) {
56
56
  return '';
57
57
  }
58
58
  const expanded = expandHome(trimmed);
59
59
  // If the path is relative, treat it as relative to the workspace root (default: repo root).
60
- const workspaceDir = getWorkspaceDir(rootDir);
60
+ const workspaceDir = getWorkspaceDir(rootDir, env);
61
61
  return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
62
62
  }
63
63
 
64
- export function getComponentDir(rootDir, name) {
64
+ export function getComponentDir(rootDir, name, env = process.env) {
65
65
  const stacksKey = componentDirEnvKey(name);
66
66
  const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
67
- const fromEnv = normalizePathForEnv(rootDir, process.env[stacksKey] ?? process.env[legacyKey]);
67
+ const fromEnv = normalizePathForEnv(rootDir, env[stacksKey] ?? env[legacyKey], env);
68
68
  if (fromEnv) {
69
69
  return fromEnv;
70
70
  }
71
- return join(getComponentsDir(rootDir), name);
71
+ return join(getComponentsDir(rootDir, env), name);
72
72
  }
73
73
 
74
- export function getStackName() {
75
- const raw = process.env.HAPPY_STACKS_STACK?.trim()
76
- ? process.env.HAPPY_STACKS_STACK.trim()
77
- : process.env.HAPPY_LOCAL_STACK?.trim()
78
- ? 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()
79
79
  : '';
80
80
  return raw || 'main';
81
81
  }
82
82
 
83
- export function getStackLabel(stackName = getStackName()) {
84
- 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}`;
85
86
  }
86
87
 
87
- export function getLegacyStackLabel(stackName = getStackName()) {
88
- 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}`;
89
91
  }
90
92
 
91
- export function getStacksStorageRoot() {
92
- 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();
93
95
  if (fromEnv) {
94
96
  return expandHome(fromEnv);
95
97
  }
@@ -100,19 +102,20 @@ export function getLegacyStorageRoot() {
100
102
  return LEGACY_STORAGE_ROOT;
101
103
  }
102
104
 
103
- export function resolveStackBaseDir(stackName = getStackName()) {
104
- const preferredRoot = getStacksStorageRoot();
105
- const newBase = join(preferredRoot, stackName);
106
- 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);
107
110
  const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
108
111
 
109
112
  // Prefer the new layout by default.
110
113
  //
111
114
  // For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
112
115
  // This avoids breaking existing stacks until `happys stack migrate` is run.
113
- if (allowLegacy && stackName !== 'main') {
114
- const newEnv = join(preferredRoot, stackName, 'env');
115
- 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');
116
119
  if (!existsSync(newEnv) && existsSync(legacyEnv)) {
117
120
  return { baseDir: legacyBase, isLegacy: true };
118
121
  }
@@ -121,30 +124,31 @@ export function resolveStackBaseDir(stackName = getStackName()) {
121
124
  return { baseDir: newBase, isLegacy: false };
122
125
  }
123
126
 
124
- export function resolveStackEnvPath(stackName = getStackName()) {
125
- 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);
126
130
  // New layout: ~/.happy/stacks/<name>/env
127
- const newEnv = join(getStacksStorageRoot(), stackName, 'env');
131
+ const newEnv = join(getStacksStorageRoot(env), name, 'env');
128
132
  // Legacy layout: ~/.happy/local/stacks/<name>/env
129
- const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
133
+ const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
130
134
  const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
131
135
 
132
136
  if (existsSync(newEnv)) {
133
- return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(), stackName) };
137
+ return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(env), name) };
134
138
  }
135
139
  if (allowLegacy && existsSync(legacyEnv)) {
136
- 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) };
137
141
  }
138
142
  return { envPath: newEnv, isLegacy, baseDir: activeBase };
139
143
  }
140
144
 
141
- export function getDefaultAutostartPaths() {
142
- const stackName = getStackName();
143
- const { baseDir, isLegacy } = resolveStackBaseDir(stackName);
145
+ export function getDefaultAutostartPaths(env = process.env) {
146
+ const stackName = getStackName(env);
147
+ const { baseDir, isLegacy } = resolveStackBaseDir(stackName, env);
144
148
  const logsDir = join(baseDir, 'logs');
145
149
 
146
- const primaryLabel = getStackLabel(stackName);
147
- const legacyLabel = getLegacyStackLabel(stackName);
150
+ const primaryLabel = getStackLabel(stackName, env);
151
+ const legacyLabel = getLegacyStackLabel(stackName, env);
148
152
  const primaryPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${primaryLabel}.plist`);
149
153
  const legacyPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${legacyLabel}.plist`);
150
154
 
@@ -0,0 +1,25 @@
1
+ export async function runWithConcurrencyLimit({ items, limit, fn }) {
2
+ const list = Array.isArray(items) ? items : [];
3
+ const max = Number(limit);
4
+ const concurrency = Number.isFinite(max) && max > 0 ? Math.floor(max) : 4;
5
+
6
+ const results = new Array(list.length);
7
+ let nextIndex = 0;
8
+
9
+ const worker = async () => {
10
+ while (true) {
11
+ const i = nextIndex;
12
+ nextIndex += 1;
13
+ if (i >= list.length) return;
14
+ results[i] = await fn(list[i], i);
15
+ }
16
+ };
17
+
18
+ const workers = [];
19
+ for (let i = 0; i < Math.min(concurrency, list.length); i++) {
20
+ workers.push(worker());
21
+ }
22
+ await Promise.all(workers);
23
+ return results;
24
+ }
25
+
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
- import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { createHash } from 'node:crypto';
6
6
 
7
7
  import { pathExists } from '../fs/fs.mjs';
@@ -113,14 +113,37 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
113
113
  }
114
114
  };
115
115
 
116
+ const patchesMtimeMs = async () => {
117
+ // Happy's mobile app (and some other repos) use patch-package and keep patches under `patches/`.
118
+ // If a patch file changes but yarn.lock/package.json do not, Yarn won't reinstall and
119
+ // patch-package won't re-apply the patch, leading to confusing "why isn't my patch wired?"
120
+ // failures later (e.g. during iOS pod install).
121
+ const patchesDir = join(dir, 'patches');
122
+ if (!(await pathExists(patchesDir))) return 0;
123
+ try {
124
+ const entries = await readdir(patchesDir, { withFileTypes: true });
125
+ let max = 0;
126
+ for (const e of entries) {
127
+ if (!e.isFile()) continue;
128
+ if (!e.name.endsWith('.patch')) continue;
129
+ const m = await mtimeMs(join(patchesDir, e.name));
130
+ if (m > max) max = m;
131
+ }
132
+ return max;
133
+ } catch {
134
+ return 0;
135
+ }
136
+ };
137
+
116
138
  if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
117
139
  const lockM = await mtimeMs(yarnLock);
118
140
  const pkgM = await mtimeMs(pkgJson);
119
141
  const intM = await mtimeMs(yarnIntegrity);
120
- if (!intM || lockM > intM || pkgM > intM) {
142
+ const patchM = await patchesMtimeMs();
143
+ if (!intM || lockM > intM || pkgM > intM || patchM > intM) {
121
144
  if (!quiet) {
122
145
  // eslint-disable-next-line no-console
123
- console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
146
+ console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
124
147
  }
125
148
  await run(pm.cmd, ['install'], { cwd: dir, stdio });
126
149
  }
@@ -161,14 +184,20 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
161
184
  // - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
162
185
  const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
163
186
  const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
164
- if (mode === 'never') {
165
- return { built: false, reason: 'mode_never' };
166
- }
167
187
  const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
168
188
  const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
169
189
  const gitSig = await computeGitWorktreeSignature(cliDir);
170
190
  const prev = await readJsonIfExists(buildStatePath);
171
191
 
192
+ // "never" should prevent rebuild churn, but it must not make the stack unrunnable.
193
+ // If the dist entrypoint is missing, build once even in "never" mode.
194
+ if (mode === 'never') {
195
+ if (await pathExists(distEntrypoint)) {
196
+ return { built: false, reason: 'mode_never' };
197
+ }
198
+ // fallthrough to build
199
+ }
200
+
172
201
  if (mode === 'auto') {
173
202
  // If dist doesn't exist, we must build.
174
203
  if (!(await pathExists(distEntrypoint))) {
@@ -186,6 +215,18 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
186
215
  const pm = await getComponentPm(cliDir);
187
216
  await run(pm.cmd, ['build'], { cwd: cliDir });
188
217
 
218
+ // Sanity check: happy-cli daemon entrypoint must exist after a successful build.
219
+ // Without this, watch-based rebuilds can restart the daemon into a MODULE_NOT_FOUND crash,
220
+ // which looks like the UI "dies out of nowhere" even though the root cause is missing build output.
221
+ if (!(await pathExists(distEntrypoint))) {
222
+ throw new Error(
223
+ `[local] happy-cli build finished but did not produce expected entrypoint.\n` +
224
+ `Expected: ${distEntrypoint}\n` +
225
+ `Fix: run the component build directly and inspect its output:\n` +
226
+ ` cd "${cliDir}" && ${pm.cmd} build`
227
+ );
228
+ }
229
+
189
230
  // Persist new build state (best-effort).
190
231
  const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
191
232
  if (nowSig) {
@@ -301,17 +342,33 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
301
342
  }
302
343
 
303
344
  export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
304
- const pm = await getComponentPm(dir);
345
+ const usesObjectStyle = typeof dir === 'object' && dir !== null;
346
+ const componentDir = usesObjectStyle ? dir.dir : dir;
347
+ const componentLabel = usesObjectStyle ? dir.label : label;
348
+ const componentBin = usesObjectStyle ? dir.bin : bin;
349
+ const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
350
+ const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
351
+ const options = usesObjectStyle ? (dir.options ?? {}) : {};
352
+
353
+ const pm = await getComponentPm(componentDir);
305
354
  if (pm.name === 'yarn') {
306
- return spawnProc(label, pm.cmd, ['run', bin, ...args], env, { cwd: dir });
355
+ return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
307
356
  }
308
- return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { cwd: dir });
357
+ return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
309
358
  }
310
359
 
311
360
  export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
312
- const pm = await getComponentPm(dir);
361
+ const usesObjectStyle = typeof dir === 'object' && dir !== null;
362
+ const componentDir = usesObjectStyle ? dir.dir : dir;
363
+ const componentLabel = usesObjectStyle ? dir.label : label;
364
+ const componentScript = usesObjectStyle ? dir.script : script;
365
+ const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
366
+ const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
367
+ const options = usesObjectStyle ? (dir.options ?? {}) : {};
368
+
369
+ const pm = await getComponentPm(componentDir);
313
370
  if (pm.name === 'yarn') {
314
- return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
371
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
315
372
  }
316
- return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
373
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
317
374
  }
@@ -1,13 +1,27 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
+ function nextLineBreakIndex(s) {
4
+ const n = s.indexOf('\n');
5
+ const r = s.indexOf('\r');
6
+ if (n < 0) return r;
7
+ if (r < 0) return n;
8
+ return Math.min(n, r);
9
+ }
10
+
11
+ function consumeLineBreak(buf) {
12
+ if (buf.startsWith('\r\n')) return buf.slice(2);
13
+ if (buf.startsWith('\n') || buf.startsWith('\r')) return buf.slice(1);
14
+ return buf;
15
+ }
16
+
3
17
  function writeWithPrefix(stream, prefix, bufState, chunk) {
4
18
  const s = chunk.toString();
5
19
  bufState.buf += s;
6
20
  while (true) {
7
- const idx = bufState.buf.indexOf('\n');
21
+ const idx = nextLineBreakIndex(bufState.buf);
8
22
  if (idx < 0) break;
9
23
  const line = bufState.buf.slice(0, idx);
10
- bufState.buf = bufState.buf.slice(idx + 1);
24
+ bufState.buf = consumeLineBreak(bufState.buf.slice(idx));
11
25
  stream.write(`${prefix}${line}\n`);
12
26
  }
13
27
  }
@@ -133,3 +147,63 @@ export async function runCapture(cmd, args, options = {}) {
133
147
  });
134
148
  }
135
149
 
150
+ export async function runCaptureResult(cmd, args, options = {}) {
151
+ const { timeoutMs, ...spawnOptions } = options ?? {};
152
+ const startedAt = Date.now();
153
+ return await new Promise((resolvePromise) => {
154
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
155
+ let out = '';
156
+ let err = '';
157
+ const t =
158
+ Number.isFinite(timeoutMs) && timeoutMs > 0
159
+ ? setTimeout(() => {
160
+ try {
161
+ proc.kill('SIGKILL');
162
+ } catch {
163
+ // ignore
164
+ }
165
+ resolvePromise({
166
+ ok: false,
167
+ exitCode: null,
168
+ signal: null,
169
+ out,
170
+ err,
171
+ timedOut: true,
172
+ startedAt,
173
+ finishedAt: Date.now(),
174
+ durationMs: Date.now() - startedAt,
175
+ });
176
+ }, timeoutMs)
177
+ : null;
178
+ proc.stdout?.on('data', (d) => (out += d.toString()));
179
+ proc.stderr?.on('data', (d) => (err += d.toString()));
180
+ proc.on('error', (e) => {
181
+ if (t) clearTimeout(t);
182
+ resolvePromise({
183
+ ok: false,
184
+ exitCode: null,
185
+ signal: null,
186
+ out,
187
+ err: err + (err.endsWith('\n') || !err ? '' : '\n') + String(e) + '\n',
188
+ timedOut: false,
189
+ startedAt,
190
+ finishedAt: Date.now(),
191
+ durationMs: Date.now() - startedAt,
192
+ });
193
+ });
194
+ proc.on('close', (code, signal) => {
195
+ if (t) clearTimeout(t);
196
+ resolvePromise({
197
+ ok: code === 0,
198
+ exitCode: code,
199
+ signal: signal ?? null,
200
+ out,
201
+ err,
202
+ timedOut: false,
203
+ startedAt,
204
+ finishedAt: Date.now(),
205
+ durationMs: Date.now() - startedAt,
206
+ });
207
+ });
208
+ });
209
+ }
@@ -0,0 +1,74 @@
1
+ import { inferRemoteNameForOwner, parseGithubOwner } from '../git/worktrees.mjs';
2
+ import { gitCapture, gitOk, normalizeRemoteName, resolveRemoteDefaultBranch, ensureRemoteRefAvailable } from '../git/git.mjs';
3
+
4
+ async function currentBranchName({ cwd }) {
5
+ const branch = (await gitCapture({ cwd, args: ['branch', '--show-current'] }).catch(() => '')).trim();
6
+ return branch;
7
+ }
8
+
9
+ function branchOwnerPrefix(branch) {
10
+ const b = String(branch ?? '').trim();
11
+ if (!b || !b.includes('/')) return '';
12
+ return b.split('/')[0] ?? '';
13
+ }
14
+
15
+ async function inferRemoteFromBranchOwner({ cwd }) {
16
+ const branch = await currentBranchName({ cwd });
17
+ const owner = branchOwnerPrefix(branch);
18
+ if (!owner) return '';
19
+
20
+ // Confirm this "owner" is plausible (matches at least one remote's GitHub owner).
21
+ for (const remoteName of ['upstream', 'origin', 'fork']) {
22
+ try {
23
+ const url = (await gitCapture({ cwd, args: ['remote', 'get-url', remoteName] })).trim();
24
+ const parsedOwner = parseGithubOwner(url);
25
+ if (parsedOwner && parsedOwner === owner) {
26
+ return remoteName;
27
+ }
28
+ } catch {
29
+ // ignore
30
+ }
31
+ }
32
+
33
+ // Fall back to the generic inference helper (it checks remotes in priority order).
34
+ return await inferRemoteNameForOwner({ repoDir: cwd, owner });
35
+ }
36
+
37
+ export async function resolveBaseRef({
38
+ cwd,
39
+ baseRefOverride = '',
40
+ baseRemoteOverride = '',
41
+ baseBranchOverride = '',
42
+ stackRemoteFallback = '',
43
+ } = {}) {
44
+ const repoDir = String(cwd ?? '').trim();
45
+ if (!repoDir) {
46
+ throw new Error('[review] missing cwd for base resolution');
47
+ }
48
+
49
+ if (!(await gitOk({ cwd: repoDir, args: ['rev-parse', '--is-inside-work-tree'] }))) {
50
+ throw new Error(`[review] not a git repository: ${repoDir}`);
51
+ }
52
+
53
+ const explicitRef = String(baseRefOverride ?? '').trim();
54
+ if (explicitRef) {
55
+ return { baseRef: explicitRef, remote: '', branch: '' };
56
+ }
57
+
58
+ const stackFallback = String(stackRemoteFallback ?? '').trim();
59
+ const inferredRemote = await inferRemoteFromBranchOwner({ cwd: repoDir });
60
+ const rawRemote = String(baseRemoteOverride ?? '').trim() || inferredRemote || stackFallback || 'upstream';
61
+ const remote = await normalizeRemoteName({ cwd: repoDir, remote: rawRemote });
62
+
63
+ const branch = String(baseBranchOverride ?? '').trim() || (await resolveRemoteDefaultBranch({ cwd: repoDir, remote }));
64
+ const ok = await ensureRemoteRefAvailable({ cwd: repoDir, remote, branch });
65
+ if (!ok) {
66
+ throw new Error(
67
+ `[review] unable to resolve base ref refs/remotes/${remote}/${branch} in ${repoDir}\n` +
68
+ `[review] hint: ensure remote "${remote}" exists and has a configured HEAD/default branch (or pass --base-ref).`
69
+ );
70
+ }
71
+
72
+ return { baseRef: `${remote}/${branch}`, remote, branch };
73
+ }
74
+
@@ -0,0 +1,54 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { runCapture } from '../proc/proc.mjs';
7
+ import { resolveBaseRef } from './base_ref.mjs';
8
+
9
+ async function runGit(cwd, args) {
10
+ await runCapture('git', args, { cwd });
11
+ }
12
+
13
+ async function makeRepoWithRemoteHead() {
14
+ const root = await mkdtemp(join(tmpdir(), 'hs-review-base-ref-'));
15
+ const remote = join(root, 'remote.git');
16
+ const local = join(root, 'local');
17
+
18
+ await runGit(root, ['init', '--bare', remote]);
19
+ await runGit(root, ['init', '-b', 'main', local]);
20
+ await runGit(local, ['config', 'user.email', 'test@example.com']);
21
+ await runGit(local, ['config', 'user.name', 'Test User']);
22
+ await writeFile(join(local, 'file.txt'), 'hello\n', 'utf-8');
23
+ await runGit(local, ['add', '.']);
24
+ await runGit(local, ['commit', '-m', 'initial']);
25
+ await runGit(local, ['remote', 'add', 'upstream', remote]);
26
+ await runGit(local, ['push', '-u', 'upstream', 'main']);
27
+ // Ensure refs/remotes/upstream/HEAD exists.
28
+ await runGit(local, ['remote', 'set-head', 'upstream', '--auto']);
29
+
30
+ return { root, local };
31
+ }
32
+
33
+ test('resolveBaseRef uses explicit --base-ref override', async () => {
34
+ const { root, local } = await makeRepoWithRemoteHead();
35
+ try {
36
+ const res = await resolveBaseRef({ cwd: local, baseRefOverride: 'upstream/main' });
37
+ assert.equal(res.baseRef, 'upstream/main');
38
+ } finally {
39
+ await rm(root, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test('resolveBaseRef infers default branch from refs/remotes/<remote>/HEAD', async () => {
44
+ const { root, local } = await makeRepoWithRemoteHead();
45
+ try {
46
+ const res = await resolveBaseRef({ cwd: local, baseRemoteOverride: 'upstream' });
47
+ assert.equal(res.baseRef, 'upstream/main');
48
+ assert.equal(res.remote, 'upstream');
49
+ assert.equal(res.branch, 'main');
50
+ } finally {
51
+ await rm(root, { recursive: true, force: true });
52
+ }
53
+ });
54
+
@@ -0,0 +1,19 @@
1
+ import { runCaptureResult } from '../../proc/proc.mjs';
2
+
3
+ export async function runCodeRabbitReview({ repoDir, baseRef, env }) {
4
+ const args = [
5
+ 'review',
6
+ '--plain',
7
+ '--no-color',
8
+ '--type',
9
+ 'all',
10
+ '--cwd',
11
+ repoDir,
12
+ ];
13
+ if (baseRef) {
14
+ args.push('--base', baseRef);
15
+ }
16
+ const res = await runCaptureResult('coderabbit', args, { cwd: repoDir, env });
17
+ return { ...res, stdout: res.out, stderr: res.err };
18
+ }
19
+
@@ -0,0 +1,51 @@
1
+ import { runCaptureResult } from '../../proc/proc.mjs';
2
+
3
+ export function extractCodexReviewFromJsonl(jsonlText) {
4
+ const lines = String(jsonlText ?? '')
5
+ .split('\n')
6
+ .map((l) => l.trim())
7
+ .filter(Boolean);
8
+
9
+ // JSONL events typically look like: { "type": "...", "payload": {...} } or similar.
10
+ // We keep this resilient by searching for keys matching the exec output format.
11
+ for (const line of lines) {
12
+ let obj = null;
13
+ try {
14
+ obj = JSON.parse(line);
15
+ } catch {
16
+ continue;
17
+ }
18
+ const msg = obj?.msg ?? obj?.payload ?? obj;
19
+ // We’ve observed EventMsg names like "ExitedReviewMode" in Codex protocol events.
20
+ // Accept several shapes:
21
+ // - { msg: { ExitedReviewMode: { review_output: {...} } } }
22
+ // - { type: "ExitedReviewMode", review_output: {...} }
23
+ const exited =
24
+ msg?.ExitedReviewMode ??
25
+ (obj?.type === 'ExitedReviewMode' ? obj : null) ??
26
+ (msg?.type === 'ExitedReviewMode' ? msg : null);
27
+
28
+ const reviewOutput = exited?.review_output ?? exited?.reviewOutput ?? null;
29
+ if (reviewOutput) return reviewOutput;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ export async function runCodexReview({ repoDir, baseRef, env, jsonMode }) {
35
+ const args = ['review', '--cd', repoDir, '--color=never'];
36
+
37
+ if (baseRef) {
38
+ args.push('--base', baseRef);
39
+ } else {
40
+ // Codex requires one of --uncommitted/--base/--commit/prompt; baseRef should exist in our flow.
41
+ args.push('--uncommitted');
42
+ }
43
+
44
+ if (jsonMode) {
45
+ args.push('--json');
46
+ }
47
+
48
+ const res = await runCaptureResult('codex', args, { cwd: repoDir, env });
49
+ return { ...res, stdout: res.out, stderr: res.err };
50
+ }
51
+
@@ -0,0 +1,24 @@
1
+ import { getComponentsDir, getComponentDir } from '../paths/paths.mjs';
2
+ import { join } from 'node:path';
3
+
4
+ export function isStackMode(env = process.env) {
5
+ const stack = String(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').trim();
6
+ const envFile = String(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
7
+ return Boolean(stack && envFile);
8
+ }
9
+
10
+ export function defaultComponentCheckoutDir(rootDir, component) {
11
+ return join(getComponentsDir(rootDir), component);
12
+ }
13
+
14
+ export function resolveDefaultStackReviewComponents({ rootDir, components }) {
15
+ const list = Array.isArray(components) ? components : [];
16
+ const out = [];
17
+ for (const c of list) {
18
+ const effective = getComponentDir(rootDir, c);
19
+ const def = defaultComponentCheckoutDir(rootDir, c);
20
+ if (effective !== def) out.push(c);
21
+ }
22
+ return out;
23
+ }
24
+
@@ -0,0 +1,36 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { resolveDefaultStackReviewComponents } from './targets.mjs';
4
+
5
+ test('resolveDefaultStackReviewComponents returns only non-default pinned components', () => {
6
+ const rootDir = '/tmp/hs-root';
7
+ const keys = [
8
+ 'HAPPY_STACKS_WORKSPACE_DIR',
9
+ 'HAPPY_STACKS_COMPONENT_DIR_HAPPY',
10
+ 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT',
11
+ 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI',
12
+ 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER',
13
+ ];
14
+ const old = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
15
+ try {
16
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = '/tmp/hs-root';
17
+ // Default checkouts
18
+ process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY = '';
19
+ process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = '';
20
+ // Pinned overrides
21
+ process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = '/tmp/custom/happy-cli';
22
+ process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = '/tmp/custom/happy-server';
23
+
24
+ const comps = resolveDefaultStackReviewComponents({
25
+ rootDir,
26
+ components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
27
+ });
28
+ assert.deepEqual(comps.sort(), ['happy-cli', 'happy-server'].sort());
29
+ } finally {
30
+ for (const k of keys) {
31
+ if (old[k] == null) delete process.env[k];
32
+ else process.env[k] = old[k];
33
+ }
34
+ }
35
+ });
36
+