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
@@ -14,6 +14,18 @@ const PRIMARY_STORAGE_ROOT = join(homedir(), '.happy', 'stacks');
14
14
  const LEGACY_STORAGE_ROOT = join(homedir(), '.happy', 'local');
15
15
  const PRIMARY_HOME_DIR = join(homedir(), '.happy-stacks');
16
16
 
17
+ // Upstream monorepo layout (slopus/happy):
18
+ // - expo-app/ (Happy UI)
19
+ // - cli/ (happy-cli)
20
+ // - server/ (happy-server)
21
+ const HAPPY_MONOREPO_COMPONENT_SUBDIR = {
22
+ happy: 'expo-app',
23
+ 'happy-cli': 'cli',
24
+ 'happy-server': 'server',
25
+ // Server flavors share a single server package in the monorepo.
26
+ 'happy-server-light': 'server',
27
+ };
28
+
17
29
  export function getRootDir(importMetaUrl) {
18
30
  return dirname(dirname(fileURLToPath(importMetaUrl)));
19
31
  }
@@ -26,8 +38,8 @@ export function getHappyStacksHomeDir(env = process.env) {
26
38
  return PRIMARY_HOME_DIR;
27
39
  }
28
40
 
29
- export function getWorkspaceDir(cliRootDir = null) {
30
- const fromEnv = (process.env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
41
+ export function getWorkspaceDir(cliRootDir = null, env = process.env) {
42
+ const fromEnv = (env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
31
43
  if (fromEnv) {
32
44
  return expandHome(fromEnv);
33
45
  }
@@ -41,8 +53,8 @@ export function getWorkspaceDir(cliRootDir = null) {
41
53
  return cliRootDir ? cliRootDir : defaultWorkspace;
42
54
  }
43
55
 
44
- export function getComponentsDir(rootDir) {
45
- const workspaceDir = getWorkspaceDir(rootDir);
56
+ export function getComponentsDir(rootDir, env = process.env) {
57
+ const workspaceDir = getWorkspaceDir(rootDir, env);
46
58
  return join(workspaceDir, 'components');
47
59
  }
48
60
 
@@ -50,46 +62,152 @@ export function componentDirEnvKey(name) {
50
62
  return `HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
51
63
  }
52
64
 
53
- function normalizePathForEnv(rootDir, raw) {
65
+ function normalizePathForEnv(rootDir, raw, env = process.env) {
54
66
  const trimmed = (raw ?? '').trim();
55
67
  if (!trimmed) {
56
68
  return '';
57
69
  }
58
70
  const expanded = expandHome(trimmed);
59
71
  // If the path is relative, treat it as relative to the workspace root (default: repo root).
60
- const workspaceDir = getWorkspaceDir(rootDir);
72
+ const workspaceDir = getWorkspaceDir(rootDir, env);
61
73
  return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
62
74
  }
63
75
 
64
- export function getComponentDir(rootDir, name) {
76
+ export function isHappyMonorepoComponentName(name) {
77
+ return Object.prototype.hasOwnProperty.call(HAPPY_MONOREPO_COMPONENT_SUBDIR, String(name ?? '').trim());
78
+ }
79
+
80
+ export function happyMonorepoSubdirForComponent(name) {
81
+ return HAPPY_MONOREPO_COMPONENT_SUBDIR[String(name ?? '').trim()] ?? null;
82
+ }
83
+
84
+ export function isHappyMonorepoRoot(dir) {
85
+ const d = String(dir ?? '').trim();
86
+ if (!d) return false;
87
+ try {
88
+ return (
89
+ existsSync(join(d, 'expo-app', 'package.json')) &&
90
+ existsSync(join(d, 'cli', 'package.json')) &&
91
+ existsSync(join(d, 'server', 'package.json'))
92
+ );
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ export function coerceHappyMonorepoRootFromPath(path) {
99
+ const p = String(path ?? '').trim();
100
+ if (!p) return null;
101
+ let cur = resolve(p);
102
+ while (true) {
103
+ if (isHappyMonorepoRoot(cur)) return cur;
104
+ const parent = dirname(cur);
105
+ if (parent === cur) return null;
106
+ cur = parent;
107
+ }
108
+ }
109
+
110
+ function resolveHappyMonorepoPackageDir({ monorepoRoot, component }) {
111
+ const sub = happyMonorepoSubdirForComponent(component);
112
+ if (!sub) return null;
113
+ return join(monorepoRoot, sub);
114
+ }
115
+
116
+ export function getComponentRepoDir(rootDir, name, env = process.env) {
117
+ const componentDir = getComponentDir(rootDir, name, env);
118
+ const n = String(name ?? '').trim();
119
+ if (isHappyMonorepoComponentName(n)) {
120
+ const root = coerceHappyMonorepoRootFromPath(componentDir);
121
+ if (root) return root;
122
+ }
123
+ return componentDir;
124
+ }
125
+
126
+ export function getComponentDir(rootDir, name, env = process.env) {
65
127
  const stacksKey = componentDirEnvKey(name);
66
128
  const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
67
- const fromEnv = normalizePathForEnv(rootDir, process.env[stacksKey] ?? process.env[legacyKey]);
68
- if (fromEnv) {
129
+ const fromEnv = normalizePathForEnv(rootDir, env[stacksKey] ?? env[legacyKey], env);
130
+ const n = String(name ?? '').trim();
131
+
132
+ // If the component is part of the happy monorepo, allow pointing the env var at either:
133
+ // - the monorepo root, OR
134
+ // - the package directory (expo-app/cli/server), OR
135
+ // - any path inside those (we normalize to the package dir).
136
+ if (fromEnv && isHappyMonorepoComponentName(n)) {
137
+ const root = coerceHappyMonorepoRootFromPath(fromEnv);
138
+ if (root) {
139
+ const pkg = resolveHappyMonorepoPackageDir({ monorepoRoot: root, component: n });
140
+ return pkg || fromEnv;
141
+ }
69
142
  return fromEnv;
70
143
  }
71
- return join(getComponentsDir(rootDir), name);
144
+
145
+ if (fromEnv) return fromEnv;
146
+
147
+ const componentsDir = getComponentsDir(rootDir, env);
148
+ const defaultDir = join(componentsDir, n);
149
+
150
+ // Unified server flavors:
151
+ // If happy-server-light isn't explicitly configured, allow it to reuse the happy-server checkout
152
+ // when that checkout contains the sqlite schema (new: prisma/sqlite/schema.prisma; legacy: prisma/schema.sqlite.prisma).
153
+ if (n === 'happy-server-light') {
154
+ const fullServerDir = getComponentDir(rootDir, 'happy-server', env);
155
+ try {
156
+ if (
157
+ fullServerDir &&
158
+ (existsSync(join(fullServerDir, 'prisma', 'sqlite', 'schema.prisma')) ||
159
+ existsSync(join(fullServerDir, 'prisma', 'schema.sqlite.prisma')))
160
+ ) {
161
+ return fullServerDir;
162
+ }
163
+ } catch {
164
+ // ignore
165
+ }
166
+ }
167
+
168
+ // Monorepo default behavior:
169
+ // - If components/happy is a monorepo checkout, derive all monorepo component dirs from it.
170
+ // - This allows a single checkout at components/happy to satisfy happy, happy-cli, and happy-server.
171
+ if (isHappyMonorepoComponentName(n)) {
172
+ // If the defaultDir is itself a monorepo root (common for "happy"), map to its package dir.
173
+ if (existsSync(defaultDir) && isHappyMonorepoRoot(defaultDir)) {
174
+ return resolveHappyMonorepoPackageDir({ monorepoRoot: defaultDir, component: n }) || defaultDir;
175
+ }
176
+ // If the legacy defaultDir exists (multi-repo), keep it.
177
+ if (existsSync(defaultDir) && existsSync(join(defaultDir, 'package.json'))) {
178
+ return defaultDir;
179
+ }
180
+ // Fallback: derive from the monorepo root at components/happy if present.
181
+ const monorepoRoot = join(componentsDir, 'happy');
182
+ if (existsSync(monorepoRoot) && isHappyMonorepoRoot(monorepoRoot)) {
183
+ return resolveHappyMonorepoPackageDir({ monorepoRoot, component: n }) || defaultDir;
184
+ }
185
+ }
186
+
187
+ return defaultDir;
72
188
  }
73
189
 
74
- export function getStackName() {
75
- const raw = process.env.HAPPY_STACKS_STACK?.trim()
76
- ? process.env.HAPPY_STACKS_STACK.trim()
77
- : process.env.HAPPY_LOCAL_STACK?.trim()
78
- ? process.env.HAPPY_LOCAL_STACK.trim()
190
+ export function getStackName(env = process.env) {
191
+ const raw = env.HAPPY_STACKS_STACK?.trim()
192
+ ? env.HAPPY_STACKS_STACK.trim()
193
+ : env.HAPPY_LOCAL_STACK?.trim()
194
+ ? env.HAPPY_LOCAL_STACK.trim()
79
195
  : '';
80
196
  return raw || 'main';
81
197
  }
82
198
 
83
- export function getStackLabel(stackName = getStackName()) {
84
- return stackName === 'main' ? PRIMARY_LABEL_BASE : `${PRIMARY_LABEL_BASE}.${stackName}`;
199
+ export function getStackLabel(stackName = null, env = process.env) {
200
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
201
+ return name === 'main' ? PRIMARY_LABEL_BASE : `${PRIMARY_LABEL_BASE}.${name}`;
85
202
  }
86
203
 
87
- export function getLegacyStackLabel(stackName = getStackName()) {
88
- return stackName === 'main' ? LEGACY_LABEL_BASE : `${LEGACY_LABEL_BASE}.${stackName}`;
204
+ export function getLegacyStackLabel(stackName = null, env = process.env) {
205
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
206
+ return name === 'main' ? LEGACY_LABEL_BASE : `${LEGACY_LABEL_BASE}.${name}`;
89
207
  }
90
208
 
91
- export function getStacksStorageRoot() {
92
- const fromEnv = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
209
+ export function getStacksStorageRoot(env = process.env) {
210
+ const fromEnv = (env.HAPPY_STACKS_STORAGE_DIR ?? env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
93
211
  if (fromEnv) {
94
212
  return expandHome(fromEnv);
95
213
  }
@@ -100,19 +218,20 @@ export function getLegacyStorageRoot() {
100
218
  return LEGACY_STORAGE_ROOT;
101
219
  }
102
220
 
103
- export function resolveStackBaseDir(stackName = getStackName()) {
104
- const preferredRoot = getStacksStorageRoot();
105
- const newBase = join(preferredRoot, stackName);
106
- const legacyBase = stackName === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', stackName);
221
+ export function resolveStackBaseDir(stackName = null, env = process.env) {
222
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
223
+ const preferredRoot = getStacksStorageRoot(env);
224
+ const newBase = join(preferredRoot, name);
225
+ const legacyBase = name === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', name);
107
226
  const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
108
227
 
109
228
  // Prefer the new layout by default.
110
229
  //
111
230
  // For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
112
231
  // This avoids breaking existing stacks until `happys stack migrate` is run.
113
- if (allowLegacy && stackName !== 'main') {
114
- const newEnv = join(preferredRoot, stackName, 'env');
115
- const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
232
+ if (allowLegacy && name !== 'main') {
233
+ const newEnv = join(preferredRoot, name, 'env');
234
+ const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
116
235
  if (!existsSync(newEnv) && existsSync(legacyEnv)) {
117
236
  return { baseDir: legacyBase, isLegacy: true };
118
237
  }
@@ -121,30 +240,31 @@ export function resolveStackBaseDir(stackName = getStackName()) {
121
240
  return { baseDir: newBase, isLegacy: false };
122
241
  }
123
242
 
124
- export function resolveStackEnvPath(stackName = getStackName()) {
125
- const { baseDir: activeBase, isLegacy } = resolveStackBaseDir(stackName);
243
+ export function resolveStackEnvPath(stackName = null, env = process.env) {
244
+ const name = (stackName ?? '').toString().trim() || getStackName(env);
245
+ const { baseDir: activeBase, isLegacy } = resolveStackBaseDir(name, env);
126
246
  // New layout: ~/.happy/stacks/<name>/env
127
- const newEnv = join(getStacksStorageRoot(), stackName, 'env');
247
+ const newEnv = join(getStacksStorageRoot(env), name, 'env');
128
248
  // Legacy layout: ~/.happy/local/stacks/<name>/env
129
- const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
249
+ const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
130
250
  const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
131
251
 
132
252
  if (existsSync(newEnv)) {
133
- return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(), stackName) };
253
+ return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(env), name) };
134
254
  }
135
255
  if (allowLegacy && existsSync(legacyEnv)) {
136
- return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', stackName) };
256
+ return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', name) };
137
257
  }
138
258
  return { envPath: newEnv, isLegacy, baseDir: activeBase };
139
259
  }
140
260
 
141
- export function getDefaultAutostartPaths() {
142
- const stackName = getStackName();
143
- const { baseDir, isLegacy } = resolveStackBaseDir(stackName);
261
+ export function getDefaultAutostartPaths(env = process.env) {
262
+ const stackName = getStackName(env);
263
+ const { baseDir, isLegacy } = resolveStackBaseDir(stackName, env);
144
264
  const logsDir = join(baseDir, 'logs');
145
265
 
146
- const primaryLabel = getStackLabel(stackName);
147
- const legacyLabel = getLegacyStackLabel(stackName);
266
+ const primaryLabel = getStackLabel(stackName, env);
267
+ const legacyLabel = getLegacyStackLabel(stackName, env);
148
268
  const primaryPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${primaryLabel}.plist`);
149
269
  const legacyPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${legacyLabel}.plist`);
150
270
 
@@ -187,4 +307,3 @@ export function getDefaultAutostartPaths() {
187
307
  legacyStderrPath,
188
308
  };
189
309
  }
190
-
@@ -0,0 +1,58 @@
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 { getComponentDir, getComponentRepoDir } from './paths.mjs';
8
+
9
+ async function withTempRoot(t) {
10
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-paths-monorepo-'));
11
+ t.after(async () => {
12
+ await rm(dir, { recursive: true, force: true });
13
+ });
14
+ return dir;
15
+ }
16
+
17
+ async function writeHappyMonorepoStub({ rootDir }) {
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
+ return monoRoot;
26
+ }
27
+
28
+ test('getComponentDir derives monorepo component package dirs from components/happy', async (t) => {
29
+ const rootDir = await withTempRoot(t);
30
+ const env = { HAPPY_STACKS_WORKSPACE_DIR: rootDir };
31
+
32
+ const monoRoot = await writeHappyMonorepoStub({ rootDir });
33
+ assert.equal(getComponentDir(rootDir, 'happy', env), join(monoRoot, 'expo-app'));
34
+ assert.equal(getComponentDir(rootDir, 'happy-cli', env), join(monoRoot, 'cli'));
35
+ assert.equal(getComponentDir(rootDir, 'happy-server', env), join(monoRoot, 'server'));
36
+ assert.equal(getComponentDir(rootDir, 'happy-server-light', env), join(monoRoot, 'server'));
37
+ });
38
+
39
+ test('getComponentRepoDir returns the shared monorepo root for monorepo components', async (t) => {
40
+ const rootDir = await withTempRoot(t);
41
+ const env = { HAPPY_STACKS_WORKSPACE_DIR: rootDir };
42
+
43
+ const monoRoot = await writeHappyMonorepoStub({ rootDir });
44
+ assert.equal(getComponentRepoDir(rootDir, 'happy', env), monoRoot);
45
+ assert.equal(getComponentRepoDir(rootDir, 'happy-cli', env), monoRoot);
46
+ assert.equal(getComponentRepoDir(rootDir, 'happy-server', env), monoRoot);
47
+ assert.equal(getComponentRepoDir(rootDir, 'happy-server-light', env), monoRoot);
48
+ });
49
+
50
+ test('getComponentDir normalizes monorepo env overrides that point inside the repo', async (t) => {
51
+ const rootDir = await withTempRoot(t);
52
+ const env = { HAPPY_STACKS_WORKSPACE_DIR: rootDir };
53
+
54
+ const monoRoot = await writeHappyMonorepoStub({ rootDir });
55
+
56
+ env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = join(monoRoot, 'cli', 'src');
57
+ assert.equal(getComponentDir(rootDir, 'happy-cli', env), join(monoRoot, 'cli'));
58
+ });
@@ -0,0 +1,45 @@
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 { getComponentDir } from './paths.mjs';
8
+
9
+ test('getComponentDir prefers happy-server for happy-server-light when unified schema exists', async (t) => {
10
+ const rootDir = await mkdtemp(join(tmpdir(), 'happy-stacks-paths-server-flavors-'));
11
+ t.after(async () => {
12
+ await rm(rootDir, { recursive: true, force: true });
13
+ });
14
+
15
+ const env = { HAPPY_STACKS_WORKSPACE_DIR: rootDir };
16
+ const fullDir = join(rootDir, 'components', 'happy-server');
17
+ await mkdir(join(fullDir, 'prisma', 'sqlite'), { recursive: true });
18
+ await writeFile(join(fullDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
19
+
20
+ assert.equal(getComponentDir(rootDir, 'happy-server-light', env), fullDir);
21
+ });
22
+
23
+ test('getComponentDir falls back to components/happy-server-light when unified schema is missing', async (t) => {
24
+ const rootDir = await mkdtemp(join(tmpdir(), 'happy-stacks-paths-server-flavors-'));
25
+ t.after(async () => {
26
+ await rm(rootDir, { recursive: true, force: true });
27
+ });
28
+
29
+ const env = { HAPPY_STACKS_WORKSPACE_DIR: rootDir };
30
+ const expected = join(rootDir, 'components', 'happy-server-light');
31
+ assert.equal(getComponentDir(rootDir, 'happy-server-light', env), expected);
32
+ });
33
+
34
+ test('getComponentDir does not alias when HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT is set', async (t) => {
35
+ const rootDir = await mkdtemp(join(tmpdir(), 'happy-stacks-paths-server-flavors-'));
36
+ t.after(async () => {
37
+ await rm(rootDir, { recursive: true, force: true });
38
+ });
39
+
40
+ const env = {
41
+ HAPPY_STACKS_WORKSPACE_DIR: rootDir,
42
+ HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT: '/tmp/custom/server-light',
43
+ };
44
+ assert.equal(getComponentDir(rootDir, 'happy-server-light', env), '/tmp/custom/server-light');
45
+ });
@@ -28,7 +28,6 @@ export async function runCaptureIfCommandExists(cmd, args, { cwd, env, timeoutMs
28
28
  }
29
29
  }
30
30
 
31
- export async function commandExists(cmd, { cwd } = {}) {
32
- return Boolean(await resolveCommandPath(cmd, { cwd }));
31
+ export async function commandExists(cmd, { cwd, env, timeoutMs } = {}) {
32
+ return Boolean(await resolveCommandPath(cmd, { cwd, env, timeoutMs }));
33
33
  }
34
-
@@ -0,0 +1,25 @@
1
+ export async function runWithConcurrencyLimit({ items, limit, fn }) {
2
+ const list = Array.isArray(items) ? items : [];
3
+ const max = Number(limit);
4
+ const concurrency = Number.isFinite(max) && max > 0 ? Math.floor(max) : 4;
5
+
6
+ const results = new Array(list.length);
7
+ let nextIndex = 0;
8
+
9
+ const worker = async () => {
10
+ while (true) {
11
+ const i = nextIndex;
12
+ nextIndex += 1;
13
+ if (i >= list.length) return;
14
+ results[i] = await fn(list[i], i);
15
+ }
16
+ };
17
+
18
+ const workers = [];
19
+ for (let i = 0; i < Math.min(concurrency, list.length); i++) {
20
+ workers.push(worker());
21
+ }
22
+ await Promise.all(workers);
23
+ return results;
24
+ }
25
+