happy-stacks 0.0.0 → 0.1.2

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 (42) hide show
  1. package/README.md +22 -4
  2. package/bin/happys.mjs +76 -5
  3. package/docs/server-flavors.md +61 -2
  4. package/docs/stacks.md +16 -4
  5. package/extras/swiftbar/auth-login.sh +5 -5
  6. package/extras/swiftbar/happy-stacks.5s.sh +83 -41
  7. package/extras/swiftbar/happys-term.sh +151 -0
  8. package/extras/swiftbar/happys.sh +52 -0
  9. package/extras/swiftbar/lib/render.sh +74 -56
  10. package/extras/swiftbar/lib/system.sh +37 -6
  11. package/extras/swiftbar/lib/utils.sh +180 -4
  12. package/extras/swiftbar/pnpm-term.sh +2 -122
  13. package/extras/swiftbar/pnpm.sh +2 -13
  14. package/extras/swiftbar/set-server-flavor.sh +8 -8
  15. package/extras/swiftbar/wt-pr.sh +1 -1
  16. package/package.json +1 -1
  17. package/scripts/auth.mjs +374 -3
  18. package/scripts/daemon.mjs +78 -11
  19. package/scripts/dev.mjs +122 -17
  20. package/scripts/init.mjs +238 -32
  21. package/scripts/migrate.mjs +292 -0
  22. package/scripts/mobile.mjs +51 -19
  23. package/scripts/run.mjs +118 -26
  24. package/scripts/service.mjs +176 -37
  25. package/scripts/stack.mjs +665 -22
  26. package/scripts/stop.mjs +157 -0
  27. package/scripts/tailscale.mjs +147 -21
  28. package/scripts/typecheck.mjs +145 -0
  29. package/scripts/ui_gateway.mjs +248 -0
  30. package/scripts/uninstall.mjs +3 -3
  31. package/scripts/utils/cli_registry.mjs +23 -0
  32. package/scripts/utils/config.mjs +9 -1
  33. package/scripts/utils/env.mjs +37 -15
  34. package/scripts/utils/expo.mjs +94 -0
  35. package/scripts/utils/happy_server_infra.mjs +430 -0
  36. package/scripts/utils/pm.mjs +11 -2
  37. package/scripts/utils/ports.mjs +51 -13
  38. package/scripts/utils/proc.mjs +46 -5
  39. package/scripts/utils/server.mjs +37 -0
  40. package/scripts/utils/stack_stop.mjs +206 -0
  41. package/scripts/utils/validate.mjs +42 -1
  42. package/scripts/worktrees.mjs +53 -7
