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.
- package/README.md +314 -0
- package/bin/happys.mjs +168 -0
- package/docs/menubar.md +186 -0
- package/docs/mobile-ios.md +134 -0
- package/docs/remote-access.md +43 -0
- package/docs/server-flavors.md +79 -0
- package/docs/stacks.md +218 -0
- package/docs/tauri.md +62 -0
- package/docs/worktrees-and-forks.md +395 -0
- package/extras/swiftbar/auth-login.sh +31 -0
- package/extras/swiftbar/happy-stacks.5s.sh +218 -0
- package/extras/swiftbar/icons/happy-green.png +0 -0
- package/extras/swiftbar/icons/happy-orange.png +0 -0
- package/extras/swiftbar/icons/happy-red.png +0 -0
- package/extras/swiftbar/icons/logo-white.png +0 -0
- package/extras/swiftbar/install.sh +191 -0
- package/extras/swiftbar/lib/git.sh +330 -0
- package/extras/swiftbar/lib/icons.sh +105 -0
- package/extras/swiftbar/lib/render.sh +774 -0
- package/extras/swiftbar/lib/system.sh +190 -0
- package/extras/swiftbar/lib/utils.sh +205 -0
- package/extras/swiftbar/pnpm-term.sh +125 -0
- package/extras/swiftbar/pnpm.sh +21 -0
- package/extras/swiftbar/set-interval.sh +62 -0
- package/extras/swiftbar/set-server-flavor.sh +57 -0
- package/extras/swiftbar/wt-pr.sh +95 -0
- package/package.json +58 -0
- package/scripts/auth.mjs +272 -0
- package/scripts/build.mjs +204 -0
- package/scripts/cli-link.mjs +58 -0
- package/scripts/completion.mjs +364 -0
- package/scripts/daemon.mjs +349 -0
- package/scripts/dev.mjs +181 -0
- package/scripts/doctor.mjs +342 -0
- package/scripts/happy.mjs +79 -0
- package/scripts/init.mjs +232 -0
- package/scripts/install.mjs +379 -0
- package/scripts/menubar.mjs +107 -0
- package/scripts/mobile.mjs +305 -0
- package/scripts/run.mjs +236 -0
- package/scripts/self.mjs +298 -0
- package/scripts/server_flavor.mjs +125 -0
- package/scripts/service.mjs +526 -0
- package/scripts/stack.mjs +815 -0
- package/scripts/tailscale.mjs +278 -0
- package/scripts/uninstall.mjs +190 -0
- package/scripts/utils/args.mjs +17 -0
- package/scripts/utils/cli.mjs +24 -0
- package/scripts/utils/cli_registry.mjs +262 -0
- package/scripts/utils/config.mjs +40 -0
- package/scripts/utils/dotenv.mjs +30 -0
- package/scripts/utils/env.mjs +138 -0
- package/scripts/utils/env_file.mjs +59 -0
- package/scripts/utils/env_local.mjs +25 -0
- package/scripts/utils/fs.mjs +11 -0
- package/scripts/utils/paths.mjs +184 -0
- package/scripts/utils/pm.mjs +294 -0
- package/scripts/utils/ports.mjs +66 -0
- package/scripts/utils/proc.mjs +66 -0
- package/scripts/utils/runtime.mjs +30 -0
- package/scripts/utils/server.mjs +41 -0
- package/scripts/utils/smoke_help.mjs +45 -0
- package/scripts/utils/validate.mjs +47 -0
- package/scripts/utils/wizard.mjs +69 -0
- package/scripts/utils/worktrees.mjs +78 -0
- package/scripts/where.mjs +105 -0
- package/scripts/worktrees.mjs +1721 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { spawnProc, run, runCapture } from './utils/proc.mjs';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { chmod, copyFile, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Daemon lifecycle helpers for happy-stacks.
|
|
10
|
+
*
|
|
11
|
+
* Centralizes:
|
|
12
|
+
* - stopping old daemons (legacy + local home dirs)
|
|
13
|
+
* - cleaning stale lock/state
|
|
14
|
+
* - starting daemon and handling first-time auth
|
|
15
|
+
* - printing actionable diagnostics
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export function cleanupStaleDaemonState(homeDir) {
|
|
19
|
+
const statePath = join(homeDir, 'daemon.state.json');
|
|
20
|
+
const lockPath = join(homeDir, 'daemon.state.json.lock');
|
|
21
|
+
|
|
22
|
+
if (!existsSync(lockPath)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// If lock PID exists and is running, keep lock/state.
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(lockPath, 'utf-8').trim();
|
|
29
|
+
const pid = Number(raw);
|
|
30
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return;
|
|
34
|
+
} catch {
|
|
35
|
+
// stale pid
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If state PID exists and is running, keep lock/state.
|
|
43
|
+
if (existsSync(statePath)) {
|
|
44
|
+
try {
|
|
45
|
+
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
46
|
+
const pid = typeof state?.pid === 'number' ? state.pid : null;
|
|
47
|
+
if (pid) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return;
|
|
51
|
+
} catch {
|
|
52
|
+
// stale pid
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
61
|
+
try { unlinkSync(statePath); } catch { /* ignore */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getLatestDaemonLogPath(homeDir) {
|
|
65
|
+
try {
|
|
66
|
+
const logsDir = join(homeDir, 'logs');
|
|
67
|
+
const files = readdirSync(logsDir).filter((f) => f.endsWith('-daemon.log')).sort();
|
|
68
|
+
if (!files.length) return null;
|
|
69
|
+
return join(logsDir, files[files.length - 1]);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readLastLines(path, lines = 60) {
|
|
76
|
+
try {
|
|
77
|
+
const content = readFileSync(path, 'utf-8');
|
|
78
|
+
const parts = content.split('\n');
|
|
79
|
+
return parts.slice(Math.max(0, parts.length - lines)).join('\n');
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function excerptIndicatesMissingAuth(excerpt) {
|
|
86
|
+
if (!excerpt) return false;
|
|
87
|
+
return (
|
|
88
|
+
excerpt.includes('[AUTH] No credentials found') ||
|
|
89
|
+
excerpt.includes('No credentials found, starting authentication flow')
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function authLoginHint() {
|
|
94
|
+
const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
95
|
+
return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function seedCredentialsIfMissing({ cliHomeDir }) {
|
|
99
|
+
const sources = [
|
|
100
|
+
// Legacy happy-local storage root (most common for existing users).
|
|
101
|
+
join(homedir(), '.happy', 'local', 'cli'),
|
|
102
|
+
// Older global location.
|
|
103
|
+
join(homedir(), '.happy'),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const copyIfMissing = async ({ relPath, mode, label }) => {
|
|
107
|
+
const target = join(cliHomeDir, relPath);
|
|
108
|
+
if (existsSync(target)) {
|
|
109
|
+
return { copied: false, source: null, target };
|
|
110
|
+
}
|
|
111
|
+
const sourceDir = sources.find((d) => existsSync(join(d, relPath)));
|
|
112
|
+
if (!sourceDir) {
|
|
113
|
+
return { copied: false, source: null, target };
|
|
114
|
+
}
|
|
115
|
+
const source = join(sourceDir, relPath);
|
|
116
|
+
await mkdir(cliHomeDir, { recursive: true });
|
|
117
|
+
await copyFile(source, target);
|
|
118
|
+
await chmod(target, mode).catch(() => {});
|
|
119
|
+
console.log(`[local] migrated ${label}: ${source} -> ${target}`);
|
|
120
|
+
return { copied: true, source, target };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// access.key holds the auth token + encryption material (keep tight permissions)
|
|
124
|
+
const access = await copyIfMissing({ relPath: 'access.key', mode: 0o600, label: 'CLI credentials (access.key)' })
|
|
125
|
+
.catch((err) => {
|
|
126
|
+
console.warn(`[local] failed to migrate CLI credentials into ${cliHomeDir}:`, err);
|
|
127
|
+
return { copied: false, source: null, target: join(cliHomeDir, 'access.key') };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// settings.json holds machineId and other client state; migrate to keep your machine identity stable.
|
|
131
|
+
const settings = await copyIfMissing({ relPath: 'settings.json', mode: 0o600, label: 'CLI settings (settings.json)' })
|
|
132
|
+
.catch((err) => {
|
|
133
|
+
console.warn(`[local] failed to migrate CLI settings into ${cliHomeDir}:`, err);
|
|
134
|
+
return { copied: false, source: null, target: join(cliHomeDir, 'settings.json') };
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return { ok: true, copied: access.copied || settings.copied, access, settings };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function killDaemonFromLockFile({ cliHomeDir }) {
|
|
141
|
+
const lockPath = join(cliHomeDir, 'daemon.state.json.lock');
|
|
142
|
+
if (!existsSync(lockPath)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let pid = null;
|
|
147
|
+
try {
|
|
148
|
+
const raw = readFileSync(lockPath, 'utf-8').trim();
|
|
149
|
+
const n = Number(raw);
|
|
150
|
+
if (Number.isFinite(n) && n > 0) {
|
|
151
|
+
pid = n;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// ignore
|
|
155
|
+
}
|
|
156
|
+
if (!pid) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// If pid is alive, confirm it looks like a happy daemon and terminate it.
|
|
161
|
+
try {
|
|
162
|
+
process.kill(pid, 0);
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let cmd = '';
|
|
168
|
+
try {
|
|
169
|
+
cmd = await runCapture('ps', ['-p', String(pid), '-o', 'command=']);
|
|
170
|
+
} catch {
|
|
171
|
+
cmd = '';
|
|
172
|
+
}
|
|
173
|
+
const looksLikeDaemon = cmd.includes(' daemon ') || cmd.includes('daemon start') || cmd.includes('daemon start-sync');
|
|
174
|
+
if (!looksLikeDaemon) {
|
|
175
|
+
console.warn(`[local] refusing to kill pid ${pid} from lock file (doesn't look like daemon): ${cmd.trim()}`);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
process.kill(pid, 'SIGTERM');
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
await delay(500);
|
|
185
|
+
try {
|
|
186
|
+
process.kill(pid, 0);
|
|
187
|
+
// Still alive: hard kill.
|
|
188
|
+
process.kill(pid, 'SIGKILL');
|
|
189
|
+
} catch {
|
|
190
|
+
// exited
|
|
191
|
+
}
|
|
192
|
+
console.log(`[local] killed stuck daemon pid ${pid} (from ${lockPath})`);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function waitForCredentialsFile({ path, timeoutMs, isShuttingDown }) {
|
|
197
|
+
const deadline = Date.now() + timeoutMs;
|
|
198
|
+
while (!isShuttingDown() && Date.now() < deadline) {
|
|
199
|
+
try {
|
|
200
|
+
if (existsSync(path)) {
|
|
201
|
+
const raw = readFileSync(path, 'utf-8').trim();
|
|
202
|
+
if (raw.length > 0) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// ignore
|
|
208
|
+
}
|
|
209
|
+
await delay(500);
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl }) {
|
|
215
|
+
return {
|
|
216
|
+
...baseEnv,
|
|
217
|
+
HAPPY_SERVER_URL: internalServerUrl,
|
|
218
|
+
HAPPY_WEBAPP_URL: publicServerUrl,
|
|
219
|
+
HAPPY_HOME_DIR: cliHomeDir,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir }) {
|
|
224
|
+
const env = {
|
|
225
|
+
...process.env,
|
|
226
|
+
HAPPY_SERVER_URL: internalServerUrl,
|
|
227
|
+
HAPPY_HOME_DIR: cliHomeDir,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await new Promise((resolve) => {
|
|
232
|
+
const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], env, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
233
|
+
proc.on('exit', () => resolve());
|
|
234
|
+
});
|
|
235
|
+
} catch {
|
|
236
|
+
// ignore
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// If the daemon never wrote daemon.state.json (e.g. it got stuck in auth in a non-interactive context),
|
|
240
|
+
// stopLocalDaemon() can't find it. Fall back to the lock file PID.
|
|
241
|
+
await killDaemonFromLockFile({ cliHomeDir });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function startLocalDaemonWithAuth({
|
|
245
|
+
cliBin,
|
|
246
|
+
cliHomeDir,
|
|
247
|
+
internalServerUrl,
|
|
248
|
+
publicServerUrl,
|
|
249
|
+
isShuttingDown,
|
|
250
|
+
}) {
|
|
251
|
+
const baseEnv = { ...process.env };
|
|
252
|
+
const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
|
|
253
|
+
|
|
254
|
+
// If this is a migrated/new stack home dir, seed credentials from the user's existing login (best-effort)
|
|
255
|
+
// to avoid requiring an interactive auth flow under launchd.
|
|
256
|
+
await seedCredentialsIfMissing({ cliHomeDir });
|
|
257
|
+
|
|
258
|
+
// Stop any existing daemon (best-effort) in both legacy and local home dirs.
|
|
259
|
+
const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
|
|
260
|
+
try {
|
|
261
|
+
await new Promise((resolve) => {
|
|
262
|
+
const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], legacyEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
263
|
+
proc.on('exit', () => resolve());
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
await new Promise((resolve) => {
|
|
270
|
+
const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], daemonEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
271
|
+
proc.on('exit', () => resolve());
|
|
272
|
+
});
|
|
273
|
+
} catch {
|
|
274
|
+
// ignore
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// If state is missing and stop couldn't find it, force-stop the lock PID (otherwise repeated restarts accumulate daemons).
|
|
278
|
+
await killDaemonFromLockFile({ cliHomeDir: join(homedir(), '.happy') });
|
|
279
|
+
await killDaemonFromLockFile({ cliHomeDir });
|
|
280
|
+
|
|
281
|
+
// Clean up stale lock/state files that can block daemon start.
|
|
282
|
+
cleanupStaleDaemonState(join(homedir(), '.happy'));
|
|
283
|
+
cleanupStaleDaemonState(cliHomeDir);
|
|
284
|
+
|
|
285
|
+
const credentialsPath = join(cliHomeDir, 'access.key');
|
|
286
|
+
|
|
287
|
+
const startOnce = async () => {
|
|
288
|
+
const exitCode = await new Promise((resolve) => {
|
|
289
|
+
const proc = spawnProc('daemon', cliBin, ['daemon', 'start'], daemonEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
290
|
+
proc.on('exit', (code) => resolve(code ?? 0));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (exitCode === 0) {
|
|
294
|
+
return { ok: true, exitCode, excerpt: null, logPath: null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const logPath = getLatestDaemonLogPath(cliHomeDir) || getLatestDaemonLogPath(join(homedir(), '.happy'));
|
|
298
|
+
const excerpt = logPath ? readLastLines(logPath, 120) : null;
|
|
299
|
+
return { ok: false, exitCode, excerpt, logPath };
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const first = await startOnce();
|
|
303
|
+
if (!first.ok) {
|
|
304
|
+
if (first.excerpt) {
|
|
305
|
+
console.error(`[local] daemon failed to start; last daemon log (${first.logPath}):\n${first.excerpt}`);
|
|
306
|
+
} else {
|
|
307
|
+
console.error('[local] daemon failed to start; no daemon log found');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (excerptIndicatesMissingAuth(first.excerpt)) {
|
|
311
|
+
console.error(
|
|
312
|
+
`[local] daemon is not authenticated yet (expected on first run).\n` +
|
|
313
|
+
`[local] Keeping the server running so you can login.\n` +
|
|
314
|
+
`[local] In another terminal, run:\n` +
|
|
315
|
+
`${authLoginHint()}\n` +
|
|
316
|
+
`[local] Waiting for credentials at ${credentialsPath}...`
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const ok = await waitForCredentialsFile({ path: credentialsPath, timeoutMs: 10 * 60_000, isShuttingDown });
|
|
320
|
+
if (!ok) {
|
|
321
|
+
throw new Error('Timed out waiting for daemon credentials (auth login not completed)');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
console.log('[local] credentials detected, retrying daemon start...');
|
|
325
|
+
const second = await startOnce();
|
|
326
|
+
if (!second.ok) {
|
|
327
|
+
if (second.excerpt) {
|
|
328
|
+
console.error(`[local] daemon still failed to start; last daemon log (${second.logPath}):\n${second.excerpt}`);
|
|
329
|
+
}
|
|
330
|
+
throw new Error('Failed to start daemon (after credentials were created)');
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
console.error(`[local] To authenticate against this local server, run:\n${authLoginHint()}`);
|
|
334
|
+
throw new Error('Failed to start daemon');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Confirm daemon status (best-effort)
|
|
339
|
+
try {
|
|
340
|
+
await run('node', [cliBin, 'daemon', 'status'], { env: daemonEnv });
|
|
341
|
+
} catch {
|
|
342
|
+
// ignore
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function daemonStatusSummary({ cliBin, cliHomeDir, internalServerUrl, publicServerUrl }) {
|
|
347
|
+
const env = getDaemonEnv({ baseEnv: process.env, cliHomeDir, internalServerUrl, publicServerUrl });
|
|
348
|
+
return await runCapture('node', [cliBin, 'daemon', 'status'], { env });
|
|
349
|
+
}
|
package/scripts/dev.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import './utils/env.mjs';
|
|
2
|
+
import { parseArgs } from './utils/args.mjs';
|
|
3
|
+
import { killProcessTree } from './utils/proc.mjs';
|
|
4
|
+
import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
|
|
5
|
+
import { killPortListeners } from './utils/ports.mjs';
|
|
6
|
+
import { getServerComponentName, waitForServerReady } from './utils/server.mjs';
|
|
7
|
+
import { ensureDepsInstalled, pmSpawnScript, requireDir } from './utils/pm.mjs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
|
|
12
|
+
import { resolvePublicServerUrl } from './tailscale.mjs';
|
|
13
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
14
|
+
import { assertServerComponentDirMatches } from './utils/validate.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Dev mode stack:
|
|
18
|
+
* - happy-server-light
|
|
19
|
+
* - happy-cli daemon
|
|
20
|
+
* - Expo web dev server (watch/reload)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
const { flags, kv } = parseArgs(argv);
|
|
26
|
+
const json = wantsJson(argv, { flags });
|
|
27
|
+
if (wantsHelp(argv, { flags })) {
|
|
28
|
+
printResult({
|
|
29
|
+
json,
|
|
30
|
+
data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon'], json: true },
|
|
31
|
+
text: [
|
|
32
|
+
'[dev] usage:',
|
|
33
|
+
' happys dev [--server=happy-server|happy-server-light] [--json]',
|
|
34
|
+
' note: --json prints the resolved config (dry-run) and exits.',
|
|
35
|
+
].join('\n'),
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const rootDir = getRootDir(import.meta.url);
|
|
40
|
+
|
|
41
|
+
const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
|
|
42
|
+
? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
|
|
43
|
+
: 3005;
|
|
44
|
+
|
|
45
|
+
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
46
|
+
const defaultPublicUrl = `http://localhost:${serverPort}`;
|
|
47
|
+
const envPublicUrl = process.env.HAPPY_LOCAL_SERVER_URL?.trim() ? process.env.HAPPY_LOCAL_SERVER_URL.trim() : '';
|
|
48
|
+
const resolved = await resolvePublicServerUrl({
|
|
49
|
+
internalServerUrl,
|
|
50
|
+
defaultPublicUrl,
|
|
51
|
+
envPublicUrl,
|
|
52
|
+
allowEnable: true,
|
|
53
|
+
});
|
|
54
|
+
const publicServerUrl = resolved.publicServerUrl;
|
|
55
|
+
|
|
56
|
+
const serverComponentName = getServerComponentName({ kv });
|
|
57
|
+
if (serverComponentName === 'both') {
|
|
58
|
+
throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
|
|
62
|
+
const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
|
|
63
|
+
|
|
64
|
+
const serverDir = getComponentDir(rootDir, serverComponentName);
|
|
65
|
+
const uiDir = getComponentDir(rootDir, 'happy');
|
|
66
|
+
const cliDir = getComponentDir(rootDir, 'happy-cli');
|
|
67
|
+
|
|
68
|
+
assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
|
|
69
|
+
|
|
70
|
+
await requireDir(serverComponentName, serverDir);
|
|
71
|
+
await requireDir('happy', uiDir);
|
|
72
|
+
await requireDir('happy-cli', cliDir);
|
|
73
|
+
|
|
74
|
+
const cliBin = join(cliDir, 'bin', 'happy.mjs');
|
|
75
|
+
const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
|
|
76
|
+
? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
|
|
77
|
+
: join(getDefaultAutostartPaths().baseDir, 'cli');
|
|
78
|
+
|
|
79
|
+
if (json) {
|
|
80
|
+
printResult({
|
|
81
|
+
json,
|
|
82
|
+
data: {
|
|
83
|
+
mode: 'dev',
|
|
84
|
+
serverComponentName,
|
|
85
|
+
serverDir,
|
|
86
|
+
uiDir,
|
|
87
|
+
cliDir,
|
|
88
|
+
serverPort,
|
|
89
|
+
internalServerUrl,
|
|
90
|
+
publicServerUrl,
|
|
91
|
+
startUi,
|
|
92
|
+
startDaemon,
|
|
93
|
+
cliHomeDir,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const children = [];
|
|
100
|
+
let shuttingDown = false;
|
|
101
|
+
const baseEnv = { ...process.env };
|
|
102
|
+
|
|
103
|
+
// Start server
|
|
104
|
+
await killPortListeners(serverPort, { label: 'server' });
|
|
105
|
+
const serverEnv = {
|
|
106
|
+
...baseEnv,
|
|
107
|
+
PORT: String(serverPort),
|
|
108
|
+
PUBLIC_URL: publicServerUrl,
|
|
109
|
+
// Avoid noisy failures if a previous run left the metrics port busy.
|
|
110
|
+
METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
|
|
111
|
+
};
|
|
112
|
+
await ensureDepsInstalled(serverDir, serverComponentName);
|
|
113
|
+
const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'dev', env: serverEnv });
|
|
114
|
+
children.push(server);
|
|
115
|
+
|
|
116
|
+
await waitForServerReady(internalServerUrl);
|
|
117
|
+
console.log(`[local] server ready at ${internalServerUrl}`);
|
|
118
|
+
console.log(
|
|
119
|
+
`[local] tip: to run 'happy' from your terminal *against this local server* (and have sessions show up in the UI), use:\n` +
|
|
120
|
+
`export HAPPY_SERVER_URL=\"${internalServerUrl}\"\n` +
|
|
121
|
+
`export HAPPY_HOME_DIR=\"${cliHomeDir}\"\n` +
|
|
122
|
+
`export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Start daemon (detached daemon process managed by happy-cli)
|
|
126
|
+
if (startDaemon) {
|
|
127
|
+
await startLocalDaemonWithAuth({
|
|
128
|
+
cliBin,
|
|
129
|
+
cliHomeDir,
|
|
130
|
+
internalServerUrl,
|
|
131
|
+
publicServerUrl,
|
|
132
|
+
isShuttingDown: () => shuttingDown,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Start UI (Expo web dev server)
|
|
137
|
+
if (startUi) {
|
|
138
|
+
await ensureDepsInstalled(uiDir, 'happy');
|
|
139
|
+
const uiEnv = { ...baseEnv };
|
|
140
|
+
delete uiEnv.CI;
|
|
141
|
+
uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = publicServerUrl;
|
|
142
|
+
uiEnv.EXPO_PUBLIC_DEBUG = uiEnv.EXPO_PUBLIC_DEBUG ?? '1';
|
|
143
|
+
const ui = await pmSpawnScript({ label: 'ui', dir: uiDir, script: 'web', env: uiEnv });
|
|
144
|
+
children.push(ui);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const shutdown = async () => {
|
|
148
|
+
if (shuttingDown) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
shuttingDown = true;
|
|
152
|
+
console.log('\n[local] shutting down...');
|
|
153
|
+
|
|
154
|
+
if (startDaemon) {
|
|
155
|
+
await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const child of children) {
|
|
159
|
+
if (child.exitCode == null) {
|
|
160
|
+
killProcessTree(child, 'SIGINT');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await delay(1500);
|
|
165
|
+
for (const child of children) {
|
|
166
|
+
if (child.exitCode == null) {
|
|
167
|
+
killProcessTree(child, 'SIGKILL');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
process.on('SIGINT', () => shutdown().then(() => process.exit(0)));
|
|
173
|
+
process.on('SIGTERM', () => shutdown().then(() => process.exit(0)));
|
|
174
|
+
|
|
175
|
+
await new Promise(() => {});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
main().catch((err) => {
|
|
179
|
+
console.error('[local] failed:', err);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|