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
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ test('happys <stack> <cmd> ... rewrites to happys stack <cmd> <stack> ... when stack exists', async () => {
22
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
23
+ const rootDir = dirname(scriptsDir);
24
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-shorthand-'));
25
+
26
+ const storageDir = join(tmp, 'storage');
27
+ const homeDir = join(tmp, 'home');
28
+ const stackName = 'exp-test';
29
+
30
+ const envPath = join(storageDir, stackName, 'env');
31
+ await mkdir(dirname(envPath), { recursive: true });
32
+ await mkdir(homeDir, { recursive: true });
33
+ await writeFile(envPath, 'FOO=bar\n', 'utf-8');
34
+
35
+ const baseEnv = {
36
+ ...process.env,
37
+ HAPPY_STACKS_HOME_DIR: homeDir,
38
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
39
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
40
+ };
41
+
42
+ const res = await runNode([join(rootDir, 'bin', 'happys.mjs'), stackName, 'env', 'path', '--json'], {
43
+ cwd: rootDir,
44
+ env: baseEnv,
45
+ });
46
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
47
+
48
+ const out = JSON.parse(res.stdout || '{}');
49
+ assert.equal(out.ok, true);
50
+ assert.ok(
51
+ typeof out.envPath === 'string' && out.envPath.endsWith(`/${stackName}/env`),
52
+ `expected envPath to end with /${stackName}/env, got: ${out.envPath}`
53
+ );
54
+ });
55
+
@@ -0,0 +1,128 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const cleanEnv = {};
12
+ for (const [k, v] of Object.entries(env ?? {})) {
13
+ if (v == null) continue;
14
+ cleanEnv[k] = String(v);
15
+ }
16
+ const proc = spawn(process.execPath, args, { cwd, env: cleanEnv, stdio: ['ignore', 'pipe', 'pipe'] });
17
+ let stdout = '';
18
+ let stderr = '';
19
+ proc.stdout.on('data', (d) => (stdout += String(d)));
20
+ proc.stderr.on('data', (d) => (stderr += String(d)));
21
+ proc.on('error', reject);
22
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
23
+ });
24
+ }
25
+
26
+ async function touchWorktree(dir) {
27
+ await mkdir(dir, { recursive: true });
28
+ // In a git worktree, ".git" is often a file; our detection treats either file or dir as truthy.
29
+ await writeFile(join(dir, '.git'), 'gitdir: /dev/null\n', 'utf-8');
30
+ }
31
+
32
+ test('happys stack wt <stack> -- list defaults to active-only (no exhaustive enumeration)', async () => {
33
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
34
+ const rootDir = dirname(scriptsDir);
35
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-wt-list-'));
36
+
37
+ const storageDir = join(tmp, 'storage');
38
+ const homeDir = join(tmp, 'home');
39
+ const workspaceDir = join(tmp, 'workspace');
40
+ const componentsDir = join(workspaceDir, 'components');
41
+ const stackName = 'exp-test';
42
+
43
+ // Create isolated worktrees on disk (inside our temp workspace).
44
+ const wtRoot = join(componentsDir, '.worktrees');
45
+ const happyActive = join(wtRoot, 'happy', 'slopus', 'pr', 'active-branch');
46
+ const happyOther = join(wtRoot, 'happy', 'slopus', 'pr', 'other-branch');
47
+ const cliActive = join(wtRoot, 'happy-cli', 'slopus', 'pr', 'cli-active');
48
+ const cliOther = join(wtRoot, 'happy-cli', 'slopus', 'pr', 'cli-other');
49
+ await touchWorktree(happyActive);
50
+ await touchWorktree(happyOther);
51
+ await touchWorktree(cliActive);
52
+ await touchWorktree(cliOther);
53
+
54
+ // Stack env selects the active worktrees.
55
+ const envPath = join(storageDir, stackName, 'env');
56
+ await mkdir(dirname(envPath), { recursive: true });
57
+ await writeFile(
58
+ envPath,
59
+ [
60
+ `HAPPY_STACKS_STACK=${stackName}`,
61
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY=${happyActive}`,
62
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliActive}`,
63
+ '',
64
+ ].join('\n'),
65
+ 'utf-8'
66
+ );
67
+
68
+ const baseEnv = {
69
+ ...process.env,
70
+ // Prevent loading the user's real ~/.happy-stacks/.env via canonical discovery.
71
+ HAPPY_STACKS_HOME_DIR: homeDir,
72
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
73
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
74
+ };
75
+
76
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'wt', stackName, '--', 'list'], { cwd: rootDir, env: baseEnv });
77
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
78
+
79
+ assert.ok(res.stdout.includes(`- active: ${happyActive}`), `expected happy active in output\n${res.stdout}`);
80
+ assert.ok(res.stdout.includes(`- active: ${cliActive}`), `expected happy-cli active in output\n${res.stdout}`);
81
+
82
+ // Should NOT enumerate other worktrees unless --all was passed.
83
+ assert.ok(!res.stdout.includes(`- ${happyOther}`), `expected happy other to be omitted\n${res.stdout}`);
84
+ assert.ok(!res.stdout.includes(`- ${cliOther}`), `expected happy-cli other to be omitted\n${res.stdout}`);
85
+ });
86
+
87
+ test('happys stack wt <stack> -- list --all shows all worktrees (opt-in)', async () => {
88
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
89
+ const rootDir = dirname(scriptsDir);
90
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-wt-list-'));
91
+
92
+ const storageDir = join(tmp, 'storage');
93
+ const homeDir = join(tmp, 'home');
94
+ const workspaceDir = join(tmp, 'workspace');
95
+ const componentsDir = join(workspaceDir, 'components');
96
+ const stackName = 'exp-test';
97
+
98
+ const wtRoot = join(componentsDir, '.worktrees');
99
+ const happyActive = join(wtRoot, 'happy', 'slopus', 'pr', 'active-branch');
100
+ const happyOther = join(wtRoot, 'happy', 'slopus', 'pr', 'other-branch');
101
+ await touchWorktree(happyActive);
102
+ await touchWorktree(happyOther);
103
+
104
+ const envPath = join(storageDir, stackName, 'env');
105
+ await mkdir(dirname(envPath), { recursive: true });
106
+ await writeFile(
107
+ envPath,
108
+ [`HAPPY_STACKS_STACK=${stackName}`, `HAPPY_STACKS_COMPONENT_DIR_HAPPY=${happyActive}`, ''].join('\n'),
109
+ 'utf-8'
110
+ );
111
+
112
+ const baseEnv = {
113
+ ...process.env,
114
+ HAPPY_STACKS_HOME_DIR: homeDir,
115
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
116
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
117
+ };
118
+
119
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'wt', stackName, '--', 'list', '--all'], {
120
+ cwd: rootDir,
121
+ env: baseEnv,
122
+ });
123
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
124
+
125
+ assert.ok(res.stdout.includes(`- active: ${happyActive}`), `expected happy active in output\n${res.stdout}`);
126
+ assert.ok(res.stdout.includes(`- ${happyOther}`), `expected happy other to be listed with --all\n${res.stdout}`);
127
+ });
128
+
package/scripts/tui.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { spawn } from 'node:child_process';
3
+ import { mkdir } from 'node:fs/promises';
3
4
  import { join, resolve, sep } from 'node:path';
