happy-stacks 0.2.0 → 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 (94) hide show
  1. package/README.md +59 -22
  2. package/bin/happys.mjs +2 -2
  3. package/package.json +1 -1
  4. package/scripts/auth.mjs +49 -202
  5. package/scripts/build.mjs +5 -6
  6. package/scripts/cli-link.mjs +3 -3
  7. package/scripts/completion.mjs +5 -5
  8. package/scripts/daemon.mjs +9 -17
  9. package/scripts/dev.mjs +18 -27
  10. package/scripts/doctor.mjs +20 -36
  11. package/scripts/edison.mjs +102 -77
  12. package/scripts/happy.mjs +8 -19
  13. package/scripts/init.mjs +5 -13
  14. package/scripts/install.mjs +8 -8
  15. package/scripts/lint.mjs +8 -29
  16. package/scripts/menubar.mjs +6 -13
  17. package/scripts/migrate.mjs +11 -21
  18. package/scripts/mobile.mjs +13 -12
  19. package/scripts/run.mjs +15 -15
  20. package/scripts/self.mjs +11 -29
  21. package/scripts/server_flavor.mjs +4 -4
  22. package/scripts/service.mjs +18 -28
  23. package/scripts/setup.mjs +26 -122
  24. package/scripts/setup_pr.mjs +11 -28
  25. package/scripts/stack.mjs +111 -161
  26. package/scripts/stop.mjs +3 -3
  27. package/scripts/tailscale.mjs +7 -10
  28. package/scripts/test.mjs +8 -29
  29. package/scripts/tui.mjs +8 -38
  30. package/scripts/typecheck.mjs +8 -29
  31. package/scripts/ui_gateway.mjs +1 -1
  32. package/scripts/uninstall.mjs +6 -6
  33. package/scripts/utils/{dev_auth_key.mjs → auth/dev_key.mjs} +2 -8
  34. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  35. package/scripts/utils/{handy_master_secret.mjs → auth/handy_master_secret.mjs} +6 -32
  36. package/scripts/utils/cli/flags.mjs +17 -0
  37. package/scripts/utils/cli/normalize.mjs +16 -0
  38. package/scripts/utils/cli/smoke_help.mjs +2 -2
  39. package/scripts/utils/cli/wizard.mjs +1 -1
  40. package/scripts/utils/crypto/tokens.mjs +14 -0
  41. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +4 -4
  42. package/scripts/utils/{dev_expo_web.mjs → dev/expo_web.mjs} +5 -5
  43. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +7 -7
  44. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  45. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  46. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  47. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  48. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  49. package/scripts/utils/env/read.mjs +30 -0
  50. package/scripts/utils/env/values.mjs +13 -0
  51. package/scripts/utils/{expo.mjs → expo/expo.mjs} +3 -9
  52. package/scripts/utils/fs/json.mjs +25 -0
  53. package/scripts/utils/fs/ops.mjs +29 -0
  54. package/scripts/utils/fs/package_json.mjs +8 -0
  55. package/scripts/utils/fs/tail.mjs +12 -0
  56. package/scripts/utils/git/refs.mjs +26 -0
  57. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +3 -3
  58. package/scripts/utils/net/dns.mjs +10 -0
  59. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  60. package/scripts/utils/{localhost_host.mjs → paths/localhost_host.mjs} +2 -10
  61. package/scripts/utils/{paths.mjs → paths/paths.mjs} +10 -7
  62. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  63. package/scripts/utils/proc/commands.mjs +34 -0
  64. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  65. package/scripts/utils/proc/package_scripts.mjs +31 -0
  66. package/scripts/utils/proc/pids.mjs +11 -0
  67. package/scripts/utils/{pm.mjs → proc/pm.mjs} +65 -152
  68. package/scripts/utils/{proc.mjs → proc/proc.mjs} +1 -0
  69. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  70. package/scripts/utils/server/port.mjs +68 -0
  71. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  72. package/scripts/utils/server/urls.mjs +91 -0
  73. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  74. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  75. package/scripts/utils/{stack_context.mjs → stack/context.mjs} +2 -2
  76. package/scripts/utils/stack/dirs.mjs +27 -0
  77. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  78. package/scripts/utils/stack/names.mjs +12 -0
  79. package/scripts/utils/{stack_runtime_state.mjs → stack/runtime_state.mjs} +10 -27
  80. package/scripts/utils/{stacks.mjs → stack/stacks.mjs} +9 -2
  81. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +2 -2
  82. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +9 -15
  83. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  84. package/scripts/utils/ui/text.mjs +16 -0
  85. package/scripts/where.mjs +6 -6
  86. package/scripts/worktrees.mjs +30 -58
  87. package/scripts/utils/server_port.mjs +0 -9
  88. package/scripts/utils/server_urls.mjs +0 -54
  89. /package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +0 -0
  90. /package/scripts/utils/{auth_sources.mjs → auth/sources.mjs} +0 -0
  91. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  92. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  93. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  94. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -2,7 +2,9 @@ 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