@@ -0,0 +1,430 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import net from 'node:net';
5
+ import { join } from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+
8
+ import { parseDotenv } from './dotenv.mjs';
9
+ import { ensureEnvFileUpdated } from './env_file.mjs';
10
+ import { pmExecBin } from './pm.mjs';
11
+ import { run, runCapture } from './proc.mjs';
12
+
13
+ function base64Url(buf) {
14
+ return Buffer.from(buf)
15
+ .toString('base64')
16
+ .replaceAll('+', '-')
17
+ .replaceAll('/', '_')
18
+ .replaceAll('=', '');
19
+ }
20
+
21
+ function randomToken(lenBytes = 24) {
22
+ return base64Url(randomBytes(lenBytes));
23
+ }
24
+
25
+ function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
26
+ const s = String(raw ?? '')
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9-]+/g, '-')
29
+ .replace(/-+/g, '-')
30
+ .replace(/^-+/, '')
31
+ .replace(/-+$/, '');
32
+ return s || fallback;
33
+ }
34
+
35
+ function coercePort(v) {
36
+ const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN;
37
+ return Number.isFinite(n) && n > 0 ? n : null;
38
+ }
39
+
40
+ async function isPortFree(port) {
41
+ return await new Promise((resolvePromise) => {
42
+ const srv = net.createServer();
43
+ srv.unref();
44
+ srv.on('error', () => resolvePromise(false));
45
+ srv.listen({ port, host: '127.0.0.1' }, () => {
46
+ srv.close(() => resolvePromise(true));
47
+ });
48
+ });
49
+ }
50
+
51
+ async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
52
+ let port = startPort;
53
+ for (let i = 0; i < 200; i++) {
54
+ // eslint-disable-next-line no-await-in-loop
55
+ if (!reservedPorts.has(port) && (await isPortFree(port))) {
56
+ return port;
57
+ }
58
+ port += 1;
59
+ }
60
+ throw new Error(`[infra] unable to find a free port starting at ${startPort}`);
61
+ }
62
+
63
+ async function readEnvObject(envPath) {
64
+ try {
65
+ const raw = await readFile(envPath, 'utf-8');
66
+ return Object.fromEntries(parseDotenv(raw).entries());
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ async function ensureTextFile({ path, generate }) {
73
+ if (existsSync(path)) {
74
+ const v = (await readFile(path, 'utf-8')).trim();
75
+ if (v) return v;
76
+ }
77
+ const next = String(generate()).trim();
78
+ await mkdir(join(path, '..'), { recursive: true }).catch(() => {});
79
+ await writeFile(path, next + '\n', 'utf-8');
80
+ return next;
81
+ }
82
+
83
+ function composeProjectName(stackName) {
84
+ return sanitizeDnsLabel(`happy-stacks-${stackName}-happy-server`, { fallback: 'happy-stacks-happy-server' });
85
+ }
86
+
87
+ export async function stopHappyServerManagedInfra({ stackName, baseDir, removeVolumes = false }) {
88
+ const infraDir = join(baseDir, 'happy-server', 'infra');
89
+ const composePath = join(infraDir, 'docker-compose.yml');
90
+ if (!existsSync(composePath)) {
91
+ return { ok: true, skipped: true, reason: 'missing_compose', composePath };
92
+ }
93
+
94
+ try {
95
+ await ensureDockerCompose();
96
+ } catch (e) {
97
+ return {
98
+ ok: false,
99
+ skipped: true,
100
+ reason: 'docker_unavailable',
101
+ error: e instanceof Error ? e.message : String(e),
102
+ composePath,
103
+ };
104
+ }
105
+
106
+ const projectName = composeProjectName(stackName);
107
+ const args = ['down', '--remove-orphans', ...(removeVolumes ? ['--volumes'] : [])];
108
+ await dockerCompose({ composePath, projectName, args, options: { cwd: baseDir } });
109
+ return { ok: true, skipped: false, projectName, composePath };
110
+ }
111
+
112
+ function buildComposeYaml({
113
+ infraDir,
114
+ pgPort,
115
+ pgUser,
116
+ pgPassword,
117
+ pgDb,
118
+ redisPort,
119
+ minioPort,
120
+ minioConsolePort,
121
+ s3AccessKey,
122
+ s3SecretKey,
123
+ s3Bucket,
124
+ }) {
125
+ // Keep it explicit (no env substitution); we generate this file per stack.
126
+ return `services:
127
+ postgres:
128
+ image: postgres:16-alpine
129
+ environment:
130
+ POSTGRES_USER: ${pgUser}
131
+ POSTGRES_PASSWORD: ${pgPassword}
132
+ POSTGRES_DB: ${pgDb}
133
+ ports:
134
+ - "127.0.0.1:${pgPort}:5432"
135
+ volumes:
136
+ - "${join(infraDir, 'pgdata')}:/var/lib/postgresql/data"
137
+ healthcheck:
138
+ test: ["CMD-SHELL", "pg_isready -U ${pgUser} -d ${pgDb}"]
139
+ interval: 2s
140
+ timeout: 3s
141
+ retries: 30
142
+
143
+ redis:
144
+ image: redis:7-alpine
145
+ command: ["redis-server", "--appendonly", "yes"]
146
+ ports:
147
+ - "127.0.0.1:${redisPort}:6379"
148
+ volumes:
149
+ - "${join(infraDir, 'redis')}:/data"
150
+ healthcheck:
151
+ test: ["CMD", "redis-cli", "ping"]
152
+ interval: 2s
153
+ timeout: 3s
154
+ retries: 30
155
+
156
+ minio:
157
+ image: minio/minio:latest
158
+ command: ["server", "/data", "--console-address", ":9001"]
159
+ environment:
160
+ MINIO_ROOT_USER: ${s3AccessKey}
161
+ MINIO_ROOT_PASSWORD: ${s3SecretKey}
162
+ ports:
163
+ - "127.0.0.1:${minioPort}:9000"
164
+ - "127.0.0.1:${minioConsolePort}:9001"
165
+ volumes:
166
+ - "${join(infraDir, 'minio')}:/data"
167
+
168
+ minio-init:
169
+ image: minio/mc:latest
170
+ depends_on:
171
+ - minio
172
+ entrypoint: ["/bin/sh", "-lc"]
173
+ command: >
174
+ mc alias set local http://minio:9000 ${s3AccessKey} ${s3SecretKey} &&
175
+ mc mb -p local/${s3Bucket} || true &&
176
+ mc anonymous set download local/${s3Bucket} || true
177
+ restart: "no"
178
+ `;
179
+ }
180
+
181
+ async function ensureDockerCompose() {
182
+ const waitMsRaw = (process.env.HAPPY_STACKS_DOCKER_WAIT_MS ?? process.env.HAPPY_LOCAL_DOCKER_WAIT_MS ?? '').trim();
183
+ const waitMs = waitMsRaw ? Number(waitMsRaw) : process.stdout.isTTY ? 0 : 60_000;
184
+ const deadline = waitMs > 0 ? Date.now() + waitMs : Date.now();
185
+
186
+ try {
187
+ await runCapture('docker', ['compose', 'version'], { timeoutMs: 10_000 });
188
+ } catch (e) {
189
+ const msg = e?.message ? String(e.message) : String(e);
190
+ throw new Error(
191
+ `[infra] docker compose is required for managed happy-server stacks.\n` +
192
+ `Fix: install Docker Desktop and ensure \`docker compose\` works.\n` +
193
+ `Details: ${msg}`
194
+ );
195
+ }
196
+
197
+ const autostartRaw = (process.env.HAPPY_STACKS_DOCKER_AUTOSTART ?? process.env.HAPPY_LOCAL_DOCKER_AUTOSTART ?? '').trim();
198
+ const autostart = autostartRaw ? autostartRaw !== '0' : !process.stdout.isTTY;
199
+
200
+ // Ensure the Docker daemon is ready (launchd/SwiftBar often runs before Docker Desktop starts).
201
+ // If not ready, wait up to waitMs (non-interactive default: 60s) to avoid restart loops.
202
+ while (true) {
203
+ try {
204
+ await runCapture('docker', ['info'], { timeoutMs: 10_000 });
205
+ return;
206
+ } catch (e) {
207
+ if (autostart) {
208
+ await maybeStartDockerDaemon().catch(() => {});
209
+ }
210
+ if (Date.now() >= deadline) {
211
+ const msg = e?.message ? String(e.message) : String(e);
212
+ throw new Error(
213
+ `[infra] docker is installed but the daemon is not ready.\n` +
214
+ `Fix: start Docker Desktop, or disable managed infra (HAPPY_STACKS_MANAGED_INFRA=0).\n` +
215
+ `You can also increase wait time with HAPPY_STACKS_DOCKER_WAIT_MS, or disable auto-start with HAPPY_STACKS_DOCKER_AUTOSTART=0.\n` +
216
+ `Details: ${msg}`
217
+ );
218
+ }
219
+ // eslint-disable-next-line no-await-in-loop
220
+ await delay(1000);
221
+ }
222
+ }
223
+ }
224
+
225
+ async function maybeStartDockerDaemon() {
226
+ // Best-effort. This may be a no-op depending on platform/permissions.
227
+ if (process.platform === 'darwin') {
228
+ const app = (process.env.HAPPY_STACKS_DOCKER_APP ?? process.env.HAPPY_LOCAL_DOCKER_APP ?? '/Applications/Docker.app').trim();
229
+ // `open` exits quickly; Docker Desktop will start in the background.
230
+ await runCapture('open', ['-gj', '-a', app], { timeoutMs: 5_000 }).catch(() => {});
231
+ return;
232
+ }
233
+
234
+ if (process.platform === 'linux') {
235
+ // Rootless / Docker Desktop / system Docker can differ. Try a few user-scope units first.
236
+ const candidates = ['docker.service', 'docker.socket', 'docker-desktop.service', 'docker-desktop'];
237
+ for (const unit of candidates) {
238
+ // eslint-disable-next-line no-await-in-loop
239
+ await runCapture('systemctl', ['--user', 'start', unit], { timeoutMs: 5_000 }).catch(() => {});
240
+ }
241
+ // As a last resort, try system scope (may fail without sudo; ignore).
242
+ await runCapture('systemctl', ['start', 'docker'], { timeoutMs: 5_000 }).catch(() => {});
243
+ }
244
+ }
245
+
246
+ async function dockerCompose({ composePath, projectName, args, options = {} }) {
247
+ await run('docker', ['compose', '-f', composePath, '-p', projectName, ...args], options);
248
+ }
249
+
250
+ async function waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb }) {
251
+ const deadline = Date.now() + 60_000;
252
+ while (Date.now() < deadline) {
253
+ try {
254
+ await runCapture(
255
+ 'docker',
256
+ ['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'postgres', 'pg_isready', '-U', pgUser, '-d', pgDb],
257
+ { timeoutMs: 5_000 }
258
+ );
259
+ return;
260
+ } catch {
261
+ // ignore
262
+ }
263
+ // eslint-disable-next-line no-await-in-loop
264
+ await delay(800);
265
+ }
266
+ throw new Error('[infra] timed out waiting for postgres to become ready');
267
+ }
268
+
269
+ async function waitForHealthyRedis({ composePath, projectName }) {
270
+ const deadline = Date.now() + 30_000;
271
+ while (Date.now() < deadline) {
272
+ try {
273
+ const out = await runCapture('docker', ['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'redis', 'redis-cli', 'ping'], {
274
+ timeoutMs: 5_000,
275
+ });
276
+ if (out.trim().toUpperCase().includes('PONG')) {
277
+ return;
278
+ }
279
+ } catch {
280
+ // ignore
281
+ }
282
+ // eslint-disable-next-line no-await-in-loop
283
+ await delay(600);
284
+ }
285
+ throw new Error('[infra] timed out waiting for redis to become ready');
286
+ }
287
+
288
+ export async function ensureHappyServerManagedInfra({
289
+ stackName,
290
+ baseDir,
291
+ serverPort,
292
+ publicServerUrl,
293
+ envPath,
294
+ env = process.env,
295
+ }) {
296
+ await ensureDockerCompose();
297
+
298
+ const infraDir = join(baseDir, 'happy-server', 'infra');
299
+ await mkdir(infraDir, { recursive: true });
300
+
301
+ const existingEnv = envPath ? await readEnvObject(envPath) : {};
302
+ const reservedPorts = new Set();
303
+
304
+ // Reserve known ports (if present) to avoid picking duplicates when auto-filling.
305
+ for (const key of [
306
+ 'HAPPY_STACKS_SERVER_PORT',
307
+ 'HAPPY_LOCAL_SERVER_PORT',
308
+ 'HAPPY_STACKS_PG_PORT',
309
+ 'HAPPY_STACKS_REDIS_PORT',
310
+ 'HAPPY_STACKS_MINIO_PORT',
311
+ 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
312
+ ]) {
313
+ const p = coercePort(existingEnv[key] ?? env[key]);
314
+ if (p) reservedPorts.add(p);
315
+ }
316
+ if (Number.isFinite(serverPort) && serverPort > 0) reservedPorts.add(serverPort);
317
+
318
+ const pgPort = coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ?? (await pickNextFreePort(serverPort + 1000, { reservedPorts }));
319
+ reservedPorts.add(pgPort);
320
+ const redisPort =
321
+ coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ?? (await pickNextFreePort(pgPort + 1, { reservedPorts }));
322
+ reservedPorts.add(redisPort);
323
+ const minioPort =
324
+ coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ?? (await pickNextFreePort(redisPort + 1, { reservedPorts }));
325
+ reservedPorts.add(minioPort);
326
+ const minioConsolePort =
327
+ coercePort(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? env.HAPPY_STACKS_MINIO_CONSOLE_PORT) ??
328
+ (await pickNextFreePort(minioPort + 1, { reservedPorts }));
329
+ reservedPorts.add(minioConsolePort);
330
+
331
+ const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? env.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
332
+ const pgPassword = (existingEnv.HAPPY_STACKS_PG_PASSWORD ?? env.HAPPY_STACKS_PG_PASSWORD ?? '').trim() || randomToken(24);
333
+ const pgDb = (existingEnv.HAPPY_STACKS_PG_DATABASE ?? env.HAPPY_STACKS_PG_DATABASE ?? 'handy').trim() || 'handy';
334
+
335
+ const s3Bucket =
336
+ (existingEnv.S3_BUCKET ?? env.S3_BUCKET ?? '').trim() || sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
337
+ const s3AccessKey = (existingEnv.S3_ACCESS_KEY ?? env.S3_ACCESS_KEY ?? '').trim() || randomToken(12);
338
+ const s3SecretKey = (existingEnv.S3_SECRET_KEY ?? env.S3_SECRET_KEY ?? '').trim() || randomToken(24);
339
+
340
+ const secretFile = (existingEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim()
341
+ ? (existingEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE).trim()
342
+ : join(baseDir, 'happy-server', 'handy-master-secret.txt');
343
+ const handyMasterSecret = (existingEnv.HANDY_MASTER_SECRET ?? env.HANDY_MASTER_SECRET ?? '').trim()
344
+ ? (existingEnv.HANDY_MASTER_SECRET ?? env.HANDY_MASTER_SECRET).trim()
345
+ : await ensureTextFile({ path: secretFile, generate: () => randomToken(32) });
346
+
347
+ const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
348
+ const redisUrl = `redis://127.0.0.1:${redisPort}`;
349
+ const s3Host = '127.0.0.1';
350
+ const s3UseSsl = 'false';
351
+ const pub = String(publicServerUrl ?? '').trim().replace(/\/+$/, '');
352
+ if (!pub) {
353
+ throw new Error('[infra] publicServerUrl is required for managed infra (to set S3_PUBLIC_URL)');
354
+ }
355
+ const s3PublicUrl = `${pub}/files`;
356
+
357
+ if (envPath) {
358
+ await ensureEnvFileUpdated({
359
+ envPath,
360
+ updates: [
361
+ { key: 'HAPPY_STACKS_PG_PORT', value: String(pgPort) },
362
+ { key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) },
363
+ { key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) },
364
+ { key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) },
365
+ { key: 'HAPPY_STACKS_PG_USER', value: pgUser },
366
+ { key: 'HAPPY_STACKS_PG_PASSWORD', value: pgPassword },
367
+ { key: 'HAPPY_STACKS_PG_DATABASE', value: pgDb },
368
+ { key: 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE', value: secretFile },
369
+ // Vars consumed by happy-server:
370
+ { key: 'DATABASE_URL', value: databaseUrl },
371
+ { key: 'REDIS_URL', value: redisUrl },
372
+ { key: 'S3_HOST', value: s3Host },
373
+ { key: 'S3_PORT', value: String(minioPort) },
374
+ { key: 'S3_USE_SSL', value: s3UseSsl },
375
+ { key: 'S3_ACCESS_KEY', value: s3AccessKey },
376
+ { key: 'S3_SECRET_KEY', value: s3SecretKey },
377
+ { key: 'S3_BUCKET', value: s3Bucket },
378
+ { key: 'S3_PUBLIC_URL', value: s3PublicUrl },
379
+ ],
380
+ });
381
+ }
382
+
383
+ const composePath = join(infraDir, 'docker-compose.yml');
384
+ const projectName = composeProjectName(stackName);
385
+ const yaml = buildComposeYaml({
386
+ infraDir,
387
+ pgPort,
388
+ pgUser,
389
+ pgPassword,
390
+ pgDb,
391
+ redisPort,
392
+ minioPort,
393
+ minioConsolePort,
394
+ s3AccessKey,
395
+ s3SecretKey,
396
+ s3Bucket,
397
+ });
398
+ await writeFile(composePath, yaml, 'utf-8');
399
+
400
+ await dockerCompose({ composePath, projectName, args: ['up', '-d', '--remove-orphans'], options: { cwd: baseDir } });
401
+ await waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb });
402
+ await waitForHealthyRedis({ composePath, projectName });
403
+
404
+ // Ensure bucket exists (idempotent)
405
+ await dockerCompose({ composePath, projectName, args: ['run', '--rm', 'minio-init'], options: { cwd: baseDir } });
406
+
407
+ return {
408
+ composePath,
409
+ projectName,
410
+ infraDir,
411
+ env: {
412
+ DATABASE_URL: databaseUrl,
413
+ REDIS_URL: redisUrl,
414
+ S3_HOST: s3Host,
415
+ S3_PORT: String(minioPort),
416
+ S3_USE_SSL: s3UseSsl,
417
+ S3_ACCESS_KEY: s3AccessKey,
418
+ S3_SECRET_KEY: s3SecretKey,
419
+ S3_BUCKET: s3Bucket,
420
+ S3_PUBLIC_URL: s3PublicUrl,
421
+ HANDY_MASTER_SECRET: handyMasterSecret,
422
+ },
423
+ };
424
+ }
425
+
426
+ export async function applyHappyServerMigrations({ serverDir, env }) {
427
+ // Non-interactive + idempotent. Safe for dev; also safe for managed stacks on start.
428
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
429
+ }
430
+
@@ -180,6 +180,14 @@ export async function pmSpawnScript({ label, dir, script, env, options = {} }) {
180
180
  return spawnProc(label, pm.cmd, ['--silent', script], env, { ...options, cwd: dir });
181
181
  }
