happy-stacks 0.0.0 → 0.1.2

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 (42) hide show
  1. package/README.md +22 -4
  2. package/bin/happys.mjs +76 -5
  3. package/docs/server-flavors.md +61 -2
  4. package/docs/stacks.md +16 -4
  5. package/extras/swiftbar/auth-login.sh +5 -5
  6. package/extras/swiftbar/happy-stacks.5s.sh +83 -41
  7. package/extras/swiftbar/happys-term.sh +151 -0
  8. package/extras/swiftbar/happys.sh +52 -0
  9. package/extras/swiftbar/lib/render.sh +74 -56
  10. package/extras/swiftbar/lib/system.sh +37 -6
  11. package/extras/swiftbar/lib/utils.sh +180 -4
  12. package/extras/swiftbar/pnpm-term.sh +2 -122
  13. package/extras/swiftbar/pnpm.sh +2 -13
  14. package/extras/swiftbar/set-server-flavor.sh +8 -8
  15. package/extras/swiftbar/wt-pr.sh +1 -1
  16. package/package.json +1 -1
  17. package/scripts/auth.mjs +374 -3
  18. package/scripts/daemon.mjs +78 -11
  19. package/scripts/dev.mjs +122 -17
  20. package/scripts/init.mjs +238 -32
  21. package/scripts/migrate.mjs +292 -0
  22. package/scripts/mobile.mjs +51 -19
  23. package/scripts/run.mjs +118 -26
  24. package/scripts/service.mjs +176 -37
  25. package/scripts/stack.mjs +665 -22
  26. package/scripts/stop.mjs +157 -0
  27. package/scripts/tailscale.mjs +147 -21
  28. package/scripts/typecheck.mjs +145 -0
  29. package/scripts/ui_gateway.mjs +248 -0
  30. package/scripts/uninstall.mjs +3 -3
  31. package/scripts/utils/cli_registry.mjs +23 -0
  32. package/scripts/utils/config.mjs +9 -1
  33. package/scripts/utils/env.mjs +37 -15
  34. package/scripts/utils/expo.mjs +94 -0
  35. package/scripts/utils/happy_server_infra.mjs +430 -0
  36. package/scripts/utils/pm.mjs +11 -2
  37. package/scripts/utils/ports.mjs +51 -13
  38. package/scripts/utils/proc.mjs +46 -5
  39. package/scripts/utils/server.mjs +37 -0
  40. package/scripts/utils/stack_stop.mjs +206 -0
  41. package/scripts/utils/validate.mjs +42 -1
  42. package/scripts/worktrees.mjs +53 -7
@@ -61,6 +61,52 @@ export function cleanupStaleDaemonState(homeDir) {
61
61
  try { unlinkSync(statePath); } catch { /* ignore */ }
62
62
  }
63
63
 
