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
@@ -0,0 +1,38 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { getLegacyStorageRoot, getStacksStorageRoot } from './paths/paths.mjs';
6
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './env/sandbox.mjs';
7
+
8
+ export async function listAllStackNames() {
9
+ const names = new Set(['main']);
10
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
11
+ const roots = [
12
+ // New layout: ~/.happy/stacks/<name>/env
13
+ getStacksStorageRoot(),
14
+ // Legacy layout: ~/.happy/local/stacks/<name>/env
15
+ ...(allowLegacy ? [join(getLegacyStorageRoot(), 'stacks')] : []),
16
+ ];
17
+
18
+ for (const root of roots) {
19
+ let entries = [];
20
+ try {
21
+ // eslint-disable-next-line no-await-in-loop
22
+ entries = await readdir(root, { withFileTypes: true });
23
+ } catch {
24
+ entries = [];
25
+ }
26
+ for (const ent of entries) {
27
+ if (!ent.isDirectory()) continue;
28
+ const name = ent.name;
29
+ if (!name || name.startsWith('.')) continue;
30
+ const envPath = join(root, name, 'env');
31
+ if (existsSync(envPath)) {
32
+ names.add(name);
33
+ }
34
+ }
35
+ }
36
+
37
+ return Array.from(names).sort();
38
+ }
@@ -0,0 +1,17 @@
1
+ import qrcodeTerminal from 'qrcode-terminal';
2
+
3
+ export async function renderQrAscii(text, { small = true } = {}) {
4
+ const qrText = String(text ?? '');
5
+ if (!qrText) return { ok: false, lines: [], error: 'empty QR payload' };
6
+ try {
7
+ const out = await new Promise((resolvePromise) => {
8
+ qrcodeTerminal.generate(qrText, { small: Boolean(small) }, (qr) => resolvePromise(String(qr ?? '')));
9
+ });
10
+ // Important: keep whitespace; scanners rely on quiet-zone padding.
11
+ const lines = String(out ?? '').replace(/\r/g, '').split('\n');
12
+ return { ok: true, lines, error: null };
13
+ } catch (e) {
14
+ return { ok: false, lines: [], error: e instanceof Error ? e.message : String(e) };
15
+ }
16
+ }
17
+
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import { getComponentsDir } from './paths/paths.mjs';
4
+
5
+ function isInside(path, dir) {
6
+ const p = resolve(path);
7
+ const d = resolve(dir);
8
+ return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
9
+ }
10
+
11
+ export function detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir }) {
12
+ const componentsDir = getComponentsDir(rootDir);
13
+
14
+ const other = serverComponentName === 'happy-server-light' ? 'happy-server' : serverComponentName === 'happy-server' ? 'happy-server-light' : null;
15
+ if (!other) {
16
+ return null;
17
+ }
18
+
19
+ const otherRepo = resolve(componentsDir, other);
20
+ const otherWts = resolve(componentsDir, '.worktrees', other);
21
+
22
+ if (isInside(serverDir, otherRepo) || isInside(serverDir, otherWts)) {
23
+ return { expected: serverComponentName, actual: other, serverDir };
24
+ }
25
+
26
+ return null;
27
+ }
28
+
29
+ export function assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir }) {
30
+ const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir });
31
+ if (!mismatch) {
32
+ return;
33
+ }
34
+
35
+ const hint =
36
+ mismatch.expected === 'happy-server-light'
37
+ ? 'Fix: either switch flavor (`happys srv use happy-server`) or switch the active checkout for happy-server-light (`happys wt use happy-server-light default` or a worktree under .worktrees/happy-server-light/).'
38
+ : 'Fix: either switch flavor (`happys srv use happy-server-light`) or switch the active checkout for happy-server (`happys wt use happy-server default` or a worktree under .worktrees/happy-server/).';
39
+
40
+ throw new Error(
41
+ `[server] server component dir mismatch:\n` +
42
+ `- selected flavor: ${mismatch.expected}\n` +
43
+ `- but HAPPY_STACKS_COMPONENT_DIR_* points inside: ${mismatch.actual}\n` +
44
+ `- path: ${mismatch.serverDir}\n` +
45
+ `${hint}`
46
+ );
47
+ }
48
+
49
+ function detectPrismaProvider(schemaText) {
50
+ // Best-effort parse of:
51
+ // datasource db { provider = "sqlite" ... }
52
+ const m = schemaText.match(/datasource\s+db\s*\{[\s\S]*?\bprovider\s*=\s*\"([a-zA-Z0-9_-]+)\"/m);
53
+ return m?.[1] ?? '';
54
+ }
55
+
56
+ export function assertServerPrismaProviderMatches({ serverComponentName, serverDir }) {
57
+ const schemaPath = join(serverDir, 'prisma', 'schema.prisma');
58
+ let schemaText = '';
59
+ try {
60
+ schemaText = readFileSync(schemaPath, 'utf-8');
61
+ } catch {
62
+ // If it doesn't exist, skip validation; not every server component necessarily uses Prisma.
63
+ return;
64
+ }
65
+
66
+ const provider = detectPrismaProvider(schemaText);
67
+ if (!provider) {
68
+ return;
69
+ }
70
+
71
+ if (serverComponentName === 'happy-server-light' && provider !== 'sqlite') {
72
+ throw new Error(
73
+ `[server] happy-server-light expects Prisma datasource provider \"sqlite\", but found \"${provider}\" in:\n` +
74
+ `- ${schemaPath}\n` +
75
+ `This usually means you're pointing happy-server-light at an upstream happy-server checkout/PR (Postgres).\n` +
76
+ `Fix: either switch server flavor to happy-server, or point happy-server-light at a fork checkout that keeps sqlite support.`
77
+ );
78
+ }
79
+
80
+ if (serverComponentName === 'happy-server' && provider === 'sqlite') {
81
+ throw new Error(
82
+ `[server] happy-server expects Prisma datasource provider \"postgresql\", but found \"sqlite\" in:\n` +
83
+ `- ${schemaPath}\n` +
84
+ `Fix: either switch server flavor to happy-server-light, or point happy-server at the full-server checkout.`
85
+ );
86
+ }
87
+ }
88
+
@@ -14,10 +14,13 @@ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
14
14
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
15
  import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
16
16
  import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
17
+ import { isSandboxed } from './utils/env/sandbox.mjs';
17
18
  import { existsSync } from 'node:fs';
18
19
  import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
19
20
  import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
20
21
 
22
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
23
+
21
24
  function getActiveStackName() {
22
25
  return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
23
26
  }
@@ -162,41 +165,100 @@ async function installDependencies({ dir }) {
162
165
  return { installed: false, reason: 'no package manager detected (no package.json)' };
163
166
  }
164
167
 
168
+ // IMPORTANT:
169
+ // When a caller requests --json, stdout must be reserved for JSON output only.
170
+ // Package managers (especially Yarn) write progress to stdout, which would corrupt JSON parsing
171
+ // in wrappers like `stack pr`.
172
+ const jsonMode = Boolean((process.argv ?? []).includes('--json'));
173
+ const runForJson = async (cmd, args) => {
174
+ try {
175
+ const out = await runCapture(cmd, args, { cwd: dir });
176
+ if (out) process.stderr.write(out);
177
+ } catch (e) {
178
+ const out = String(e?.out ?? '');
179
+ const err = String(e?.err ?? '');
180
+ if (out) process.stderr.write(out);
181
+ if (err) process.stderr.write(err);
182
+ throw e;
183
+ }
184
+ };
185
+
165
186
  if (pm.kind === 'pnpm') {
166
- await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
187
+ if (jsonMode) {
188
+ await runForJson('pnpm', ['install', '--frozen-lockfile']);
189
+ } else {
190
+ await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
191
+ }
167
192
  return { installed: true, reason: null };
168
193
  }
169
194
  if (pm.kind === 'yarn') {
170
195
  // Works for yarn classic; yarn berry will ignore/translate flags as needed.
171
- await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir });
196
+ if (jsonMode) {
197
+ await runForJson('yarn', ['install', '--frozen-lockfile']);
198
+ } else {
199
+ await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir });
200
+ }
172
201
  return { installed: true, reason: null };
