happy-stacks 0.4.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 (104) hide show
  1. package/README.md +64 -33
  2. package/bin/happys.mjs +44 -1
  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 +1 -2
  10. package/docs/monorepo-migration.md +286 -0
  11. package/docs/server-flavors.md +19 -3
  12. package/docs/stacks.md +35 -0
  13. package/package.json +1 -1
  14. package/scripts/auth.mjs +21 -3
  15. package/scripts/build.mjs +1 -1
  16. package/scripts/dev.mjs +20 -7
  17. package/scripts/doctor.mjs +0 -4
  18. package/scripts/edison.mjs +2 -2
  19. package/scripts/env.mjs +150 -0
  20. package/scripts/env_cmd.test.mjs +128 -0
  21. package/scripts/init.mjs +5 -2
  22. package/scripts/install.mjs +99 -57
  23. package/scripts/migrate.mjs +3 -12
  24. package/scripts/monorepo.mjs +1096 -0
  25. package/scripts/monorepo_port.test.mjs +1470 -0
  26. package/scripts/review.mjs +715 -24
  27. package/scripts/review_pr.mjs +5 -20
  28. package/scripts/run.mjs +21 -15
  29. package/scripts/setup.mjs +147 -25
  30. package/scripts/setup_pr.mjs +19 -28
  31. package/scripts/stack.mjs +493 -157
  32. package/scripts/stack_archive_cmd.test.mjs +91 -0
  33. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  34. package/scripts/stack_env_cmd.test.mjs +87 -0
  35. package/scripts/stack_happy_cmd.test.mjs +126 -0
  36. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  37. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  38. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  39. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  40. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  41. package/scripts/stack_wt_list.test.mjs +128 -0
  42. package/scripts/tui.mjs +88 -2
  43. package/scripts/utils/cli/cli_registry.mjs +20 -5
  44. package/scripts/utils/cli/cwd_scope.mjs +56 -2
  45. package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
  46. package/scripts/utils/cli/prereqs.mjs +8 -5
  47. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  48. package/scripts/utils/cli/wizard.mjs +17 -9
  49. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  50. package/scripts/utils/dev/daemon.mjs +14 -1
  51. package/scripts/utils/dev/expo_dev.mjs +188 -4
  52. package/scripts/utils/dev/server.mjs +21 -17
  53. package/scripts/utils/edison/git_roots.mjs +29 -0
  54. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  55. package/scripts/utils/env/env.mjs +7 -3
  56. package/scripts/utils/env/env_file.mjs +4 -2
  57. package/scripts/utils/env/env_file.test.mjs +44 -0
  58. package/scripts/utils/git/worktrees.mjs +63 -12
  59. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  60. package/scripts/utils/net/tcp_forward.mjs +162 -0
  61. package/scripts/utils/paths/paths.mjs +118 -3
  62. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  63. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  64. package/scripts/utils/proc/commands.mjs +2 -3
  65. package/scripts/utils/proc/pm.mjs +113 -16
  66. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  67. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  68. package/scripts/utils/proc/proc.mjs +68 -10
  69. package/scripts/utils/proc/proc.test.mjs +77 -0
  70. package/scripts/utils/review/chunks.mjs +55 -0
  71. package/scripts/utils/review/chunks.test.mjs +51 -0
  72. package/scripts/utils/review/findings.mjs +165 -0
  73. package/scripts/utils/review/findings.test.mjs +85 -0
  74. package/scripts/utils/review/head_slice.mjs +153 -0
  75. package/scripts/utils/review/head_slice.test.mjs +91 -0
  76. package/scripts/utils/review/instructions/deep.md +20 -0
  77. package/scripts/utils/review/runners/coderabbit.mjs +56 -14
  78. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  79. package/scripts/utils/review/runners/codex.mjs +32 -22
  80. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  81. package/scripts/utils/review/slices.mjs +140 -0
  82. package/scripts/utils/review/slices.test.mjs +32 -0
  83. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  84. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  85. package/scripts/utils/server/prisma_import.mjs +37 -0
  86. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  87. package/scripts/utils/server/ui_env.mjs +14 -0
  88. package/scripts/utils/server/ui_env.test.mjs +46 -0
  89. package/scripts/utils/server/validate.mjs +53 -16
  90. package/scripts/utils/server/validate.test.mjs +89 -0
  91. package/scripts/utils/stack/editor_workspace.mjs +4 -4
  92. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  93. package/scripts/utils/stack/startup.mjs +113 -13
  94. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  95. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  96. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  97. package/scripts/utils/tailscale/ip.mjs +116 -0
  98. package/scripts/utils/ui/ansi.mjs +39 -0
  99. package/scripts/where.mjs +2 -2
  100. package/scripts/worktrees.mjs +627 -137
  101. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  102. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  103. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  104. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
@@ -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
 
@@ -20,14 +26,24 @@ export function getWorktreesRoot(rootDir, env = process.env) {
20
26
  }
21
27
 
22
28
  export function componentRepoDir(rootDir, component, env = process.env) {
23
- return join(getComponentsDir(rootDir, env), component);
29
+ return getComponentRepoDir(rootDir, component, env);
30
+ }
31
+
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;
24
39
  }
