happy-stacks 0.0.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 (67) hide show
  1. package/README.md +314 -0
  2. package/bin/happys.mjs +168 -0
  3. package/docs/menubar.md +186 -0
  4. package/docs/mobile-ios.md +134 -0
  5. package/docs/remote-access.md +43 -0
  6. package/docs/server-flavors.md +79 -0
  7. package/docs/stacks.md +218 -0
  8. package/docs/tauri.md +62 -0
  9. package/docs/worktrees-and-forks.md +395 -0
  10. package/extras/swiftbar/auth-login.sh +31 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +218 -0
  12. package/extras/swiftbar/icons/happy-green.png +0 -0
  13. package/extras/swiftbar/icons/happy-orange.png +0 -0
  14. package/extras/swiftbar/icons/happy-red.png +0 -0
  15. package/extras/swiftbar/icons/logo-white.png +0 -0
  16. package/extras/swiftbar/install.sh +191 -0
  17. package/extras/swiftbar/lib/git.sh +330 -0
  18. package/extras/swiftbar/lib/icons.sh +105 -0
  19. package/extras/swiftbar/lib/render.sh +774 -0
  20. package/extras/swiftbar/lib/system.sh +190 -0
  21. package/extras/swiftbar/lib/utils.sh +205 -0
  22. package/extras/swiftbar/pnpm-term.sh +125 -0
  23. package/extras/swiftbar/pnpm.sh +21 -0
  24. package/extras/swiftbar/set-interval.sh +62 -0
  25. package/extras/swiftbar/set-server-flavor.sh +57 -0
  26. package/extras/swiftbar/wt-pr.sh +95 -0
  27. package/package.json +58 -0
  28. package/scripts/auth.mjs +272 -0
  29. package/scripts/build.mjs +204 -0
  30. package/scripts/cli-link.mjs +58 -0
  31. package/scripts/completion.mjs +364 -0
  32. package/scripts/daemon.mjs +349 -0
  33. package/scripts/dev.mjs +181 -0
  34. package/scripts/doctor.mjs +342 -0
  35. package/scripts/happy.mjs +79 -0
  36. package/scripts/init.mjs +232 -0
  37. package/scripts/install.mjs +379 -0
  38. package/scripts/menubar.mjs +107 -0
  39. package/scripts/mobile.mjs +305 -0
  40. package/scripts/run.mjs +236 -0
  41. package/scripts/self.mjs +298 -0
  42. package/scripts/server_flavor.mjs +125 -0
  43. package/scripts/service.mjs +526 -0
  44. package/scripts/stack.mjs +815 -0
  45. package/scripts/tailscale.mjs +278 -0
  46. package/scripts/uninstall.mjs +190 -0
  47. package/scripts/utils/args.mjs +17 -0
  48. package/scripts/utils/cli.mjs +24 -0
  49. package/scripts/utils/cli_registry.mjs +262 -0
  50. package/scripts/utils/config.mjs +40 -0
  51. package/scripts/utils/dotenv.mjs +30 -0
  52. package/scripts/utils/env.mjs +138 -0
  53. package/scripts/utils/env_file.mjs +59 -0
  54. package/scripts/utils/env_local.mjs +25 -0
  55. package/scripts/utils/fs.mjs +11 -0
  56. package/scripts/utils/paths.mjs +184 -0
  57. package/scripts/utils/pm.mjs +294 -0
  58. package/scripts/utils/ports.mjs +66 -0
  59. package/scripts/utils/proc.mjs +66 -0
  60. package/scripts/utils/runtime.mjs +30 -0
  61. package/scripts/utils/server.mjs +41 -0
  62. package/scripts/utils/smoke_help.mjs +45 -0
  63. package/scripts/utils/validate.mjs +47 -0
  64. package/scripts/utils/wizard.mjs +69 -0
  65. package/scripts/utils/worktrees.mjs +78 -0
  66. package/scripts/where.mjs +105 -0
  67. package/scripts/worktrees.mjs +1721 -0
