happy-stacks 0.3.0 → 0.5.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 (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,67 @@
1
+ import { runCapture } from '../proc/proc.mjs';
2
+
3
+ export async function gitCapture({ cwd, args }) {
4
+ return String(await runCapture('git', args, { cwd }));
5
+ }
6
+
7
+ export async function gitOk({ cwd, args }) {
8
+ try {
9
+ await runCapture('git', args, { cwd });
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ export async function normalizeRemoteName({ cwd, remote }) {
17
+ const want = String(remote ?? '').trim();
18
+ if (!want) return want;
19
+
20
+ if (await gitOk({ cwd, args: ['remote', 'get-url', want] })) return want;
21
+
22
+ // Treat origin/fork as interchangeable if one exists.
23
+ if (want === 'origin' && (await gitOk({ cwd, args: ['remote', 'get-url', 'fork'] }))) return 'fork';
24
+ if (want === 'fork' && (await gitOk({ cwd, args: ['remote', 'get-url', 'origin'] }))) return 'origin';
25
+
26
+ return want;
27
+ }
28
+
29
+ export async function resolveRemoteDefaultBranch({ cwd, remote }) {
30
+ const r = String(remote ?? '').trim();
31
+ if (!r) return 'main';
32
+
33
+ // Prefer refs/remotes/<remote>/HEAD when available.
34
+ try {
35
+ const headRef = (await gitCapture({ cwd, args: ['symbolic-ref', '-q', '--short', `refs/remotes/${r}/HEAD`] })).trim();
36
+ if (headRef.startsWith(`${r}/`)) {
37
+ return headRef.slice(r.length + 1);
38
+ }
39
+ } catch {
40
+ // ignore
41
+ }
42
+
43
+ // Fallback: parse `git remote show` output.
44
+ try {
45
+ const out = await gitCapture({ cwd, args: ['remote', 'show', r] });
46
+ for (const line of out.split('\n')) {
47
+ const m = line.match(/^\s*HEAD branch:\s*(.+)\s*$/);
48
+ if (m?.[1]) return m[1].trim();
49
+ }
50
+ } catch {
51
+ // ignore
52
+ }
53
+
54
+ return 'main';
55
+ }
56
+
57
+ export async function ensureRemoteRefAvailable({ cwd, remote, branch }) {
58
+ const r = String(remote ?? '').trim();
59
+ const b = String(branch ?? '').trim();
60
+ if (!r || !b) return false;
61
+ const ref = `refs/remotes/${r}/${b}`;
62
+ if (await gitOk({ cwd, args: ['show-ref', '--verify', '--quiet', ref] })) return true;
63
+ // Best-effort fetch of the default branch.
64
+ await gitCapture({ cwd, args: ['fetch', '--quiet', r, b] }).catch(() => '');
65
+ return await gitOk({ cwd, args: ['show-ref', '--verify', '--quiet', ref] });
66
+ }
67
+
@@ -1,6 +1,12 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { readdir } from 'node:fs/promises';
2
- import { isAbsolute, join, resolve } from 'node:path';
3
- import { getComponentsDir } from '../paths/paths.mjs';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import {
5
+ coerceHappyMonorepoRootFromPath,
6
+ getComponentRepoDir,
7
+ getComponentsDir,
8
+ happyMonorepoSubdirForComponent,
9
+ } from '../paths/paths.mjs';
4
10
  import { pathExists } from '../fs/fs.mjs';
5
11
  import { run, runCapture } from '../proc/proc.mjs';
6
12
 
@@ -15,31 +21,55 @@ export function parseGithubOwner(remoteUrl) {
15
21
  return m?.groups?.owner ?? null;
16
22
  }
17
23
 
18
- export function getWorktreesRoot(rootDir) {
19
- return join(getComponentsDir(rootDir), '.worktrees');
24
+ export function getWorktreesRoot(rootDir, env = process.env) {
25
+ return join(getComponentsDir(rootDir, env), '.worktrees');
20
26
  }
21
27
 
22
- export function componentRepoDir(rootDir, component) {
23
- return join(getComponentsDir(rootDir), component);
28
+ export function componentRepoDir(rootDir, component, env = process.env) {
29
+ return getComponentRepoDir(rootDir, component, env);
24
30
  }
25
31
 
26
- export function isComponentWorktreePath({ rootDir, component, dir }) {
32
+ function worktreeRepoKeyForComponent({ rootDir, component, env = process.env }) {
33
+ const c = String(component ?? '').trim();
34
+ const repoDir = componentRepoDir(rootDir, c, env);
35
+ const mono = coerceHappyMonorepoRootFromPath(repoDir);
36
+ // In monorepo mode, all worktrees live under `.worktrees/happy/` regardless of which
37
+ // package (expo-app/cli/server) the user is operating on.
38
+ return mono ? 'happy' : c;
39
+ }
40
+
41
+ export function isComponentWorktreePath({ rootDir, component, dir, env = process.env }) {
27
42
  const raw = String(dir ?? '').trim();
28
43
  if (!raw) return false;
29
44
  const abs = resolve(raw);
30
- const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
45
+ const key = worktreeRepoKeyForComponent({ rootDir, component, env });
46
+ const root = resolve(join(getWorktreesRoot(rootDir, env), key)) + '/';
31
47
  return abs.startsWith(root);
32
48
  }
33
49
 
34
- export function worktreeSpecFromDir({ rootDir, component, dir }) {
50
+ export function worktreeSpecFromDir({ rootDir, component, dir, env = process.env }) {
35
51
  const raw = String(dir ?? '').trim();
36
52
  if (!raw) return null;
37
- if (!isComponentWorktreePath({ rootDir, component, dir: raw })) return null;
53
+ if (!isComponentWorktreePath({ rootDir, component, dir: raw, env })) return null;
38
54
  const abs = resolve(raw);
39
- const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
40
- const rel = abs.slice(root.length).split('/').filter(Boolean);
55
+ const key = worktreeRepoKeyForComponent({ rootDir, component, env });
56
+ const base = resolve(join(getWorktreesRoot(rootDir, env), key));
57
+
58
+ // Normalize to the actual worktree root directory (the one containing `.git`) so
59
+ // package subdirectories like `.../cli` don't corrupt the computed spec.
60
+ let cur = abs;
61
+ while (cur && cur !== base && cur !== dirname(cur)) {
62
+ if (existsSync(join(cur, '.git'))) {
63
+ break;
64
+ }
65
+ cur = dirname(cur);
66
+ }
67
+ if (!cur || cur === base || cur === dirname(cur)) return null;
68
+
69
+ const root = base + '/';
70
+ if (!cur.startsWith(root)) return null;
71
+ const rel = cur.slice(root.length).split('/').filter(Boolean);
41
72
  if (rel.length < 2) return null;
42
- // rel = [owner, ...branchParts]
43
73
  return rel.join('/');
44
74
  }
45
75
 
@@ -69,30 +99,45 @@ export async function createWorktreeFromBaseWorktree({
69
99
  baseWorktreeSpec,
70
100
  remoteName = 'upstream',
71
101
  depsMode = '',
102
+ env = process.env,
72
103
  }) {
73
104
  const args = ['wt', 'new', component, slug, `--remote=${remoteName}`, `--base-worktree=${baseWorktreeSpec}`];
74
105
  if (depsMode) args.push(`--deps=${depsMode}`);
75
- await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), ...args], { cwd: rootDir });
106
+ await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), ...args], { cwd: rootDir, env });
76
107
 
77
- const repoDir = componentRepoDir(rootDir, component);
108
+ const repoDir = componentRepoDir(rootDir, component, env);
78
109
  const owner = await getRemoteOwner({ repoDir, remoteName });
79
- return join(getWorktreesRoot(rootDir), component, owner, ...slug.split('/'));
110
+ const key = worktreeRepoKeyForComponent({ rootDir, component, env });
111
+ const wtRoot = join(getWorktreesRoot(rootDir, env), key, owner, ...slug.split('/'));
112
+ const sub = happyMonorepoSubdirForComponent(component);
113
+ const monoRoot = sub ? coerceHappyMonorepoRootFromPath(wtRoot) : null;
114
+ return sub && monoRoot ? join(monoRoot, sub) : wtRoot;
80
115
  }
81
116
 
82
- export function resolveComponentSpecToDir({ rootDir, component, spec }) {
117
+ export function resolveComponentSpecToDir({ rootDir, component, spec, env = process.env }) {
83
118
  const raw = (spec ?? '').trim();
84
119
  if (!raw || raw === 'default') {
85
120
  return null;
86
121
  }
87
122
  if (isAbsolute(raw)) {
123
+ const monoRoot = coerceHappyMonorepoRootFromPath(raw);
124
+ const sub = happyMonorepoSubdirForComponent(component);
125
+ if (monoRoot && sub) {
126
+ return join(monoRoot, sub);
127
+ }
88
128
  return raw;
89
129
  }
90
- // Treat as <owner>/<branch...> under components/.worktrees/<component>/...
91
- return join(getWorktreesRoot(rootDir), component, ...raw.split('/'));
130
+ // Treat as <owner>/<branch...> under components/.worktrees/<repoKey>/...
131
+ const key = worktreeRepoKeyForComponent({ rootDir, component, env });
132
+ const wtRoot = join(getWorktreesRoot(rootDir, env), key, ...raw.split('/'));
133
+ const sub = happyMonorepoSubdirForComponent(component);
134
+ const monoRoot = sub ? coerceHappyMonorepoRootFromPath(wtRoot) : null;
135
+ return sub && monoRoot ? join(monoRoot, sub) : wtRoot;
92
136
  }
93
137
 
94
- export async function listWorktreeSpecs({ rootDir, component }) {
95
- const dir = join(getWorktreesRoot(rootDir), component);
138
+ export async function listWorktreeSpecs({ rootDir, component, env = process.env }) {
139
+ const key = worktreeRepoKeyForComponent({ rootDir, component, env });
140
+ const dir = join(getWorktreesRoot(rootDir, env), key);
96
141
  const specs = [];
97
142
  try {
98
143
  const walk = async (d, prefixParts) => {
@@ -103,6 +148,9 @@ export async function listWorktreeSpecs({ rootDir, component }) {
103
148
  const nextPrefix = [...prefixParts, e.name];
104
149
  if (await pathExists(join(p, '.git'))) {
105
150
  specs.push(nextPrefix.join('/'));
151
+ // IMPORTANT: do not recurse into worktree roots (they contain full repos and can be huge).
152
+ // Worktrees are leaf nodes for our purposes.
153
+ continue;
106
154
  }
107
155
  await walk(p, nextPrefix);
108
156
  }
@@ -125,10 +173,17 @@ export async function getRemoteOwner({ repoDir, remoteName = 'upstream' }) {
125
173
  return owner;
126
174
  }
127
175
 
128
- export async function createWorktree({ rootDir, component, slug, remoteName = 'upstream' }) {
176
+ export async function createWorktree({ rootDir, component, slug, remoteName = 'upstream', env = process.env }) {
129
177
  // Create without modifying env.local (unless caller passes --use elsewhere).
130
- await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), 'wt', 'new', component, slug, `--remote=${remoteName}`], { cwd: rootDir });
131
- const repoDir = componentRepoDir(rootDir, component);
178
+ await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), 'wt', 'new', component, slug, `--remote=${remoteName}`], {
179
+ cwd: rootDir,
180
+ env,
181
+ });
182
+ const repoDir = componentRepoDir(rootDir, component, env);
132
183
  const owner = await getRemoteOwner({ repoDir, remoteName });
133
- return join(getWorktreesRoot(rootDir), component, owner, ...slug.split('/'));
184
+ const key = worktreeRepoKeyForComponent({ rootDir, component, env });
185
+ const wtRoot = join(getWorktreesRoot(rootDir, env), key, owner, ...slug.split('/'));
186
+ const sub = happyMonorepoSubdirForComponent(component);
187
+ const monoRoot = sub ? coerceHappyMonorepoRootFromPath(wtRoot) : null;
188
+ return sub && monoRoot ? join(monoRoot, sub) : wtRoot;
134
189
  }
@@ -0,0 +1,54 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { worktreeSpecFromDir } from './worktrees.mjs';
8
+
9
+ async function withTempRoot(t) {
10
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-worktrees-monorepo-'));
11
+ t.after(async () => {
12
+ await rm(dir, { recursive: true, force: true });
13
+ });
14
+ return dir;
15
+ }
16
+
17
+ async function writeHappyMonorepoStub({ rootDir, worktreeRoot }) {
18
+ const monoRoot = join(rootDir, 'components', 'happy');
19
+ await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
20
+ await mkdir(join(monoRoot, 'cli'), { recursive: true });
21
+ await mkdir(join(monoRoot, 'server'), { recursive: true });
22
+ await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
23
+ await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
24
+ await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
25
+
26
+ // Also stub a monorepo worktree root (same structure) for spec parsing.
27
+ await mkdir(join(worktreeRoot, 'expo-app'), { recursive: true });
28
+ await mkdir(join(worktreeRoot, 'cli'), { recursive: true });
29
+ await mkdir(join(worktreeRoot, 'server'), { recursive: true });
30
+ await writeFile(join(worktreeRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
31
+ return { monoRoot };
32
+ }
33
+
34
+ test('worktreeSpecFromDir normalizes monorepo package dirs to the worktree spec', async (t) => {
35
+ const rootDir = await withTempRoot(t);
36
+ const env = { HAPPY_STACKS_WORKSPACE_DIR: rootDir };
37
+
38
+ const wtRoot = join(rootDir, 'components', '.worktrees', 'happy', 'slopus', 'pr', '123-fix-monorepo');
39
+ await mkdir(wtRoot, { recursive: true });
40
+ await writeHappyMonorepoStub({ rootDir, worktreeRoot: wtRoot });
41
+
42
+ assert.equal(
43
+ worktreeSpecFromDir({ rootDir, component: 'happy', dir: join(wtRoot, 'expo-app'), env }),
44
+ 'slopus/pr/123-fix-monorepo'
45
+ );
46
+ assert.equal(
47
+ worktreeSpecFromDir({ rootDir, component: 'happy-cli', dir: join(wtRoot, 'cli'), env }),
48
+ 'slopus/pr/123-fix-monorepo'
49
+ );
50
+ assert.equal(
51
+ worktreeSpecFromDir({ rootDir, component: 'happy-server', dir: join(wtRoot, 'server'), env }),
52
+ 'slopus/pr/123-fix-monorepo'
53
+ );
54
+ });
@@ -0,0 +1,94 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { parseDotenv } from './env/dotenv.mjs';
7
+ import { resolveStackEnvPath } from './paths/paths.mjs';
8
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './auth_sources.mjs';
9
+
10
+ async function readTextIfExists(path) {
11
+ try {
12
+ if (!path || !existsSync(path)) return null;
13
+ const raw = await readFile(path, 'utf-8');
14
+ const t = raw.trim();
15
+ return t ? t : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function parseEnvToObject(raw) {
22
+ const parsed = parseDotenv(raw ?? '');
23
+ return Object.fromEntries(parsed.entries());
24
+ }
25
+
26
+ function getEnvValue(env, key) {
27
+ const v = (env?.[key] ?? '').toString().trim();
28
+ return v || '';
29
+ }
30
+
31
+ function stackExistsSync(stackName) {
32
+ if (stackName === 'main') return true;
33
+ const envPath = resolveStackEnvPath(stackName).envPath;
34
+ return existsSync(envPath);
35
+ }
36
+
37
+ export async function resolveHandyMasterSecretFromStack({
38
+ stackName,
39
+ requireStackExists = false,
40
+ allowLegacyAuthSource = true,
41
+ allowLegacyMainFallback = true,
42
+ } = {}) {
43
+ const name = String(stackName ?? '').trim() || 'main';
44
+
45
+ if (isLegacyAuthSourceName(name)) {
46
+ if (!allowLegacyAuthSource) {
47
+ throw new Error(
48
+ '[auth] legacy auth source is disabled in sandbox mode.\n' +
49
+ 'Reason: it reads from ~/.happy (global user state).\n' +
50
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
51
+ );
52
+ }
53
+ const baseDir = getLegacyHappyBaseDir();
54
+ const legacySecretPath = join(baseDir, 'server-light', 'handy-master-secret.txt');
55
+ const secret = await readTextIfExists(legacySecretPath);
56
+ return secret ? { secret, source: legacySecretPath } : { secret: null, source: null };
57
+ }
58
+
59
+ if (requireStackExists && !stackExistsSync(name)) {
60
+ throw new Error(`[auth] cannot copy auth: source stack "${name}" does not exist`);
61
+ }
62
+
63
+ const resolved = resolveStackEnvPath(name);
64
+ const sourceBaseDir = resolved.baseDir;
65
+ const sourceEnvPath = resolved.envPath;
66
+ const raw = await readTextIfExists(sourceEnvPath);
67
+ const env = raw ? parseEnvToObject(raw) : {};
68
+
69
+ const inline = getEnvValue(env, 'HANDY_MASTER_SECRET');
70
+ if (inline) {
71
+ return { secret: inline, source: `${sourceEnvPath} (HANDY_MASTER_SECRET)` };
72
+ }
73
+
74
+ const secretFile = getEnvValue(env, 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE');
75
+ if (secretFile) {
76
+ const secret = await readTextIfExists(secretFile);
77
+ if (secret) return { secret, source: secretFile };
78
+ }
79
+
80
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR') || join(sourceBaseDir, 'server-light');
81
+ const secretPath = join(dataDir, 'handy-master-secret.txt');
82
+ const secret = await readTextIfExists(secretPath);
83
+ if (secret) return { secret, source: secretPath };
84
+
85
+ // Last-resort legacy: if main has never been migrated to stack dirs.
86
+ if (name === 'main' && allowLegacyMainFallback) {
87
+ const legacy = join(homedir(), '.happy', 'server-light', 'handy-master-secret.txt');
88
+ const legacySecret = await readTextIfExists(legacy);
89
+ if (legacySecret) return { secret: legacySecret, source: legacy };
90
+ }
91
+
92
+ return { secret: null, source: null };
93
+ }
94
+
@@ -0,0 +1,31 @@
1
+ import { sanitizeBundleIdSegment, sanitizeUrlScheme } from './identifiers.mjs';
2
+
3
+ export function resolveMobileExpoConfig({ env = process.env } = {}) {
4
+ const user = sanitizeBundleIdSegment(env.USER ?? env.USERNAME ?? 'user');
5
+ const defaultLocalBundleId = `com.happy.local.${user}.dev`;
6
+
7
+ const appEnv = env.APP_ENV ?? env.HAPPY_STACKS_APP_ENV ?? env.HAPPY_LOCAL_APP_ENV ?? 'development';
8
+ const iosAppName = (env.HAPPY_STACKS_IOS_APP_NAME ?? env.HAPPY_LOCAL_IOS_APP_NAME ?? '').toString();
9
+ const iosBundleId = (env.HAPPY_STACKS_IOS_BUNDLE_ID ?? env.HAPPY_LOCAL_IOS_BUNDLE_ID ?? defaultLocalBundleId).toString();
10
+ // Happy Stacks convention:
11
+ // - dev-client QR should open a dedicated "Happy Stacks Dev" app (not a per-stack release build)
12
+ // - so default to a stable happy-stacks-specific scheme unless explicitly overridden.
13
+ const scheme = sanitizeUrlScheme(
14
+ (env.HAPPY_STACKS_MOBILE_SCHEME ??
15
+ env.HAPPY_LOCAL_MOBILE_SCHEME ??
16
+ env.HAPPY_STACKS_DEV_CLIENT_SCHEME ??
17
+ env.HAPPY_LOCAL_DEV_CLIENT_SCHEME ??
18
+ 'happystacks-dev')
19
+ .toString()
20
+ );
21
+ const host = (env.HAPPY_STACKS_MOBILE_HOST ?? env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan').toString();
22
+
23
+ return {
24
+ appEnv,
25
+ iosAppName,
26
+ iosBundleId,
27
+ scheme,
28
+ host,
29
+ };
30
+ }
31
+
@@ -0,0 +1,60 @@
1
+ import { getEnvValueAny } from '../env/values.mjs';
2
+ import { pickLanIpv4 } from '../net/lan_ip.mjs';
3
+ import { resolveMobileExpoConfig } from './config.mjs';
4
+
5
+ function normalizeHostMode(raw) {
6
+ const v = String(raw ?? '').trim().toLowerCase();
7
+ if (v === 'localhost' || v === 'local') return 'localhost';
8
+ if (v === 'lan' || v === 'ip') return 'lan';
9
+ if (v === 'tunnel') return 'tunnel';
10
+ return v || 'lan';
11
+ }
12
+
13
+ export function resolveMobileHostMode(env = process.env) {
14
+ // Prefer explicit host vars (so TUI/setup-pr match the same knobs Expo uses).
15
+ const raw =
16
+ getEnvValueAny(env, ['HAPPY_STACKS_MOBILE_HOST', 'HAPPY_LOCAL_MOBILE_HOST']) ||
17
+ resolveMobileExpoConfig({ env }).host ||
18
+ 'lan';
19
+ return normalizeHostMode(raw);
20
+ }
21
+
22
+ export function resolveMobileScheme(env = process.env) {
23
+ return String(resolveMobileExpoConfig({ env }).scheme || '').trim();
24
+ }
25
+
26
+ export function resolveMetroUrlForMobile({ env = process.env, port }) {
27
+ const p = Number(port);
28
+ if (!Number.isFinite(p) || p <= 0) return '';
29
+
30
+ const mode = resolveMobileHostMode(env);
31
+ if (mode === 'localhost') {
32
+ return `http://localhost:${p}`;
33
+ }
34
+ if (mode === 'lan') {
35
+ const ip = pickLanIpv4();
36
+ return `http://${ip || 'localhost'}:${p}`;
37
+ }
38
+ // Tunnel URLs are controlled by Expo; we can't reliably derive them locally.
39
+ // Fall back to localhost so the URL is at least correct for the host machine.
40
+ return `http://localhost:${p}`;
41
+ }
42
+
43
+ export function resolveDevClientDeepLink({ scheme, metroUrl }) {
44
+ const s = String(scheme ?? '').trim();
45
+ const url = String(metroUrl ?? '').trim();
46
+ if (!url) return '';
47
+ if (!s) return url;
48
+ return `${s}://expo-development-client/?url=${encodeURIComponent(url)}`;
49
+ }
50
+
51
+ export function resolveMobileQrPayload({ env = process.env, port }) {
52
+ const metroUrl = resolveMetroUrlForMobile({ env, port });
53
+ const scheme = resolveMobileScheme(env);
54
+ const deepLink = resolveDevClientDeepLink({ scheme, metroUrl });
55
+ // Match Expo CLI / @expo/cli UrlCreator: QR encodes the dev-client deep link.
56
+ // Note: iOS Camera will still offer to open custom schemes when the app is installed.
57
+ const payload = deepLink || metroUrl;
58
+ return { scheme, metroUrl, deepLink, payload };
59
+ }
60
+
@@ -0,0 +1,47 @@
1
+ function sanitizeToken(raw, { allowDots = false } = {}) {
2
+ const s = (raw ?? '').toString().trim().toLowerCase();
3
+ const re = allowDots ? /[^a-z0-9.-]+/g : /[^a-z0-9-]+/g;
4
+ const out = s.replace(re, '-').replace(/^-+|-+$/g, '').replace(/-+/g, '-');
5
+ return out;
6
+ }
7
+
8
+ export function sanitizeBundleIdSegment(s) {
9
+ const seg = sanitizeToken(s, { allowDots: false });
10
+ if (!seg) return 'app';
11
+ // Bundle id segments should not start with a digit; prefix if needed.
12
+ return /^[a-z]/.test(seg) ? seg : `s${seg}`;
13
+ }
14
+
15
+ export function sanitizeUrlScheme(s) {
16
+ // iOS URL schemes must start with a letter and may contain letters/digits/+.-.
17
+ const raw = (s ?? '').toString().trim().toLowerCase();
18
+ const cleaned = raw.replace(/[^a-z0-9+.-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
19
+ if (!cleaned) return 'happystacks-dev';
20
+ return /^[a-z]/.test(cleaned) ? cleaned : `h${cleaned}`;
21
+ }
22
+
23
+ export function stackSlugForMobileIds(stackName) {
24
+ const raw = (stackName ?? '').toString().trim();
25
+ return sanitizeBundleIdSegment(raw || 'stack');
26
+ }
27
+
28
+ export function defaultDevClientIdentity({ user = null } = {}) {
29
+ const u = sanitizeBundleIdSegment(user ?? 'user');
30
+ return {
31
+ iosAppName: 'Happy Stacks Dev',
32
+ iosBundleId: `com.happystacks.dev.${u}`,
33
+ scheme: 'happystacks-dev',
34
+ };
35
+ }
36
+
37
+ export function defaultStackReleaseIdentity({ stackName, user = null, appName = null } = {}) {
38
+ const slug = stackSlugForMobileIds(stackName);
39
+ const u = sanitizeBundleIdSegment(user ?? 'user');
40
+ const label = (appName ?? '').toString().trim();
41
+ return {
42
+ iosAppName: label || `Happy (${stackName})`,
43
+ iosBundleId: `com.happystacks.stack.${u}.${slug}`,
44
+ scheme: sanitizeUrlScheme(`happystacks-${slug}`),
45
+ };
46
+ }
47
+
@@ -0,0 +1,42 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ defaultDevClientIdentity,
6
+ defaultStackReleaseIdentity,
7
+ sanitizeBundleIdSegment,
8
+ sanitizeUrlScheme,
9
+ stackSlugForMobileIds,
10
+ } from './identifiers.mjs';
11
+
12
+ test('sanitizeBundleIdSegment produces a safe segment', () => {
13
+ assert.equal(sanitizeBundleIdSegment(' PR272-107 '), 'pr272-107');
14
+ assert.equal(sanitizeBundleIdSegment('---'), 'app');
15
+ assert.equal(sanitizeBundleIdSegment('123'), 's123');
16
+ });
17
+
18
+ test('sanitizeUrlScheme produces a safe scheme', () => {
19
+ assert.equal(sanitizeUrlScheme('HappyStacks-Dev'), 'happystacks-dev');
20
+ assert.equal(sanitizeUrlScheme('123bad'), 'h123bad');
21
+ assert.equal(sanitizeUrlScheme(''), 'happystacks-dev');
22
+ });
23
+
24
+ test('stackSlugForMobileIds derives a stable slug', () => {
25
+ assert.equal(stackSlugForMobileIds('pr272-107-fixes-2026-01-15'), 'pr272-107-fixes-2026-01-15');
26
+ assert.equal(stackSlugForMobileIds(' Weird Name '), 'weird-name');
27
+ });
28
+
29
+ test('defaultDevClientIdentity is stable and safe', () => {
30
+ const id = defaultDevClientIdentity({ user: 'Leeroy' });
31
+ assert.equal(id.iosAppName, 'Happy Stacks Dev');
32
+ assert.equal(id.scheme, 'happystacks-dev');
33
+ assert.equal(id.iosBundleId, 'com.happystacks.dev.leeroy');
34
+ });
35
+
36
+ test('defaultStackReleaseIdentity is per-stack', () => {
37
+ const id = defaultStackReleaseIdentity({ stackName: 'pr272-107', user: 'Leeroy' });
38
+ assert.equal(id.iosBundleId, 'com.happystacks.stack.leeroy.pr272-107');
39
+ assert.equal(id.scheme, 'happystacks-pr272-107');
40
+ assert.equal(id.iosAppName, 'Happy (pr272-107)');
41
+ });
42
+