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
@@ -1,73 +1,18 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
- import net from 'node:net';
5
3
  import { join } from 'node:path';
6
4
  import { setTimeout as delay } from 'node:timers/promises';
7
5
 
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
- }
6
+ import { ensureEnvFileUpdated } from '../../env/env_file.mjs';
7
+ import { readEnvObjectFromFile } from '../../env/read.mjs';
8
+ import { sanitizeDnsLabel } from '../../net/dns.mjs';
9
+ import { pickNextFreeTcpPort } from '../../net/ports.mjs';
10
+ import { pmExecBin } from '../../proc/pm.mjs';
11
+ import { run, runCapture } from '../../proc/proc.mjs';
12
+ import { randomToken } from '../../crypto/tokens.mjs';
13
+ import { coercePort, INFRA_RESERVED_PORT_KEYS } from '../port.mjs';
62
14
 
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
- }
15
+ const readEnvObject = readEnvObjectFromFile;
71
16
 
72
17
  async function ensureTextFile({ path, generate }) {
73
18
  if (existsSync(path)) {
@@ -243,8 +188,26 @@ async function maybeStartDockerDaemon() {
243
188
  }
244
189
  }
245
190
 
246
- async function dockerCompose({ composePath, projectName, args, options = {} }) {
247
- await run('docker', ['compose', '-f', composePath, '-p', projectName, ...args], options);
191
+ async function dockerCompose({ composePath, projectName, args, options = {}, quiet = false, retries = 0 }) {
192
+ const cmdArgs = ['compose', '-f', composePath, '-p', projectName, ...args];
193
+ let attempt = 0;
194
+ // eslint-disable-next-line no-constant-condition
195
+ while (true) {
196
+ try {
197
+ if (quiet) {
198
+ // Capture stderr so callers can surface it in structured JSON errors.
199
+ await runCapture('docker', cmdArgs, { timeoutMs: 120_000, ...options });
200
+ } else {
201
+ await run('docker', cmdArgs, { ...options, stdio: options?.stdio ?? 'inherit' });
202
+ }
203
+ return;
204
+ } catch (e) {
205
+ if (attempt >= retries) throw e;
206
+ attempt += 1;
207
+ // eslint-disable-next-line no-await-in-loop
208
+ await delay(800);
209
+ }
210
+ }
248
211
  }
249
212
 
250
213
  async function waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb }) {
@@ -285,6 +248,25 @@ async function waitForHealthyRedis({ composePath, projectName }) {
285
248
  throw new Error('[infra] timed out waiting for redis to become ready');
286
249
  }
287
250
 
251
+ async function waitForMinioReady({ composePath, projectName }) {
252
+ const deadline = Date.now() + 30_000;
253
+ while (Date.now() < deadline) {
254
+ try {
255
+ // Minio doesn't ship a healthcheck in our compose; exec'ing a trivial command is a good enough
256
+ // readiness proxy for running/accepting execs before we run minio-init.
257
+ await runCapture('docker', ['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'minio', 'sh', '-lc', 'echo ok'], {
258
+ timeoutMs: 5_000,
259
+ });
260
+ return;
261
+ } catch {
262
+ // ignore
263
+ }
264
+ // eslint-disable-next-line no-await-in-loop
265
+ await delay(600);
266
+ }
267
+ throw new Error('[infra] timed out waiting for minio to become ready');
268
+ }
269
+
288
270
  export async function ensureHappyServerManagedInfra({
289
271
  stackName,
290
272
  baseDir,
@@ -292,6 +274,8 @@ export async function ensureHappyServerManagedInfra({
292
274
  publicServerUrl,
293
275
  envPath,
294
276
  env = process.env,
277
+ quiet = false,
278
+ skipMinioInit = false,
295
279
  }) {
296
280
  await ensureDockerCompose();
297
281
 
@@ -302,30 +286,27 @@ export async function ensureHappyServerManagedInfra({
302
286
  const reservedPorts = new Set();
303
287
 
304
288
  // 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
- ]) {
289
+ for (const key of INFRA_RESERVED_PORT_KEYS) {
313
290
  const p = coercePort(existingEnv[key] ?? env[key]);
314
291
  if (p) reservedPorts.add(p);
315
292
  }
316
293
  if (Number.isFinite(serverPort) && serverPort > 0) reservedPorts.add(serverPort);
317
294
 
318
- const pgPort = coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ?? (await pickNextFreePort(serverPort + 1000, { reservedPorts }));
295
+ const pgPort =
296
+ coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ??
297
+ (await pickNextFreeTcpPort(serverPort + 1000, { reservedPorts }));
319
298
  reservedPorts.add(pgPort);
320
299
  const redisPort =
321
- coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ?? (await pickNextFreePort(pgPort + 1, { reservedPorts }));
300
+ coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ??
301
+ (await pickNextFreeTcpPort(pgPort + 1, { reservedPorts }));
322
302
  reservedPorts.add(redisPort);
323
303
  const minioPort =
324
- coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ?? (await pickNextFreePort(redisPort + 1, { reservedPorts }));
304
+ coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ??
305
+ (await pickNextFreeTcpPort(redisPort + 1, { reservedPorts }));
325
306
  reservedPorts.add(minioPort);
326
307
  const minioConsolePort =
327
308
  coercePort(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? env.HAPPY_STACKS_MINIO_CONSOLE_PORT) ??
328
- (await pickNextFreePort(minioPort + 1, { reservedPorts }));
309
+ (await pickNextFreeTcpPort(minioPort + 1, { reservedPorts }));
329
310
  reservedPorts.add(minioConsolePort);
330
311
 
331
312
  const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? env.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
@@ -355,27 +336,45 @@ export async function ensureHappyServerManagedInfra({
355
336
  const s3PublicUrl = `${pub}/files`;
356
337
 
357
338
  if (envPath) {
339
+ // Ephemeral stacks should not pin ports in env files. In stack runtime, callers set
340
+ // HAPPY_STACKS_EPHEMERAL_PORTS=1 (via stack.runtime.json overlay) while the stack owner is alive.
341
+ //
342
+ // For offline tooling (e.g. auth seeding) we still want to preserve the invariant:
343
+ // - non-main stacks are ephemeral-by-default unless the user explicitly pinned ports already.
344
+ const runtimeEphemeral = (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
345
+ const alreadyPinnedPorts =
346
+ Boolean((existingEnv.HAPPY_STACKS_PG_PORT ?? '').trim()) ||
347
+ Boolean((existingEnv.HAPPY_STACKS_REDIS_PORT ?? '').trim()) ||
348
+ Boolean((existingEnv.HAPPY_STACKS_MINIO_PORT ?? '').trim()) ||
349
+ Boolean((existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? '').trim());
350
+ const ephemeralPorts = runtimeEphemeral || (stackName !== 'main' && !alreadyPinnedPorts);
358
351
  await ensureEnvFileUpdated({
359
352
  envPath,
360
353
  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) },
354
+ // Stable credentials/files: persist these so restarts keep the same DB/user and S3 creds.
365
355
  { key: 'HAPPY_STACKS_PG_USER', value: pgUser },
366
356
  { key: 'HAPPY_STACKS_PG_PASSWORD', value: pgPassword },
367
357
  { key: 'HAPPY_STACKS_PG_DATABASE', value: pgDb },
368
358
  { 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
359
  { key: 'S3_ACCESS_KEY', value: s3AccessKey },
376
360
  { key: 'S3_SECRET_KEY', value: s3SecretKey },
377
361
  { key: 'S3_BUCKET', value: s3Bucket },
378
- { key: 'S3_PUBLIC_URL', value: s3PublicUrl },
362
+ // Ports + derived URLs: persist only when ports are explicitly pinned (non-ephemeral mode).
363
+ ...(ephemeralPorts
364
+ ? []
365
+ : [
366
+ { key: 'HAPPY_STACKS_PG_PORT', value: String(pgPort) },
367
+ { key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) },
368
+ { key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) },
369
+ { key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) },
370
+ // Vars consumed by happy-server:
371
+ { key: 'DATABASE_URL', value: databaseUrl },
372
+ { key: 'REDIS_URL', value: redisUrl },
373
+ { key: 'S3_HOST', value: s3Host },
374
+ { key: 'S3_PORT', value: String(minioPort) },
375
+ { key: 'S3_USE_SSL', value: s3UseSsl },
376
+ { key: 'S3_PUBLIC_URL', value: s3PublicUrl },
377
+ ]),
379
378
  ],
380
379
  });
381
380
  }
@@ -397,12 +396,28 @@ export async function ensureHappyServerManagedInfra({
397
396
  });
398
397
  await writeFile(composePath, yaml, 'utf-8');
399
398
 
400
- await dockerCompose({ composePath, projectName, args: ['up', '-d', '--remove-orphans'], options: { cwd: baseDir } });
399
+ await dockerCompose({
400
+ composePath,
401
+ projectName,
402
+ args: ['up', '-d', '--remove-orphans'],
403
+ options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
404
+ quiet,
405
+ });
401
406
  await waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb });
402
407
  await waitForHealthyRedis({ composePath, projectName });
403
408
 
404
- // Ensure bucket exists (idempotent)
405
- await dockerCompose({ composePath, projectName, args: ['run', '--rm', 'minio-init'], options: { cwd: baseDir } });
409
+ if (!skipMinioInit) {
410
+ // Ensure bucket exists (idempotent). This can race with Minio startup; retry a few times.
411
+ await waitForMinioReady({ composePath, projectName });
412
+ await dockerCompose({
413
+ composePath,
414
+ projectName,
415
+ args: ['run', '--rm', '--no-deps', 'minio-init'],
416
+ options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
417
+ quiet,
418
+ retries: 3,
419
+ });
420
+ }
406
421
 
407
422
  return {
408
423
  composePath,
@@ -423,8 +438,8 @@ export async function ensureHappyServerManagedInfra({
423
438
  };
424
439
  }
425
440
 
426
- export async function applyHappyServerMigrations({ serverDir, env }) {
441
+ export async function applyHappyServerMigrations({ serverDir, env, quiet = false }) {
427
442
  // Non-interactive + idempotent. Safe for dev; also safe for managed stacks on start.
428
- await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
443
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env, quiet });
429
444
  }
430
445
 
@@ -0,0 +1,68 @@
1
+ import { readEnvValueFromFile } from '../env/read.mjs';
2
+
3
+ export const STACK_RESERVED_PORT_KEYS = [
4
+ 'HAPPY_STACKS_SERVER_PORT',
5
+ 'HAPPY_LOCAL_SERVER_PORT',
6
+ 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
7
+ 'HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT',
8
+ 'HAPPY_STACKS_PG_PORT',
9
+ 'HAPPY_STACKS_REDIS_PORT',
10
+ 'HAPPY_STACKS_MINIO_PORT',
11
+ 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
12
+ ];
13
+
14
+ export const INFRA_RESERVED_PORT_KEYS = [
15
+ 'HAPPY_STACKS_SERVER_PORT',
16
+ 'HAPPY_LOCAL_SERVER_PORT',
17
+ 'HAPPY_STACKS_PG_PORT',
18
+ 'HAPPY_STACKS_REDIS_PORT',
19
+ 'HAPPY_STACKS_MINIO_PORT',
20
+ 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
21
+ ];
22
+
23
+ export function coercePort(v) {
24
+ const s = String(v ?? '').trim();
25
+ if (!s) return null;
26
+ const n = Number(s);
27
+ return Number.isFinite(n) && n > 0 ? n : null;
28
+ }
29
+
30
+ export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
31
+ const raw =
32
+ (env.HAPPY_STACKS_SERVER_PORT ?? '').toString().trim() ||
33
+ (env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim() ||
34
+ '';
35
+ const n = raw ? Number(raw) : Number(defaultPort);
36
+ return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
37
+ }
38
+
39
+ export function listPortsFromEnvObject(env, keys) {
40
+ const obj = env && typeof env === 'object' ? env : {};
41
+ const list = Array.isArray(keys) ? keys : [];
42
+ const out = [];
43
+ for (const k of list) {
44
+ const p = coercePort(obj[k]);
45
+ if (p) out.push(p);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export async function readServerPortFromEnvFile(envPath, { defaultPort = 3005 } = {}) {
51
+ const v =
52
+ (await readEnvValueFromFile(envPath, 'HAPPY_STACKS_SERVER_PORT')) ||
53
+ (await readEnvValueFromFile(envPath, 'HAPPY_LOCAL_SERVER_PORT')) ||
54
+ '';
55
+ const n = v ? Number(String(v).trim()) : Number(defaultPort);
56
+ return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
57
+ }
58
+
59
+ // For stack env files, "missing" means "ephemeral stack" (no pinned port).
60
+ export async function readPinnedServerPortFromEnvFile(envPath) {
61
+ const v =
62
+ (await readEnvValueFromFile(envPath, 'HAPPY_STACKS_SERVER_PORT')) ||
63
+ (await readEnvValueFromFile(envPath, 'HAPPY_LOCAL_SERVER_PORT')) ||
64
+ '';
65
+ const n = v ? Number(String(v).trim()) : NaN;
66
+ return Number.isFinite(n) && n > 0 ? n : null;
67
+ }
68
+
@@ -59,6 +59,18 @@ export async function isHappyServerRunning(baseUrl) {
59
59
  return true;
60
60
  }
61
61
 
62
+ export async function waitForHappyHealthOk(baseUrl, { timeoutMs = 60_000, intervalMs = 300 } = {}) {
63
+ const deadline = Date.now() + timeoutMs;
64
+ while (Date.now() < deadline) {
65
+ // eslint-disable-next-line no-await-in-loop
66
+ const health = await fetchHappyHealth(baseUrl);
67
+ if (health.ok) return true;
68
+ // eslint-disable-next-line no-await-in-loop
69
+ await delay(intervalMs);
70
+ }
71
+ return false;
72
+ }
73
+
62
74
  export async function waitForServerReady(url) {
63
75
  const deadline = Date.now() + 60_000;
64
76
  while (Date.now() < deadline) {
@@ -76,3 +88,27 @@ export async function waitForServerReady(url) {
76
88
  throw new Error(`Timed out waiting for server at ${url}`);
77
89
  }
78
90
 
91
+ // Used for UI readiness checks (Expo / gateway / server). Treat any HTTP response as "up".
92
+ export async function waitForHttpOk(url, { timeoutMs = 15_000, intervalMs = 250 } = {}) {
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ try {
96
+ const ctl = new AbortController();
97
+ const t = setTimeout(() => ctl.abort(), Math.min(2500, Math.max(250, intervalMs)));
98
+ try {
99
+ const res = await fetch(url, { method: 'GET', signal: ctl.signal });
100
+ if (res.status >= 100 && res.status < 600) {
101
+ return;
102
+ }
103
+ } finally {
104
+ clearTimeout(t);
105
+ }
106
+ } catch {
107
+ // ignore
108
+ }
109
+ // eslint-disable-next-line no-await-in-loop
110
+ await delay(intervalMs);
111
+ }
112
+ throw new Error(`Timed out waiting for HTTP response from ${url} after ${timeoutMs}ms`);
113
+ }
114
+
@@ -0,0 +1,91 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+
3
+ import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
4
+ import { resolvePublicServerUrl } from '../../tailscale.mjs';
5
+ import { resolveServerPortFromEnv } from './port.mjs';
6
+
7
+ function stackEnvExplicitlySetsPublicUrl({ env, stackName }) {
8
+ try {
9
+ const envPath =
10
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
+ resolveStackEnvPath(stackName).envPath;
12
+ if (!envPath || !existsSync(envPath)) return false;
13
+ const raw = readFileSync(envPath, 'utf-8');
14
+ return /^(HAPPY_STACKS_SERVER_URL|HAPPY_LOCAL_SERVER_URL)=/m.test(raw);
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function stackEnvExplicitlySetsWebappUrl({ env, stackName }) {
21
+ try {
22
+ const envPath =
23
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
24
+ resolveStackEnvPath(stackName).envPath;
25
+ if (!envPath || !existsSync(envPath)) return false;
26
+ const raw = readFileSync(envPath, 'utf-8');
27
+ return /^HAPPY_WEBAPP_URL=/m.test(raw);
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export function getPublicServerUrlEnvOverride({ env = process.env, serverPort, stackName = null } = {}) {
34
+ const defaultPublicUrl = `http://localhost:${serverPort}`;
35
+ const name =
36
+ (stackName ?? '').toString().trim() ||
37
+ (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
38
+ getStackName();
39
+
40
+ let envPublicUrl =
41
+ (env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
42
+
43
+ // Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
44
+ if (name !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName: name })) {
45
+ envPublicUrl = '';
46
+ }
47
+
48
+ return { defaultPublicUrl, envPublicUrl, publicServerUrl: envPublicUrl || defaultPublicUrl };
49
+ }
50
+
51
+ export function getWebappUrlEnvOverride({ env = process.env, stackName = null } = {}) {
52
+ const name =
53
+ (stackName ?? '').toString().trim() ||
54
+ (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
55
+ getStackName();
56
+
57
+ let envWebappUrl = (env.HAPPY_WEBAPP_URL ?? '').toString().trim() || '';
58
+
59
+ // Safety: for non-main stacks, ignore a global HAPPY_WEBAPP_URL unless it was explicitly set in the stack env file.
60
+ if (name !== 'main' && envWebappUrl && !stackEnvExplicitlySetsWebappUrl({ env, stackName: name })) {
61
+ envWebappUrl = '';
62
+ }
63
+
64
+ return { envWebappUrl };
65
+ }
66
+
67
+ export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
68
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
69
+ const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
70
+ const resolved = await resolvePublicServerUrl({
71
+ internalServerUrl,
72
+ defaultPublicUrl,
73
+ envPublicUrl,
74
+ allowEnable,
75
+ });
76
+ return {
77
+ internalServerUrl,
78
+ defaultPublicUrl,
79
+ envPublicUrl,
80
+ publicServerUrl: resolved.publicServerUrl,
81
+ publicServerUrlSource: resolved.source,
82
+ };
83
+ }
84
+
85
+ export function getInternalServerUrl({ env = process.env, defaultPort = 3005 } = {}) {
86
+ const port = resolveServerPortFromEnv({ env, defaultPort });
87
+ return { port, internalServerUrl: `http://127.0.0.1:${port}` };
88
+ }
89
+
90
+ export { resolveServerPortFromEnv };
91
+
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join, resolve, sep } from 'node:path';
3
- import { getComponentsDir } from './paths.mjs';
3
+ import { getComponentsDir } from '../paths/paths.mjs';
4
4
 
5
5
  function isInside(path, dir) {
6
6
  const p = resolve(path);
@@ -0,0 +1,142 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, join } from 'node:path';
3
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
4
+
5
+ import { runCapture } from '../proc/proc.mjs';
6
+ import { getDefaultAutostartPaths } from '../paths/paths.mjs';
7
+ import { resolveInstalledCliRoot, resolveInstalledPath } from '../paths/runtime.mjs';
8
+
9
+ function plistPathForLabel(label) {
10
+ return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
11
+ }
12
+
13
+ function xmlEscape(s) {
14
+ return String(s ?? '')
15
+ .replaceAll('&', '&amp;')
16
+ .replaceAll('<', '&lt;')
17
+ .replaceAll('>', '&gt;')
18
+ .replaceAll('"', '&quot;')
19
+ .replaceAll("'", '&apos;');
20
+ }
21
+
22
+ function plistXml({ label, programArgs, env = {}, stdoutPath, stderrPath, workingDirectory }) {
23
+ const envEntries = Object.entries(env ?? {}).filter(([k, v]) => String(k).trim() && String(v ?? '').trim());
24
+ const programArgsXml = programArgs.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
25
+ const envXml = envEntries
26
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
27
+ .join('\n');
28
+ const workingDirXml = workingDirectory
29
+ ? `\n <key>WorkingDirectory</key>\n <string>${xmlEscape(workingDirectory)}</string>\n`
30
+ : '\n';
31
+
32
+ return `<?xml version="1.0" encoding="UTF-8"?>
33
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
34
+ <plist version="1.0">
35
+ <dict>
36
+ <key>Label</key>
37
+ <string>${xmlEscape(label)}</string>
38
+
39
+ <key>ProgramArguments</key>
40
+ <array>
41
+ ${programArgsXml}
42
+ </array>
43
+
44
+ <key>RunAtLoad</key>
45
+ <true/>
46
+ <key>KeepAlive</key>
47
+ <true/>
48
+ ${workingDirXml} <key>StandardOutPath</key>
49
+ <string>${xmlEscape(stdoutPath)}</string>
50
+ <key>StandardErrorPath</key>
51
+ <string>${xmlEscape(stderrPath)}</string>
52
+
53
+ <key>EnvironmentVariables</key>
54
+ <dict>
55
+ ${envXml}
56
+ </dict>
57
+ </dict>
58
+ </plist>
59
+ `;
60
+ }
61
+
62
+ export async function ensureMacAutostartEnabled({ rootDir, label, env }) {
63
+ if (process.platform !== 'darwin') {
64
+ throw new Error('[local] macOS autostart is only supported on Darwin');
65
+ }
66
+ const l = String(label ?? '').trim();
67
+ if (!l) throw new Error('[local] missing launchd label');
68
+
69
+ const plistPath = plistPathForLabel(l);
70
+ const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
71
+ await mkdir(dirname(plistPath), { recursive: true }).catch(() => {});
72
+ await mkdir(dirname(stdoutPath), { recursive: true }).catch(() => {});
73
+ await mkdir(dirname(stderrPath), { recursive: true }).catch(() => {});
74
+
75
+ const programArgs = [process.execPath, resolveInstalledPath(rootDir, 'bin/happys.mjs'), 'start'];
76
+ const mergedEnv = {
77
+ ...(env ?? {}),
78
+ // Ensure a reasonable PATH for subprocesses (git/docker/etc) in launchd’s minimal environment.
79
+ PATH: (process.env.PATH ?? '').trim() || '/usr/bin:/bin:/usr/sbin:/sbin',
80
+ };
81
+
82
+ const xml = plistXml({
83
+ label: l,
84
+ programArgs,
85
+ env: mergedEnv,
86
+ stdoutPath,
87
+ stderrPath,
88
+ workingDirectory: resolveInstalledCliRoot(rootDir),
89
+ });
90
+
91
+ const tmp = join(dirname(plistPath), `.tmp.${l}.${Date.now()}.plist`);
92
+ await writeFile(tmp, xml, 'utf-8');
93
+ await rename(tmp, plistPath);
94
+
95
+ // Best-effort load/enable; `scripts/service.mjs` has a more robust bootstrap fallback.
96
+ try {
97
+ await runCapture('launchctl', ['load', '-w', plistPath]);
98
+ } catch {
99
+ // ignore
100
+ }
101
+ }
102
+
103
+ export async function ensureMacAutostartDisabled({ label }) {
104
+ if (process.platform !== 'darwin') {
105
+ return;
106
+ }
107
+ const l = String(label ?? '').trim();
108
+ if (!l) return;
109
+ const plistPath = plistPathForLabel(l);
110
+
111
+ const uidRaw = Number(process.env.UID);
112
+ const uid = Number.isFinite(uidRaw) ? uidRaw : null;
113
+
114
+ try {
115
+ await runCapture('launchctl', ['unload', '-w', plistPath]);
116
+ } catch {
117
+ // ignore
118
+ }
119
+ try {
120
+ await runCapture('launchctl', ['unload', plistPath]);
121
+ } catch {
122
+ // ignore
123
+ }
124
+ if (uid != null) {
125
+ try {
126
+ await runCapture('launchctl', ['disable', `gui/${uid}/${l}`]);
127
+ } catch {
128
+ // ignore
129
+ }
130
+ try {
131
+ await runCapture('launchctl', ['bootout', `gui/${uid}`, plistPath]);
132
+ } catch {
133
+ // ignore
134
+ }
135
+ }
136
+ try {
137
+ await runCapture('launchctl', ['remove', l]);
138
+ } catch {
139
+ // ignore
140
+ }
141
+ }
142
+
@@ -0,0 +1,23 @@
1
+ import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
2
+ import { getStackRuntimeStatePath } from './runtime_state.mjs';
3
+
4
+ export function resolveStackContext({ env = process.env, autostart = null } = {}) {
5
+ const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
6
+ const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName();
7
+ const stackMode = Boolean(explicitStack);
8
+
9
+ const envPath =
10
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
+ resolveStackEnvPath(stackName).envPath;
12
+
13
+ const runtimeStatePath =
14
+ (env.HAPPY_STACKS_RUNTIME_STATE_PATH ?? env.HAPPY_LOCAL_RUNTIME_STATE_PATH ?? '').toString().trim() ||
15
+ getStackRuntimeStatePath(stackName);
16
+
17
+ const explicitEphemeral =
18
+ (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
19
+ const ephemeral = explicitEphemeral || (stackMode && stackName !== 'main');
20
+
21
+ return { stackMode, stackName, envPath, runtimeStatePath, ephemeral };
22
+ }
23
+