64
+ export function checkDaemonState(cliHomeDir) {
65
+ const statePath = join(cliHomeDir, 'daemon.state.json');
66
+ const lockPath = join(cliHomeDir, 'daemon.state.json.lock');
67
+
68
+ const alive = (pid) => {
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ };
76
+
77
+ if (existsSync(statePath)) {
78
+ try {
79
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'));
80
+ const pid = Number(state?.pid);
81
+ if (Number.isFinite(pid) && pid > 0) {
82
+ return alive(pid) ? { status: 'running', pid } : { status: 'stale_state', pid };
83
+ }
84
+ return { status: 'bad_state', pid: null };
85
+ } catch {
86
+ return { status: 'bad_state', pid: null };
87
+ }
88
+ }
89
+
90
+ if (existsSync(lockPath)) {
91
+ try {
92
+ const pid = Number(readFileSync(lockPath, 'utf-8').trim());
93
+ if (Number.isFinite(pid) && pid > 0) {
94
+ return alive(pid) ? { status: 'starting', pid } : { status: 'stale_lock', pid };
95
+ }
96
+ return { status: 'bad_lock', pid: null };
97
+ } catch {
98
+ return { status: 'bad_lock', pid: null };
99
+ }
100
+ }
101
+
102
+ return { status: 'stopped', pid: null };
103
+ }
104
+
105
+ export function isDaemonRunning(cliHomeDir) {
106
+ const s = checkDaemonState(cliHomeDir);
107
+ return s.status === 'running' || s.status === 'starting';
108
+ }
109
+
64
110
  function getLatestDaemonLogPath(homeDir) {
65
111
  try {
66
112
  const logsDir = join(homeDir, 'logs');
@@ -95,6 +141,11 @@ function authLoginHint() {
95
141
  return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
96
142
  }
97
143
 
144
+ function authCopyFromMainHint() {
145
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
146
+ return stackName === 'main' ? null : `happys stack auth ${stackName} copy-from main`;
147
+ }
148
+
98
149
  async function seedCredentialsIfMissing({ cliHomeDir }) {
99
150
  const sources = [
100
151
  // Legacy happy-local storage root (most common for existing users).
@@ -247,7 +298,9 @@ export async function startLocalDaemonWithAuth({
247
298
  internalServerUrl,
248
299
  publicServerUrl,
249
300
  isShuttingDown,
301
+ forceRestart = false,
250
302
  }) {
303
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
251
304
  const baseEnv = { ...process.env };
252
305
  const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
253
306
 
@@ -255,16 +308,14 @@ export async function startLocalDaemonWithAuth({
255
308
  // to avoid requiring an interactive auth flow under launchd.
256
309
  await seedCredentialsIfMissing({ cliHomeDir });
257
310
 
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
311
+ const existing = checkDaemonState(cliHomeDir);
312
+ if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
313
+ // eslint-disable-next-line no-console
314
+ console.log(`[local] daemon already running for stack home (pid=${existing.pid})`);
315
+ return;
267
316
  }
317
+
318
+ // Stop any existing daemon for THIS stack home dir.
268
319
  try {
269
320
  await new Promise((resolve) => {
270
321
  const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], daemonEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
@@ -274,12 +325,26 @@ export async function startLocalDaemonWithAuth({
274
325
  // ignore
275
326
  }
276
327
 
328
+ // Best-effort: for the main stack, also stop the legacy global daemon home (~/.happy) to prevent legacy overlap.
329
+ if (stackName === 'main') {
330
+ const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
331
+ try {
332
+ await new Promise((resolve) => {
333
+ const proc = spawnProc('daemon', cliBin, ['daemon', 'stop'], legacyEnv, { stdio: ['ignore', 'pipe', 'pipe'] });
334
+ proc.on('exit', () => resolve());
335
+ });
336
+ } catch {
337
+ // ignore
338
+ }
339
+ // If state is missing and stop couldn't find it, force-stop the lock PID (otherwise repeated restarts accumulate daemons).
340
+ await killDaemonFromLockFile({ cliHomeDir: join(homedir(), '.happy') });
341
+ cleanupStaleDaemonState(join(homedir(), '.happy'));
342
+ }
343
+
277
344
  // 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
345
  await killDaemonFromLockFile({ cliHomeDir });
280
346
 
281
347
  // Clean up stale lock/state files that can block daemon start.
282
- cleanupStaleDaemonState(join(homedir(), '.happy'));
283
348
  cleanupStaleDaemonState(cliHomeDir);
284
349
 
285
350
  const credentialsPath = join(cliHomeDir, 'access.key');
@@ -308,11 +373,13 @@ export async function startLocalDaemonWithAuth({
308
373
  }
309
374
 
310
375
  if (excerptIndicatesMissingAuth(first.excerpt)) {
376
+ const copyHint = authCopyFromMainHint();
311
377
  console.error(
312
378
  `[local] daemon is not authenticated yet (expected on first run).\n` +
313
379
  `[local] Keeping the server running so you can login.\n` +
314
380
  `[local] In another terminal, run:\n` +
315
381
  `${authLoginHint()}\n` +
382
+ (copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '') +
316
383
  `[local] Waiting for credentials at ${credentialsPath}...`
317
384
  );
318
385
 
package/scripts/dev.mjs CHANGED
@@ -2,16 +2,18 @@ import './utils/env.mjs';
2
2
  import { parseArgs } from './utils/args.mjs';
3
3
  import { killProcessTree } from './utils/proc.mjs';
4
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';
5
+ import { killPortListeners, pickNextFreeTcpPort } from './utils/ports.mjs';
6
+ import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server.mjs';
7
+ import { ensureDepsInstalled, pmSpawnBin, pmSpawnScript, requireDir } from './utils/pm.mjs';
8
8
  import { join } from 'node:path';
9
9
  import { setTimeout as delay } from 'node:timers/promises';
10
10
  import { homedir } from 'node:os';
11
- import { startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
11
+ import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
12
12
  import { resolvePublicServerUrl } from './tailscale.mjs';
13
13
  import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
14
- import { assertServerComponentDirMatches } from './utils/validate.mjs';
14
+ import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/validate.mjs';
15
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/happy_server_infra.mjs';
16
+ import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo.mjs';
15
17
 
16
18
  /**
17
19
  * Dev mode stack:
@@ -27,10 +29,10 @@ async function main() {
27
29
  if (wantsHelp(argv, { flags })) {
28
30
  printResult({
29
31
  json,
30
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon'], json: true },
32
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart'], json: true },
31
33
  text: [
32
34
  '[dev] usage:',
33
- ' happys dev [--server=happy-server|happy-server-light] [--json]',
35
+ ' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
34
36
  ' note: --json prints the resolved config (dry-run) and exits.',
35
37
  ].join('\n'),
36
38
  });
@@ -60,21 +62,24 @@ async function main() {
60
62
 
61
63
  const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
62
64
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
65
+ const restart = flags.has('--restart');
63
66
 
64
67
  const serverDir = getComponentDir(rootDir, serverComponentName);
65
68
  const uiDir = getComponentDir(rootDir, 'happy');
66
69
  const cliDir = getComponentDir(rootDir, 'happy-cli');
67
70
 
68
71
  assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
72
+ assertServerPrismaProviderMatches({ serverComponentName, serverDir });
69
73
 
70
74
  await requireDir(serverComponentName, serverDir);
71
75
  await requireDir('happy', uiDir);
72
76
  await requireDir('happy-cli', cliDir);
73
77
 
74
78
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
79
+ const autostart = getDefaultAutostartPaths();
75
80
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
76
81
  ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
77
- : join(getDefaultAutostartPaths().baseDir, 'cli');
82
+ : join(autostart.baseDir, 'cli');
78
83
 
79
84
  if (json) {
80
85
  printResult({
@@ -100,8 +105,23 @@ async function main() {
100
105
  let shuttingDown = false;
101
106
  const baseEnv = { ...process.env };
102
107
 
103
- // Start server
104
- await killPortListeners(serverPort, { label: 'server' });
108
+ const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
109
+ const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
110
+
111
+ // UI dev server state (worktree-scoped)
112
+ const uiPaths = getExpoStatePaths({ baseDir: autostart.baseDir, kind: 'ui-dev', projectDir: uiDir, stateFileName: 'ui.state.json' });
113
+ const uiRunning = startUi ? await isStateProcessRunning(uiPaths.statePath) : { running: false, state: null };
114
+ let uiAlreadyRunning = Boolean(uiRunning.running);
115
+
116
+ if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startUi || uiAlreadyRunning)) {
117
+ console.log(`[local] dev: stack already running (server=${internalServerUrl}${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''}${startUi ? ` ui=${uiAlreadyRunning ? 'running' : 'stopped'}` : ''})`);
118
+ return;
119
+ }
120
+
121
+ // Start server (only if not already healthy)
122
+ if (!serverAlreadyRunning || restart) {
123
+ await killPortListeners(serverPort, { label: 'server' });
124
+ }
105
125
  const serverEnv = {
106
126
  ...baseEnv,
107
127
  PORT: String(serverPort),
@@ -109,12 +129,56 @@ async function main() {
109
129
  // Avoid noisy failures if a previous run left the metrics port busy.
110
130
  METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
111
131
  };
112
- await ensureDepsInstalled(serverDir, serverComponentName);
113
- const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'dev', env: serverEnv });
114
- children.push(server);
132
+ if (serverComponentName === 'happy-server-light') {
133
+ const dataDir = baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR?.trim()
134
+ ? baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR.trim()
135
+ : join(autostart.baseDir, 'server-light');
136
+ serverEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
137
+ serverEnv.HAPPY_SERVER_LIGHT_FILES_DIR = baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR?.trim()
138
+ ? baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR.trim()
139
+ : join(dataDir, 'files');
140
+ }
141
+ if (serverComponentName === 'happy-server') {
142
+ const managed = (baseEnv.HAPPY_STACKS_MANAGED_INFRA ?? baseEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1') !== '0';
143
+ if (managed) {
144
+ const envPath = baseEnv.HAPPY_STACKS_ENV_FILE ?? baseEnv.HAPPY_LOCAL_ENV_FILE ?? '';
145
+ const infra = await ensureHappyServerManagedInfra({
146
+ stackName: autostart.stackName,
147
+ baseDir: autostart.baseDir,
148
+ serverPort,
149
+ publicServerUrl,
150
+ envPath,
151
+ env: baseEnv,
152
+ });
153
+ Object.assign(serverEnv, infra.env);
154
+ }
115
155
 
116
- await waitForServerReady(internalServerUrl);
117
- console.log(`[local] server ready at ${internalServerUrl}`);
156
+ const autoMigrate = (baseEnv.HAPPY_STACKS_PRISMA_MIGRATE ?? baseEnv.HAPPY_LOCAL_PRISMA_MIGRATE ?? '1') !== '0';
157
+ if (autoMigrate) {
158
+ await applyHappyServerMigrations({ serverDir, env: serverEnv });
159
+ }
160
+ }
161
+ await ensureDepsInstalled(serverDir, serverComponentName);
162
+ // For happy-server: the upstream `dev` script is not stack-safe (kills fixed ports, reads .env.dev).
163
+ // Use `start` and rely on stack-scoped env + optional migrations above.
164
+ //
165
+ // For happy-server-light: the upstream `dev` script runs `prisma db push` automatically. If you want to skip
166
+ // it (e.g. big sqlite DB), set HAPPY_STACKS_PRISMA_PUSH=0 to use `start` even in dev mode.
167
+ const prismaPush = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '1').trim() !== '0';
168
+ const serverScript =
169
+ serverComponentName === 'happy-server'
170
+ ? 'start'
171
+ : serverComponentName === 'happy-server-light' && !prismaPush
172
+ ? 'start'
173
+ : 'dev';
174
+ if (!serverAlreadyRunning || restart) {
175
+ const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
176
+ children.push(server);
177
+ await waitForServerReady(internalServerUrl);
178
+ console.log(`[local] server ready at ${internalServerUrl}`);
179
+ } else {
180
+ console.log(`[local] server already running at ${internalServerUrl}`);
181
+ }
118
182
  console.log(
119
183
  `[local] tip: to run 'happy' from your terminal *against this local server* (and have sessions show up in the UI), use:\n` +
120
184
  `export HAPPY_SERVER_URL=\"${internalServerUrl}\"\n` +
@@ -130,6 +194,7 @@ async function main() {
130
194
  internalServerUrl,
131
195
  publicServerUrl,
132
196
  isShuttingDown: () => shuttingDown,
197
+ forceRestart: restart,
133
198
  });
134
199
  }
135
200
 
@@ -140,8 +205,48 @@ async function main() {
140
205
  delete uiEnv.CI;
141
206
  uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = publicServerUrl;
142
207
  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);
208
+
209
+ await ensureExpoIsolationEnv({
210
+ env: uiEnv,
211
+ stateDir: uiPaths.stateDir,
212
+ expoHomeDir: uiPaths.expoHomeDir,
213
+ tmpDir: uiPaths.tmpDir,
214
+ });
215
+
216
+ // Expo uses Metro (default 8081). If it's already used by another worktree/stack,
217
+ // Expo prompts to pick another port, which fails in non-interactive mode.
218
+ // Pick a free port up-front to make LLM/CI/service runs reliable.
219
+ const defaultMetroPort = 8081;
220
+ const metroPort = await pickNextFreeTcpPort(defaultMetroPort);
221
+ uiEnv.RCT_METRO_PORT = String(metroPort);
222
+ // eslint-disable-next-line no-console
223
+ console.log(`[local] ui: starting Expo web (metro port=${metroPort})`);
224
+
225
+ const uiArgs = ['start', '--web', '--port', String(metroPort)];
226
+ if (wantsExpoClearCache({ env: baseEnv })) {
227
+ uiArgs.push('--clear');
228
+ }
229
+
230
+ if (!uiAlreadyRunning || restart) {
231
+ if (restart && uiRunning.state?.pid) {
232
+ const prevPid = Number(uiRunning.state.pid);
233
+ const prevPort = Number(uiRunning.state.port);
234
+ if (Number.isFinite(prevPort) && prevPort > 0) {
235
+ await killPortListeners(prevPort, { label: 'ui' });
236
+ }
237
+ await killPid(prevPid);
238
+ uiAlreadyRunning = false;
239
+ }
240
+ const ui = await pmSpawnBin({ label: 'ui', dir: uiDir, bin: 'expo', args: uiArgs, env: uiEnv });
241
+ children.push(ui);
242
+ try {
243
+ await writePidState(uiPaths.statePath, { pid: ui.pid, port: metroPort, uiDir, startedAt: new Date().toISOString() });
244
+ } catch {
245
+ // ignore
246
+ }
247
+ } else {
248
+ console.log('[local] ui already running (skipping Expo start)');
249
+ }
145
250
  }
146
251
 
147
252
  const shutdown = async () => {