happy-stacks 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,110 @@
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 { inferComponentFromCwd } from './cwd_scope.mjs';
8
+
9
+ async function withTempRoot(t) {
10
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-cwd-scope-'));
11
+ t.after(async () => {
12
+ await rm(dir, { recursive: true, force: true });
13
+ });
14
+ return dir;
15
+ }
16
+
17
+ test('inferComponentFromCwd resolves components/<component> repo root', async (t) => {
18
+ const rootDir = await withTempRoot(t);
19
+ const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
20
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
21
+ t.after(() => {
22
+ if (prevWorkspace == null) {
23
+ delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
24
+ } else {
25
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
26
+ }
27
+ });
28
+
29
+ const repoRoot = join(rootDir, 'components', 'happy');
30
+ await mkdir(join(repoRoot, 'src'), { recursive: true });
31
+ await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
32
+
33
+ const invokedCwd = join(repoRoot, 'src');
34
+ const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy', 'happy-cli'] });
35
+ assert.deepEqual(inferred, { component: 'happy', repoDir: repoRoot });
36
+ });
37
+
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) => {
69
+ const rootDir = await withTempRoot(t);
70
+ const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
71
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
72
+ t.after(() => {
73
+ if (prevWorkspace == null) {
74
+ delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
75
+ } else {
76
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
77
+ }
78
+ });
79
+
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');
87
+ await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
88
+
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 });
92
+ });
93
+
94
+ test('inferComponentFromCwd returns null outside known component roots', async (t) => {
95
+ const rootDir = await withTempRoot(t);
96
+ const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
97
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
98
+ t.after(() => {
99
+ if (prevWorkspace == null) {
100
+ delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
101
+ } else {
102
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
103
+ }
104
+ });
105
+
106
+ const invokedCwd = join(rootDir, 'somewhere', 'else');
107
+ await mkdir(invokedCwd, { recursive: true });
108
+ const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy'] });
109
+ assert.equal(inferred, null);
110
+ });
@@ -0,0 +1,157 @@
1
+ import { createReadStream, existsSync } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+
5
+ function splitLines(s) {
6
+ return String(s ?? '').split(/\r?\n/);
7
+ }
8
+
9
+ function supportsAnsi() {
10
+ if (!process.stdout.isTTY) return false;
11
+ if (process.env.NO_COLOR) return false;
12
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
13
+ return true;
14
+ }
15
+
16
+ function dim(s) {
17
+ return supportsAnsi() ? `\x1b[2m${s}\x1b[0m` : String(s);
18
+ }
19
+
20
+ /**
21
+ * Lightweight file log forwarder (tail-like) with pause/resume.
22
+ *
23
+ * - Always advances the file offset (prevents backpressure issues).
24
+ * - While paused, it buffers the last N lines and prints them once resumed.
25
+ */
26
+ export function createFileLogForwarder({
27
+ path,
28
+ enabled = true,
29
+ pollMs = 200,
30
+ maxBytesPerTick = 256 * 1024,
31
+ bufferedLinesWhilePaused = 120,
32
+ startFromEnd = true,
33
+ label = 'logs',
34
+ } = {}) {
35
+ const p = String(path ?? '').trim();
36
+ if (!enabled || !p) {
37
+ return {
38
+ ok: false,
39
+ start: async () => {},
40
+ stop: async () => {},
41
+ pause: () => {},
42
+ resume: () => {},
43
+ isPaused: () => false,
44
+ path: p,
45
+ };
46
+ }
47
+
48
+ let running = false;
49
+ let paused = false;
50
+ let offset = 0;
51
+ let partial = '';
52
+ let buffered = [];
53
+
54
+ const pushBufferedLine = (line) => {
55
+ if (!line) return;
56
+ buffered.push(line);
57
+ if (buffered.length > bufferedLinesWhilePaused) {
58
+ buffered = buffered.slice(buffered.length - bufferedLinesWhilePaused);
59
+ }
60
+ };
61
+
62
+ const flushBuffered = () => {
63
+ if (!buffered.length) return;
64
+ // eslint-disable-next-line no-console
65
+ console.log(dim(`[${label}] (showing last ${buffered.length} lines while paused)`));
66
+ for (const l of buffered) {
67
+ // eslint-disable-next-line no-console
68
+ console.log(l);
69
+ }
70
+ buffered = [];
71
+ };
72
+
73
+ const readNewBytes = async () => {
74
+ if (!existsSync(p)) return;
75
+ let st = null;
76
+ try {
77
+ st = await stat(p);
78
+ } catch {
79
+ return;
80
+ }
81
+ const size = Number(st?.size ?? 0);
82
+ if (!Number.isFinite(size) || size <= 0) return;
83
+ if (size < offset) {
84
+ // truncated/rotated
85
+ offset = 0;
86
+ }
87
+ if (size === offset) return;
88
+
89
+ const end = Math.min(size, offset + maxBytesPerTick);
90
+ const start = offset;
91
+ offset = end;
92
+
93
+ await new Promise((resolvePromise) => {
94
+ const chunks = [];
95
+ const stream = createReadStream(p, { start, end: end - 1 });
96
+ stream.on('data', (d) => chunks.push(Buffer.from(d)));
97
+ stream.on('error', () => resolvePromise());
98
+ stream.on('close', () => {
99
+ const text = partial + Buffer.concat(chunks).toString('utf-8');
100
+ const lines = splitLines(text);
101
+ partial = lines.pop() ?? '';
102
+ for (const line of lines) {
103
+ if (paused) {
104
+ pushBufferedLine(line);
105
+ } else {
106
+ // eslint-disable-next-line no-console
107
+ console.log(line);
108
+ }
109
+ }
110
+ resolvePromise();
111
+ });
112
+ });
113
+ };
114
+
115
+ const loop = async () => {
116
+ while (running) {
117
+ // eslint-disable-next-line no-await-in-loop
118
+ await readNewBytes();
119
+ // eslint-disable-next-line no-await-in-loop
120
+ await delay(pollMs);
121
+ }
122
+ };
123
+
124
+ return {
125
+ ok: true,
126
+ path: p,
127
+ start: async () => {
128
+ if (running) return;
129
+ running = true;
130
+ // By default, start at end (don't replay historical logs).
131
+ if (startFromEnd) {
132
+ try {
133
+ const st = await stat(p);
134
+ offset = Number(st?.size ?? 0) || 0;
135
+ } catch {
136
+ offset = 0;
137
+ }
138
+ } else {
139
+ offset = 0;
140
+ }
141
+ void loop();
142
+ },
143
+ stop: async () => {
144
+ running = false;
145
+ },
146
+ pause: () => {
147
+ paused = true;
148
+ buffered = [];
149
+ },
150
+ resume: () => {
151
+ paused = false;
152
+ flushBuffered();
153
+ },
154
+ isPaused: () => paused,
155
+ };
156
+ }
157
+
@@ -0,0 +1,75 @@
1
+ import { commandExists } from '../proc/commands.mjs';
2
+
3
+ function formatMissingTool({ name, why, install }) {
4
+ return [`- ${name}: ${why}`, ...(install?.length ? install.map((l) => ` ${l}`) : [])].join('\n');
5
+ }
6
+
7
+ export async function assertCliPrereqs({ git = false, pnpm = false, codex = false, coderabbit = false } = {}) {
8
+ const missing = [];
9
+
10
+ if (git) {
11
+ const hasGit = await commandExists('git');
12
+ if (!hasGit) {
13
+ const install =
14
+ process.platform === 'darwin'
15
+ ? ['Install Xcode Command Line Tools: `xcode-select --install`', 'Or install Git via Homebrew: `brew install git`']
16
+ : ['Install Git using your package manager (e.g. `apt install git`, `dnf install git`)'];
17
+ missing.push({
18
+ name: 'git',
19
+ why: 'required for cloning + updating PR worktrees',
20
+ install,
21
+ });
22
+ }
23
+ }
24
+
25
+ if (pnpm) {
26
+ const hasPnpm = await commandExists('pnpm');
27
+ const hasYarn = await commandExists('yarn');
28
+ if (!hasPnpm && !hasYarn) {
29
+ missing.push({
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
+ ],
36
+ });
37
+ }
38
+ }
39
+
40
+ if (codex) {
41
+ const hasCodex = await commandExists('codex');
42
+ if (!hasCodex) {
43
+ missing.push({
44
+ name: 'codex',
45
+ why: 'required to run Codex review',
46
+ install: [
47
+ 'Install Codex CLI and ensure `codex` is on PATH',
48
+ 'If using a managed install, ensure your PATH includes the Codex binary',
49
+ ],
50
+ });
51
+ }
52
+ }
53
+
54
+ if (coderabbit) {
55
+ const hasCodeRabbit = await commandExists('coderabbit');
56
+ if (!hasCodeRabbit) {
57
+ missing.push({
58
+ name: 'coderabbit',
59
+ why: 'required to run CodeRabbit CLI review',
60
+ install: [
61
+ 'Install CodeRabbit CLI: `curl -fsSL https://cli.coderabbit.ai/install.sh | sh`',
62
+ 'Then authenticate: `coderabbit auth login`',
63
+ ],
64
+ });
65
+ }
66
+ }
67
+
68
+ if (!missing.length) return;
69
+
70
+ throw new Error(
71
+ `[prereqs] missing required tools:\n` +
72
+ `${missing.map(formatMissingTool).join('\n')}\n\n` +
73
+ `[prereqs] After installing, re-run the command.`
74
+ );
75
+ }
@@ -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
+
@@ -0,0 +1,126 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+
6
+ function isTty() {
7
+ return Boolean(process.stdout.isTTY && process.stderr.isTTY);
8
+ }
9
+
10
+ function spinnerFrames() {
11
+ return ['|', '/', '-', '\\'];
12
+ }
13
+
14
+ export function createStepPrinter({ enabled = true } = {}) {
15
+ const tty = enabled && isTty();
16
+ const frames = spinnerFrames();
17
+ let timer = null;
18
+ let idx = 0;
19
+ let currentLine = '';
20
+
21
+ const write = (s) => process.stdout.write(s);
22
+
23
+ const start = (label) => {
24
+ if (!tty) {
25
+ write(`- [..] ${label}\n`);
26
+ return;
27
+ }
28
+ currentLine = `- [${frames[idx % frames.length]}] ${label}`;
29
+ write(currentLine);
30
+ timer = setInterval(() => {
31
+ idx++;
32
+ const next = `- [${frames[idx % frames.length]}] ${label}`;
33
+ const pad = currentLine.length > next.length ? ' '.repeat(currentLine.length - next.length) : '';
34
+ currentLine = next;
35
+ write(`\r${next}${pad}`);
36
+ }, 120);
37
+ };
38
+
39
+ const stop = (result, label) => {
40
+ if (timer) clearInterval(timer);
41
+ timer = null;
42
+ if (!tty) {
43
+ write(`- [${result}] ${label}\n`);
44
+ return;
45
+ }
46
+ const out = `- [${result}] ${label}`;
47
+ const pad = currentLine.length > out.length ? ' '.repeat(currentLine.length - out.length) : '';
48
+ currentLine = '';
49
+ write(`\r${out}${pad}\n`);
50
+ };
51
+
52
+ const info = (line) => {
53
+ write(`${line}\n`);
54
+ };
55
+
56
+ return { start, stop, info };
57
+ }
58
+
59
+ export async function runCommandLogged({
60
+ label,
61
+ cmd,
62
+ args,
63
+ cwd,
64
+ env,
65
+ logPath,
66
+ showSteps = true,
67
+ quiet = true,
68
+ }) {
69
+ const steps = createStepPrinter({ enabled: showSteps });
70
+ if (quiet) {
71
+ await mkdir(dirname(logPath), { recursive: true }).catch(() => {});
72
+ }
73
+
74
+ steps.start(label);
75
+
76
+ const child = spawn(cmd, args, {
77
+ cwd,
78
+ env,
79
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
80
+ shell: false,
81
+ });
82
+
83
+ let stdout = '';
84
+ let stderr = '';
85
+ let logStream = null;
86
+ if (quiet) {
87
+ logStream = createWriteStream(logPath, { flags: 'a' });
88
+ child.stdout?.on('data', (d) => {
89
+ const s = d.toString();
90
+ stdout += s;
91
+ logStream?.write(s);
92
+ });
93
+ child.stderr?.on('data', (d) => {
94
+ const s = d.toString();
95
+ stderr += s;
96
+ logStream?.write(s);
97
+ });
98
+ }
99
+
100
+ const res = await new Promise((resolvePromise, rejectPromise) => {
101
+ child.on('error', rejectPromise);
102
+ child.on('close', (code, signal) => resolvePromise({ code: code ?? 1, signal: signal ?? null }));
103
+ });
104
+
105
+ try {
106
+ logStream?.end();
107
+ } catch {
108
+ // ignore
109
+ }
110
+
111
+ if (res.code === 0) {
112
+ steps.stop('✓', label);
113
+ return { ok: true, code: 0, stdout, stderr, logPath };
114
+ }
115
+
116
+ steps.stop('x', label);
117
+ const err = new Error(`${cmd} failed (code=${res.code}${res.signal ? `, sig=${res.signal}` : ''})`);
118
+ err.code = 'EEXIT';
119
+ err.exitCode = res.code;
120
+ err.signal = res.signal;
121
+ err.stdout = stdout;
122
+ err.stderr = stderr;
123
+ err.logPath = logPath;
124
+ throw err;
125
+ }
126
+
@@ -0,0 +1,12 @@
1
+ export function getVerbosityLevel(env = process.env) {
2
+ const raw = (env.HAPPY_STACKS_VERBOSE ?? '').toString().trim();
3
+ if (!raw) return 0;
4
+ const n = Number(raw);
5
+ if (!Number.isFinite(n)) return 1;
6
+ return Math.max(0, Math.min(3, Math.floor(n)));
7
+ }
8
+
9
+ export function isVerbose(env = process.env) {
10
+ return getVerbosityLevel(env) > 0;
11
+ }
12
+
@@ -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
-
@@ -0,0 +1,60 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { promptWorktreeSource } from './wizard.mjs';
5
+
6
+ test('promptWorktreeSource does not list worktrees unless user selects "pick"', async () => {
7
+ let listed = 0;
8
+ const listWorktreeSpecs = async () => {
9
+ listed++;
10
+ return ['slopus/pr/123'];
11
+ };
12
+
13
+ const promptSelect = async () => 'default';
14
+ const prompt = async () => '';
15
+
16
+ const res = await promptWorktreeSource({
17
+ rl: {},
18
+ rootDir: '/tmp',
19
+ component: 'happy',
20
+ stackName: 'exp1',
21
+ createRemote: 'upstream',
22
+ deps: { listWorktreeSpecs, promptSelect, prompt },
23
+ });
24
+
25
+ assert.equal(res, 'default');
26
+ assert.equal(listed, 0);
27
+ });
28
+
29
+ test('promptWorktreeSource lists worktrees when user selects "pick"', async () => {
30
+ let listed = 0;
31
+ const listWorktreeSpecs = async () => {
32
+ listed++;
33
+ return ['slopus/pr/123', 'slopus/pr/456'];
34
+ };
35
+
36
+ let selectCount = 0;
37
+ const promptSelect = async (_rl, { title }) => {
38
+ selectCount++;
39
+ if (selectCount === 1) {
40
+ assert.ok(title.startsWith('Select '));
41
+ return 'pick';
42
+ }
43
+ assert.ok(title.startsWith('Available '));
44
+ return 'slopus/pr/456';
45
+ };
46
+ const prompt = async () => '';
47
+
48
+ const res = await promptWorktreeSource({
49
+ rl: {},
50
+ rootDir: '/tmp',
51
+ component: 'happy',
52
+ stackName: 'exp1',
53
+ createRemote: 'upstream',
54
+ deps: { listWorktreeSpecs, promptSelect, prompt },
55
+ });
56
+
57
+ assert.equal(res, 'slopus/pr/456');
58
+ assert.equal(listed, 1);
59
+ });
60
+