happy-stacks 0.1.2 → 0.2.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 (91) hide show
  1. package/README.md +121 -83
  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 +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -1,12 +1,12 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
- import net from 'node:net';
5
4
  import { join } from 'node:path';
6
5
  import { setTimeout as delay } from 'node:timers/promises';
7
6
 
8
7
  import { parseDotenv } from './dotenv.mjs';
9
8
  import { ensureEnvFileUpdated } from './env_file.mjs';
9
+ import { pickNextFreeTcpPort } from './ports.mjs';
10
10
  import { pmExecBin } from './pm.mjs';
11
11
  import { run, runCapture } from './proc.mjs';
12
12
 
@@ -37,29 +37,6 @@ function coercePort(v) {
37
37
  return Number.isFinite(n) && n > 0 ? n : null;
38
38
  }
39
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
40
  async function readEnvObject(envPath) {
64
41
  try {
65
42
  const raw = await readFile(envPath, 'utf-8');
@@ -243,8 +220,26 @@ async function maybeStartDockerDaemon() {
243
220
  }
244
221
  }
245
222
 
246
- async function dockerCompose({ composePath, projectName, args, options = {} }) {
247
- await run('docker', ['compose', '-f', composePath, '-p', projectName, ...args], options);
223
+ async function dockerCompose({ composePath, projectName, args, options = {}, quiet = false, retries = 0 }) {
224
+ const cmdArgs = ['compose', '-f', composePath, '-p', projectName, ...args];
225
+ let attempt = 0;
226
+ // eslint-disable-next-line no-constant-condition
227
+ while (true) {
228
+ try {
229
+ if (quiet) {
230
+ // Capture stderr so callers can surface it in structured JSON errors.
231
+ await runCapture('docker', cmdArgs, { timeoutMs: 120_000, ...options });
232
+ } else {
233
+ await run('docker', cmdArgs, { ...options, stdio: options?.stdio ?? 'inherit' });
234
+ }
235
+ return;
236
+ } catch (e) {
237
+ if (attempt >= retries) throw e;
238
+ attempt += 1;
239
+ // eslint-disable-next-line no-await-in-loop
240
+ await delay(800);
241
+ }
242
+ }
248
243
  }
249
244
 
250
245
  async function waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb }) {
@@ -285,6 +280,25 @@ async function waitForHealthyRedis({ composePath, projectName }) {
285
280
  throw new Error('[infra] timed out waiting for redis to become ready');
286
281
  }
287
282
 
283
+ async function waitForMinioReady({ composePath, projectName }) {
284
+ const deadline = Date.now() + 30_000;
285
+ while (Date.now() < deadline) {
286
+ try {
287
+ // Minio doesn't ship a healthcheck in our compose; exec'ing a trivial command is a good enough
288
+ // readiness proxy for running/accepting execs before we run minio-init.
289
+ await runCapture('docker', ['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'minio', 'sh', '-lc', 'echo ok'], {
290
+ timeoutMs: 5_000,
291
+ });
292
+ return;
293
+ } catch {
294
+ // ignore
295
+ }
296
+ // eslint-disable-next-line no-await-in-loop
297
+ await delay(600);
298
+ }
299
+ throw new Error('[infra] timed out waiting for minio to become ready');
300
+ }
301
+
288
302
  export async function ensureHappyServerManagedInfra({
289
303
  stackName,
290
304
  baseDir,
@@ -292,6 +306,8 @@ export async function ensureHappyServerManagedInfra({
292
306
  publicServerUrl,
293
307
  envPath,
294
308
  env = process.env,
309
+ quiet = false,
310
+ skipMinioInit = false,
295
311
  }) {
296
312
  await ensureDockerCompose();
297
313
 
@@ -315,17 +331,21 @@ export async function ensureHappyServerManagedInfra({
315
331
  }
316
332
  if (Number.isFinite(serverPort) && serverPort > 0) reservedPorts.add(serverPort);
317
333
 
318
- const pgPort = coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ?? (await pickNextFreePort(serverPort + 1000, { reservedPorts }));
334
+ const pgPort =
335
+ coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ??
336
+ (await pickNextFreeTcpPort(serverPort + 1000, { reservedPorts }));
319
337
  reservedPorts.add(pgPort);
320
338
  const redisPort =
321
- coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ?? (await pickNextFreePort(pgPort + 1, { reservedPorts }));
339
+ coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ??
340
+ (await pickNextFreeTcpPort(pgPort + 1, { reservedPorts }));
322
341
  reservedPorts.add(redisPort);
323
342
  const minioPort =
324
- coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ?? (await pickNextFreePort(redisPort + 1, { reservedPorts }));
343
+ coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ??
344
+ (await pickNextFreeTcpPort(redisPort + 1, { reservedPorts }));
325
345
  reservedPorts.add(minioPort);
326
346
  const minioConsolePort =
327
347
  coercePort(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? env.HAPPY_STACKS_MINIO_CONSOLE_PORT) ??
328
- (await pickNextFreePort(minioPort + 1, { reservedPorts }));
348
+ (await pickNextFreeTcpPort(minioPort + 1, { reservedPorts }));
329
349
  reservedPorts.add(minioConsolePort);
330
350
 
331
351
  const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? env.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
@@ -355,27 +375,45 @@ export async function ensureHappyServerManagedInfra({
355
375
  const s3PublicUrl = `${pub}/files`;
356
376
 
357
377
  if (envPath) {
378
+ // Ephemeral stacks should not pin ports in env files. In stack runtime, callers set
379
+ // HAPPY_STACKS_EPHEMERAL_PORTS=1 (via stack.runtime.json overlay) while the stack owner is alive.
380
+ //
381
+ // For offline tooling (e.g. auth seeding) we still want to preserve the invariant:
382
+ // - non-main stacks are ephemeral-by-default unless the user explicitly pinned ports already.
383
+ const runtimeEphemeral = (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
384
+ const alreadyPinnedPorts =
385
+ Boolean((existingEnv.HAPPY_STACKS_PG_PORT ?? '').trim()) ||
386
+ Boolean((existingEnv.HAPPY_STACKS_REDIS_PORT ?? '').trim()) ||
387
+ Boolean((existingEnv.HAPPY_STACKS_MINIO_PORT ?? '').trim()) ||
388
+ Boolean((existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? '').trim());
389
+ const ephemeralPorts = runtimeEphemeral || (stackName !== 'main' && !alreadyPinnedPorts);
358
390
  await ensureEnvFileUpdated({
359
391
  envPath,
360
392
  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) },
393
+ // Stable credentials/files: persist these so restarts keep the same DB/user and S3 creds.
365
394
  { key: 'HAPPY_STACKS_PG_USER', value: pgUser },
366
395
  { key: 'HAPPY_STACKS_PG_PASSWORD', value: pgPassword },
367
396
  { key: 'HAPPY_STACKS_PG_DATABASE', value: pgDb },
368
397
  { 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
398
  { key: 'S3_ACCESS_KEY', value: s3AccessKey },
376
399
  { key: 'S3_SECRET_KEY', value: s3SecretKey },
377
400
  { key: 'S3_BUCKET', value: s3Bucket },
378
- { key: 'S3_PUBLIC_URL', value: s3PublicUrl },
401
+ // Ports + derived URLs: persist only when ports are explicitly pinned (non-ephemeral mode).
402
+ ...(ephemeralPorts
403
+ ? []
404
+ : [
405
+ { key: 'HAPPY_STACKS_PG_PORT', value: String(pgPort) },
406
+ { key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) },
407
+ { key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) },
408
+ { key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) },
409
+ // Vars consumed by happy-server:
410
+ { key: 'DATABASE_URL', value: databaseUrl },
411
+ { key: 'REDIS_URL', value: redisUrl },
412
+ { key: 'S3_HOST', value: s3Host },
413
+ { key: 'S3_PORT', value: String(minioPort) },
414
+ { key: 'S3_USE_SSL', value: s3UseSsl },
415
+ { key: 'S3_PUBLIC_URL', value: s3PublicUrl },
416
+ ]),
379
417
  ],
380
418
  });
381
419
  }
@@ -397,12 +435,28 @@ export async function ensureHappyServerManagedInfra({
397
435
  });
398
436
  await writeFile(composePath, yaml, 'utf-8');
399
437
 
400
- await dockerCompose({ composePath, projectName, args: ['up', '-d', '--remove-orphans'], options: { cwd: baseDir } });
438
+ await dockerCompose({
439
+ composePath,
440
+ projectName,
441
+ args: ['up', '-d', '--remove-orphans'],
442
+ options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
443
+ quiet,
444
+ });
401
445
  await waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb });
402
446
  await waitForHealthyRedis({ composePath, projectName });
403
447
 
404
- // Ensure bucket exists (idempotent)
405
- await dockerCompose({ composePath, projectName, args: ['run', '--rm', 'minio-init'], options: { cwd: baseDir } });
448
+ if (!skipMinioInit) {
449
+ // Ensure bucket exists (idempotent). This can race with Minio startup; retry a few times.
450
+ await waitForMinioReady({ composePath, projectName });
451
+ await dockerCompose({
452
+ composePath,
453
+ projectName,
454
+ args: ['run', '--rm', '--no-deps', 'minio-init'],
455
+ options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
456
+ quiet,
457
+ retries: 3,
458
+ });
459
+ }
406
460
 
407
461
  return {
408
462
  composePath,
@@ -423,8 +477,8 @@ export async function ensureHappyServerManagedInfra({
423
477
  };
424
478
  }
425
479
 
426
- export async function applyHappyServerMigrations({ serverDir, env }) {
480
+ export async function applyHappyServerMigrations({ serverDir, env, quiet = false }) {
427
481
  // Non-interactive + idempotent. Safe for dev; also safe for managed stacks on start.
428
- await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
482
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env, quiet });
429
483
  }
430
484
 
@@ -0,0 +1,17 @@
1
+ import { getStackName } from './paths.mjs';
2
+
3
+ function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
4
+ const s = String(raw ?? '')
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9-]+/g, '-')
7
+ .replace(/-+/g, '-')
8
+ .replace(/^-+/, '')
9
+ .replace(/-+$/, '');
10
+ return s || fallback;
11
+ }
12
+
13
+ export function resolveLocalhostHost({ stackMode, stackName = getStackName() } = {}) {
14
+ if (!stackMode) return 'localhost';
15
+ if (!stackName || stackName === 'main') return 'localhost';
16
+ return `happy-${sanitizeDnsLabel(stackName)}.localhost`;
17
+ }
@@ -0,0 +1,135 @@
1
+ import { runCapture } from './proc.mjs';
2
+ import { killPid } from './expo.mjs';
3
+
4
+ export async function getPsEnvLine(pid) {
5
+ const n = Number(pid);
6
+ if (!Number.isFinite(n) || n <= 1) return null;
7
+ if (process.platform === 'win32') return null;
8
+ try {
9
+ const out = await runCapture('ps', ['eww', '-p', String(n)]);
10
+ // Output usually includes a header line and then a single process line.
11
+ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
12
+ if (lines.length >= 2) return lines[1];
13
+ if (lines.length === 1) return lines[0];
14
+ return null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export async function listPidsWithEnvNeedle(needle) {
21
+ const n = String(needle ?? '').trim();
22
+ if (!n) return [];
23
+ if (process.platform === 'win32') return [];
24
+ try {
25
+ // Include environment variables (eww) so we can match on HAPPY_STACKS_ENV_FILE=/.../env safely.
26
+ const out = await runCapture('ps', ['eww', '-ax', '-o', 'pid=,command=']);
27
+ const pids = [];
28
+ for (const line of out.split('\n')) {
29
+ if (!line.includes(n)) continue;
30
+ const m = line.trim().match(/^(\d+)\s+/);
31
+ if (!m) continue;
32
+ const pid = Number(m[1]);
33
+ if (Number.isFinite(pid) && pid > 1) {
34
+ pids.push(pid);
35
+ }
36
+ }
37
+ return Array.from(new Set(pids));
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ export async function getProcessGroupId(pid) {
44
+ const n = Number(pid);
45
+ if (!Number.isFinite(n) || n <= 1) return null;
46
+ if (process.platform === 'win32') return null;
47
+ try {
48
+ const out = await runCapture('ps', ['-o', 'pgid=', '-p', String(n)]);
49
+ const raw = out.trim();
50
+ const pgid = raw ? Number(raw) : NaN;
51
+ return Number.isFinite(pgid) && pgid > 1 ? pgid : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export async function isPidOwnedByStack(pid, { stackName, envPath, cliHomeDir } = {}) {
58
+ const line = await getPsEnvLine(pid);
59
+ if (!line) return false;
60
+ const sn = String(stackName ?? '').trim();
61
+ const ep = String(envPath ?? '').trim();
62
+ const ch = String(cliHomeDir ?? '').trim();
63
+
64
+ // Require at least one stack identifier.
65
+ const hasStack =
66
+ (sn && (line.includes(`HAPPY_STACKS_STACK=${sn}`) || line.includes(`HAPPY_LOCAL_STACK=${sn}`))) ||
67
+ (!sn && (line.includes('HAPPY_STACKS_STACK=') || line.includes('HAPPY_LOCAL_STACK=')));
68
+ if (!hasStack) return false;
69
+
70
+ // Prefer env-file binding (strongest).
71
+ if (ep) {
72
+ if (line.includes(`HAPPY_STACKS_ENV_FILE=${ep}`) || line.includes(`HAPPY_LOCAL_ENV_FILE=${ep}`)) {
73
+ return true;
74
+ }
75
+ }
76
+
77
+ // Fallback: CLI home dir binding (useful for daemon-related processes).
78
+ if (ch) {
79
+ if (line.includes(`HAPPY_HOME_DIR=${ch}`) || line.includes(`HAPPY_STACKS_CLI_HOME_DIR=${ch}`) || line.includes(`HAPPY_LOCAL_CLI_HOME_DIR=${ch}`)) {
80
+ return true;
81
+ }
82
+ }
83
+
84
+ return false;
85
+ }
86
+
87
+ export async function killPidOwnedByStack(pid, { stackName, envPath, cliHomeDir, label = 'process', json = false } = {}) {
88
+ const ok = await isPidOwnedByStack(pid, { stackName, envPath, cliHomeDir });
89
+ if (!ok) {
90
+ if (!json) {
91
+ // eslint-disable-next-line no-console
92
+ console.warn(`[stack] refusing to kill ${label} pid=${pid} (cannot prove it belongs to stack ${stackName ?? ''})`);
93
+ }
94
+ return { killed: false, reason: 'not_owned' };
95
+ }
96
+ await killPid(pid);
97
+ return { killed: true, reason: 'killed' };
98
+ }
99
+
100
+ export async function killProcessGroupOwnedByStack(
101
+ pid,
102
+ { stackName, envPath, cliHomeDir, label = 'process-group', json = false, signal = 'SIGTERM' } = {}
103
+ ) {
104
+ const ok = await isPidOwnedByStack(pid, { stackName, envPath, cliHomeDir });
105
+ if (!ok) {
106
+ if (!json) {
107
+ // eslint-disable-next-line no-console
108
+ console.warn(`[stack] refusing to kill ${label} pid=${pid} (cannot prove it belongs to stack ${stackName ?? ''})`);
109
+ }
110
+ return { killed: false, reason: 'not_owned' };
111
+ }
112
+ const pgid = await getProcessGroupId(pid);
113
+ if (!pgid) {
114
+ await killPid(pid);
115
+ return { killed: true, reason: 'killed_pid_only' };
116
+ }
117
+ try {
118
+ process.kill(-pgid, signal);
119
+ } catch {
120
+ // ignore
121
+ }
122
+ // Escalate if still alive.
123
+ try {
124
+ process.kill(pid, 0);
125
+ try {
126
+ process.kill(-pgid, 'SIGKILL');
127
+ } catch {
128
+ // ignore
129
+ }
130
+ } catch {
131
+ // exited
132
+ }
133
+ return { killed: true, reason: 'killed_pgid', pgid };
134
+ }
135
+
@@ -2,6 +2,7 @@ import { homedir } from 'node:os';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { existsSync } from 'node:fs';
5
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
5
6
 
6
7
  const PRIMARY_APP_SLUG = 'happy-stacks';
7
8
  const LEGACY_APP_SLUG = 'happy-local';
@@ -101,12 +102,13 @@ export function resolveStackBaseDir(stackName = getStackName()) {
101
102
  const preferredRoot = getStacksStorageRoot();
102
103
  const newBase = join(preferredRoot, stackName);
103
104
  const legacyBase = stackName === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', stackName);
105
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
104
106
 
105
107
  // Prefer the new layout by default.
106
108
  //
107
109
  // For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
108
110
  // This avoids breaking existing stacks until `happys stack migrate` is run.
109
- if (stackName !== 'main') {
111
+ if (allowLegacy && stackName !== 'main') {
110
112
  const newEnv = join(preferredRoot, stackName, 'env');
111
113
  const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
112
114
  if (!existsSync(newEnv) && existsSync(legacyEnv)) {
@@ -123,11 +125,12 @@ export function resolveStackEnvPath(stackName = getStackName()) {
123
125
  const newEnv = join(getStacksStorageRoot(), stackName, 'env');
124
126
  // Legacy layout: ~/.happy/local/stacks/<name>/env
125
127
  const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
128
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
126
129
 
127
130
  if (existsSync(newEnv)) {
128
131
  return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(), stackName) };
129
132
  }
130
- if (existsSync(legacyEnv)) {
133
+ if (allowLegacy && existsSync(legacyEnv)) {
131
134
  return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', stackName) };
132
135
  }
133
136
  return { envPath: newEnv, isLegacy, baseDir: activeBase };
@@ -1,13 +1,61 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
- import { chmod, mkdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
5
+ import { createHash } from 'node:crypto';
5
6
 
6
7
  import { pathExists } from './fs.mjs';
7
8
  import { run, runCapture, spawnProc } from './proc.mjs';
8
9
  import { getDefaultAutostartPaths, getHappyStacksHomeDir } from './paths.mjs';
9
10
  import { resolveInstalledPath, resolveInstalledCliRoot } from './runtime.mjs';
10
11
 
12
+ function sha256Hex(s) {
13
+ return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
14
+ }
15
+
16
+ async function readJsonIfExists(path) {
17
+ try {
18
+ if (!path || !existsSync(path)) return null;
19
+ const raw = await readFile(path, 'utf-8');
20
+ return JSON.parse(raw);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ async function writeJsonAtomic(path, value) {
27
+ const dir = dirname(path);
28
+ await mkdir(dir, { recursive: true }).catch(() => {});
29
+ const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
30
+ await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
31
+ await rename(tmp, path);
32
+ }
33
+
34
+ function resolveBuildStatePath({ label, dir }) {
35
+ const homeDir = getHappyStacksHomeDir();
36
+ const key = sha256Hex(resolve(dir));
37
+ return join(homeDir, 'cache', 'build', label, `${key}.json`);
38
+ }
39
+
40
+ async function computeGitWorktreeSignature(dir) {
41
+ try {
42
+ // Fast path: only if this is a git worktree.
43
+ const inside = (await runCapture('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'])).trim();
44
+ if (inside !== 'true') return null;
45
+ const head = (await runCapture('git', ['-C', dir, 'rev-parse', 'HEAD'])).trim();
46
+ // Includes staged + unstaged + untracked changes; captures “dirty” vs “clean”.
47
+ const status = await runCapture('git', ['-C', dir, 'status', '--porcelain=v1']);
48
+ return {
49
+ kind: 'git',
50
+ head,
51
+ statusHash: sha256Hex(status),
52
+ signature: sha256Hex(`${head}\n${status}`),
53
+ };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
11
59
  async function commandExists(cmd, options = {}) {
12
60
  try {
13
61
  await runCapture(cmd, ['--version'], options);
@@ -53,7 +101,7 @@ export async function requireDir(label, dir) {
53
101
  );
54
102
  }
55
103
 
56
- export async function ensureDepsInstalled(dir, label) {
104
+ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
57
105
  const pkgJson = join(dir, 'package.json');
58
106
  if (!(await pathExists(pkgJson))) {
59
107
  return;
@@ -62,6 +110,7 @@ export async function ensureDepsInstalled(dir, label) {
62
110
  const nodeModules = join(dir, 'node_modules');
63
111
  const pnpmModulesMeta = join(dir, 'node_modules', '.modules.yaml');
64
112
  const pm = await getComponentPm(dir);
113
+ const stdio = quiet ? 'ignore' : 'inherit';
65
114
 
66
115
  if (await pathExists(nodeModules)) {
67
116
  const yarnLock = join(dir, 'yarn.lock');
@@ -71,10 +120,12 @@ export async function ensureDepsInstalled(dir, label) {
71
120
  // If this repo is Yarn-managed (yarn.lock present) but node_modules was created by pnpm,
72
121
  // reinstall with Yarn to restore upstream-locked dependency versions.
73
122
  if (pm.name === 'yarn' && (await pathExists(pnpmModulesMeta))) {
74
- // eslint-disable-next-line no-console
75
- console.log(`[local] converting ${label} dependencies back to yarn (reinstalling node_modules)...`);
123
+ if (!quiet) {
124
+ // eslint-disable-next-line no-console
125
+ console.log(`[local] converting ${label} dependencies back to yarn (reinstalling node_modules)...`);
126
+ }
76
127
  await rm(nodeModules, { recursive: true, force: true });
77
- await run(pm.cmd, ['install'], { cwd: dir });
128
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
78
129
  }
79
130
 
80
131
  // If dependencies changed since the last install, re-run install even if node_modules exists.
@@ -92,9 +143,11 @@ export async function ensureDepsInstalled(dir, label) {
92
143
  const pkgM = await mtimeMs(pkgJson);
93
144
  const intM = await mtimeMs(yarnIntegrity);
94
145
  if (!intM || lockM > intM || pkgM > intM) {
95
- // eslint-disable-next-line no-console
96
- console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
97
- await run(pm.cmd, ['install'], { cwd: dir });
146
+ if (!quiet) {
147
+ // eslint-disable-next-line no-console
148
+ console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
149
+ }
150
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
98
151
  }
99
152
  }
100
153
 
@@ -102,29 +155,75 @@ export async function ensureDepsInstalled(dir, label) {
102
155
  const lockM = await mtimeMs(pnpmLock);
103
156
  const metaM = await mtimeMs(pnpmModulesMeta);
104
157
  if (!metaM || lockM > metaM) {
105
- // eslint-disable-next-line no-console
106
- console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
107
- await run(pm.cmd, ['install'], { cwd: dir });
158
+ if (!quiet) {
159
+ // eslint-disable-next-line no-console
160
+ console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
161
+ }
162
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
108
163
  }
109
164
  }
110
165
 
111
166
  return;
112
167
  }
113
168
 
114
- // eslint-disable-next-line no-console
115
- console.log(`[local] installing ${label} dependencies (first run)...`);
116
- await run(pm.cmd, ['install'], { cwd: dir });
169
+ if (!quiet) {
170
+ // eslint-disable-next-line no-console
171
+ console.log(`[local] installing ${label} dependencies (first run)...`);
172
+ }
173
+ await run(pm.cmd, ['install'], { cwd: dir, stdio });
117
174
  }
118
175
 
119
176
  export async function ensureCliBuilt(cliDir, { buildCli }) {
120
177
  await ensureDepsInstalled(cliDir, 'happy-cli');
121
178
  if (!buildCli) {
122
- return;
179
+ return { built: false, reason: 'disabled' };
180
+ }
181
+ // Default: build only when needed (fast + reliable for worktrees that haven't been built yet).
182
+ //
183
+ // You can force always-build by setting:
184
+ // - HAPPY_STACKS_CLI_BUILD_MODE=always (legacy: HAPPY_LOCAL_CLI_BUILD_MODE=always)
185
+ // Or disable via:
186
+ // - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
187
+ const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
188
+ const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
189
+ if (mode === 'never') {
190
+ return { built: false, reason: 'mode_never' };
191
+ }
192
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
193
+ const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
194
+ const gitSig = await computeGitWorktreeSignature(cliDir);
195
+ const prev = await readJsonIfExists(buildStatePath);
196
+
197
+ if (mode === 'auto') {
198
+ // If dist doesn't exist, we must build.
199
+ if (!(await pathExists(distEntrypoint))) {
200
+ // fallthrough to build
201
+ } else if (gitSig && prev?.signature && prev.signature === gitSig.signature) {
202
+ return { built: false, reason: 'up_to_date' };
203
+ } else if (!gitSig) {
204
+ // No git info: best-effort skip if dist exists (keeps this fast outside git worktrees).
205
+ return { built: false, reason: 'no_git_info' };
206
+ }
123
207
  }
208
+
124
209
  // eslint-disable-next-line no-console
125
210
  console.log('[local] building happy-cli...');
126
211
  const pm = await getComponentPm(cliDir);
127
212
  await run(pm.cmd, ['build'], { cwd: cliDir });
213
+
214
+ // Persist new build state (best-effort).
215
+ const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
216
+ if (nowSig) {
217
+ await writeJsonAtomic(buildStatePath, {
218
+ label: 'happy-cli',
219
+ dir: resolve(cliDir),
220
+ signature: nowSig.signature,
221
+ head: nowSig.head,
222
+ statusHash: nowSig.statusHash,
223
+ builtAt: new Date().toISOString(),
224
+ }).catch(() => {});
225
+ }
226
+ return { built: true, reason: mode === 'always' ? 'mode_always' : 'changed' };
128
227
  }
129
228
 
130
229
  function getPathEntries() {
@@ -153,8 +252,9 @@ export async function ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli }) {
153
252
 
154
253
  const shim = `#!/bin/bash
155
254
  set -euo pipefail
156
- HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
157
- HAPPYS="$HOME_DIR/bin/happys"
255
+ # Prefer the sibling happys shim (works for sandbox installs too).
256
+ BIN_DIR="$(cd "$(dirname "$0")" && pwd)"
257
+ HAPPYS="$BIN_DIR/happys"
158
258
  if [[ -x "$HAPPYS" ]]; then
159
259
  exec "$HAPPYS" happy "$@"
160
260
  fi
@@ -188,13 +288,14 @@ export async function pmSpawnBin({ label, dir, bin, args, env, options = {} }) {
188
288
  return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
189
289
  }
190
290
 
191
- export async function pmExecBin({ dir, bin, args, env }) {
291
+ export async function pmExecBin({ dir, bin, args, env, quiet = false }) {
192
292
  const pm = await getComponentPm(dir);
293
+ const stdio = quiet ? 'ignore' : 'inherit';
193
294
  if (pm.name === 'yarn') {
194
- await run(pm.cmd, [bin, ...args], { env, cwd: dir });
295
+ await run(pm.cmd, [bin, ...args], { env, cwd: dir, stdio });
195
296
  return;
196
297
  }
197
- await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir });
298
+ await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir, stdio });
198
299
  }
199
300
 
200
301
  export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {