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
@@ -1,18 +1,25 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { parseArgs } from './utils/cli/args.mjs';
5
- import { pathExists } from './utils/fs.mjs';
6
- import { run, runCapture } from './utils/proc.mjs';
7
- import { componentDirEnvKey, getComponentDir, getComponentsDir, getHappyStacksHomeDir, getRootDir, getWorkspaceDir } from './utils/paths.mjs';
8
- import { inferRemoteNameForOwner, parseGithubOwner } from './utils/worktrees.mjs';
5
+ import { pathExists } from './utils/fs/fs.mjs';
6
+ import { run, runCapture } from './utils/proc/proc.mjs';
7
+ import { commandExists, resolveCommandPath } from './utils/proc/commands.mjs';
8
+ import { componentDirEnvKey, getComponentDir, getComponentsDir, getHappyStacksHomeDir, getRootDir, getWorkspaceDir } from './utils/paths/paths.mjs';
9
+ import { inferRemoteNameForOwner, parseGithubOwner } from './utils/git/worktrees.mjs';
10
+ import { getWorktreesRoot } from './utils/git/worktrees.mjs';
11
+ import { parseGithubPullRequest, sanitizeSlugPart } from './utils/git/refs.mjs';
12
+ import { readTextIfExists } from './utils/fs/ops.mjs';
9
13
  import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
10
14
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
11
- import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
12
- import { ensureEnvFileUpdated } from './utils/env_file.mjs';
15
+ import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
16
+ import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
17
+ import { isSandboxed } from './utils/env/sandbox.mjs';
13
18
  import { existsSync } from 'node:fs';
14
- import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/config.mjs';
15
- import { detectServerComponentDirMismatch } from './utils/validate.mjs';
19
+ import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
20
+ import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
21
+
22
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
16
23
 
17
24
  function getActiveStackName() {
18
25
  return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
@@ -22,10 +29,6 @@ function isMainStack() {
22
29
  return getActiveStackName() === 'main';
23
30
  }
24
31
 
25
- function getWorktreesRoot(rootDir) {
26
- return join(getComponentsDir(rootDir), '.worktrees');
27
- }
28
-
29
32
  function resolveComponentWorktreeDir({ rootDir, component, spec }) {
30
33
  const worktreesRoot = getWorktreesRoot(rootDir);
31
34
  const raw = (spec ?? '').trim();
@@ -51,32 +54,6 @@ function resolveComponentWorktreeDir({ rootDir, component, spec }) {
51
54
  return join(worktreesRoot, component, ...raw.split('/'));
52
55
  }
53
56
 
54
- function parseGithubPullRequest(input) {
55
- const raw = (input ?? '').trim();
56
- if (!raw) return null;
57
- if (/^\d+$/.test(raw)) {
58
- return { number: Number(raw), owner: null, repo: null };
59
- }
60
- // https://github.com/<owner>/<repo>/pull/<num>
61
- const m = raw.match(/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<num>\d+)/);
62
- if (!m?.groups?.num) return null;
63
- return {
64
- number: Number(m.groups.num),
65
- owner: m.groups.owner ?? null,
66
- repo: m.groups.repo ?? null,
67
- };
68
- }
69
-
70
- function sanitizeSlugPart(s) {
71
- return (s ?? '')
72
- .toString()
73
- .trim()
74
- .toLowerCase()
75
- .replace(/[^a-z0-9._/-]+/g, '-')
76
- .replace(/-+/g, '-')
77
- .replace(/^-+|-+$/g, '');
78
- }
79
-
80
57
  async function isWorktreeClean(dir) {
81
58
  const dirty = (await git(dir, ['status', '--porcelain'])).trim();
82
59
  return !dirty;
@@ -188,41 +165,100 @@ async function installDependencies({ dir }) {
188
165
  return { installed: false, reason: 'no package manager detected (no package.json)' };
189
166
  }
190
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
+
191
186
  if (pm.kind === 'pnpm') {
192
- 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
+ }
193
192
  return { installed: true, reason: null };
194
193
  }
195
194
  if (pm.kind === 'yarn') {
196
195
  // Works for yarn classic; yarn berry will ignore/translate flags as needed.
197
- 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
+ }
198
201
  return { installed: true, reason: null };
199
202
  }
200
203
  // npm
201
204
  if (pm.lockfile && pm.lockfile !== 'package.json') {
202
- await run('npm', ['ci'], { cwd: dir });
205
+ if (jsonMode) {
206
+ await runForJson('npm', ['ci']);
207
+ } else {
208
+ await run('npm', ['ci'], { cwd: dir });
209
+ }
203
210
  } else {
204
- await run('npm', ['install'], { cwd: dir });
211
+ if (jsonMode) {
212
+ await runForJson('npm', ['install']);
213
+ } else {
214
+ await run('npm', ['install'], { cwd: dir });
215
+ }
205
216
  }
