happy-stacks 0.1.2 → 0.3.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 (116) hide show
  1. package/README.md +164 -89
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -0
  64. package/scripts/utils/dev/daemon.mjs +104 -0
  65. package/scripts/utils/dev/expo_web.mjs +112 -0
  66. package/scripts/utils/dev/server.mjs +183 -0
  67. package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
@@ -0,0 +1,317 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
5
+ import { createHash } from 'node:crypto';
6
+
7
+ import { pathExists } from '../fs/fs.mjs';
8
+ import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
9
+ import { run, runCapture, spawnProc } from './proc.mjs';
10
+ import { commandExists } from './commands.mjs';
11
+ import { getDefaultAutostartPaths, getHappyStacksHomeDir } from '../paths/paths.mjs';
12
+ import { resolveInstalledPath, resolveInstalledCliRoot } from '../paths/runtime.mjs';
13
+
14
+ function sha256Hex(s) {
15
+ return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
16
+ }
17
+
18
+ function resolveBuildStatePath({ label, dir }) {
19
+ const homeDir = getHappyStacksHomeDir();
20
+ const key = sha256Hex(resolve(dir));
21
+ return join(homeDir, 'cache', 'build', label, `${key}.json`);
22
+ }
23
+
24
+ async function computeGitWorktreeSignature(dir) {
25
+ try {
26
+ // Fast path: only if this is a git worktree.
27
+ const inside = (await runCapture('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'])).trim();
28
+ if (inside !== 'true') return null;
29
+ const head = (await runCapture('git', ['-C', dir, 'rev-parse', 'HEAD'])).trim();
30
+ // Includes staged + unstaged + untracked changes; captures “dirty” vs “clean”.
31
+ const status = await runCapture('git', ['-C', dir, 'status', '--porcelain=v1']);
32
+ return {
33
+ kind: 'git',
34
+ head,
35
+ statusHash: sha256Hex(status),
36
+ signature: sha256Hex(`${head}\n${status}`),
37
+ };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export async function requirePnpm() {
44
+ if (await commandExists('pnpm')) {
45
+ return;
46
+ }
47
+ throw new Error(
48
+ '[local] pnpm is required to install dependencies for Happy Stacks.\n' +
49
+ 'Install it via Corepack: `corepack enable && corepack prepare pnpm@latest --activate`'
50
+ );
51
+ }
52
+
53
+ async function getComponentPm(dir) {
54
+ const yarnLock = join(dir, 'yarn.lock');
55
+ if (await pathExists(yarnLock)) {
56
+ // IMPORTANT: when happy-stacks itself is pinned to pnpm via Corepack, running `yarn`
57
+ // from the happy-stacks cwd can be blocked. Always probe yarn with cwd=componentDir.
58
+ if (!(await commandExists('yarn', { cwd: dir }))) {
59
+ throw new Error(`[local] yarn is required for component at ${dir} (yarn.lock present). Install it via Corepack: \`corepack enable\``);
60
+ }
61
+ return { name: 'yarn', cmd: 'yarn' };
62
+ }
63
+
64
+ // Default fallback if no yarn.lock: use pnpm.
65
+ await requirePnpm();
66
+ return { name: 'pnpm', cmd: 'pnpm' };
67
+ }
68
+
69
+ export async function requireDir(label, dir) {
70
+ if (await pathExists(dir)) {
71
+ return;
72
+ }
73
+ throw new Error(
74
+ `[local] missing ${label} at ${dir}\n` +
75
+ `Run: happys bootstrap (auto-clones missing components), or place the repo under components/`
76
+ );
77
+ }
78
+
79
+ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
80
+ const pkgJson = join(dir, 'package.json');
81
+ if (!(await pathExists(pkgJson))) {
82
+ return;
83
+ }
84
+
85
+ const nodeModules = join(dir, 'node_modules');
86
+ const pnpmModulesMeta = join(dir, 'node_modules', '.modules.yaml');
87
+ const pm = await getComponentPm(dir);
88
+ const stdio = quiet ? 'ignore' : 'inherit';
89
+
90
+ if (await pathExists(nodeModules)) {
91
+ const yarnLock = join(dir, 'yarn.lock');
92
+ const yarnIntegrity = join(nodeModules, '.yarn-integrity');
93
+ const pnpmLock = join(dir, 'pnpm-lock.yaml');
94
+
95
+ // If this repo is Yarn-managed (yarn.lock present) but node_modules was created by pnpm,
96
+ // reinstall with Yarn to restore upstream-locked dependency versions.
97
+ if (pm.name === 'yarn' && (await pathExists(pnpmModulesMeta))) {
98
+ if (!quiet) {
99
+ // eslint-disable-next-line no-console
100
+ console.log(`[local] converting ${label} dependencies back to yarn (reinstalling node_modules)...`);
101
+ }
102
+ await rm(nodeModules, { recursive: true, force: true });
103
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
104
+ }
105
+
106
+ // If dependencies changed since the last install, re-run install even if node_modules exists.
107
+ const mtimeMs = async (p) => {
108
+ try {
109
+ const s = await stat(p);
110
+ return s.mtimeMs ?? 0;
111
+ } catch {
112
+ return 0;
113
+ }
114
+ };
115
+
116
+ if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
117
+ const lockM = await mtimeMs(yarnLock);
118
+ const pkgM = await mtimeMs(pkgJson);
119
+ const intM = await mtimeMs(yarnIntegrity);
120
+ if (!intM || lockM > intM || pkgM > intM) {
121
+ if (!quiet) {
122
+ // eslint-disable-next-line no-console
123
+ console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
124
+ }
125
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
126
+ }
127
+ }
128
+
129
+ if (pm.name === 'pnpm' && (await pathExists(pnpmLock))) {
130
+ const lockM = await mtimeMs(pnpmLock);
131
+ const metaM = await mtimeMs(pnpmModulesMeta);
132
+ if (!metaM || lockM > metaM) {
133
+ if (!quiet) {
134
+ // eslint-disable-next-line no-console
135
+ console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
136
+ }
137
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
138
+ }
139
+ }
140
+
141
+ return;
142
+ }
143
+
144
+ if (!quiet) {
145
+ // eslint-disable-next-line no-console
146
+ console.log(`[local] installing ${label} dependencies (first run)...`);
147
+ }
148
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
149
+ }
150
+
151
+ export async function ensureCliBuilt(cliDir, { buildCli }) {
152
+ await ensureDepsInstalled(cliDir, 'happy-cli');
153
+ if (!buildCli) {
154
+ return { built: false, reason: 'disabled' };
155
+ }
156
+ // Default: build only when needed (fast + reliable for worktrees that haven't been built yet).
157
+ //
158
+ // You can force always-build by setting:
159
+ // - HAPPY_STACKS_CLI_BUILD_MODE=always (legacy: HAPPY_LOCAL_CLI_BUILD_MODE=always)
160
+ // Or disable via:
161
+ // - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
162
+ const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
163
+ const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
164
+ if (mode === 'never') {
165
+ return { built: false, reason: 'mode_never' };
166
+ }
167
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
168
+ const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
169
+ const gitSig = await computeGitWorktreeSignature(cliDir);
170
+ const prev = await readJsonIfExists(buildStatePath);
171
+
172
+ if (mode === 'auto') {
173
+ // If dist doesn't exist, we must build.
174
+ if (!(await pathExists(distEntrypoint))) {
175
+ // fallthrough to build
176
+ } else if (gitSig && prev?.signature && prev.signature === gitSig.signature) {
177
+ return { built: false, reason: 'up_to_date' };
178
+ } else if (!gitSig) {
179
+ // No git info: best-effort skip if dist exists (keeps this fast outside git worktrees).
180
+ return { built: false, reason: 'no_git_info' };
181
+ }
182
+ }
183
+
184
+ // eslint-disable-next-line no-console
185
+ console.log('[local] building happy-cli...');
186
+ const pm = await getComponentPm(cliDir);
187
+ await run(pm.cmd, ['build'], { cwd: cliDir });
188
+
189
+ // Persist new build state (best-effort).
190
+ const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
191
+ if (nowSig) {
192
+ await writeJsonAtomic(buildStatePath, {
193
+ label: 'happy-cli',
194
+ dir: resolve(cliDir),
195
+ signature: nowSig.signature,
196
+ head: nowSig.head,
197
+ statusHash: nowSig.statusHash,
198
+ builtAt: new Date().toISOString(),
199
+ }).catch(() => {});
200
+ }
201
+ return { built: true, reason: mode === 'always' ? 'mode_always' : 'changed' };
202
+ }
203
+
204
+ function getPathEntries() {
205
+ const raw = process.env.PATH ?? '';
206
+ const delimiter = process.platform === 'win32' ? ';' : ':';
207
+ return raw.split(delimiter).filter(Boolean);
208
+ }
209
+
210
+ function isPathInside(path, dir) {
211
+ const p = resolve(path);
212
+ const d = resolve(dir);
213
+ return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
214
+ }
215
+
216
+ export async function ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli }) {
217
+ if (!npmLinkCli) {
218
+ return;
219
+ }
220
+
221
+ const homeDir = getHappyStacksHomeDir();
222
+ const binDir = join(homeDir, 'bin');
223
+ await mkdir(binDir, { recursive: true });
224
+
225
+ const happysShim = join(binDir, 'happys');
226
+ const happyShim = join(binDir, 'happy');
227
+
228
+ const shim = `#!/bin/bash
229
+ set -euo pipefail
230
+ # Prefer the sibling happys shim (works for sandbox installs too).
231
+ BIN_DIR="$(cd "$(dirname "$0")" && pwd)"
232
+ HAPPYS="$BIN_DIR/happys"
233
+ if [[ -x "$HAPPYS" ]]; then
234
+ exec "$HAPPYS" happy "$@"
235
+ fi
236
+
237
+ # Fallback: run happy-stacks from runtime install if present.
238
+ HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-\${HAPPY_LOCAL_HOME_DIR:-$HOME/.happy-stacks}}"
239
+ RUNTIME="$HOME_DIR/runtime/node_modules/happy-stacks/bin/happys.mjs"
240
+ if [[ -f "$RUNTIME" ]]; then
241
+ exec node "$RUNTIME" happy "$@"
242
+ fi
243
+
244
+ echo "error: cannot find happys shim or runtime install" >&2
245
+ exit 1
246
+ `;
247
+
248
+ const writeIfChanged = async (path, text) => {
249
+ let existing = '';
250
+ try {
251
+ existing = await readFile(path, 'utf-8');
252
+ } catch {
253
+ existing = '';
254
+ }
255
+ if (existing === text) return false;
256
+ await writeFile(path, text, 'utf-8');
257
+ return true;
258
+ };
259
+
260
+ await writeIfChanged(happyShim, shim);
261
+ await chmod(happyShim, 0o755).catch(() => {});
262
+
263
+ // happys shim: use node + CLI root; if runtime install exists, prefer it.
264
+ const cliRoot = resolveInstalledCliRoot(rootDir);
265
+ const happysShimText = `#!/bin/bash
266
+ set -euo pipefail
267
+ exec node "${resolveInstalledPath(rootDir, 'bin/happys.mjs')}" "$@"
268
+ `;
269
+ await writeIfChanged(happysShim, happysShimText);
270
+ await chmod(happysShim, 0o755).catch(() => {});
271
+
272
+ // If user’s PATH points at a legacy install path, try to make it sane (best-effort).
273
+ const entries = getPathEntries();
274
+ const legacyBin = join(homedir(), '.happy-stacks', 'bin');
275
+ const newBin = join(getDefaultAutostartPaths().baseDir, 'bin');
276
+ if (entries.some((p) => isPathInside(p, legacyBin)) && !entries.some((p) => isPathInside(p, newBin))) {
277
+ // eslint-disable-next-line no-console
278
+ console.log(`[local] note: your PATH includes ${legacyBin}; recommended path is ${newBin}`);
279
+ }
280
+
281
+ return { ok: true, cliRoot, binDir, happyShim, happysShim };
282
+ }
283
+
284
+ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
285
+ const usesObjectStyle = typeof dirOrOpts === 'object' && dirOrOpts !== null;
286
+
287
+ const dir = usesObjectStyle ? dirOrOpts.dir : dirOrOpts;
288
+ const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
289
+ const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
290
+
291
+ const env = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
292
+ const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
293
+ const stdio = quiet ? 'ignore' : 'inherit';
294
+
295
+ const pm = await getComponentPm(dir);
296
+ if (pm.name === 'yarn') {
297
+ await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
298
+ return;
299
+ }
300
+ await run(pm.cmd, ['exec', bin, ...args], { cwd: dir, env, stdio });
301
+ }
302
+
303
+ export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
304
+ const pm = await getComponentPm(dir);
305
+ if (pm.name === 'yarn') {
306
+ return spawnProc(label, pm.cmd, ['run', bin, ...args], env, { cwd: dir });
307
+ }
308
+ return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { cwd: dir });
309
+ }
310
+
311
+ export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
312
+ const pm = await getComponentPm(dir);
313
+ if (pm.name === 'yarn') {
314
+ return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
315
+ }
316
+ return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
317
+ }
@@ -1,5 +1,23 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
+ function writeWithPrefix(stream, prefix, bufState, chunk) {
4
+ const s = chunk.toString();
5
+ bufState.buf += s;
6
+ while (true) {
7
+ const idx = bufState.buf.indexOf('\n');
8
+ if (idx < 0) break;
9
+ const line = bufState.buf.slice(0, idx);
10
+ bufState.buf = bufState.buf.slice(idx + 1);
11
+ stream.write(`${prefix}${line}\n`);
12
+ }
13
+ }
14
+
15
+ function flushPrefixed(stream, prefix, bufState) {
16
+ if (!bufState.buf) return;
17
+ stream.write(`${prefix}${bufState.buf}\n`);
18
+ bufState.buf = '';
19
+ }
20
+
3
21
  export function spawnProc(label, cmd, args, env, options = {}) {
4
22
  const child = spawn(cmd, args, {
5
23
  env,
@@ -10,8 +28,17 @@ export function spawnProc(label, cmd, args, env, options = {}) {
10
28
  ...options,
11
29
  });
12
30
 
13
- child.stdout?.on('data', (d) => process.stdout.write(`[${label}] ${d.toString()}`));
14
- child.stderr?.on('data', (d) => process.stderr.write(`[${label}] ${d.toString()}`));
31
+ const outState = { buf: '' };
32
+ const errState = { buf: '' };
33
+ const outPrefix = `[${label}] `;
34
+ const errPrefix = `[${label}] `;
35
+
36
+ child.stdout?.on('data', (d) => writeWithPrefix(process.stdout, outPrefix, outState, d));
37
+ child.stderr?.on('data', (d) => writeWithPrefix(process.stderr, errPrefix, errState, d));
38
+ child.on('close', () => {
39
+ flushPrefixed(process.stdout, outPrefix, outState);
40
+ flushPrefixed(process.stderr, errPrefix, errState);
41
+ });
15
42
  child.on('exit', (code, sig) => {
16
43
  if (code !== 0) {
17
44
  process.stderr.write(`[${label}] exited (code=${code}, sig=${sig})\n`);
@@ -105,3 +132,4 @@ export async function runCapture(cmd, args, options = {}) {
105
132
  });
106
133
  });
107
134
  }
135
+
@@ -0,0 +1,63 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ function safeWatch(path, handler) {
4
+ try {
5
+ // Node supports recursive watching on macOS and Windows. On Linux this may throw; we fail closed by returning null.
6
+ return watch(path, { recursive: true }, handler);
7
+ } catch {
8
+ try {
9
+ return watch(path, {}, handler);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Very small, dependency-free debounced watcher.
18
+ * Intended for dev ergonomics (rebuild/restart), not for correctness-critical logic.
19
+ */
20
+ export function watchDebounced({ paths, debounceMs = 500, onChange } = {}) {
21
+ const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
22
+ if (!list.length) return null;
23
+ if (typeof onChange !== 'function') return null;
24
+
25
+ let closed = false;
26
+ let t = null;
27
+ const watchers = [];
28
+
29
+ const trigger = (eventType, filename) => {
30
+ if (closed) return;
31
+ if (t) clearTimeout(t);
32
+ t = setTimeout(() => {
33
+ t = null;
34
+ try {
35
+ onChange({ eventType, filename });
36
+ } catch {
37
+ // ignore
38
+ }
39
+ }, debounceMs);
40
+ };
41
+
42
+ for (const p of list) {
43
+ const w = safeWatch(p, trigger);
44
+ if (w) watchers.push(w);
45
+ }
46
+
47
+ if (!watchers.length) return null;
48
+
49
+ return {
50
+ close() {
51
+ closed = true;
52
+ if (t) clearTimeout(t);
53
+ for (const w of watchers) {
54
+ try {
55
+ w.close();
56
+ } catch {
57
+ // ignore
58
+ }
59
+ }
60
+ },
61
+ };
62
+ }
63
+