happy-stacks 0.2.0 → 0.4.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 (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -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,16 @@
1
+ export function normalizeProfile(raw) {
2
+ const v = (raw ?? '').trim().toLowerCase();
3
+ if (!v) return '';
4
+ if (v === 'selfhost' || v === 'self-host' || v === 'self_host' || v === 'host') return 'selfhost';
5
+ if (v === 'dev' || v === 'developer' || v === 'develop' || v === 'development') return 'dev';
6
+ return '';
7
+ }
8
+
9
+ export function normalizeServerComponent(raw) {
10
+ const v = (raw ?? '').trim().toLowerCase();
11
+ if (!v) return '';
12
+ if (v === 'light' || v === 'server-light' || v === 'happy-server-light') return 'happy-server-light';
13
+ if (v === 'server' || v === 'full' || v === 'happy-server') return 'happy-server';
14
+ return '';
15
+ }
16
+
@@ -0,0 +1,72 @@
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
+ if (!hasPnpm) {
28
+ 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`'],
32
+ });
33
+ }
34
+ }
35
+
36
+ if (codex) {
37
+ const hasCodex = await commandExists('codex');
38
+ if (!hasCodex) {
39
+ missing.push({
40
+ name: 'codex',
41
+ why: 'required to run Codex review',
42
+ install: [
43
+ 'Install Codex CLI and ensure `codex` is on PATH',
44
+ 'If using a managed install, ensure your PATH includes the Codex binary',
45
+ ],
46
+ });
47
+ }
48
+ }
49
+
50
+ if (coderabbit) {
51
+ const hasCodeRabbit = await commandExists('coderabbit');
52
+ if (!hasCodeRabbit) {
53
+ missing.push({
54
+ name: 'coderabbit',
55
+ why: 'required to run CodeRabbit CLI review',
56
+ install: [
57
+ 'Install CodeRabbit CLI: `curl -fsSL https://cli.coderabbit.ai/install.sh | sh`',
58
+ 'Then authenticate: `coderabbit auth login`',
59
+ ],
60
+ });
61
+ }
62
+ }
63
+
64
+ if (!missing.length) return;
65
+
66
+ throw new Error(
67
+ `[prereqs] missing required tools:\n` +
68
+ `${missing.map(formatMissingTool).join('\n')}\n\n` +
69
+ `[prereqs] After installing, re-run the command.`
70
+ );
71
+ }
72
+
@@ -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
+
@@ -6,8 +6,8 @@ import { dirname } from 'node:path';
6
6
  import { getHappysRegistry } from './cli_registry.mjs';
7
7
 
8
8
  function cliRootDir() {
9
- // scripts/utils/* -> scripts -> repo root
10
- return dirname(dirname(dirname(fileURLToPath(import.meta.url))));
9
+ // scripts/utils/cli/* -> scripts/utils -> scripts -> repo root
10
+ return dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))));
11
11
  }
12
12
 
13
13
  function runOrThrow(label, args) {
@@ -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
+
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
- import { listWorktreeSpecs } from '../worktrees.mjs';
2
+ import { listWorktreeSpecs } from '../git/worktrees.mjs';
3
3
 
4
4
  export function isTty() {
5
5
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -0,0 +1,14 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ export function base64Url(buf) {
4
+ return Buffer.from(buf)
5
+ .toString('base64')
6
+ .replaceAll('+', '-')
7
+ .replaceAll('/', '_')
8
+ .replaceAll('=', '');
9
+ }
10
+
11
+ export function randomToken(lenBytes = 24) {
12
+ return base64Url(randomBytes(lenBytes));
13
+ }
14
+
@@ -1,13 +1,33 @@
1
- import { resolve } from 'node:path';
1
+ import { join, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
2
3
 
3
- import { ensureCliBuilt, ensureDepsInstalled } from './pm.mjs';
4
- import { watchDebounced } from './watch.mjs';
5
- import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './stack_startup.mjs';
6
- import { startLocalDaemonWithAuth } from '../daemon.mjs';
4
+ import { ensureCliBuilt, ensureDepsInstalled } from '../proc/pm.mjs';
5
+ import { watchDebounced } from '../proc/watch.mjs';
6
+ import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from '../stack/startup.mjs';
7
+ import { startLocalDaemonWithAuth } from '../../daemon.mjs';
7
8
 
8
9
  export async function ensureDevCliReady({ cliDir, buildCli }) {
9
10
  await ensureDepsInstalled(cliDir, 'happy-cli');
10
- return await ensureCliBuilt(cliDir, { buildCli });
11
+ const res = await ensureCliBuilt(cliDir, { buildCli });
12
+
13
+ // Fail closed: dev mode must never start the daemon without a usable happy-cli build output.
14
+ // Even if the user disabled CLI builds globally (or build mode is "never"), missing dist will
15
+ // cause an immediate MODULE_NOT_FOUND crash when spawning the daemon.
16
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
17
+ if (!existsSync(distEntrypoint)) {
18
+ // Last-chance recovery: force a build once.
19
+ await ensureCliBuilt(cliDir, { buildCli: true });
20
+ if (!existsSync(distEntrypoint)) {
21
+ throw new Error(
22
+ `[local] happy-cli build output is missing.\n` +
23
+ `Expected: ${distEntrypoint}\n` +
24
+ `Fix: run the component build directly and inspect its output:\n` +
25
+ ` cd "${cliDir}" && yarn build`
26
+ );
27
+ }
28
+ }
29
+
30
+ return res;
11
31
  }
12
32
 
13
33
  export async function prepareDaemonAuthSeed({
@@ -77,8 +97,25 @@ export function watchHappyCliAndRestartDaemon({
77
97
  if (!enabled || !startDaemon) return null;
78
98
 
79
99
  let inFlight = false;
100
+
101
+ // IMPORTANT:
102
+ // Watch only source/config paths, not build outputs. Watching the whole repo can
103
+ // trigger rebuild loops because `yarn build` writes to `dist/` (and may touch other
104
+ // generated files), which then retriggers the watcher.
105
+ const watchPaths = [
106
+ join(cliDir, 'src'),
107
+ join(cliDir, 'bin'),
108
+ join(cliDir, 'codex'),
109
+ join(cliDir, 'package.json'),
110
+ join(cliDir, 'tsconfig.json'),
111
+ join(cliDir, 'tsconfig.build.json'),
112
+ join(cliDir, 'pkgroll.config.mjs'),
113
+ join(cliDir, 'yarn.lock'),
114
+ join(cliDir, 'pnpm-lock.yaml'),
115
+ ].filter((p) => existsSync(p));
116
+
80
117
  return watchDebounced({
81
- paths: [resolve(cliDir)],
118
+ paths: (watchPaths.length ? watchPaths : [cliDir]).map((p) => resolve(p)),
82
119
  debounceMs: 500,
83
120
  onChange: async () => {
84
121
  if (isShuttingDown?.()) return;
@@ -88,6 +125,13 @@ export function watchHappyCliAndRestartDaemon({
88
125
  // eslint-disable-next-line no-console
89
126
  console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
90
127
  await ensureCliBuilt(cliDir, { buildCli });
128
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
129
+ if (!existsSync(distEntrypoint)) {
130
+ console.warn(
131
+ `[local] watch: happy-cli build did not produce ${distEntrypoint}; refusing to restart daemon to avoid downtime.`
132
+ );
133
+ return;
134
+ }
91
135
  await startLocalDaemonWithAuth({
92
136
  cliBin,
93
137
  cliHomeDir,