206
217
  return { installed: true, reason: null };
207
218
  }
208
219
 
209
- 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 }) {
210
235
  if (!depsMode || depsMode === 'none') {
211
236
  return { mode: 'none', linked: false, installed: false, message: null };
212
237
  }
213
238
 
214
239
  // Prefer explicit baseDir if provided, otherwise link from the primary checkout (repoRoot).
215
240
  const linkFrom = baseDir || repoRoot;
241
+ const allowSymlink = allowNodeModulesSymlinkForComponent(component);
216
242
 
217
243
  if (depsMode === 'link' || depsMode === 'link-or-install') {
218
- const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
219
- if (res.linked) {
220
- return { mode: depsMode, linked: true, installed: false, message: null };
221
- }
222
- if (depsMode === 'link') {
223
- 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
224
261
  }
225
- // fall through to install
226
262
  }
227
263
 
228
264
  const inst = await installDependencies({ dir: worktreeDir });
@@ -456,11 +492,9 @@ async function migrateComponentWorktrees({ rootDir, component }) {
456
492
  }
457
493
 
458
494
  async function cmdMigrate({ rootDir }) {
459
- const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
460
-
461
495
  let totalMoved = 0;
462
496
  let totalRenamed = 0;
463
- for (const component of components) {
497
+ for (const component of DEFAULT_COMPONENTS) {
464
498
  const res = await migrateComponentWorktrees({ rootDir, component });
465
499
  totalMoved += res.moved;
466
500
  totalRenamed += res.renamed;
@@ -469,13 +503,13 @@ async function cmdMigrate({ rootDir }) {
469
503
  // If the persisted config pins any component dir to a legacy location, attempt to rewrite it.
470
504
  const envUpdates = [];
471
505
 
472
- // Keep in sync with scripts/utils/env_local.mjs selection logic.
506
+ // Keep in sync with scripts/utils/env/env_local.mjs selection logic.
473
507
  const explicitEnv = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
474
508
  const hasHomeConfig = existsSync(getHomeEnvPath()) || existsSync(getHomeEnvLocalPath());
475
509
  const envPath = explicitEnv ? explicitEnv : hasHomeConfig ? resolveUserConfigEnvPath({ cliRootDir: rootDir }) : join(rootDir, 'env.local');
476
510
 
477
511
  if (await pathExists(envPath)) {
478
- const raw = await readFile(envPath, 'utf-8');
512
+ const raw = (await readTextIfExists(envPath)) ?? '';
479
513
  const rewrite = (v) => {
480
514
  if (!v.includes('/components/')) {
481
515
  return v;
@@ -705,7 +739,7 @@ async function cmdNew({ rootDir, argv }) {
705
739
  }
706
740
 
707
741
  const depsMode = parseDepsMode(kv.get('--deps'));
708
- const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
742
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode, component });
709
743
 
710
744
  const shouldUse = flags.has('--use');
711
745
  const force = flags.has('--force');
@@ -791,8 +825,14 @@ async function cmdPr({ rootDir, argv }) {
791
825
  throw new Error(`[wt] unable to parse PR: ${prInput}`);
792
826
  }
793
827
 
794
- const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
795
- 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);
796
836
 
797
837
  const slugExtra = sanitizeSlugPart(kv.get('--slug') ?? '');
798
838
  const slug = slugExtra ? `pr/${pr.number}-${slugExtra}` : `pr/${pr.number}`;
@@ -809,7 +849,9 @@ async function cmdPr({ rootDir, argv }) {
809
849
  }
810
850
 
811
851
  // Fetch PR head ref (GitHub convention). Use + to allow force-updated PR branches when --force is set.
812
- 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();
813
855
  let oldHead = null;
814
856
  const prRef = `refs/pull/${pr.number}/head`;
815
857
  if (exists) {
@@ -825,14 +867,17 @@ async function cmdPr({ rootDir, argv }) {
825
867
  }
826
868
 
827
869
  oldHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
828
- await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
870
+ await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
829
871
  const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
830
872
 
831
873
  const isAncestor = await gitOk(repoRoot, ['merge-base', '--is-ancestor', oldHead, newTip]);
832
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`;
833
878
  throw new Error(
834
879
  `[wt] PR update is not a fast-forward (likely force-push) for ${branchName}\n` +
835
- `[wt] re-run with: happys wt pr ${component} ${pr.number} --remote=${remoteName} --update --force`
880
+ hint
836
881
  );
837
882
  }
838
883
 
@@ -863,16 +908,22 @@ async function cmdPr({ rootDir, argv }) {
863
908
  );
864
909
  }
865
910
  } else {
866
- await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
911
+ await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
867
912
  const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
868
913
 
869
914
  const branchExists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
870
915
  if (branchExists) {
871
916
  if (!force) {
872
- 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]);
873
926
  }
874
- await git(repoRoot, ['branch', '-f', branchName, newTip]);
875
- await git(repoRoot, ['worktree', 'add', destPath, branchName]);
876
927
  } else {
877
928
  // Create worktree at PR head (new local branch).
878
929
  await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, newTip]);
@@ -881,7 +932,7 @@ async function cmdPr({ rootDir, argv }) {
881
932
 
882
933
  // Optional deps handling (useful when PR branches add/change dependencies).
883
934
  const depsMode = parseDepsMode(kv.get('--deps'));
884
- const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode });
935
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode, component });
885
936
 
886
937
  const shouldUse = flags.has('--use');
887
938
  if (shouldUse) {
@@ -1245,15 +1296,6 @@ async function cmdSync({ rootDir, argv }) {
1245
1296
  return { component, remote: remoteName, mirrorBranch, upstreamRef: `${remoteName}/${defaultBranch}` };
1246
1297
  }
1247
1298
 
1248
- async function commandExists(cmd) {
1249
- try {
1250
- const out = (await runCapture('sh', ['-lc', `command -v ${cmd} >/dev/null 2>&1 && echo yes || echo no`])).trim();
1251
- return out === 'yes';
1252
- } catch {
1253
- return false;
1254
- }
1255
- }
1256
-
1257
1299
  async function fileExists(path) {
1258
1300
  try {
1259
1301
  return await pathExists(path);
@@ -1408,14 +1450,15 @@ async function cmdCode({ rootDir, argv }) {
1408
1450
  if (!(await pathExists(dir))) {
1409
1451
  throw new Error(`[wt] target does not exist: ${dir}`);
1410
1452
  }
1411
- if (!(await commandExists('code'))) {
1453
+ const codePath = await resolveCommandPath('code', { cwd: rootDir, env: process.env });
1454
+ if (!codePath) {
1412
1455
  throw new Error("[wt] VS Code CLI 'code' not found on PATH. In VS Code: Cmd+Shift+P → 'Shell Command: Install code command in PATH'.");
1413
1456
  }
1414
1457
  if (json) {
1415
- return { component, dir, cmd: 'code' };
1458
+ return { component, dir, cmd: 'code', resolvedCmd: codePath };
1416
1459
  }
1417
- await run('code', [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1418
- return { component, dir, cmd: 'code' };
1460
+ await run(codePath, [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1461
+ return { component, dir, cmd: 'code', resolvedCmd: codePath };
1419
1462
  }
1420
1463
 
1421
1464
  async function cmdCursor({ rootDir, argv }) {
@@ -1432,14 +1475,20 @@ async function cmdCursor({ rootDir, argv }) {
1432
1475
  throw new Error(`[wt] target does not exist: ${dir}`);
1433
1476
  }
1434
1477
 
1435
- const hasCursorCli = await commandExists('cursor');
1478
+ const cursorPath = await resolveCommandPath('cursor', { cwd: rootDir, env: process.env });
1479
+ const hasCursorCli = Boolean(cursorPath);
1436
1480
  if (json) {
1437
- return { component, dir, cmd: hasCursorCli ? 'cursor' : process.platform === 'darwin' ? 'open -a Cursor' : null };
1481
+ return {
1482
+ component,
1483
+ dir,
1484
+ cmd: hasCursorCli ? 'cursor' : process.platform === 'darwin' ? 'open -a Cursor' : null,
1485
+ resolvedCmd: cursorPath || null,
1486
+ };
1438
1487
  }
1439
1488
 
1440
1489
  if (hasCursorCli) {
1441
- await run('cursor', [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1442
- return { component, dir, cmd: 'cursor' };
1490
+ await run(cursorPath, [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1491
+ return { component, dir, cmd: 'cursor', resolvedCmd: cursorPath };
1443
1492
  }
1444
1493
 
1445
1494
  if (process.platform === 'darwin') {
@@ -1450,8 +1499,6 @@ async function cmdCursor({ rootDir, argv }) {
1450
1499
  throw new Error("[wt] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
1451
1500
  }
1452
1501
 
1453
- const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
1454
-
1455
1502
  async function cmdSyncAll({ rootDir, argv }) {
1456
1503
  const { flags, kv } = parseArgs(argv);
1457
1504
  const json = wantsJson(argv, { flags });
@@ -1584,43 +1631,48 @@ async function cmdNewInteractive({ rootDir, argv }) {
1584
1631
  });
1585
1632
  }
1586
1633
 
1587
- async function cmdList({ rootDir, args }) {
1588
- const component = args[0];
1589
- if (!component) {
1590
- throw new Error('[wt] usage: happys wt list <component>');
1591
- }
1592
-
1634
+ async function cmdListOne({ rootDir, component }) {
1593
1635
  const wtRoot = getWorktreesRoot(rootDir);
1594
1636
  const dir = join(wtRoot, component);
1637
+ const key = componentDirEnvKey(component);
1638
+ const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
1639
+
1595
1640
  if (!(await pathExists(dir))) {
1596
- return { component, activeDir: (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component), worktrees: [] };
1641
+ return { component, activeDir: active, worktrees: [] };
1597
1642
  }
1598
1643
 
1599
- const leafs = [];
1644
+ const worktrees = [];
1600
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
+ }
1601
1652
  const entries = await readdir(d, { withFileTypes: true });
1602
1653
  for (const e of entries) {
1603
- if (!e.isDirectory()) {
1604
- continue;
1605
- }
1606
- const p = join(d, e.name);
1607
- leafs.push(p);
1608
- 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));
1609
1658
  }
1610
1659
  };
1611
1660
  await walk(dir);
1612
- leafs.sort();
1661
+ worktrees.sort();
1613
1662
 
1614
- const key = componentDirEnvKey(component);
1615
- const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
1663
+ return { component, activeDir: active, worktrees };
1664
+ }
1616
1665
 
1617
- const worktrees = [];
1618
- for (const p of leafs) {
1619
- if (await pathExists(join(p, '.git'))) {
1620
- 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 }));
1621
1672
  }
1673
+ return { components: DEFAULT_COMPONENTS, results };
1622
1674
  }
1623
- return { component, activeDir: active, worktrees };
1675
+ return await cmdListOne({ rootDir, component });
1624
1676
  }
1625
1677
 
1626
1678
  async function main() {
@@ -1644,7 +1696,7 @@ async function main() {
1644
1696
  ' happys wt migrate [--json]',
1645
1697
  ' happys wt sync <component> [--remote=<name>] [--json]',
1646
1698
  ' happys wt sync-all [--remote=<name>] [--json]',
1647
- ' happys wt list <component> [--json]',
1699
+ ' happys wt list [component] [--json]',
1648
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]',
1649
1701
  ' happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
1650
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]',
@@ -1665,7 +1717,7 @@ async function main() {
1665
1717
  ' "<absolute path>": explicit checkout path',
1666
1718
  '',
1667
1719
  'components:',
1668
- ' happy | happy-cli | happy-server-light | happy-server',
1720
+ ` ${DEFAULT_COMPONENTS.join(' | ')}`,
1669
1721
  ].join('\n'),
1670
1722
  });
1671
1723
  return;
@@ -1824,9 +1876,15 @@ async function main() {
1824
1876
  if (json) {
1825
1877
  printResult({ json, data: res });
1826
1878
  } else {
1827
- const lines = [`[wt] ${res.component} worktrees:`, `- active: ${res.activeDir}`];
1828
- for (const p of res.worktrees) {
1829
- 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('');
1830
1888
  }
1831
1889
  printResult({ json: false, text: lines.join('\n') });
1832
1890
  }
@@ -1,12 +0,0 @@
1
- import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
-
4
- export function isLegacyAuthSourceName(name) {
5
- const s = String(name ?? '').trim().toLowerCase();
6
- return s === 'legacy' || s === 'system' || s === 'local-install';
7
- }
8
-
9
- export function getLegacyHappyBaseDir() {
10
- return join(homedir(), '.happy');
11
- }
12
-
@@ -1,112 +0,0 @@
1
- import { ensureDepsInstalled, pmSpawnBin } from './pm.mjs';
2
- import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from './expo.mjs';
3
- import { pickDevMetroPort, resolveStackUiDevPortStart } from './dev_server.mjs';
4
- import { recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
5
- import { killProcessGroupOwnedByStack } from './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
- }
@@ -1,17 +0,0 @@
1
- import { getStackName } from './paths.mjs';
2
-
3
- function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
4
- const s = String(raw ?? '')
5
- .toLowerCase()
6
- .replace(/[^a-z0-9-]+/g, '-')
7
- .replace(/-+/g, '-')
8
- .replace(/^-+/, '')
9
- .replace(/-+$/, '');
10
- return s || fallback;
11
- }
12
-
13
- export function resolveLocalhostHost({ stackMode, stackName = getStackName() } = {}) {
14
- if (!stackMode) return 'localhost';
15
- if (!stackName || stackName === 'main') return 'localhost';
16
- return `happy-${sanitizeDnsLabel(stackName)}.localhost`;
17
- }
@@ -1,9 +0,0 @@
1
- export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
2
- const raw =
3
- (env.HAPPY_STACKS_SERVER_PORT ?? '').toString().trim() ||
4
- (env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim() ||
5
- '';
6
- const n = raw ? Number(raw) : Number(defaultPort);
7
- return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
8
- }
9
-