25
40
 
26
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, env), 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
 
@@ -36,10 +52,24 @@ export function worktreeSpecFromDir({ rootDir, component, dir, env = process.env
36
52
  if (!raw) return null;
37
53
  if (!isComponentWorktreePath({ rootDir, component, dir: raw, env })) return null;
38
54
  const abs = resolve(raw);
39
- const root = resolve(join(getWorktreesRoot(rootDir, env), 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
 
@@ -77,7 +107,11 @@ export async function createWorktreeFromBaseWorktree({
77
107
 
78
108
  const repoDir = componentRepoDir(rootDir, component, env);
79
109
  const owner = await getRemoteOwner({ repoDir, remoteName });
80
- return join(getWorktreesRoot(rootDir, env), 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;
81
115
  }
82
116
 
83
117
  export function resolveComponentSpecToDir({ rootDir, component, spec, env = process.env }) {
@@ -86,14 +120,24 @@ export function resolveComponentSpecToDir({ rootDir, component, spec, env = proc
86
120
  return null;
87
121
  }
88
122
  if (isAbsolute(raw)) {
123
+ const monoRoot = coerceHappyMonorepoRootFromPath(raw);
124
+ const sub = happyMonorepoSubdirForComponent(component);
125
+ if (monoRoot && sub) {
126
+ return join(monoRoot, sub);
127
+ }
89
128
  return raw;
90
129
  }
91
- // Treat as <owner>/<branch...> under components/.worktrees/<component>/...
92
- return join(getWorktreesRoot(rootDir, env), 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;
93
136
  }
94
137
 
95
138
  export async function listWorktreeSpecs({ rootDir, component, env = process.env }) {
96
- const dir = join(getWorktreesRoot(rootDir, env), component);
139
+ const key = worktreeRepoKeyForComponent({ rootDir, component, env });
140
+ const dir = join(getWorktreesRoot(rootDir, env), key);
97
141
  const specs = [];
98
142
  try {
99
143
  const walk = async (d, prefixParts) => {
@@ -104,6 +148,9 @@ export async function listWorktreeSpecs({ rootDir, component, env = process.env
104
148
  const nextPrefix = [...prefixParts, e.name];
105
149
  if (await pathExists(join(p, '.git'))) {
106
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;
107
154
  }
108
155
  await walk(p, nextPrefix);
109
156
  }
@@ -134,5 +181,9 @@ export async function createWorktree({ rootDir, component, slug, remoteName = 'u
134
181
  });
135
182
  const repoDir = componentRepoDir(rootDir, component, env);
136
183
  const owner = await getRemoteOwner({ repoDir, remoteName });
137
- return join(getWorktreesRoot(rootDir, env), 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;
138
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,162 @@
1
+ /**
2
+ * Lightweight TCP port forwarder using Node.js built-in `net` module.
3
+ *
4
+ * Used to expose Expo dev server (which binds to LAN IP) on the Tailscale interface,
5
+ * enabling remote device access over Tailscale without modifying Expo's binding behavior.
6
+ *
7
+ * Can be run standalone:
8
+ * node tcp_forward.mjs --listen-host=100.x.y.z --listen-port=8081 --target-host=192.168.1.50 --target-port=8081
9
+ *
10
+ * Or imported and spawned as a managed child process.
11
+ */
12
+
13
+ import net from 'node:net';
14
+
15
+ /**
16
+ * Create a TCP forwarding server.
17
+ *
18
+ * @param {Object} options
19
+ * @param {string} options.listenHost - Host/IP to listen on (e.g., Tailscale IP)
20
+ * @param {number} options.listenPort - Port to listen on
21
+ * @param {string} options.targetHost - Host/IP to forward to (e.g., LAN IP or 127.0.0.1)
22
+ * @param {number} options.targetPort - Port to forward to
23
+ * @param {string} [options.label] - Label for logging (default: 'tcp-forward')
24
+ * @returns {net.Server}
25
+ */
26
+ export function createTcpForwarder({ listenHost, listenPort, targetHost, targetPort, label = 'tcp-forward' }) {
27
+ const server = net.createServer((clientSocket) => {
28
+ const targetSocket = net.createConnection({ host: targetHost, port: targetPort }, () => {
29
+ // Connection established, pipe data both ways
30
+ clientSocket.pipe(targetSocket);
31
+ targetSocket.pipe(clientSocket);
32
+ });
33
+
34
+ // Handle errors on both sockets
35
+ clientSocket.on('error', (err) => {
36
+ if (err.code !== 'ECONNRESET') {
37
+ process.stderr.write(`[${label}] client error: ${err.message}\n`);
38
+ }
39
+ targetSocket.destroy();
40
+ });
41
+
42
+ targetSocket.on('error', (err) => {
43
+ if (err.code !== 'ECONNRESET' && err.code !== 'ECONNREFUSED') {
44
+ process.stderr.write(`[${label}] target error: ${err.message}\n`);
45
+ }
46
+ clientSocket.destroy();
47
+ });
48
+
49
+ // Clean up on close
50
+ clientSocket.on('close', () => targetSocket.destroy());
51
+ targetSocket.on('close', () => clientSocket.destroy());
52
+ });
53
+
54
+ server.on('error', (err) => {
55
+ process.stderr.write(`[${label}] server error: ${err.message}\n`);
56
+ });
57
+
58
+ return server;
59
+ }
60
+
61
+ /**
62
+ * Start a TCP forwarder and return a promise that resolves when listening.
63
+ *
64
+ * @param {Object} options - Same as createTcpForwarder
65
+ * @returns {Promise<{ server: net.Server, address: string, port: number }>}
66
+ */
67
+ export async function startTcpForwarder(options) {
68
+ const { listenHost, listenPort, label = 'tcp-forward' } = options;
69
+ const server = createTcpForwarder(options);
70
+
71
+ return new Promise((resolve, reject) => {
72
+ server.once('error', reject);
73
+ server.listen(listenPort, listenHost, () => {
74
+ server.removeListener('error', reject);
75
+ const addr = server.address();
76
+ const address = typeof addr === 'object' ? addr.address : listenHost;
77
+ const port = typeof addr === 'object' ? addr.port : listenPort;
78
+ process.stdout.write(`[${label}] forwarding ${address}:${port} -> ${options.targetHost}:${options.targetPort}\n`);
79
+ resolve({ server, address, port });
80
+ });
81
+ });
82
+ }
83
+
84
+ function trySendIpc(msg) {
85
+ try {
86
+ if (typeof process.send === 'function') {
87
+ process.send(msg);
88
+ }
89
+ } catch {
90
+ // ignore
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Gracefully stop a TCP forwarder server.
96
+ *
97
+ * @param {net.Server} server
98
+ * @param {string} [label]
99
+ * @returns {Promise<void>}
100
+ */
101
+ export async function stopTcpForwarder(server, label = 'tcp-forward') {
102
+ if (!server) return;
103
+ return new Promise((resolve) => {
104
+ server.close(() => {
105
+ process.stdout.write(`[${label}] stopped\n`);
106
+ resolve();
107
+ });
108
+ // Force-close after timeout
109
+ setTimeout(() => {
110
+ resolve();
111
+ }, 2000);
112
+ });
113
+ }
114
+
115
+ // Standalone CLI mode
116
+ if (import.meta.url === `file://${process.argv[1]}`) {
117
+ const args = process.argv.slice(2);
118
+ const kv = new Map();
119
+ for (const arg of args) {
120
+ const m = arg.match(/^--([^=]+)=(.*)$/);
121
+ if (m) kv.set(m[1], m[2]);
122
+ }
123
+
124
+ const listenHost = kv.get('listen-host') || kv.get('listenHost');
125
+ const listenPort = Number(kv.get('listen-port') || kv.get('listenPort'));
126
+ const targetHost = kv.get('target-host') || kv.get('targetHost') || '127.0.0.1';
127
+ const targetPort = Number(kv.get('target-port') || kv.get('targetPort'));
128
+ const label = kv.get('label') || 'tcp-forward';
129
+
130
+ if (!listenHost || !listenPort || !targetPort) {
131
+ console.error('Usage: node tcp_forward.mjs --listen-host=<ip> --listen-port=<port> --target-host=<ip> --target-port=<port> [--label=<label>]');
132
+ process.exit(1);
133
+ }
134
+
135
+ const shutdown = () => {
136
+ process.stdout.write(`\n[${label}] shutting down...\n`);
137
+ process.exit(0);
138
+ };
139
+
140
+ process.on('SIGINT', shutdown);
141
+ process.on('SIGTERM', shutdown);
142
+
143
+ startTcpForwarder({ listenHost, listenPort, targetHost, targetPort, label })
144
+ .then(() => {
145
+ trySendIpc({ type: 'ready', listenHost, listenPort, targetHost, targetPort, label });
146
+ // Keep running until signal
147
+ })
148
+ .catch((err) => {
149
+ trySendIpc({
150
+ type: 'error',
151
+ code: err && typeof err === 'object' ? err.code : null,
152
+ message: err instanceof Error ? err.message : String(err ?? 'unknown error'),
153
+ listenHost,
154
+ listenPort,
155
+ targetHost,
156
+ targetPort,
157
+ label,
158
+ });
159
+ console.error(`[${label}] failed to start: ${err.message}`);
160
+ process.exit(1);
161
+ });
162
+ }
@@ -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
  }
@@ -61,14 +73,118 @@ function normalizePathForEnv(rootDir, raw, env = process.env) {
61
73
  return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
62
74
  }
63
75
 
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
+
64
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
129
  const fromEnv = normalizePathForEnv(rootDir, env[stacksKey] ?? env[legacyKey], env);
68
- if (fromEnv) {
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, env), 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
190
  export function getStackName(env = process.env) {
@@ -191,4 +307,3 @@ export function getDefaultAutostartPaths(env = process.env) {
191
307
  legacyStderrPath,
192
308
  };
193
309
  }
194
-
@@ -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
-