173
202
  }
174
203
  // npm
175
204
  if (pm.lockfile && pm.lockfile !== 'package.json') {
176
- await run('npm', ['ci'], { cwd: dir });
205
+ if (jsonMode) {
206
+ await runForJson('npm', ['ci']);
207
+ } else {
208
+ await run('npm', ['ci'], { cwd: dir });
209
+ }
177
210
  } else {
178
- await run('npm', ['install'], { cwd: dir });
211
+ if (jsonMode) {
212
+ await runForJson('npm', ['install']);
213
+ } else {
214
+ await run('npm', ['install'], { cwd: dir });
215
+ }
179
216
  }
180
217
  return { installed: true, reason: null };
181
218
  }
182
219
 
183
- async function maybeSetupDeps({ repoRoot, baseDir, worktreeDir, depsMode }) {
220
+ function allowNodeModulesSymlinkForComponent(component) {
221
+ const c = String(component ?? '').trim();
222
+ if (!c) return true;
223
+ // Expo/Metro commonly breaks with symlinked node_modules. Avoid symlinks for the Happy UI worktree by default.
224
+ // Override if you *really* want to experiment:
225
+ // HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK=1
226
+ const allowHappySymlink =
227
+ (process.env.HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK ?? process.env.HAPPY_LOCAL_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK ?? '')
228
+ .toString()
229
+ .trim() === '1';
230
+ if (c === 'happy' && !allowHappySymlink) return false;
231
+ return true;
232
+ }
233
+
234
+ async function maybeSetupDeps({ repoRoot, baseDir, worktreeDir, depsMode, component }) {
184
235
  if (!depsMode || depsMode === 'none') {
185
236
  return { mode: 'none', linked: false, installed: false, message: null };
186
237
  }
187
238
 
188
239
  // Prefer explicit baseDir if provided, otherwise link from the primary checkout (repoRoot).
189
240
  const linkFrom = baseDir || repoRoot;
241
+ const allowSymlink = allowNodeModulesSymlinkForComponent(component);
190
242
 
191
243
  if (depsMode === 'link' || depsMode === 'link-or-install') {
192
- const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
193
- if (res.linked) {
194
- return { mode: depsMode, linked: true, installed: false, message: null };
195
- }
196
- if (depsMode === 'link') {
197
- return { mode: depsMode, linked: false, installed: false, message: res.reason };
244
+ if (!allowSymlink) {
245
+ const msg =
246
+ `[wt] refusing to symlink node_modules for ${component} (Expo/Metro is often broken by symlinks).\n` +
247
+ `[wt] Fix: use --deps=install (recommended). To override: set HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK=1`;
248
+ if (depsMode === 'link') {
249
+ return { mode: depsMode, linked: false, installed: false, message: msg };
250
+ }
251
+ // link-or-install: fall through to install.
252
+ } else {
253
+ const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
254
+ if (res.linked) {
255
+ return { mode: depsMode, linked: true, installed: false, message: null };
256
+ }
257
+ if (depsMode === 'link') {
258
+ return { mode: depsMode, linked: false, installed: false, message: res.reason };
259
+ }
260
+ // fall through to install
198
261
  }
199
- // fall through to install
200
262
  }
201
263
 
202
264
  const inst = await installDependencies({ dir: worktreeDir });
@@ -430,11 +492,9 @@ async function migrateComponentWorktrees({ rootDir, component }) {
430
492
  }
431
493
 
432
494
  async function cmdMigrate({ rootDir }) {
433
- const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
434
-
435
495
  let totalMoved = 0;
436
496
  let totalRenamed = 0;
437
- for (const component of components) {
497
+ for (const component of DEFAULT_COMPONENTS) {
438
498
  const res = await migrateComponentWorktrees({ rootDir, component });
439
499
  totalMoved += res.moved;
440
500
  totalRenamed += res.renamed;
@@ -679,7 +739,7 @@ async function cmdNew({ rootDir, argv }) {
679
739
  }
680
740
 
681
741
  const depsMode = parseDepsMode(kv.get('--deps'));
682
- const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
742
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode, component });
683
743
 
684
744
  const shouldUse = flags.has('--use');
685
745
  const force = flags.has('--force');
@@ -765,8 +825,14 @@ async function cmdPr({ rootDir, argv }) {
765
825
  throw new Error(`[wt] unable to parse PR: ${prInput}`);
766
826
  }
767
827
 
768
- const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
769
- const { owner } = await resolveRemoteOwner(repoRoot, remoteName);
828
+ const remoteFromArg = (kv.get('--remote') ?? '').trim();
829
+ const canFetchByUrl = !remoteFromArg && pr.owner && pr.repo;
830
+ const fetchTarget = canFetchByUrl ? `https://github.com/${pr.owner}/${pr.repo}.git` : null;
831
+
832
+ // If we can fetch directly from the PR URL's repo, do it. This avoids any assumptions about local
833
+ // remote names like "origin" vs "upstream" and works even when the repo doesn't have that remote set up.
834
+ const remoteName = canFetchByUrl ? '' : await normalizeRemoteName(repoRoot, remoteFromArg || 'upstream');
835
+ const { owner } = canFetchByUrl ? { owner: pr.owner } : await resolveRemoteOwner(repoRoot, remoteName);
770
836
 
771
837
  const slugExtra = sanitizeSlugPart(kv.get('--slug') ?? '');
772
838
  const slug = slugExtra ? `pr/${pr.number}-${slugExtra}` : `pr/${pr.number}`;
@@ -783,7 +849,9 @@ async function cmdPr({ rootDir, argv }) {
783
849
  }
784
850
 
785
851
  // Fetch PR head ref (GitHub convention). Use + to allow force-updated PR branches when --force is set.
786
- const force = flags.has('--force');
852
+ // In sandbox mode, be more aggressive: the entire workspace is disposable, so it's safe to
853
+ // reset an existing local PR branch to the fetched PR head if needed.
854
+ const force = flags.has('--force') || isSandboxed();
787
855
  let oldHead = null;
788
856
  const prRef = `refs/pull/${pr.number}/head`;
789
857
  if (exists) {
@@ -799,14 +867,17 @@ async function cmdPr({ rootDir, argv }) {
799
867
  }
800
868
 
801
869
  oldHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
802
- await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
870
+ await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
803
871
  const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
804
872
 
805
873
  const isAncestor = await gitOk(repoRoot, ['merge-base', '--is-ancestor', oldHead, newTip]);
806
874
  if (!isAncestor && !force) {
875
+ const hint = fetchTarget
876
+ ? `[wt] re-run with: happys wt pr ${component} ${pr.number} --update --force`
877
+ : `[wt] re-run with: happys wt pr ${component} ${pr.number} --remote=${remoteName} --update --force`;
807
878
  throw new Error(
808
879
  `[wt] PR update is not a fast-forward (likely force-push) for ${branchName}\n` +
809
- `[wt] re-run with: happys wt pr ${component} ${pr.number} --remote=${remoteName} --update --force`
880
+ hint
810
881
  );
811
882
  }
812
883
 
@@ -837,16 +908,22 @@ async function cmdPr({ rootDir, argv }) {
837
908
  );
838
909
  }
839
910
  } else {
840
- await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
911
+ await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
841
912
  const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
842
913
 
843
914
  const branchExists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
844
915
  if (branchExists) {
845
916
  if (!force) {
846
- throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
917
+ // If the branch already points at the fetched PR tip, we can safely just attach a worktree.
918
+ const branchHead = (await git(repoRoot, ['rev-parse', branchName])).trim();
919
+ if (branchHead !== newTip) {
920
+ throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
921
+ }
922
+ await git(repoRoot, ['worktree', 'add', destPath, branchName]);
923
+ } else {
924
+ await git(repoRoot, ['branch', '-f', branchName, newTip]);
925
+ await git(repoRoot, ['worktree', 'add', destPath, branchName]);
847
926
  }
848
- await git(repoRoot, ['branch', '-f', branchName, newTip]);
849
- await git(repoRoot, ['worktree', 'add', destPath, branchName]);
850
927
  } else {
851
928
  // Create worktree at PR head (new local branch).
852
929
  await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, newTip]);
@@ -855,7 +932,7 @@ async function cmdPr({ rootDir, argv }) {
855
932
 
856
933
  // Optional deps handling (useful when PR branches add/change dependencies).
857
934
  const depsMode = parseDepsMode(kv.get('--deps'));
858
- const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode });
935
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode, component });
859
936
 
