happy-stacks 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -0,0 +1,31 @@
1
+ import { join } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ import { pathExists } from '../fs/fs.mjs';
5
+ import { requirePnpm } from './pm.mjs';
6
+
7
+ export async function detectPackageManagerCmd(dir) {
8
+ if (await pathExists(join(dir, 'yarn.lock'))) {
9
+ return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
10
+ }
11
+ await requirePnpm();
12
+ return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
13
+ }
14
+
15
+ export async function readPackageJsonScripts(dir) {
16
+ try {
17
+ const raw = await readFile(join(dir, 'package.json'), 'utf-8');
18
+ const pkg = JSON.parse(raw);
19
+ const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
20
+ return scripts;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function pickFirstScript(scripts, candidates) {
27
+ if (!scripts) return null;
28
+ const list = Array.isArray(candidates) ? candidates : [];
29
+ return list.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
30
+ }
31
+
@@ -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
+
@@ -0,0 +1,11 @@
1
+ export function isPidAlive(pid) {
2
+ const n = Number(pid);
3
+ if (!Number.isFinite(n) || n <= 1) return false;
4
+ try {
5
+ process.kill(n, 0);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
@@ -1,36 +1,20 @@
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
- import { pathExists } from './fs.mjs';
7
+ import { pathExists } from '../fs/fs.mjs';
8
+ import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
8
9
  import { run, runCapture, spawnProc } from './proc.mjs';
9
- import { getDefaultAutostartPaths, getHappyStacksHomeDir } from './paths.mjs';
10
- import { resolveInstalledPath, resolveInstalledCliRoot } from './runtime.mjs';
10
+ import { commandExists } from './commands.mjs';
11
+ import { getDefaultAutostartPaths, getHappyStacksHomeDir } from '../paths/paths.mjs';
12
+ import { resolveInstalledPath, resolveInstalledCliRoot } from '../paths/runtime.mjs';
11
13
 
12
14
  function sha256Hex(s) {
13
15
  return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
14
16
  }
15
17
 
16
- async function readJsonIfExists(path) {
17
- try {
18
- if (!path || !existsSync(path)) return null;
19
- const raw = await readFile(path, 'utf-8');
20
- return JSON.parse(raw);
21
- } catch {
22
- return null;
23
- }
24
- }
25
-
26
- async function writeJsonAtomic(path, value) {
27
- const dir = dirname(path);
28
- await mkdir(dir, { recursive: true }).catch(() => {});
29
- const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
30
- await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
31
- await rename(tmp, path);
32
- }
33
-
34
18
  function resolveBuildStatePath({ label, dir }) {
35
19
  const homeDir = getHappyStacksHomeDir();
36
20
  const key = sha256Hex(resolve(dir));
@@ -56,15 +40,6 @@ async function computeGitWorktreeSignature(dir) {
56
40
  }
57
41
  }
58
42
 
59
- async function commandExists(cmd, options = {}) {
60
- try {
61
- await runCapture(cmd, ['--version'], options);
62
- return true;
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
43
  export async function requirePnpm() {
69
44
  if (await commandExists('pnpm')) {
70
45
  return;
@@ -138,14 +113,37 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
138
113
  }
139
114
  };
140
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
+
141
138
  if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
142
139
  const lockM = await mtimeMs(yarnLock);
143
140
  const pkgM = await mtimeMs(pkgJson);
144
141
  const intM = await mtimeMs(yarnIntegrity);
145
- if (!intM || lockM > intM || pkgM > intM) {
142
+ const patchM = await patchesMtimeMs();
143
+ if (!intM || lockM > intM || pkgM > intM || patchM > intM) {
146
144
  if (!quiet) {
147
145
  // eslint-disable-next-line no-console
148
- console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
146
+ console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
149
147
  }
150
148
  await run(pm.cmd, ['install'], { cwd: dir, stdio });
151
149
  }
@@ -186,14 +184,20 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
186
184
  // - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
187
185
  const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
188
186
  const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
189
- if (mode === 'never') {
190
- return { built: false, reason: 'mode_never' };
191
- }
192
187
  const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
193
188
  const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
194
189
  const gitSig = await computeGitWorktreeSignature(cliDir);
195
190
  const prev = await readJsonIfExists(buildStatePath);
196
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
+
197
201
  if (mode === 'auto') {
198
202
  // If dist doesn't exist, we must build.
199
203
  if (!(await pathExists(distEntrypoint))) {
@@ -211,6 +215,18 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
211
215
  const pm = await getComponentPm(cliDir);
212
216
  await run(pm.cmd, ['build'], { cwd: cliDir });
213
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
+
214
230
  // Persist new build state (best-effort).
215
231
  const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
216
232
  if (nowSig) {
@@ -258,147 +274,101 @@ HAPPYS="$BIN_DIR/happys"
258
274
  if [[ -x "$HAPPYS" ]]; then
259
275
  exec "$HAPPYS" happy "$@"
260
276
  fi
261
- exec happys happy "$@"
277
+
278
+ # Fallback: run happy-stacks from runtime install if present.
279
+ HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-\${HAPPY_LOCAL_HOME_DIR:-$HOME/.happy-stacks}}"
280
+ RUNTIME="$HOME_DIR/runtime/node_modules/happy-stacks/bin/happys.mjs"
281
+ if [[ -f "$RUNTIME" ]]; then
282
+ exec node "$RUNTIME" happy "$@"
283
+ fi
284
+
285
+ echo "error: cannot find happys shim or runtime install" >&2
286
+ exit 1
262
287
  `;
263
288
 
264
- await writeFile(happyShim, shim, 'utf-8');
289
+ const writeIfChanged = async (path, text) => {
290
+ let existing = '';
291
+ try {
292
+ existing = await readFile(path, 'utf-8');
293
+ } catch {
294
+ existing = '';
295
+ }
296
+ if (existing === text) return false;
297
+ await writeFile(path, text, 'utf-8');
298
+ return true;
299
+ };
300
+
301
+ await writeIfChanged(happyShim, shim);
265
302
  await chmod(happyShim, 0o755).catch(() => {});
266
303
 
267
- // eslint-disable-next-line no-console
268
- console.log(`[local] installed 'happy' shim at ${happyShim}`);
269
- if (!existsSync(happysShim)) {
304
+ // happys shim: use node + CLI root; if runtime install exists, prefer it.
305
+ const cliRoot = resolveInstalledCliRoot(rootDir);
306
+ const happysShimText = `#!/bin/bash
307
+ set -euo pipefail
308
+ exec node "${resolveInstalledPath(rootDir, 'bin/happys.mjs')}" "$@"
309
+ `;
310
+ await writeIfChanged(happysShim, happysShimText);
311
+ await chmod(happysShim, 0o755).catch(() => {});
312
+
313
+ // If user’s PATH points at a legacy install path, try to make it sane (best-effort).
314
+ const entries = getPathEntries();
315
+ const legacyBin = join(homedir(), '.happy-stacks', 'bin');
316
+ const newBin = join(getDefaultAutostartPaths().baseDir, 'bin');
317
+ if (entries.some((p) => isPathInside(p, legacyBin)) && !entries.some((p) => isPathInside(p, newBin))) {
270
318
  // eslint-disable-next-line no-console
271
- console.log(`[local] note: run \`happys init\` to install a stable ${happysShim} shim for services/SwiftBar.`);
319
+ console.log(`[local] note: your PATH includes ${legacyBin}; recommended path is ${newBin}`);
272
320
  }
273
- }
274
321
 
275
- export async function pmSpawnScript({ label, dir, script, env, options = {} }) {
276
- const pm = await getComponentPm(dir);
277
- if (pm.name === 'yarn') {
278
- return spawnProc(label, pm.cmd, ['-s', script], env, { ...options, cwd: dir });
279
- }
280
- return spawnProc(label, pm.cmd, ['--silent', script], env, { ...options, cwd: dir });
322
+ return { ok: true, cliRoot, binDir, happyShim, happysShim };
281
323
  }
282
324
 
283
- export async function pmSpawnBin({ label, dir, bin, args, env, options = {} }) {
284
- const pm = await getComponentPm(dir);
285
- if (pm.name === 'yarn') {
286
- return spawnProc(label, pm.cmd, [bin, ...args], env, { ...options, cwd: dir });
287
- }
288
- return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
289
- }
325
+ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
326
+ const usesObjectStyle = typeof dirOrOpts === 'object' && dirOrOpts !== null;
290
327
 
291
- export async function pmExecBin({ dir, bin, args, env, quiet = false }) {
292
- const pm = await getComponentPm(dir);
328
+ const dir = usesObjectStyle ? dirOrOpts.dir : dirOrOpts;
329
+ const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
330
+ const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
331
+
332
+ const env = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
333
+ const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
293
334
  const stdio = quiet ? 'ignore' : 'inherit';
335
+
336
+ const pm = await getComponentPm(dir);
294
337
  if (pm.name === 'yarn') {
295
- await run(pm.cmd, [bin, ...args], { env, cwd: dir, stdio });
338
+ await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
296
339
  return;
297
340
  }
298
- await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir, stdio });
341
+ await run(pm.cmd, ['exec', bin, ...args], { cwd: dir, env, stdio });
299
342
  }
300
343
 
301
- export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {
302
- if (process.platform !== 'darwin') {
303
- throw new Error('[local] autostart is currently only implemented for macOS (LaunchAgents).');
304
- }
344
+ export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
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 ?? {}) : {};
305
352
 
306
- const {
307
- logsDir,
308
- stdoutPath,
309
- stderrPath,
310
- plistPath,
311
- primaryLabel,
312
- legacyLabel,
313
- primaryPlistPath,
314
- legacyPlistPath,
315
- primaryStdoutPath,
316
- primaryStderrPath,
317
- legacyStdoutPath,
318
- legacyStderrPath,
319
- } = getDefaultAutostartPaths();
320
- await mkdir(logsDir, { recursive: true });
321
-
322
- const nodePath = process.env.HAPPY_STACKS_NODE?.trim()
323
- ? process.env.HAPPY_STACKS_NODE.trim()
324
- : process.env.HAPPY_LOCAL_NODE?.trim()
325
- ? process.env.HAPPY_LOCAL_NODE.trim()
326
- : process.execPath;
327
- const installedRoot = resolveInstalledCliRoot(rootDir);
328
- const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
329
- const happysShim = join(getHappyStacksHomeDir(), 'bin', 'happys');
330
- const useShim = existsSync(happysShim);
331
-
332
- // Ensure we write to the plist path that matches the label we're installing, instead of the
333
- // "active" plist path (which might be legacy and cause filename/label mismatches).
334
- const resolvedPlistPath =
335
- label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
336
- const resolvedStdoutPath = label === primaryLabel ? primaryStdoutPath : label === legacyLabel ? legacyStdoutPath : stdoutPath;
337
- const resolvedStderrPath = label === primaryLabel ? primaryStderrPath : label === legacyLabel ? legacyStderrPath : stderrPath;
338
-
339
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
340
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
341
- <plist version="1.0">
342
- <dict>
343
- <key>Label</key>
344
- <string>${label}</string>
345
- <key>ProgramArguments</key>
346
- <array>
347
- ${useShim ? `<string>${happysShim}</string>` : `<string>${nodePath}</string>\n <string>${happysEntrypoint}</string>`}
348
- <string>start</string>
349
- </array>
350
- <key>WorkingDirectory</key>
351
- <string>${installedRoot}</string>
352
- <key>RunAtLoad</key>
353
- <true/>
354
- <key>KeepAlive</key>
355
- <true/>
356
- <key>StandardOutPath</key>
357
- <string>${resolvedStdoutPath}</string>
358
- <key>StandardErrorPath</key>
359
- <string>${resolvedStderrPath}</string>
360
- <key>EnvironmentVariables</key>
361
- <dict>
362
- ${Object.entries(env)
363
- .map(([k, v]) => ` <key>${k}</key>\n <string>${String(v)}</string>`)
364
- .join('\n')}
365
- </dict>
366
- </dict>
367
- </plist>
368
- `;
369
-
370
- await mkdir(dirname(resolvedPlistPath), { recursive: true });
371
- await writeFile(resolvedPlistPath, plist, 'utf-8');
372
-
373
- // Best-effort (works on most macOS setups). If it fails, the plist still exists and can be loaded manually.
374
- try {
375
- await run('launchctl', ['unload', '-w', resolvedPlistPath]);
376
- } catch {
377
- // ignore
353
+ const pm = await getComponentPm(componentDir);
354
+ if (pm.name === 'yarn') {
355
+ return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
378
356
  }
379
- await run('launchctl', ['load', '-w', resolvedPlistPath]);
357
+ return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
380
358
  }
381
359
 
382
- export async function ensureMacAutostartDisabled({ label = 'com.happy.local' }) {
383
- if (process.platform !== 'darwin') {
384
- return;
385
- }
386
- const { primaryLabel, legacyLabel, primaryPlistPath, legacyPlistPath } = getDefaultAutostartPaths();
387
- const resolvedPlistPath =
388
- label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
389
- try {
390
- await run('launchctl', ['unload', '-w', resolvedPlistPath]);
391
- } catch {
392
- // Old-style unload can fail on newer macOS; fall back to modern bootout.
393
- try {
394
- const uid = typeof process.getuid === 'function' ? process.getuid() : null;
395
- if (uid != null) {
396
- await run('launchctl', ['bootout', `gui/${uid}/${label}`]);
397
- }
398
- } catch {
399
- // ignore
400
- }
360
+ export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
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);
370
+ if (pm.name === 'yarn') {
371
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
401
372
  }
402
- // eslint-disable-next-line no-console
403
- console.log(`[local] autostart disabled (${label})`);
373
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
404
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
  }
@@ -132,3 +146,64 @@ export async function runCapture(cmd, args, options = {}) {
132
146
  });
133
147
  });
134
148
  }
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
+