182
182
 
183
+ export async function pmSpawnBin({ label, dir, bin, args, env, options = {} }) {
184
+ const pm = await getComponentPm(dir);
185
+ if (pm.name === 'yarn') {
186
+ return spawnProc(label, pm.cmd, [bin, ...args], env, { ...options, cwd: dir });
187
+ }
188
+ return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
189
+ }
190
+
183
191
  export async function pmExecBin({ dir, bin, args, env }) {
184
192
  const pm = await getComponentPm(dir);
185
193
  if (pm.name === 'yarn') {
@@ -217,6 +225,8 @@ export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.lo
217
225
  : process.execPath;
218
226
  const installedRoot = resolveInstalledCliRoot(rootDir);
219
227
  const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
228
+ const happysShim = join(getHappyStacksHomeDir(), 'bin', 'happys');
229
+ const useShim = existsSync(happysShim);
220
230
 
221
231
  // Ensure we write to the plist path that matches the label we're installing, instead of the
222
232
  // "active" plist path (which might be legacy and cause filename/label mismatches).
@@ -233,8 +243,7 @@ export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.lo
233
243
  <string>${label}</string>
234
244
  <key>ProgramArguments</key>
235
245
  <array>
236
- <string>${nodePath}</string>
237
- <string>${happysEntrypoint}</string>
246
+ ${useShim ? `<string>${happysShim}</string>` : `<string>${nodePath}</string>\n <string>${happysEntrypoint}</string>`}
238
247
  <string>start</string>
239
248
  </array>
240
249
  <key>WorkingDirectory</key>
@@ -1,17 +1,10 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
+ import net from 'node:net';
2
3
  import { runCapture } from './proc.mjs';
3
4
 
4
- /**
5
- * Best-effort: kill any processes LISTENing on a TCP port.
6
- * Used to avoid EADDRINUSE when a previous run left a server behind.
7
- */
8
- export async function killPortListeners(port, { label = 'port' } = {}) {
9
- if (!Number.isFinite(port) || port <= 0) {
10
- return [];
11
- }
12
- if (process.platform === 'win32') {
13
- return [];
14
- }
5
+ async function listListenPids(port) {
6
+ if (!Number.isFinite(port) || port <= 0) return [];
7
+ if (process.platform === 'win32') return [];
15
8
 
16
9
  let raw = '';
17
10
  try {
@@ -21,10 +14,10 @@ export async function killPortListeners(port, { label = 'port' } = {}) {
21
14
  `command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`,
22
15
  ]);
23
16
  } catch {
24
- return [];
17
+ raw = '';
25
18
  }
26
19
 
27
- const pids = Array.from(
20
+ return Array.from(
28
21
  new Set(
29
22
  raw
30
23
  .split(/\s+/g)
@@ -34,6 +27,21 @@ export async function killPortListeners(port, { label = 'port' } = {}) {
34
27
  .filter((n) => Number.isInteger(n) && n > 1)
35
28
  )
36
29
  );
30
+ }
31
+
32
+ /**
33
+ * Best-effort: kill any processes LISTENing on a TCP port.
34
+ * Used to avoid EADDRINUSE when a previous run left a server behind.
35
+ */
36
+ export async function killPortListeners(port, { label = 'port' } = {}) {
37
+ if (!Number.isFinite(port) || port <= 0) {
38
+ return [];
39
+ }
40
+ if (process.platform === 'win32') {
41
+ return [];
42
+ }
43
+
44
+ const pids = await listListenPids(port);
37
45
 
38
46
  if (!pids.length) {
39
47
  return [];
@@ -64,3 +72,33 @@ export async function killPortListeners(port, { label = 'port' } = {}) {
64
72
  return pids;
65
73
  }
66
74
 
75
+ export async function isTcpPortFree(port, { host = '127.0.0.1' } = {}) {
76
+ if (!Number.isFinite(port) || port <= 0) return false;
77
+
78
+ // Prefer lsof-based detection to catch IPv6 listeners (e.g. TCP *:8081 (LISTEN))
79
+ // which can make a "bind 127.0.0.1" probe incorrectly report "free" on macOS.
80
+ const pids = await listListenPids(port);
81
+ if (pids.length) return false;
82
+
83
+ // Fallback: attempt to bind.
84
+ return await new Promise((resolvePromise) => {
85
+ const srv = net.createServer();
86
+ srv.unref();
87
+ srv.on('error', () => resolvePromise(false));
88
+ srv.listen({ port, host }, () => {
89
+ srv.close(() => resolvePromise(true));
90
+ });
91
+ });
92
+ }
93
+
94
+ export async function pickNextFreeTcpPort(startPort, { reservedPorts = new Set(), host = '127.0.0.1', tries = 200 } = {}) {
95
+ let port = startPort;
96
+ for (let i = 0; i < tries; i++) {
97
+ // eslint-disable-next-line no-await-in-loop
98
+ if (!reservedPorts.has(port) && (await isTcpPortFree(port, { host }))) {
99
+ return port;
100
+ }
101
+ port += 1;
102
+ }
103
+ throw new Error(`[local] unable to find a free TCP port starting at ${startPort}`);
104
+ }
@@ -39,28 +39,69 @@ export function killProcessTree(child, signal) {
39
39
  }
40
40
 
41
41
  export async function run(cmd, args, options = {}) {
42
+ const { timeoutMs, ...spawnOptions } = options ?? {};
42
43
  await new Promise((resolvePromise, rejectPromise) => {
43
- const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...options });
44
+ const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...spawnOptions });
45
+ const t =
46
+ Number.isFinite(timeoutMs) && timeoutMs > 0
47
+ ? setTimeout(() => {
48
+ try {
49
+ proc.kill('SIGKILL');
50
+ } catch {
51
+ // ignore
52
+ }
53
+ const e = new Error(`${cmd} timed out after ${timeoutMs}ms`);
54
+ e.code = 'ETIMEDOUT';
55
+ rejectPromise(e);
56
+ }, timeoutMs)
57
+ : null;
44
58
  proc.on('error', rejectPromise);
45
59
  proc.on('exit', (code) => (code === 0 ? resolvePromise() : rejectPromise(new Error(`${cmd} failed (code=${code})`))));
60
+ proc.on('exit', () => {
61
+ if (t) clearTimeout(t);
62
+ });
46
63
  });
47
64
  }
48
65
 
49
66
  export async function runCapture(cmd, args, options = {}) {
67
+ const { timeoutMs, ...spawnOptions } = options ?? {};
50
68
  return await new Promise((resolvePromise, rejectPromise) => {
51
- const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...options });
69
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
52
70
  let out = '';
53
71
  let err = '';
72
+ const t =
73
+ Number.isFinite(timeoutMs) && timeoutMs > 0
74
+ ? setTimeout(() => {
75
+ try {
76
+ proc.kill('SIGKILL');
77
+ } catch {
78
+ // ignore
79
+ }
80
+ const e = new Error(`${cmd} ${args.join(' ')} timed out after ${timeoutMs}ms`);
81
+ e.code = 'ETIMEDOUT';
82
+ e.out = out;
83
+ e.err = err;
84
+ rejectPromise(e);
85
+ }, timeoutMs)
86
+ : null;
54
87
  proc.stdout?.on('data', (d) => (out += d.toString()));
55
88
  proc.stderr?.on('data', (d) => (err += d.toString()));
56
89
  proc.on('error', rejectPromise);
57
- proc.on('exit', (code) => {
90
+ proc.on('exit', (code, signal) => {
91
+ if (t) clearTimeout(t);
58
92
  if (code === 0) {
59
93
  resolvePromise(out);
60
94
  } else {
61
- rejectPromise(new Error(`${cmd} ${args.join(' ')} failed (code=${code}): ${err.trim()}`));
95
+ const e = new Error(
96
+ `${cmd} ${args.join(' ')} failed (code=${code ?? 'null'}, sig=${signal ?? 'null'}): ${err.trim()}`
97
+ );
98
+ e.code = 'EEXIT';
99
+ e.exitCode = code;
100
+ e.signal = signal;
101
+ e.out = out;
102
+ e.err = err;
103
+ rejectPromise(e);
62
104
  }
63
105
  });
64
106
  });
65
107
  }
66
-
@@ -22,6 +22,43 @@ export function getServerComponentName({ kv } = {}) {
22
22
  return raw;
23
23
  }
24
24
 
25
+ export async function fetchHappyHealth(baseUrl) {
26
+ const ctl = new AbortController();
27
+ const t = setTimeout(() => ctl.abort(), 1500);
28
+ try {
29
+ const url = baseUrl.replace(/\/+$/, '') + '/health';
30
+ const res = await fetch(url, { method: 'GET', signal: ctl.signal });
31
+ const text = await res.text();
32
+ let json = null;
33
+ try {
34
+ json = text ? JSON.parse(text) : null;
35
+ } catch {
36
+ json = null;
37
+ }
38
+ return { ok: res.ok, status: res.status, json, text };
39
+ } catch {
40
+ return { ok: false, status: null, json: null, text: null };
41
+ } finally {
42
+ clearTimeout(t);
43
+ }
44
+ }
45
+
46
+ export async function isHappyServerRunning(baseUrl) {
47
+ const health = await fetchHappyHealth(baseUrl);
48
+ if (!health.ok) return false;
49
+ // Both happy-server and happy-server-light use `service: 'happy-server'` today.
50
+ // Treat any ok health response as "running" to avoid duplicate spawns.
51
+ const svc = typeof health.json?.service === 'string' ? health.json.service : '';
52
+ const status = typeof health.json?.status === 'string' ? health.json.status : '';
53
+ if (svc && svc !== 'happy-server') {
54
+ return false;
55
+ }
56
+ if (status && status !== 'ok') {
57
+ return false;
58
+ }
59
+ return true;
60
+ }
61
+
25
62
  export async function waitForServerReady(url) {
26
63
  const deadline = Date.now() + 60_000;
27
64
  while (Date.now() < deadline) {