860
937
  const shouldUse = flags.has('--use');
861
938
  if (shouldUse) {
@@ -1422,8 +1499,6 @@ async function cmdCursor({ rootDir, argv }) {
1422
1499
  throw new Error("[wt] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
1423
1500
  }
1424
1501
 
1425
- const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
1426
-
1427
1502
  async function cmdSyncAll({ rootDir, argv }) {
1428
1503
  const { flags, kv } = parseArgs(argv);
1429
1504
  const json = wantsJson(argv, { flags });
@@ -1556,43 +1631,48 @@ async function cmdNewInteractive({ rootDir, argv }) {
1556
1631
  });
1557
1632
  }
1558
1633
 
1559
- async function cmdList({ rootDir, args }) {
1560
- const component = args[0];
1561
- if (!component) {
1562
- throw new Error('[wt] usage: happys wt list <component>');
1563
- }
1564
-
1634
+ async function cmdListOne({ rootDir, component }) {
1565
1635
  const wtRoot = getWorktreesRoot(rootDir);
1566
1636
  const dir = join(wtRoot, component);
1637
+ const key = componentDirEnvKey(component);
1638
+ const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
1639
+
1567
1640
  if (!(await pathExists(dir))) {
1568
- return { component, activeDir: (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component), worktrees: [] };
1641
+ return { component, activeDir: active, worktrees: [] };
1569
1642
  }
1570
1643
 
1571
- const leafs = [];
1644
+ const worktrees = [];
1572
1645
  const walk = async (d) => {
1646
+ // In git worktrees, ".git" is usually a file that points to the shared git dir.
1647
+ // If this is a worktree root, record it and do not descend into it (avoids traversing huge trees like node_modules).
1648
+ if (await pathExists(join(d, '.git'))) {
1649
+ worktrees.push(d);
1650
+ return;
1651
+ }
1573
1652
  const entries = await readdir(d, { withFileTypes: true });
1574
1653
  for (const e of entries) {
1575
- if (!e.isDirectory()) {
1576
- continue;
1577
- }
1578
- const p = join(d, e.name);
1579
- leafs.push(p);
1580
- await walk(p);
1654
+ if (!e.isDirectory()) continue;
1655
+ if (e.name === 'node_modules') continue;
1656
+ if (e.name.startsWith('.')) continue;
1657
+ await walk(join(d, e.name));
1581
1658
  }
1582
1659
  };
1583
1660
  await walk(dir);
1584
- leafs.sort();
1661
+ worktrees.sort();
1585
1662
 
1586
- const key = componentDirEnvKey(component);
1587
- const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
1663
+ return { component, activeDir: active, worktrees };
1664
+ }
1588
1665
 
1589
- const worktrees = [];
1590
- for (const p of leafs) {
1591
- if (await pathExists(join(p, '.git'))) {
1592
- worktrees.push(p);
1666
+ async function cmdList({ rootDir, args }) {
1667
+ const component = args[0];
1668
+ if (!component) {
1669
+ const results = [];
1670
+ for (const c of DEFAULT_COMPONENTS) {
1671
+ results.push(await cmdListOne({ rootDir, component: c }));
1593
1672
  }
1673
+ return { components: DEFAULT_COMPONENTS, results };
1594
1674
  }
1595
- return { component, activeDir: active, worktrees };
1675
+ return await cmdListOne({ rootDir, component });
1596
1676
  }
1597
1677
 
1598
1678
  async function main() {
@@ -1616,7 +1696,7 @@ async function main() {
1616
1696
  ' happys wt migrate [--json]',
1617
1697
  ' happys wt sync <component> [--remote=<name>] [--json]',
1618
1698
  ' happys wt sync-all [--remote=<name>] [--json]',
1619
- ' happys wt list <component> [--json]',
1699
+ ' happys wt list [component] [--json]',
1620
1700
  ' happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--force] [--interactive|-i] [--json]',
1621
1701
  ' happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
1622
1702
  ' happys wt pr <component> <pr-url|number> [--remote=upstream] [--slug=<name>] [--deps=none|link|install|link-or-install] [--use] [--update] [--stash|--stash-keep] [--force] [--json]',
@@ -1637,7 +1717,7 @@ async function main() {
1637
1717
  ' "<absolute path>": explicit checkout path',
1638
1718
  '',
1639
1719
  'components:',
1640
- ' happy | happy-cli | happy-server-light | happy-server',
1720
+ ` ${DEFAULT_COMPONENTS.join(' | ')}`,
1641
1721
  ].join('\n'),
1642
1722
  });
1643
1723
  return;
@@ -1796,9 +1876,15 @@ async function main() {
1796
1876
  if (json) {
1797
1877
  printResult({ json, data: res });
1798
1878
  } else {
1799
- const lines = [`[wt] ${res.component} worktrees:`, `- active: ${res.activeDir}`];
1800
- for (const p of res.worktrees) {
1801
- lines.push(`- ${p}`);
1879
+ const results = Array.isArray(res?.results) ? res.results : [res];
1880
+ const lines = [];
1881
+ for (const r of results) {
1882
+ lines.push(`[wt] ${r.component} worktrees:`);
1883
+ lines.push(`- active: ${r.activeDir}`);
1884
+ for (const p of r.worktrees) {
1885
+ lines.push(`- ${p}`);
1886
+ }
1887
+ lines.push('');
1802
1888
  }
1803
1889
  printResult({ json: false, text: lines.join('\n') });
1804
1890
  }
@@ -1,112 +0,0 @@
1
- import { ensureDepsInstalled, pmSpawnBin } from '../proc/pm.mjs';
2
- import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from '../expo/expo.mjs';
3
- import { pickDevMetroPort, resolveStackUiDevPortStart } from './server.mjs';
4
- import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
5
- import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
6
-
7
- export async function startDevExpoWebUi({
8
- startUi,
9
- uiDir,
10
- autostart,
11
- baseEnv,
12
- apiServerUrl,
13
- restart,
14
- stackMode,
15
- runtimeStatePath,
16
- stackName,
17
- envPath,
18
- children,
19
- spawnOptions = {},
20
- }) {
21
- if (!startUi) return { ok: true, skipped: true, reason: 'disabled' };
22
-
23
- await ensureDepsInstalled(uiDir, 'happy');
24
- const uiEnv = { ...baseEnv };
25
- delete uiEnv.CI;
26
- uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = apiServerUrl;
27
- uiEnv.EXPO_PUBLIC_DEBUG = uiEnv.EXPO_PUBLIC_DEBUG ?? '1';
28
-
29
- // We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
30
- uiEnv.EXPO_NO_BROWSER = '1';
31
- uiEnv.BROWSER = 'none';
32
-
33
- const uiPaths = getExpoStatePaths({
34
- baseDir: autostart.baseDir,
35
- kind: 'ui-dev',
36
- projectDir: uiDir,
37
- stateFileName: 'ui.state.json',
38
- });
39
-
40
- await ensureExpoIsolationEnv({
41
- env: uiEnv,
42
- stateDir: uiPaths.stateDir,
43
- expoHomeDir: uiPaths.expoHomeDir,
44
- tmpDir: uiPaths.tmpDir,
45
- });
46
-
47
- const uiRunning = await isStateProcessRunning(uiPaths.statePath);
48
- const uiAlreadyRunning = Boolean(uiRunning.running);
49
-
50
- if (uiAlreadyRunning && !restart) {
51
- const pid = Number(uiRunning.state?.pid);
52
- const port = Number(uiRunning.state?.port);
53
- if (stackMode && runtimeStatePath && Number.isFinite(pid) && pid > 1) {
54
- await recordStackRuntimeUpdate(runtimeStatePath, {
55
- processes: { expoWebPid: pid },
56
- expo: { webPort: Number.isFinite(port) && port > 0 ? port : null },
57
- }).catch(() => {});
58
- }
59
- return {
60
- ok: true,
61
- skipped: true,
62
- reason: 'already_running',
63
- pid: Number.isFinite(pid) ? pid : null,
64
- port: Number.isFinite(port) ? port : null,
65
- };
66
- }
67
-
68
- const strategy =
69
- (baseEnv.HAPPY_STACKS_UI_DEV_PORT_STRATEGY ?? baseEnv.HAPPY_LOCAL_UI_DEV_PORT_STRATEGY ?? 'ephemeral').toString().trim() ||
70
- 'ephemeral';
71
- const stable = strategy === 'stable';
72
- const startPort = stackMode && stable ? resolveStackUiDevPortStart({ env: baseEnv, stackName }) : 8081;
73
- const metroPort = await pickDevMetroPort({ startPort });
74
- uiEnv.RCT_METRO_PORT = String(metroPort);
75
-
76
- const uiArgs = ['start', '--web', '--port', String(metroPort)];
77
- if (wantsExpoClearCache({ env: baseEnv })) {
78
- uiArgs.push('--clear');
79
- }
80
-
81
- if (restart && uiRunning.state?.pid) {
82
- const prevPid = Number(uiRunning.state.pid);
83
- const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-web', json: true });
84
- if (!res.killed) {
85
- // eslint-disable-next-line no-console
86
- console.warn(
87
- `[local] ui: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
88
- `[local] ui: continuing by starting a new Expo process on a free port.`
89
- );
90
- }
91
- }
92
-
93
- // eslint-disable-next-line no-console
94
- console.log(`[local] ui: starting Expo web (metro port=${metroPort})`);
95
- const ui = await pmSpawnBin({ label: 'ui', dir: uiDir, bin: 'expo', args: uiArgs, env: uiEnv, options: spawnOptions });
96
- children.push(ui);
97
-
98
- if (stackMode && runtimeStatePath) {
99
- await recordStackRuntimeUpdate(runtimeStatePath, {
100
- processes: { expoWebPid: ui.pid },
101
- expo: { webPort: metroPort },
102
- }).catch(() => {});
103
- }
104
-
105
- try {
106
- await writePidState(uiPaths.statePath, { pid: ui.pid, port: metroPort, uiDir, startedAt: new Date().toISOString() });
107
- } catch {
108
- // ignore
109
- }
110
-
111
- return { ok: true, skipped: false, pid: ui.pid, port: metroPort, proc: ui };
112
- }