4
5
 
5
6
  import { printResult } from './utils/cli/cli.mjs';
@@ -115,6 +116,76 @@ function inferStackNameFromForwardedArgs(args) {
115
116
 
116
117
  const readEnvObject = readEnvObjectFromFile;
117
118
 
119
+ async function preflightCorepackYarnForStack({ envPath }) {
120
+ // Corepack caches (and therefore "download yarn?" prompts) are tied to XDG/HOME.
121
+ // In stack mode we isolate HOME/XDG caches per stack, which can cause Corepack to prompt
122
+ // the first time a stack runs Yarn.
123
+ //
124
+ // In `happys tui`, the child runs under a pseudo-TTY (via `script`) and the TUI consumes
125
+ // all keyboard input, so Corepack's interactive prompt deadlocks.
126
+ //
127
+ // Fix: pre-download Yarn in a *non-tty* subprocess using the stack's isolated HOME/XDG,
128
+ // so later pty runs don't prompt.
129
+ if (!envPath) return;
130
+ const baseDir = resolve(join(envPath, '..'));
131
+ const stackHome = join(baseDir, 'home');
132
+ const cacheBase = join(baseDir, 'cache');
133
+ const env = {
134
+ ...process.env,
135
+ HOME: stackHome,
136
+ USERPROFILE: stackHome,
137
+ XDG_CACHE_HOME: join(cacheBase, 'xdg'),
138
+ YARN_CACHE_FOLDER: join(cacheBase, 'yarn'),
139
+ npm_config_cache: join(cacheBase, 'npm'),
140
+ // Avoid Corepack mutating package.json automatically.
141
+ COREPACK_ENABLE_AUTO_PIN: '0',
142
+ // Best-effort: disable download prompts (may not be honored by all Corepack versions).
143
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: '0',
144
+ // Treat this as non-interactive (helps some tooling).
145
+ CI: process.env.CI ?? '1',
146
+ };
147
+
148
+ await mkdir(stackHome, { recursive: true }).catch(() => {});
149
+ await mkdir(env.XDG_CACHE_HOME, { recursive: true }).catch(() => {});
150
+ await mkdir(env.YARN_CACHE_FOLDER, { recursive: true }).catch(() => {});
151
+ await mkdir(env.npm_config_cache, { recursive: true }).catch(() => {});
152
+ await mkdir(env.COREPACK_HOME, { recursive: true }).catch(() => {});
153
+
154
+ await new Promise((resolvePromise) => {
155
+ const proc = spawn('yarn', ['--version'], {
156
+ env,
157
+ cwd: baseDir,
158
+ // Non-tty stdio: Corepack typically won't prompt; if it does, we still provide "y\n".
159
+ stdio: ['pipe', 'ignore', 'ignore'],
160
+ shell: false,
161
+ });
162
+ try {
163
+ proc.stdin?.write('y\n');
164
+ proc.stdin?.end();
165
+ } catch {
166
+ // ignore
167
+ }
168
+
169
+ const t = setTimeout(() => {
170
+ try {
171
+ proc.kill('SIGKILL');
172
+ } catch {
173
+ // ignore
174
+ }
175
+ resolvePromise();
176
+ }, 60_000);
177
+
178
+ proc.on('exit', () => {
179
+ clearTimeout(t);
180
+ resolvePromise();
181
+ });
182
+ proc.on('error', () => {
183
+ clearTimeout(t);
184
+ resolvePromise();
185
+ });
186
+ });
187
+ }
188
+
118
189
  function getEnvVal(env, key, legacyKey) {
119
190
  return getEnvValueAny(env, [key, legacyKey]) || '';
120
191
  }
@@ -294,6 +365,7 @@ async function main() {
294
365
  const forwarded = argv;
295
366
 
296
367
  const stackName = inferStackNameFromForwardedArgs(forwarded);
368
+ const { envPath: stackEnvPath } = resolveStackEnvPath(stackName);
297
369
 
298
370
  const panes = [
299
371
  mkPane('orch', 'orchestration', { visible: true, kind: 'log' }),
@@ -334,24 +406,38 @@ async function main() {
334
406
  pushLine(panes[paneIndexById.get('orch')], `[${nowTs()}] ${msg}`);
335
407
  };
336
408
 
409
+ // Preflight Yarn/Corepack for this stack before spawning the pty child.
410
+ // This prevents Corepack "download yarn? [Y/n]" prompts from deadlocking the TUI.
411
+ await preflightCorepackYarnForStack({ envPath: stackEnvPath });
412
+
337
413
  let layout = 'columns'; // single | split | columns
338
414
  let focused = paneIndexById.get('local'); // default focus
339
415
  let paused = false;
340
416
  let renderScheduled = false;
341
417
 
342
418
  const wantsPty = process.platform !== 'win32' && (await commandExists('script', { cwd: rootDir }));
419
+ // In TUI mode, we intentionally do not forward keyboard input to the child process (stdin is ignored),
420
+ // so any interactive prompts inside the child would deadlock.
421
+ // Mark the child env so dependency installers can auto-approve safe prompts (Corepack yarn downloads).
422
+ const childEnv = {
423
+ ...process.env,
424
+ HAPPY_STACKS_TUI: '1',
425
+ HAPPY_LOCAL_TUI: '1',
426
+ // Avoid Corepack mutating package.json automatically.
427
+ COREPACK_ENABLE_AUTO_PIN: '0',
428
+ };
343
429
  const child = wantsPty
344
430
  ? // Use a pseudo-terminal so tools like Expo print QR/status output that they hide in non-TTY mode.
345
431
  // `script` is available by default on macOS (and common on Linux).
346
432
  spawn('script', ['-q', '/dev/null', process.execPath, happysBin, ...forwarded], {
347
433
  cwd: rootDir,
348
- env: { ...process.env },
434
+ env: childEnv,
349
435
  stdio: ['ignore', 'pipe', 'pipe'],
350
436
  detached: process.platform !== 'win32',
351
437
  })
352
438
  : spawn(process.execPath, [happysBin, ...forwarded], {
353
439
  cwd: rootDir,
354
- env: { ...process.env },
440
+ env: childEnv,
355
441
  stdio: ['ignore', 'pipe', 'pipe'],
356
442
  detached: process.platform !== 'win32',
357
443
  });
@@ -34,7 +34,7 @@ export function getHappysRegistry() {
34
34
  aliases: ['setupPR', 'setuppr'],
35
35
  kind: 'node',
36
36
  scriptRelPath: 'scripts/setup_pr.mjs',
37
- rootUsage: 'happys setup-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev]',
37
+ rootUsage: 'happys setup-pr --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
38
38
  description: 'One-shot: set up + run a PR stack (maintainer-friendly)',
39
39
  },
40
40
  {
@@ -42,7 +42,7 @@ export function getHappysRegistry() {
42
42
  aliases: ['reviewPR', 'reviewpr'],
43
43
  kind: 'node',
44
44
  scriptRelPath: 'scripts/review_pr.mjs',
45
- rootUsage: 'happys review-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev]',
45
+ rootUsage: 'happys review-pr --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
46
46
  description: 'Run setup-pr in a temporary sandbox (auto-cleaned)',
47
47
  },
48
48
  {
@@ -54,12 +54,18 @@ export function getHappysRegistry() {
54
54
  },
55
55
  {
56
56
  name: 'where',
57
- aliases: ['env'],
58
57
  kind: 'node',
59
58
  scriptRelPath: 'scripts/where.mjs',
60
- rootUsage: 'happys where [--json] (alias: env)',
59
+ rootUsage: 'happys where [--json]',
61
60
  description: 'Show resolved paths and env sources',
62
61
  },
62
+ {
63
+ name: 'env',
64
+ kind: 'node',
65
+ scriptRelPath: 'scripts/env.mjs',
66
+ rootUsage: 'happys env set KEY=VALUE [KEY2=VALUE2...] (defaults to main stack)',
67
+ description: 'Set per-stack env vars (defaults to main)',
68
+ },
63
69
  {
64
70
  name: 'bootstrap',
65
71
  kind: 'node',
@@ -140,6 +146,13 @@ export function getHappysRegistry() {
140
146
  rootUsage: 'happys migrate light-to-server --from-stack=<name> --to-stack=<name> [--include-files] [--force] [--json]',
141
147
  description: 'Migrate data between server flavors (experimental)',
142
148
  },
149
+ {
150
+ name: 'monorepo',
151
+ kind: 'node',
152
+ scriptRelPath: 'scripts/monorepo.mjs',
153
+ rootUsage: 'happys monorepo port --target=/abs/path/to/monorepo [--branch=port/<name>] [--dry-run] [--3way] [--json]',
154
+ description: 'Port split-repo commits into monorepo (experimental)',
155
+ },
143
156
  {
144
157
  name: 'mobile',
145
158
  kind: 'node',
@@ -347,6 +360,9 @@ export function renderHappysRootHelp() {
347
360
  'usage:',
348
361
  ...usageLines.map((l) => ` ${l}`),
349
362
  '',
363
+ 'stack shorthand:',
364
+ ' happys <stack> <command> ... (equivalent to: happys stack <command> <stack> ...)',
365
+ '',
350
366
  'commands:',
351
367
  ...commandsLines,
352
368
  '',
@@ -354,4 +370,3 @@ export function renderHappysRootHelp() {
354
370
  ' happys help [command]',
355
371
  ].join('\n');
356
372
  }
357
-
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
 
4
4
  import { getWorktreesRoot } from '../git/worktrees.mjs';
5
- import { getComponentsDir } from '../paths/paths.mjs';
5
+ import { getComponentsDir, isHappyMonorepoRoot } from '../paths/paths.mjs';
6
6
 
7
7
  export function getInvokedCwd(env = process.env) {
8
8
  return String(env.HAPPY_STACKS_INVOKED_CWD ?? env.HAPPY_LOCAL_INVOKED_CWD ?? env.PWD ?? '').trim();
@@ -45,6 +45,24 @@ function findGitRoot(startDir, stopAtDir) {
45
45
  }
46
46
  }
47
47
 
48
+ function resolveHappyMonorepoComponentFromPath({ monorepoRoot, absPath }) {
49
+ const root = resolve(monorepoRoot);
50
+ const abs = resolve(absPath);
51
+ const map = [
52
+ { component: 'happy', dir: join(root, 'expo-app') },
53
+ { component: 'happy-cli', dir: join(root, 'cli') },
54
+ { component: 'happy-server', dir: join(root, 'server') },
55
+ ];
56
+ for (const m of map) {
57
+ if (isPathInside(abs, m.dir)) {
58
+ // We return the shared git root so callers can safely use it as an env override
59
+ // for any of the monorepo components.
60
+ return { component: m.component, repoDir: root };
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
48
66
  export function inferComponentFromCwd({ rootDir, invokedCwd, components }) {
49
67
  const cwd = String(invokedCwd ?? '').trim();
50
68
  const list = Array.isArray(components) ? components : [];
@@ -56,6 +74,43 @@ export function inferComponentFromCwd({ rootDir, invokedCwd, components }) {
56
74
  const componentsDir = getComponentsDir(rootDir);
57
75
  const worktreesRoot = getWorktreesRoot(rootDir);
58
76
 
77
+ // Monorepo-aware inference:
78
+ // If we're inside a happy monorepo checkout/worktree, infer which "logical component"
79
+ // (expo-app/cli/server) the user is working in and return that package dir.
80
+ //
81
+ // This enables workflows like:
82
+ // - running `happys dev` from inside components/happy/cli (should infer happy-cli)
83
+ // - running from inside components/.worktrees/happy/<owner>/<branch>/server (should infer happy-server)
84
+ {
85
+ const monorepoScopes = [
86
+ resolve(join(componentsDir, 'happy')),
87
+ resolve(join(worktreesRoot, 'happy')),
88
+ ];
89
+ for (const scope of monorepoScopes) {
90
+ if (!isPathInside(abs, scope)) continue;
91
+ const repoRoot = findGitRoot(abs, scope);
92
+ if (!repoRoot) continue;
93
+ if (!isHappyMonorepoRoot(repoRoot)) continue;
94
+
95
+ const inferred = resolveHappyMonorepoComponentFromPath({ monorepoRoot: repoRoot, absPath: abs });
96
+ if (inferred) {
97
+ // Only return components the caller asked us to consider.
98
+ if (list.includes(inferred.component)) {
99
+ return inferred;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ // If we are inside the monorepo root but not inside a known package dir, default to `happy`
105
+ // (the UI) when the caller allows it. This keeps legacy behavior where running from the
106
+ // repo root still "belongs" to the UI component.
107
+ if (list.includes('happy')) {
108
+ return { component: 'happy', repoDir: repoRoot };
109
+ }
110
+ return null;
111
+ }
112
+ }
113
+
59
114
  for (const component of list) {
60
115
  const c = String(component ?? '').trim();
61
116
  if (!c) continue;
@@ -79,4 +134,3 @@ export function inferComponentFromCwd({ rootDir, invokedCwd, components }) {
79
134
 
80
135
  return null;
81
136
  }
82
-
@@ -35,7 +35,37 @@ test('inferComponentFromCwd resolves components/<component> repo root', async (t
35
35
  assert.deepEqual(inferred, { component: 'happy', repoDir: repoRoot });
36
36
  });
37
37
 
38
- test('inferComponentFromCwd resolves components/.worktrees/<component>/<owner>/<branch> repo root', async (t) => {
38
+ test('inferComponentFromCwd resolves happy monorepo subpackages under components/happy', async (t) => {
39
+ const rootDir = await withTempRoot(t);
40
+ const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
41
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
42
+ t.after(() => {
43
+ if (prevWorkspace == null) {
44
+ delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
45
+ } else {
46
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
47
+ }
48
+ });
49
+
50
+ const monoRoot = join(rootDir, 'components', 'happy');
51
+ await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
52
+ await mkdir(join(monoRoot, 'cli', 'src'), { recursive: true });
53
+ await mkdir(join(monoRoot, 'server'), { recursive: true });
54
+ await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
55
+ await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
56
+ await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
57
+ await writeFile(join(monoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
58
+
59
+ const invokedCwd = join(monoRoot, 'cli', 'src');
60
+ const inferred = inferComponentFromCwd({
61
+ rootDir,
62
+ invokedCwd,
63
+ components: ['happy', 'happy-cli', 'happy-server'],
64
+ });
65
+ assert.deepEqual(inferred, { component: 'happy-cli', repoDir: monoRoot });
66
+ });
67
+
68
+ test('inferComponentFromCwd resolves happy monorepo worktree roots under components/.worktrees/happy', async (t) => {
39
69
  const rootDir = await withTempRoot(t);
40
70
  const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
41
71
  process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
@@ -47,14 +77,18 @@ test('inferComponentFromCwd resolves components/.worktrees/<component>/<owner>/<
47
77
  }
48
78
  });
49
79
 
50
- const wtRoot = join(rootDir, 'components', '.worktrees', 'happy', 'slopus', 'pr', '123-fix', 'nested');
51
- await mkdir(wtRoot, { recursive: true });
52
80
  const repoRoot = join(rootDir, 'components', '.worktrees', 'happy', 'slopus', 'pr', '123-fix');
81
+ await mkdir(join(repoRoot, 'expo-app'), { recursive: true });
82
+ await mkdir(join(repoRoot, 'cli', 'nested'), { recursive: true });
83
+ await mkdir(join(repoRoot, 'server'), { recursive: true });
84
+ await writeFile(join(repoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
85
+ await writeFile(join(repoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
86
+ await writeFile(join(repoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
53
87
  await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
54
88
 
55
- const invokedCwd = join(repoRoot, 'nested');
56
- const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy'] });
57
- assert.deepEqual(inferred, { component: 'happy', repoDir: repoRoot });
89
+ const invokedCwd = join(repoRoot, 'cli', 'nested');
90
+ const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy', 'happy-cli', 'happy-server'] });
91
+ assert.deepEqual(inferred, { component: 'happy-cli', repoDir: repoRoot });
58
92
  });
59
93
 
60
94
  test('inferComponentFromCwd returns null outside known component roots', async (t) => {
@@ -74,4 +108,3 @@ test('inferComponentFromCwd returns null outside known component roots', async (
74
108
  const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy'] });
75
109
  assert.equal(inferred, null);
76
110
  });
77
-
@@ -24,11 +24,15 @@ export async function assertCliPrereqs({ git = false, pnpm = false, codex = fals
24
24
 
25
25
  if (pnpm) {
26
26
  const hasPnpm = await commandExists('pnpm');
27
- if (!hasPnpm) {
27
+ const hasYarn = await commandExists('yarn');
28
+ if (!hasPnpm && !hasYarn) {
28
29
  missing.push({
29
- name: 'pnpm',
30
- why: 'required to install dependencies for Happy Stacks components',
31
- install: ['Install it via Corepack: `corepack enable && corepack prepare pnpm@latest --activate`'],
30
+ name: 'yarn/pnpm',
31
+ why: 'required to install dependencies for Happy Stacks components (varies per component)',
32
+ install: [
33
+ 'Enable Corepack (recommended): `corepack enable`',
34
+ 'Or install pnpm: `corepack prepare pnpm@latest --activate`',
35
+ ],
32
36
  });
33
37
  }
34
38
  }
@@ -69,4 +73,3 @@ export async function assertCliPrereqs({ git = false, pnpm = false, codex = fals
69
73
  `[prereqs] After installing, re-run the command.`
70
74
  );
71
75
  }
72
-
@@ -0,0 +1,34 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { assertCliPrereqs } from './prereqs.mjs';
8
+
9
+ test('assertCliPrereqs({pnpm:true}) accepts yarn when pnpm is missing', async () => {
10
+ const root = await mkdtemp(join(tmpdir(), 'hs-prereqs-yarn-'));
11
+ const oldPath = process.env.PATH;
12
+ try {
13
+ const yarnBin = join(root, 'yarn');
14
+ await writeFile(yarnBin, '#!/bin/sh\nexit 0\n', 'utf-8');
15
+ await chmod(yarnBin, 0o755);
16
+ process.env.PATH = `/bin:${root}`;
17
+
18
+ await assertCliPrereqs({ pnpm: true });
19
+ } finally {
20
+ process.env.PATH = oldPath;
21
+ await rm(root, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ test('assertCliPrereqs({pnpm:true}) throws when neither pnpm nor yarn is available', async () => {
26
+ const oldPath = process.env.PATH;
27
+ try {
28
+ process.env.PATH = '/bin';
29
+ await assert.rejects(() => assertCliPrereqs({ pnpm: true }), /pnpm|yarn/i);
30
+ } finally {
31
+ process.env.PATH = oldPath;
32
+ }
33
+ });
34
+
@@ -2,6 +2,9 @@ import { createInterface } from 'node:readline/promises';
2
2
  import { listWorktreeSpecs } from '../git/worktrees.mjs';
3
3
 
4
4
  export function isTty() {
5
+ if (process.env.HAPPY_STACKS_TEST_TTY === '1') {
6
+ return true;
7
+ }
5
8
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
6
9
  }
7
10
 
@@ -24,6 +27,8 @@ export async function promptSelect(rl, { title, options, defaultIndex = 0 }) {
24
27
  throw new Error('[wizard] no options to select from');
25
28
  }
26
29
  // eslint-disable-next-line no-console
30
+ console.log('');
31
+ // eslint-disable-next-line no-console
27
32
  console.log(title);
28
33
  for (let i = 0; i < options.length; i++) {
29
34
  // eslint-disable-next-line no-console
@@ -35,22 +40,26 @@ export async function promptSelect(rl, { title, options, defaultIndex = 0 }) {
35
40
  return options[idx].value;
36
41
  }
37
42
 
38
- export async function promptWorktreeSource({ rl, rootDir, component, stackName, createRemote = 'upstream' }) {
39
- const specs = await listWorktreeSpecs({ rootDir, component });
43
+ export async function promptWorktreeSource({ rl, rootDir, component, stackName, createRemote = 'upstream', deps = {} }) {
44
+ const promptFn = deps.prompt ?? prompt;
45
+ const promptSelectFn = deps.promptSelect ?? promptSelect;
46
+ const listWorktreeSpecsFn = deps.listWorktreeSpecs ?? listWorktreeSpecs;
40
47
 
41
48
  const baseOptions = [{ label: `default (components/${component})`, value: 'default' }];
42
- if (specs.length) {
43
- baseOptions.push({ label: 'pick existing worktree', value: 'pick' });
44
- }
49
+ baseOptions.push({ label: 'pick existing worktree', value: 'pick' });
45
50
  baseOptions.push({ label: `create new worktree (${createRemote})`, value: 'create' });
46
51
 
47
- const kind = await promptSelect(rl, { title: `Select ${component}:`, options: baseOptions, defaultIndex: 0 });
52
+ const kind = await promptSelectFn(rl, { title: `Select ${component}:`, options: baseOptions, defaultIndex: 0 });
48
53
 
49
54
  if (kind === 'default') {
50
55
  return 'default';
51
56
  }
52
57
  if (kind === 'pick') {
53
- const picked = await promptSelect(rl, {
58
+ const specs = await listWorktreeSpecsFn({ rootDir, component });
59
+ if (!specs.length) {
60
+ return 'default';
61
+ }
62
+ const picked = await promptSelectFn(rl, {
54
63
  title: `Available ${component} worktrees:`,
55
64
  options: specs.map((s) => ({ label: s, value: s })),
56
65
  defaultIndex: 0,
@@ -58,7 +67,7 @@ export async function promptWorktreeSource({ rl, rootDir, component, stackName,
58
67
  return picked;
59
68
  }
60
69
 
61
- const slug = await prompt(rl, `New worktree slug for ${component} (example: pr/${stackName}/${component}): `, {
70
+ const slug = await promptFn(rl, `New worktree slug for ${component} (example: pr/${stackName}/${component}): `, {
62
71
  defaultValue: '',
63
72
  });
64
73
  if (!slug) {
@@ -66,4 +75,3 @@ export async function promptWorktreeSource({ rl, rootDir, component, stackName,
66
75
  }
67
76
  return { create: true, slug, remote: createRemote };
68
77
  }
69
-