@@ -0,0 +1,184 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ const PRIMARY_APP_SLUG = 'happy-stacks';
7
+ const LEGACY_APP_SLUG = 'happy-local';
8
+ const PRIMARY_LABEL_BASE = 'com.happy.stacks';
9
+ const LEGACY_LABEL_BASE = 'com.happy.local';
10
+ const PRIMARY_STORAGE_ROOT = join(homedir(), '.happy', 'stacks');
11
+ const LEGACY_STORAGE_ROOT = join(homedir(), '.happy', 'local');
12
+ const PRIMARY_HOME_DIR = join(homedir(), '.happy-stacks');
13
+
14
+ export function getRootDir(importMetaUrl) {
15
+ return dirname(dirname(fileURLToPath(importMetaUrl)));
16
+ }
17
+
18
+ export function getHappyStacksHomeDir() {
19
+ const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
20
+ if (fromEnv) {
21
+ return fromEnv.replace(/^~(?=\/)/, homedir());
22
+ }
23
+ return PRIMARY_HOME_DIR;
24
+ }
25
+
26
+ export function getWorkspaceDir(cliRootDir = null) {
27
+ const fromEnv = (process.env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
28
+ if (fromEnv) {
29
+ return fromEnv.replace(/^~(?=\/)/, homedir());
30
+ }
31
+ const homeDir = getHappyStacksHomeDir();
32
+ const defaultWorkspace = join(homeDir, 'workspace');
33
+ // Prefer the default home workspace if present.
34
+ if (existsSync(defaultWorkspace)) {
35
+ return defaultWorkspace;
36
+ }
37
+ // Back-compat: for cloned-repo usage before init, keep components inside the repo.
38
+ return cliRootDir ? cliRootDir : defaultWorkspace;
39
+ }
40
+
41
+ export function getComponentsDir(rootDir) {
42
+ const workspaceDir = getWorkspaceDir(rootDir);
43
+ return join(workspaceDir, 'components');
44
+ }
45
+
46
+ export function componentDirEnvKey(name) {
47
+ return `HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
48
+ }
49
+
50
+ function normalizePathForEnv(rootDir, raw) {
51
+ const trimmed = (raw ?? '').trim();
52
+ if (!trimmed) {
53
+ return '';
54
+ }
55
+ const expanded = trimmed.replace(/^~(?=\/)/, homedir());
56
+ // If the path is relative, treat it as relative to the workspace root (default: repo root).
57
+ const workspaceDir = getWorkspaceDir(rootDir);
58
+ return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
59
+ }
60
+
61
+ export function getComponentDir(rootDir, name) {
62
+ const stacksKey = componentDirEnvKey(name);
63
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
64
+ const fromEnv = normalizePathForEnv(rootDir, process.env[stacksKey] ?? process.env[legacyKey]);
65
+ if (fromEnv) {
66
+ return fromEnv;
67
+ }
68
+ return join(getComponentsDir(rootDir), name);
69
+ }
70
+
71
+ export function getStackName() {
72
+ const raw = process.env.HAPPY_STACKS_STACK?.trim()
73
+ ? process.env.HAPPY_STACKS_STACK.trim()
74
+ : process.env.HAPPY_LOCAL_STACK?.trim()
75
+ ? process.env.HAPPY_LOCAL_STACK.trim()
76
+ : '';
77
+ return raw || 'main';
78
+ }
79
+
80
+ export function getStackLabel(stackName = getStackName()) {
81
+ return stackName === 'main' ? PRIMARY_LABEL_BASE : `${PRIMARY_LABEL_BASE}.${stackName}`;
82
+ }
83
+
84
+ export function getLegacyStackLabel(stackName = getStackName()) {
85
+ return stackName === 'main' ? LEGACY_LABEL_BASE : `${LEGACY_LABEL_BASE}.${stackName}`;
86
+ }
87
+
88
+ export function getStacksStorageRoot() {
89
+ const fromEnv = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
90
+ if (fromEnv) {
91
+ return fromEnv.replace(/^~(?=\/)/, homedir());
92
+ }
93
+ return PRIMARY_STORAGE_ROOT;
94
+ }
95
+
96
+ export function getLegacyStorageRoot() {
97
+ return LEGACY_STORAGE_ROOT;
98
+ }
99
+
100
+ export function resolveStackBaseDir(stackName = getStackName()) {
101
+ const preferredRoot = getStacksStorageRoot();
102
+ const newBase = join(preferredRoot, stackName);
103
+ const legacyBase = stackName === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', stackName);
104
+
105
+ // Prefer the new layout by default.
106
+ //
107
+ // For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
108
+ // This avoids breaking existing stacks until `happys stack migrate` is run.
109
+ if (stackName !== 'main') {
110
+ const newEnv = join(preferredRoot, stackName, 'env');
111
+ const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
112
+ if (!existsSync(newEnv) && existsSync(legacyEnv)) {
113
+ return { baseDir: legacyBase, isLegacy: true };
114
+ }
115
+ }
116
+
117
+ return { baseDir: newBase, isLegacy: false };
118
+ }
119
+
120
+ export function resolveStackEnvPath(stackName = getStackName()) {
121
+ const { baseDir: activeBase, isLegacy } = resolveStackBaseDir(stackName);
122
+ // New layout: ~/.happy/stacks/<name>/env
123
+ const newEnv = join(getStacksStorageRoot(), stackName, 'env');
124
+ // Legacy layout: ~/.happy/local/stacks/<name>/env
125
+ const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
126
+
127
+ if (existsSync(newEnv)) {
128
+ return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(), stackName) };
129
+ }
130
+ if (existsSync(legacyEnv)) {
131
+ return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', stackName) };
132
+ }
133
+ return { envPath: newEnv, isLegacy, baseDir: activeBase };
134
+ }
135
+
136
+ export function getDefaultAutostartPaths() {
137
+ const stackName = getStackName();
138
+ const { baseDir, isLegacy } = resolveStackBaseDir(stackName);
139
+ const logsDir = join(baseDir, 'logs');
140
+
141
+ const primaryLabel = getStackLabel(stackName);
142
+ const legacyLabel = getLegacyStackLabel(stackName);
143
+ const primaryPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${primaryLabel}.plist`);
144
+ const legacyPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${legacyLabel}.plist`);
145
+
146
+ const primaryStdoutPath = join(logsDir, `${PRIMARY_APP_SLUG}.out.log`);
147
+ const primaryStderrPath = join(logsDir, `${PRIMARY_APP_SLUG}.err.log`);
148
+ const legacyStdoutPath = join(logsDir, `${LEGACY_APP_SLUG}.out.log`);
149
+ const legacyStderrPath = join(logsDir, `${LEGACY_APP_SLUG}.err.log`);
150
+
151
+ // Best-effort: prefer primary, but fall back to legacy if that's what's installed.
152
+ const hasPrimaryPlist = existsSync(primaryPlistPath);
153
+ const hasLegacyPlist = existsSync(legacyPlistPath);
154
+ const hasPrimaryLogs = existsSync(primaryStdoutPath) || existsSync(primaryStderrPath);
155
+ const hasLegacyLogs = existsSync(legacyStdoutPath) || existsSync(legacyStderrPath);
156
+
157
+ const activeLabel = hasPrimaryPlist ? primaryLabel : hasLegacyPlist ? legacyLabel : primaryLabel;
158
+ const activePlistPath = hasPrimaryPlist ? primaryPlistPath : hasLegacyPlist ? legacyPlistPath : primaryPlistPath;
159
+ const stdoutPath = hasPrimaryLogs ? primaryStdoutPath : hasLegacyLogs ? legacyStdoutPath : primaryStdoutPath;
160
+ const stderrPath = hasPrimaryLogs ? primaryStderrPath : hasLegacyLogs ? legacyStderrPath : primaryStderrPath;
161
+
162
+ return {
163
+ baseDir,
164
+ logsDir,
165
+ stackName,
166
+ isLegacy,
167
+
168
+ // Active (best-effort) for commands like status/logs/start/stop.
169
+ label: activeLabel,
170
+ plistPath: activePlistPath,
171
+ stdoutPath,
172
+ stderrPath,
173
+
174
+ // Primary/legacy info (for display + migration).
175
+ primaryLabel,
176
+ legacyLabel,
177
+ primaryPlistPath,
178
+ legacyPlistPath,
179
+ primaryStdoutPath,
180
+ primaryStderrPath,
181
+ legacyStdoutPath,
182
+ legacyStderrPath,
183
+ };
184
+ }
@@ -0,0 +1,294 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { chmod, mkdir, rm, stat, writeFile } from 'node:fs/promises';
5
+
6
+ import { pathExists } from './fs.mjs';
7
+ import { run, runCapture, spawnProc } from './proc.mjs';
8
+ import { getDefaultAutostartPaths, getHappyStacksHomeDir } from './paths.mjs';
9
+ import { resolveInstalledPath, resolveInstalledCliRoot } from './runtime.mjs';
10
+
11
+ async function commandExists(cmd, options = {}) {
12
+ try {
13
+ await runCapture(cmd, ['--version'], options);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export async function requirePnpm() {
21
+ if (await commandExists('pnpm')) {
22
+ return;
23
+ }
24
+ throw new Error(
25
+ '[local] pnpm is required to install dependencies for Happy Stacks.\n' +
26
+ 'Install it via Corepack: `corepack enable && corepack prepare pnpm@latest --activate`'
27
+ );
28
+ }
29
+
30
+ async function getComponentPm(dir) {
31
+ const yarnLock = join(dir, 'yarn.lock');
32
+ if (await pathExists(yarnLock)) {
33
+ // IMPORTANT: when happy-stacks itself is pinned to pnpm via Corepack, running `yarn`
34
+ // from the happy-stacks cwd can be blocked. Always probe yarn with cwd=componentDir.
35
+ if (!(await commandExists('yarn', { cwd: dir }))) {
36
+ throw new Error(`[local] yarn is required for component at ${dir} (yarn.lock present). Install it via Corepack: \`corepack enable\``);
37
+ }
38
+ return { name: 'yarn', cmd: 'yarn' };
39
+ }
40
+
41
+ // Default fallback if no yarn.lock: use pnpm.
42
+ await requirePnpm();
43
+ return { name: 'pnpm', cmd: 'pnpm' };
44
+ }
45
+
46
+ export async function requireDir(label, dir) {
47
+ if (await pathExists(dir)) {
48
+ return;
49
+ }
50
+ throw new Error(
51
+ `[local] missing ${label} at ${dir}\n` +
52
+ `Run: happys bootstrap (auto-clones missing components), or place the repo under components/`
53
+ );
54
+ }
55
+
56
+ export async function ensureDepsInstalled(dir, label) {
57
+ const pkgJson = join(dir, 'package.json');
58
+ if (!(await pathExists(pkgJson))) {
59
+ return;
60
+ }
61
+
62
+ const nodeModules = join(dir, 'node_modules');
63
+ const pnpmModulesMeta = join(dir, 'node_modules', '.modules.yaml');
64
+ const pm = await getComponentPm(dir);
65
+
66
+ if (await pathExists(nodeModules)) {
67
+ const yarnLock = join(dir, 'yarn.lock');
68
+ const yarnIntegrity = join(nodeModules, '.yarn-integrity');
69
+ const pnpmLock = join(dir, 'pnpm-lock.yaml');
70
+
71
+ // If this repo is Yarn-managed (yarn.lock present) but node_modules was created by pnpm,
72
+ // reinstall with Yarn to restore upstream-locked dependency versions.
73
+ 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)...`);
76
+ await rm(nodeModules, { recursive: true, force: true });
77
+ await run(pm.cmd, ['install'], { cwd: dir });
78
+ }
79
+
80
+ // If dependencies changed since the last install, re-run install even if node_modules exists.
81
+ const mtimeMs = async (p) => {
82
+ try {
83
+ const s = await stat(p);
84
+ return s.mtimeMs ?? 0;
85
+ } catch {
86
+ return 0;
87
+ }
88
+ };
89
+
90
+ if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
91
+ const lockM = await mtimeMs(yarnLock);
92
+ const pkgM = await mtimeMs(pkgJson);
93
+ const intM = await mtimeMs(yarnIntegrity);
94
+ 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 });
98
+ }
99
+ }
100
+
101
+ if (pm.name === 'pnpm' && (await pathExists(pnpmLock))) {
102
+ const lockM = await mtimeMs(pnpmLock);
103
+ const metaM = await mtimeMs(pnpmModulesMeta);
104
+ 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 });
108
+ }
109
+ }
110
+
111
+ return;
112
+ }
113
+
114
+ // eslint-disable-next-line no-console
115
+ console.log(`[local] installing ${label} dependencies (first run)...`);
116
+ await run(pm.cmd, ['install'], { cwd: dir });
117
+ }
118
+
119
+ export async function ensureCliBuilt(cliDir, { buildCli }) {
120
+ await ensureDepsInstalled(cliDir, 'happy-cli');
121
+ if (!buildCli) {
122
+ return;
123
+ }
124
+ // eslint-disable-next-line no-console
125
+ console.log('[local] building happy-cli...');
126
+ const pm = await getComponentPm(cliDir);
127
+ await run(pm.cmd, ['build'], { cwd: cliDir });
128
+ }
129
+
130
+ function getPathEntries() {
131
+ const raw = process.env.PATH ?? '';
132
+ const delimiter = process.platform === 'win32' ? ';' : ':';
133
+ return raw.split(delimiter).filter(Boolean);
134
+ }
135
+
136
+ function isPathInside(path, dir) {
137
+ const p = resolve(path);
138
+ const d = resolve(dir);
139
+ return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
140
+ }
141
+
142
+ export async function ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli }) {
143
+ if (!npmLinkCli) {
144
+ return;
145
+ }
146
+
147
+ const homeDir = getHappyStacksHomeDir();
148
+ const binDir = join(homeDir, 'bin');
149
+ await mkdir(binDir, { recursive: true });
150
+
151
+ const happysShim = join(binDir, 'happys');
152
+ const happyShim = join(binDir, 'happy');
153
+
154
+ const shim = `#!/bin/bash
155
+ set -euo pipefail
156
+ HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
157
+ HAPPYS="$HOME_DIR/bin/happys"
158
+ if [[ -x "$HAPPYS" ]]; then
159
+ exec "$HAPPYS" happy "$@"
160
+ fi
161
+ exec happys happy "$@"
162
+ `;
163
+
164
+ await writeFile(happyShim, shim, 'utf-8');
165
+ await chmod(happyShim, 0o755).catch(() => {});
166
+
167
+ // eslint-disable-next-line no-console
168
+ console.log(`[local] installed 'happy' shim at ${happyShim}`);
169
+ if (!existsSync(happysShim)) {
170
+ // eslint-disable-next-line no-console
171
+ console.log(`[local] note: run \`happys init\` to install a stable ${happysShim} shim for services/SwiftBar.`);
172
+ }
173
+ }
174
+
175
+ export async function pmSpawnScript({ label, dir, script, env, options = {} }) {
176
+ const pm = await getComponentPm(dir);
177
+ if (pm.name === 'yarn') {
178
+ return spawnProc(label, pm.cmd, ['-s', script], env, { ...options, cwd: dir });
179
+ }
180
+ return spawnProc(label, pm.cmd, ['--silent', script], env, { ...options, cwd: dir });
181
+ }
182
+
183
+ export async function pmExecBin({ dir, bin, args, env }) {
184
+ const pm = await getComponentPm(dir);
185
+ if (pm.name === 'yarn') {
186
+ await run(pm.cmd, [bin, ...args], { env, cwd: dir });
187
+ return;
188
+ }
189
+ await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir });
190
+ }
191
+
192
+ export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {
193
+ if (process.platform !== 'darwin') {
194
+ throw new Error('[local] autostart is currently only implemented for macOS (LaunchAgents).');
195
+ }
196
+
197
+ const {
198
+ logsDir,
199
+ stdoutPath,
200
+ stderrPath,
201
+ plistPath,
202
+ primaryLabel,
203
+ legacyLabel,
204
+ primaryPlistPath,
205
+ legacyPlistPath,
206
+ primaryStdoutPath,
207
+ primaryStderrPath,
208
+ legacyStdoutPath,
209
+ legacyStderrPath,
210
+ } = getDefaultAutostartPaths();
211
+ await mkdir(logsDir, { recursive: true });
212
+
213
+ const nodePath = process.env.HAPPY_STACKS_NODE?.trim()
214
+ ? process.env.HAPPY_STACKS_NODE.trim()
215
+ : process.env.HAPPY_LOCAL_NODE?.trim()
216
+ ? process.env.HAPPY_LOCAL_NODE.trim()
217
+ : process.execPath;
218
+ const installedRoot = resolveInstalledCliRoot(rootDir);
219
+ const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
220
+
221
+ // Ensure we write to the plist path that matches the label we're installing, instead of the
222
+ // "active" plist path (which might be legacy and cause filename/label mismatches).
223
+ const resolvedPlistPath =
224
+ label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
225
+ const resolvedStdoutPath = label === primaryLabel ? primaryStdoutPath : label === legacyLabel ? legacyStdoutPath : stdoutPath;
226
+ const resolvedStderrPath = label === primaryLabel ? primaryStderrPath : label === legacyLabel ? legacyStderrPath : stderrPath;
227
+
228
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
229
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
230
+ <plist version="1.0">
231
+ <dict>
232
+ <key>Label</key>
233
+ <string>${label}</string>
234
+ <key>ProgramArguments</key>
235
+ <array>
236
+ <string>${nodePath}</string>
237
+ <string>${happysEntrypoint}</string>
238
+ <string>start</string>
239
+ </array>
240
+ <key>WorkingDirectory</key>
241
+ <string>${installedRoot}</string>
242
+ <key>RunAtLoad</key>
243
+ <true/>
244
+ <key>KeepAlive</key>
245
+ <true/>
246
+ <key>StandardOutPath</key>
247
+ <string>${resolvedStdoutPath}</string>
248
+ <key>StandardErrorPath</key>
249
+ <string>${resolvedStderrPath}</string>
250
+ <key>EnvironmentVariables</key>
251
+ <dict>
252
+ ${Object.entries(env)
253
+ .map(([k, v]) => ` <key>${k}</key>\n <string>${String(v)}</string>`)
254
+ .join('\n')}
255
+ </dict>
256
+ </dict>
257
+ </plist>
258
+ `;
259
+
260
+ await mkdir(dirname(resolvedPlistPath), { recursive: true });
261
+ await writeFile(resolvedPlistPath, plist, 'utf-8');
262
+
263
+ // Best-effort (works on most macOS setups). If it fails, the plist still exists and can be loaded manually.
264
+ try {
265
+ await run('launchctl', ['unload', '-w', resolvedPlistPath]);
266
+ } catch {
267
+ // ignore
268
+ }
269
+ await run('launchctl', ['load', '-w', resolvedPlistPath]);
270
+ }
271
+
272
+ export async function ensureMacAutostartDisabled({ label = 'com.happy.local' }) {
273
+ if (process.platform !== 'darwin') {
274
+ return;
275
+ }
276
+ const { primaryLabel, legacyLabel, primaryPlistPath, legacyPlistPath } = getDefaultAutostartPaths();
277
+ const resolvedPlistPath =
278
+ label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
279
+ try {
280
+ await run('launchctl', ['unload', '-w', resolvedPlistPath]);
281
+ } catch {
282
+ // Old-style unload can fail on newer macOS; fall back to modern bootout.
283
+ try {
284
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
285
+ if (uid != null) {
286
+ await run('launchctl', ['bootout', `gui/${uid}/${label}`]);
287
+ }
288
+ } catch {
289
+ // ignore
290
+ }
291
+ }
292
+ // eslint-disable-next-line no-console
293
+ console.log(`[local] autostart disabled (${label})`);
294
+ }
@@ -0,0 +1,66 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { runCapture } from './proc.mjs';
3
+
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
+ }
15
+
16
+ let raw = '';
17
+ try {
18
+ // `lsof` exits non-zero if no matches; normalize to empty output.
19
+ raw = await runCapture('sh', [
20
+ '-lc',
21
+ `command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`,
22
+ ]);
23
+ } catch {
24
+ return [];
25
+ }
26
+
27
+ const pids = Array.from(
28
+ new Set(
29
+ raw
30
+ .split(/\s+/g)
31
+ .map((s) => s.trim())
32
+ .filter(Boolean)
33
+ .map((s) => Number(s))
34
+ .filter((n) => Number.isInteger(n) && n > 1)
35
+ )
36
+ );
37
+
38
+ if (!pids.length) {
39
+ return [];
40
+ }
41
+
42
+ // eslint-disable-next-line no-console
43
+ console.log(`[local] ${label}: freeing tcp:${port} (killing pids: ${pids.join(', ')})`);
44
+
45
+ for (const pid of pids) {
46
+ try {
47
+ process.kill(pid, 'SIGTERM');
48
+ } catch {
49
+ // ignore
50
+ }
51
+ }
52
+
53
+ await delay(500);
54
+
55
+ for (const pid of pids) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ process.kill(pid, 'SIGKILL');
59
+ } catch {
60
+ // not running / no permission
61
+ }
62
+ }
63
+
64
+ return pids;
65
+ }
66
+
@@ -0,0 +1,66 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function spawnProc(label, cmd, args, env, options = {}) {
4
+ const child = spawn(cmd, args, {
5
+ env,
6
+ stdio: ['ignore', 'pipe', 'pipe'],
7
+ shell: false,
8
+ // Create a new process group so we can kill the whole tree reliably on shutdown.
9
+ detached: process.platform !== 'win32',
10
+ ...options,
11
+ });
12
+
13
+ child.stdout?.on('data', (d) => process.stdout.write(`[${label}] ${d.toString()}`));
14
+ child.stderr?.on('data', (d) => process.stderr.write(`[${label}] ${d.toString()}`));
15
+ child.on('exit', (code, sig) => {
16
+ if (code !== 0) {
17
+ process.stderr.write(`[${label}] exited (code=${code}, sig=${sig})\n`);
18
+ }
19
+ });
20
+
21
+ return child;
22
+ }
23
+
24
+ export function killProcessTree(child, signal) {
25
+ if (!child || child.exitCode != null || !child.pid) {
26
+ return;
27
+ }
28
+
29
+ try {
30
+ if (process.platform !== 'win32') {
31
+ // Kill the process group.
32
+ process.kill(-child.pid, signal);
33
+ } else {
34
+ child.kill(signal);
35
+ }
36
+ } catch {
37
+ // ignore
38
+ }
39
+ }
40
+
41
+ export async function run(cmd, args, options = {}) {
42
+ await new Promise((resolvePromise, rejectPromise) => {
43
+ const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...options });
44
+ proc.on('error', rejectPromise);
45
+ proc.on('exit', (code) => (code === 0 ? resolvePromise() : rejectPromise(new Error(`${cmd} failed (code=${code})`))));
46
+ });
47
+ }
48
+
49
+ export async function runCapture(cmd, args, options = {}) {
50
+ return await new Promise((resolvePromise, rejectPromise) => {
51
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...options });
52
+ let out = '';
53
+ let err = '';
54
+ proc.stdout?.on('data', (d) => (out += d.toString()));
55
+ proc.stderr?.on('data', (d) => (err += d.toString()));
56
+ proc.on('error', rejectPromise);
57
+ proc.on('exit', (code) => {
58
+ if (code === 0) {
59
+ resolvePromise(out);
60
+ } else {
61
+ rejectPromise(new Error(`${cmd} ${args.join(' ')} failed (code=${code}): ${err.trim()}`));
62
+ }
63
+ });
64
+ });
65
+ }
66
+
@@ -0,0 +1,30 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ function expandHome(p) {
6
+ return p.replace(/^~(?=\/)/, homedir());
7
+ }
8
+
9
+ export function getRuntimeDir() {
10
+ const fromEnv = (process.env.HAPPY_STACKS_RUNTIME_DIR ?? '').trim();
11
+ if (fromEnv) {
12
+ return expandHome(fromEnv);
13
+ }
14
+ const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() ? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim()) : join(homedir(), '.happy-stacks');
15
+ return join(homeDir, 'runtime');
16
+ }
17
+
18
+ export function resolveInstalledCliRoot(cliRootDir) {
19
+ const runtimeDir = getRuntimeDir();
20
+ const runtimePkgRoot = join(runtimeDir, 'node_modules', 'happy-stacks');
21
+ if (existsSync(runtimePkgRoot)) {
22
+ return runtimePkgRoot;
23
+ }
24
+ return cliRootDir;
25
+ }
26
+
27
+ export function resolveInstalledPath(cliRootDir, relativePath) {
28
+ return join(resolveInstalledCliRoot(cliRootDir), relativePath);
29
+ }
30
+
@@ -0,0 +1,41 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ export function getServerComponentName({ kv } = {}) {
4
+ const fromArgRaw = kv?.get('--server')?.trim() ? kv.get('--server').trim() : '';
5
+ const fromEnvRaw = process.env.HAPPY_STACKS_SERVER_COMPONENT?.trim()
6
+ ? process.env.HAPPY_STACKS_SERVER_COMPONENT.trim()
7
+ : process.env.HAPPY_LOCAL_SERVER_COMPONENT?.trim()
8
+ ? process.env.HAPPY_LOCAL_SERVER_COMPONENT.trim()
9
+ : '';
10
+ const raw = fromArgRaw || fromEnvRaw || 'happy-server-light';
11
+ const v = raw.toLowerCase();
12
+ if (v === 'light' || v === 'server-light' || v === 'happy-server-light') {
13
+ return 'happy-server-light';
14
+ }
15
+ if (v === 'server' || v === 'full' || v === 'happy-server') {
16
+ return 'happy-server';
17
+ }
18
+ if (v === 'both') {
19
+ return 'both';
20
+ }
21
+ // Allow explicit component dir names (advanced).
22
+ return raw;
23
+ }
24
+
25
+ export async function waitForServerReady(url) {
26
+ const deadline = Date.now() + 60_000;
27
+ while (Date.now() < deadline) {
28
+ try {
29
+ const res = await fetch(url, { method: 'GET' });
30
+ const text = await res.text();
31
+ if (res.ok && text.includes('Welcome to Happy Server!')) {
32
+ return;
33
+ }
34
+ } catch {
35
+ // ignore
36
+ }
37
+ await delay(300);
38
+ }
39
+ throw new Error(`Timed out waiting for server at ${url}`);
40
+ }
41
+