happy-stacks 0.1.0 → 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 (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  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/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -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/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -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
@@ -180,13 +280,22 @@ export async function pmSpawnScript({ label, dir, script, env, options = {} }) {
180
280
  return spawnProc(label, pm.cmd, ['--silent', script], env, { ...options, cwd: dir });
181
281
  }
182
282
 
183
- export async function pmExecBin({ dir, bin, args, env }) {
283
+ export async function pmSpawnBin({ label, dir, bin, args, env, options = {} }) {
284
+ const pm = await getComponentPm(dir);
285
+ if (pm.name === 'yarn') {
286
+ return spawnProc(label, pm.cmd, [bin, ...args], env, { ...options, cwd: dir });
287
+ }
288
+ return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
289
+ }
290
+
291
+ export async function pmExecBin({ dir, bin, args, env, quiet = false }) {
184
292
  const pm = await getComponentPm(dir);
293
+ const stdio = quiet ? 'ignore' : 'inherit';
185
294
  if (pm.name === 'yarn') {
186
- await run(pm.cmd, [bin, ...args], { env, cwd: dir });
295
+ await run(pm.cmd, [bin, ...args], { env, cwd: dir, stdio });
187
296
  return;
188
297
  }
189
- await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir });
298
+ await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir, stdio });
190
299
  }
191
300
 