+ import { expandHome } from './canonical_home.mjs';
7
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
6
8
 
7
9
  const PRIMARY_APP_SLUG = 'happy-stacks';
8
10
  const LEGACY_APP_SLUG = 'happy-local';
@@ -16,10 +18,10 @@ export function getRootDir(importMetaUrl) {
16
18
  return dirname(dirname(fileURLToPath(importMetaUrl)));
17
19
  }
18
20
 
19
- export function getHappyStacksHomeDir() {
20
- const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
21
+ export function getHappyStacksHomeDir(env = process.env) {
22
+ const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').trim();
21
23
  if (fromEnv) {
22
- return fromEnv.replace(/^~(?=\/)/, homedir());
24
+ return expandHome(fromEnv);
23
25
  }
24
26
  return PRIMARY_HOME_DIR;
25
27
  }
@@ -27,7 +29,7 @@ export function getHappyStacksHomeDir() {
27
29
  export function getWorkspaceDir(cliRootDir = null) {
28
30
  const fromEnv = (process.env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
29
31
  if (fromEnv) {
30
- return fromEnv.replace(/^~(?=\/)/, homedir());
32
+ return expandHome(fromEnv);
31
33
  }
32
34
  const homeDir = getHappyStacksHomeDir();
33
35
  const defaultWorkspace = join(homeDir, 'workspace');
@@ -53,7 +55,7 @@ function normalizePathForEnv(rootDir, raw) {
53
55
  if (!trimmed) {
54
56
  return '';
55
57
  }
56
- const expanded = trimmed.replace(/^~(?=\/)/, homedir());
58
+ const expanded = expandHome(trimmed);
57
59
  // If the path is relative, treat it as relative to the workspace root (default: repo root).
58
60
  const workspaceDir = getWorkspaceDir(rootDir);
59
61
  return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
@@ -89,7 +91,7 @@ export function getLegacyStackLabel(stackName = getStackName()) {
89
91
  export function getStacksStorageRoot() {
90
92
  const fromEnv = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
91
93
  if (fromEnv) {
92
- return fromEnv.replace(/^~(?=\/)/, homedir());
94
+ return expandHome(fromEnv);
93
95
  }
94
96
  return PRIMARY_STORAGE_ROOT;
95
97
  }
@@ -185,3 +187,4 @@ export function getDefaultAutostartPaths() {
185
187
  legacyStderrPath,
186
188
  };
187
189
  }
190
+
@@ -9,7 +9,9 @@ export function getRuntimeDir() {
9
9
  if (fromEnv) {
10
10
  return expandHome(fromEnv);
11
11
  }
12
- const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() ? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim()) : join(homedir(), '.happy-stacks');
12
+ const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim()
13
+ ? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim())
14
+ : join(homedir(), '.happy-stacks');
13
15
  return join(homeDir, 'runtime');
14
16
  }
15
17
 
@@ -0,0 +1,34 @@
1
+ import { runCapture } from './proc.mjs';
2
+
3
+ export async function resolveCommandPath(cmd, { cwd, env, timeoutMs } = {}) {
4
+ const c = String(cmd ?? '').trim();
5
+ if (!c) return '';
6
+
7
+ try {
8
+ if (process.platform === 'win32') {
9
+ const out = (await runCapture('where', [c], { cwd, env, timeoutMs })).trim();
10
+ const first = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
11
+ return first;
12
+ }
13
+ return (
14
+ await runCapture('sh', ['-lc', `command -v "${c}" 2>/dev/null || true`], { cwd, env, timeoutMs })
15
+ ).trim();
16
+ } catch {
17
+ return '';
18
+ }
19
+ }
20
+
21
+ export async function runCaptureIfCommandExists(cmd, args, { cwd, env, timeoutMs } = {}) {
22
+ const resolved = await resolveCommandPath(cmd, { cwd, env, timeoutMs });
23
+ if (!resolved) return '';
24
+ try {
25
+ return await runCapture(resolved, args, { cwd, env, timeoutMs });
26
+ } catch {
27
+ return '';
28
+ }
29
+ }
30
+
31
+ export async function commandExists(cmd, { cwd } = {}) {
32
+ return Boolean(await resolveCommandPath(cmd, { cwd }));
33
+ }
34
+
@@ -1,5 +1,5 @@
1
1
  import { runCapture } from './proc.mjs';
2
- import { killPid } from './expo.mjs';
2
+ import { killPid } from '../expo/expo.mjs';
3
3
 
4
4
  export async function getPsEnvLine(pid) {
5
5
  const n = Number(pid);
@@ -0,0 +1,31 @@
1
+ import { join } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ import { pathExists } from '../fs/fs.mjs';
5
+ import { requirePnpm } from './pm.mjs';
6
+
7
+ export async function detectPackageManagerCmd(dir) {
8
+ if (await pathExists(join(dir, 'yarn.lock'))) {
9
+ return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
10
+ }
11
+ await requirePnpm();
12
+ return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
13
+ }
14
+
15
+ export async function readPackageJsonScripts(dir) {
16
+ try {
17
+ const raw = await readFile(join(dir, 'package.json'), 'utf-8');
18
+ const pkg = JSON.parse(raw);
19
+ const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
20
+ return scripts;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function pickFirstScript(scripts, candidates) {
27
+ if (!scripts) return null;
28
+ const list = Array.isArray(candidates) ? candidates : [];
29
+ return list.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
30
+ }
31
+
@@ -0,0 +1,11 @@
1
+ export function isPidAlive(pid) {
2
+ const n = Number(pid);
3
+ if (!Number.isFinite(n) || n <= 1) return false;
4
+ try {
5
+ process.kill(n, 0);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
@@ -4,33 +4,17 @@ import { existsSync } from 'node:fs';
4
4
  import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { createHash } from 'node:crypto';
6
6
 
7
- import { pathExists } from './fs.mjs';
7
+ import { pathExists } from '../fs/fs.mjs';
8
+ import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
8
9
  import { run, runCapture, spawnProc } from './proc.mjs';
9
- import { getDefaultAutostartPaths, getHappyStacksHomeDir } from './paths.mjs';
10
- import { resolveInstalledPath, resolveInstalledCliRoot } from './runtime.mjs';
10
+ import { commandExists } from './commands.mjs';
11
+ import { getDefaultAutostartPaths, getHappyStacksHomeDir } from '../paths/paths.mjs';
12
+ import { resolveInstalledPath, resolveInstalledCliRoot } from '../paths/runtime.mjs';
11
13
 
12
14
  function sha256Hex(s) {
13
15
  return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
14
16
  }
15
17
 
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
18
  function resolveBuildStatePath({ label, dir }) {
35
19
  const homeDir = getHappyStacksHomeDir();
36
20
  const key = sha256Hex(resolve(dir));
@@ -56,15 +40,6 @@ async function computeGitWorktreeSignature(dir) {
56
40
  }
57
41
  }
58
42
 
59
- async function commandExists(cmd, options = {}) {
60
- try {
61
- await runCapture(cmd, ['--version'], options);
62
- return true;
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
43
  export async function requirePnpm() {
69
44
  if (await commandExists('pnpm')) {
70
45
  return;
@@ -258,147 +233,85 @@ HAPPYS="$BIN_DIR/happys"
258
233
  if [[ -x "$HAPPYS" ]]; then
259
234
  exec "$HAPPYS" happy "$@"
260
235
  fi
261
- exec happys happy "$@"
236
+
237
+ # Fallback: run happy-stacks from runtime install if present.
238
+ HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-\${HAPPY_LOCAL_HOME_DIR:-$HOME/.happy-stacks}}"
239
+ RUNTIME="$HOME_DIR/runtime/node_modules/happy-stacks/bin/happys.mjs"
240
+ if [[ -f "$RUNTIME" ]]; then
241
+ exec node "$RUNTIME" happy "$@"
242
+ fi
243
+
244
+ echo "error: cannot find happys shim or runtime install" >&2
245
+ exit 1
262
246
  `;
263
247
 
264
- await writeFile(happyShim, shim, 'utf-8');
248
+ const writeIfChanged = async (path, text) => {
249
+ let existing = '';
250
+ try {
251
+ existing = await readFile(path, 'utf-8');
252
+ } catch {
253
+ existing = '';
254
+ }
255
+ if (existing === text) return false;
256
+ await writeFile(path, text, 'utf-8');
257
+ return true;
258
+ };
259
+
260
+ await writeIfChanged(happyShim, shim);
265
261
  await chmod(happyShim, 0o755).catch(() => {});
266
262
 
267
- // eslint-disable-next-line no-console
268
- console.log(`[local] installed 'happy' shim at ${happyShim}`);
269
- if (!existsSync(happysShim)) {
263
+ // happys shim: use node + CLI root; if runtime install exists, prefer it.
264
+ const cliRoot = resolveInstalledCliRoot(rootDir);
265
+ const happysShimText = `#!/bin/bash
266
+ set -euo pipefail
267
+ exec node "${resolveInstalledPath(rootDir, 'bin/happys.mjs')}" "$@"
268
+ `;
269
+ await writeIfChanged(happysShim, happysShimText);
270
+ await chmod(happysShim, 0o755).catch(() => {});
271
+
272
+ // If user’s PATH points at a legacy install path, try to make it sane (best-effort).
273
+ const entries = getPathEntries();
274
+ const legacyBin = join(homedir(), '.happy-stacks', 'bin');
275
+ const newBin = join(getDefaultAutostartPaths().baseDir, 'bin');
276
+ if (entries.some((p) => isPathInside(p, legacyBin)) && !entries.some((p) => isPathInside(p, newBin))) {
270
277
  // eslint-disable-next-line no-console
271
- console.log(`[local] note: run \`happys init\` to install a stable ${happysShim} shim for services/SwiftBar.`);
278
+ console.log(`[local] note: your PATH includes ${legacyBin}; recommended path is ${newBin}`);
272
279
  }
280
+
281
+ return { ok: true, cliRoot, binDir, happyShim, happysShim };
273
282
  }
274
283
 
275
- export async function pmSpawnScript({ label, dir, script, env, options = {} }) {
284
+ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
285
+ const usesObjectStyle = typeof dirOrOpts === 'object' && dirOrOpts !== null;
286
+
287
+ const dir = usesObjectStyle ? dirOrOpts.dir : dirOrOpts;
288
+ const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
289
+ const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
290
+
291
+ const env = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
292
+ const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
293
+ const stdio = quiet ? 'ignore' : 'inherit';
294
+
276
295
  const pm = await getComponentPm(dir);
277
296
  if (pm.name === 'yarn') {
278
- return spawnProc(label, pm.cmd, ['-s', script], env, { ...options, cwd: dir });
297
+ await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
298
+ return;
279
299
  }
280
- return spawnProc(label, pm.cmd, ['--silent', script], env, { ...options, cwd: dir });
300
+ await run(pm.cmd, ['exec', bin, ...args], { cwd: dir, env, stdio });
281
301
  }
282
302
 
283
- export async function pmSpawnBin({ label, dir, bin, args, env, options = {} }) {
303
+ export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
284
304
  const pm = await getComponentPm(dir);
285
305
  if (pm.name === 'yarn') {
286
- return spawnProc(label, pm.cmd, [bin, ...args], env, { ...options, cwd: dir });
306
+ return spawnProc(label, pm.cmd, ['run', bin, ...args], env, { cwd: dir });
287
307
  }
288
- return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
308
+ return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { cwd: dir });
289
309
  }
290
310
 
291
- export async function pmExecBin({ dir, bin, args, env, quiet = false }) {
311
+ export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
292
312
  const pm = await getComponentPm(dir);
293
- const stdio = quiet ? 'ignore' : 'inherit';
294
313
  if (pm.name === 'yarn') {
295
- await run(pm.cmd, [bin, ...args], { env, cwd: dir, stdio });
296
- return;
297
- }
298
- await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir, stdio });
299
- }
300
-
301
- export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {
302
- if (process.platform !== 'darwin') {
303
- throw new Error('[local] autostart is currently only implemented for macOS (LaunchAgents).');
314
+ return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
304
315
  }
305
-
306
- const {
307
- logsDir,
308
- stdoutPath,
309
- stderrPath,
310
- plistPath,
311
- primaryLabel,
312
- legacyLabel,
313
- primaryPlistPath,
314
- legacyPlistPath,
315
- primaryStdoutPath,
316
- primaryStderrPath,
317
- legacyStdoutPath,
318
- legacyStderrPath,
319
- } = getDefaultAutostartPaths();
320
- await mkdir(logsDir, { recursive: true });
321
-
322
- const nodePath = process.env.HAPPY_STACKS_NODE?.trim()
323
- ? process.env.HAPPY_STACKS_NODE.trim()
324
- : process.env.HAPPY_LOCAL_NODE?.trim()
325
- ? process.env.HAPPY_LOCAL_NODE.trim()
326
- : process.execPath;
327
- const installedRoot = resolveInstalledCliRoot(rootDir);
328
- const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
329
- const happysShim = join(getHappyStacksHomeDir(), 'bin', 'happys');
330
- const useShim = existsSync(happysShim);
331
-
332
- // Ensure we write to the plist path that matches the label we're installing, instead of the
333
- // "active" plist path (which might be legacy and cause filename/label mismatches).
334
- const resolvedPlistPath =
335
- label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
336
- const resolvedStdoutPath = label === primaryLabel ? primaryStdoutPath : label === legacyLabel ? legacyStdoutPath : stdoutPath;
337
- const resolvedStderrPath = label === primaryLabel ? primaryStderrPath : label === legacyLabel ? legacyStderrPath : stderrPath;
338
-
339
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
340
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
341
- <plist version="1.0">
342
- <dict>
343
- <key>Label</key>
344
- <string>${label}</string>
345
- <key>ProgramArguments</key>
346
- <array>
347
- ${useShim ? `<string>${happysShim}</string>` : `<string>${nodePath}</string>\n <string>${happysEntrypoint}</string>`}
348
- <string>start</string>
349
- </array>
350
- <key>WorkingDirectory</key>
351
- <string>${installedRoot}</string>
352
- <key>RunAtLoad</key>
353
- <true/>
354
- <key>KeepAlive</key>
355
- <true/>
356
- <key>StandardOutPath</key>
357
- <string>${resolvedStdoutPath}</string>
358
- <key>StandardErrorPath</key>
359
- <string>${resolvedStderrPath}</string>
360
- <key>EnvironmentVariables</key>
361
- <dict>
362
- ${Object.entries(env)
363
- .map(([k, v]) => ` <key>${k}</key>\n <string>${String(v)}</string>`)
364
- .join('\n')}
365
- </dict>
366
- </dict>
367
- </plist>
368
- `;
369
-
370
- await mkdir(dirname(resolvedPlistPath), { recursive: true });
371
- await writeFile(resolvedPlistPath, plist, 'utf-8');
372
-
373
- // Best-effort (works on most macOS setups). If it fails, the plist still exists and can be loaded manually.
374
- try {
375
- await run('launchctl', ['unload', '-w', resolvedPlistPath]);
376
- } catch {
377
- // ignore
378
- }
379
- await run('launchctl', ['load', '-w', resolvedPlistPath]);
380
- }
381
-
382
- export async function ensureMacAutostartDisabled({ label = 'com.happy.local' }) {
383
- if (process.platform !== 'darwin') {
384
- return;
385
- }
386
- const { primaryLabel, legacyLabel, primaryPlistPath, legacyPlistPath } = getDefaultAutostartPaths();
387
- const resolvedPlistPath =
388
- label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
389
- try {
390
- await run('launchctl', ['unload', '-w', resolvedPlistPath]);
391
- } catch {
392
- // Old-style unload can fail on newer macOS; fall back to modern bootout.
393
- try {
394
- const uid = typeof process.getuid === 'function' ? process.getuid() : null;
395
- if (uid != null) {
396
- await run('launchctl', ['bootout', `gui/${uid}/${label}`]);
397
- }
398
- } catch {
399
- // ignore
400
- }
401
- }
402
- // eslint-disable-next-line no-console
403
- console.log(`[local] autostart disabled (${label})`);
316
+ return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
404
317
  }
@@ -132,3 +132,4 @@ export async function runCapture(cmd, args, options = {}) {
132
132
  });
133
133
  });
134
134
  }
135
+
@@ -1,50 +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
3
  import { join } from 'node:path';
5
4
  import { setTimeout as delay } from 'node:timers/promises';
6
5
 
7
- import { parseDotenv } from './dotenv.mjs';
8
- import { ensureEnvFileUpdated } from './env_file.mjs';
9
- import { pickNextFreeTcpPort } from './ports.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
- }
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';
34
14
 
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 readEnvObject(envPath) {
41
- try {
42
- const raw = await readFile(envPath, 'utf-8');
43
- return Object.fromEntries(parseDotenv(raw).entries());
44
- } catch {
45
- return {};
46
- }
47
- }
15
+ const readEnvObject = readEnvObjectFromFile;
48
16
 
49
17
  async function ensureTextFile({ path, generate }) {
50
18
  if (existsSync(path)) {
@@ -318,14 +286,7 @@ export async function ensureHappyServerManagedInfra({
318
286
  const reservedPorts = new Set();
319
287
 
320
288
  // Reserve known ports (if present) to avoid picking duplicates when auto-filling.
321
- for (const key of [
322
- 'HAPPY_STACKS_SERVER_PORT',
323
- 'HAPPY_LOCAL_SERVER_PORT',
324
- 'HAPPY_STACKS_PG_PORT',
325
- 'HAPPY_STACKS_REDIS_PORT',
326
- 'HAPPY_STACKS_MINIO_PORT',
327
- 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
328
- ]) {
289
+ for (const key of INFRA_RESERVED_PORT_KEYS) {
329
290
  const p = coercePort(existingEnv[key] ?? env[key]);
330
291
  if (p) reservedPorts.add(p);
331
292
  }
@@ -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) {