192
301
  export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {
@@ -217,6 +326,8 @@ export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.lo
217
326
  : process.execPath;
218
327
  const installedRoot = resolveInstalledCliRoot(rootDir);
219
328
  const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
329
+ const happysShim = join(getHappyStacksHomeDir(), 'bin', 'happys');
330
+ const useShim = existsSync(happysShim);
220
331
 
221
332
  // Ensure we write to the plist path that matches the label we're installing, instead of the
222
333
  // "active" plist path (which might be legacy and cause filename/label mismatches).
@@ -233,8 +344,7 @@ export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.lo
233
344
  <string>${label}</string>
234
345
  <key>ProgramArguments</key>
235
346
  <array>
236
- <string>${nodePath}</string>
237
- <string>${happysEntrypoint}</string>
347
+ ${useShim ? `<string>${happysShim}</string>` : `<string>${nodePath}</string>\n <string>${happysEntrypoint}</string>`}
238
348
  <string>start</string>
239
349
  </array>
240
350
  <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
+ }
@@ -1,5 +1,23 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
+ function writeWithPrefix(stream, prefix, bufState, chunk) {
4
+ const s = chunk.toString();
5
+ bufState.buf += s;
6
+ while (true) {
7
+ const idx = bufState.buf.indexOf('\n');
8
+ if (idx < 0) break;
9
+ const line = bufState.buf.slice(0, idx);
10
+ bufState.buf = bufState.buf.slice(idx + 1);
11
+ stream.write(`${prefix}${line}\n`);
12
+ }
13
+ }
14
+
15
+ function flushPrefixed(stream, prefix, bufState) {
16
+ if (!bufState.buf) return;
17
+ stream.write(`${prefix}${bufState.buf}\n`);
18
+ bufState.buf = '';
19
+ }
20
+
3
21
  export function spawnProc(label, cmd, args, env, options = {}) {
4
22
  const child = spawn(cmd, args, {
5
23
  env,
@@ -10,8 +28,17 @@ export function spawnProc(label, cmd, args, env, options = {}) {
10
28
  ...options,
11
29
  });
12
30
 
13
- child.stdout?.on('data', (d) => process.stdout.write(`[${label}] ${d.toString()}`));
14
- child.stderr?.on('data', (d) => process.stderr.write(`[${label}] ${d.toString()}`));
31
+ const outState = { buf: '' };
32
+ const errState = { buf: '' };
33
+ const outPrefix = `[${label}] `;
34
+ const errPrefix = `[${label}] `;
35
+
36
+ child.stdout?.on('data', (d) => writeWithPrefix(process.stdout, outPrefix, outState, d));
37
+ child.stderr?.on('data', (d) => writeWithPrefix(process.stderr, errPrefix, errState, d));
38
+ child.on('close', () => {
39
+ flushPrefixed(process.stdout, outPrefix, outState);
40
+ flushPrefixed(process.stderr, errPrefix, errState);
41
+ });
15
42
  child.on('exit', (code, sig) => {
16
43
  if (code !== 0) {
17
44
  process.stderr.write(`[${label}] exited (code=${code}, sig=${sig})\n`);
@@ -39,28 +66,69 @@ export function killProcessTree(child, signal) {
39
66
  }
40
67
 
41
68
  export async function run(cmd, args, options = {}) {
69
+ const { timeoutMs, ...spawnOptions } = options ?? {};
42
70
  await new Promise((resolvePromise, rejectPromise) => {
43
- const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...options });
71
+ const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...spawnOptions });
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} timed out after ${timeoutMs}ms`);
81
+ e.code = 'ETIMEDOUT';
82
+ rejectPromise(e);
83
+ }, timeoutMs)
84
+ : null;
44
85
  proc.on('error', rejectPromise);
45
86
  proc.on('exit', (code) => (code === 0 ? resolvePromise() : rejectPromise(new Error(`${cmd} failed (code=${code})`))));
87
+ proc.on('exit', () => {
88
+ if (t) clearTimeout(t);
89
+ });
46
90
  });
47
91
  }
48
92
 
49
93
  export async function runCapture(cmd, args, options = {}) {
94
+ const { timeoutMs, ...spawnOptions } = options ?? {};
50
95
  return await new Promise((resolvePromise, rejectPromise) => {
51
- const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...options });
96
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
52
97
  let out = '';
53
98
  let err = '';
99
+ const t =
100
+ Number.isFinite(timeoutMs) && timeoutMs > 0
101
+ ? setTimeout(() => {
102
+ try {
103
+ proc.kill('SIGKILL');
104
+ } catch {
105
+ // ignore
106
+ }
107
+ const e = new Error(`${cmd} ${args.join(' ')} timed out after ${timeoutMs}ms`);
108
+ e.code = 'ETIMEDOUT';
109
+ e.out = out;
110
+ e.err = err;
111
+ rejectPromise(e);
112
+ }, timeoutMs)
113
+ : null;
54
114
  proc.stdout?.on('data', (d) => (out += d.toString()));
55
115
  proc.stderr?.on('data', (d) => (err += d.toString()));
56
116
  proc.on('error', rejectPromise);
57
- proc.on('exit', (code) => {
117
+ proc.on('exit', (code, signal) => {
118
+ if (t) clearTimeout(t);
58
119
  if (code === 0) {
59
120
  resolvePromise(out);
60
121
  } else {
61
- rejectPromise(new Error(`${cmd} ${args.join(' ')} failed (code=${code}): ${err.trim()}`));
122
+ const e = new Error(
123
+ `${cmd} ${args.join(' ')} failed (code=${code ?? 'null'}, sig=${signal ?? 'null'}): ${err.trim()}`
124
+ );
125
+ e.code = 'EEXIT';
126
+ e.exitCode = code;
127
+ e.signal = signal;
128
+ e.out = out;
129
+ e.err = err;
130
+ rejectPromise(e);
62
131
  }
63
132
  });
64
133
  });
65
134
  }
66
-
@@ -2,9 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
 
5
- function expandHome(p) {
6
- return p.replace(/^~(?=\/)/, homedir());
7
- }
5
+ import { expandHome } from './canonical_home.mjs';
8
6
 
9
7
  export function getRuntimeDir() {
10
8
  const fromEnv = (process.env.HAPPY_STACKS_RUNTIME_DIR ?? '').trim();
@@ -0,0 +1,14 @@
1
+ export function getSandboxDir() {
2
+ const v = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
3
+ return v || '';
4
+ }
5
+
6
+ export function isSandboxed() {
7
+ return Boolean(getSandboxDir());
8
+ }
9
+
10
+ export function sandboxAllowsGlobalSideEffects() {
11
+ const raw = (process.env.HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL ?? '').trim().toLowerCase();
12
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y';
13
+ }
14
+
@@ -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) {
@@ -39,3 +76,27 @@ export async function waitForServerReady(url) {
39
76
  throw new Error(`Timed out waiting for server at ${url}`);
40
77
  }
41
78
 
79
+ // Used for UI readiness checks (Expo / gateway / server). Treat any HTTP response as "up".
80
+ export async function waitForHttpOk(url, { timeoutMs = 15_000, intervalMs = 250 } = {}) {
81
+ const deadline = Date.now() + timeoutMs;
82
+ while (Date.now() < deadline) {
83
+ try {
84
+ const ctl = new AbortController();
85
+ const t = setTimeout(() => ctl.abort(), Math.min(2500, Math.max(250, intervalMs)));
86
+ try {
87
+ const res = await fetch(url, { method: 'GET', signal: ctl.signal });
88
+ if (res.status >= 100 && res.status < 600) {
89
+ return;
90
+ }
91
+ } finally {
92
+ clearTimeout(t);
93
+ }
94
+ } catch {
95
+ // ignore
96
+ }
97
+ // eslint-disable-next-line no-await-in-loop
98
+ await delay(intervalMs);
99
+ }
100
+ throw new Error(`Timed out waiting for HTTP response from ${url} after ${timeoutMs}ms`);
101
+ }
102
+
@@ -0,0 +1,9 @@
1
+ export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
2
+ const raw =
3
+ (env.HAPPY_STACKS_SERVER_PORT ?? '').toString().trim() ||
4
+ (env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim() ||
5
+ '';
6
+ const n = raw ? Number(raw) : Number(defaultPort);
7
+ return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
8
+ }
9
+
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+
3
+ import { getStackName, resolveStackEnvPath } from './paths.mjs';
4
+ import { resolvePublicServerUrl } from '../tailscale.mjs';
5
+ import { resolveServerPortFromEnv } from './server_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
+ export function getPublicServerUrlEnvOverride({ env = process.env, serverPort } = {}) {
21
+ const defaultPublicUrl = `http://localhost:${serverPort}`;
22
+ const stackName = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() || getStackName();
23
+
24
+ let envPublicUrl =
25
+ (env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
26
+
27
+ // Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
28
+ if (stackName !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName })) {
29
+ envPublicUrl = '';
30
+ }
31
+
32
+ return { defaultPublicUrl, envPublicUrl, publicServerUrl: envPublicUrl || defaultPublicUrl };
33
+ }
34
+
35
+ export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
36
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
37
+ const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
38
+ const resolved = await resolvePublicServerUrl({
39
+ internalServerUrl,
40
+ defaultPublicUrl,
41
+ envPublicUrl,
42
+ allowEnable,
43
+ });
44
+ return {
45
+ internalServerUrl,
46
+ defaultPublicUrl,
47
+ envPublicUrl,
48
+ publicServerUrl: resolved.publicServerUrl,
49
+ publicServerUrlSource: resolved.source,
50
+ };
51
+ }
52
+
53
+ export { resolveServerPortFromEnv };
54
+
@@ -0,0 +1,23 @@
1
+ import { getStackName, resolveStackEnvPath } from './paths.mjs';
2
+ import { getStackRuntimeStatePath } from './stack_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
+