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
@@ -0,0 +1,206 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ import { getComponentDir } from './paths.mjs';
6
+ import { killPortListeners } from './ports.mjs';
7
+ import { isPidAlive, killPid, readPidState } from './expo.mjs';
8
+ import { stopLocalDaemon } from '../daemon.mjs';
9
+ import { stopHappyServerManagedInfra } from './happy_server_infra.mjs';
10
+
11
+ function parseIntOrNull(raw) {
12
+ const s = String(raw ?? '').trim();
13
+ if (!s) return null;
14
+ const n = Number(s);
15
+ return Number.isFinite(n) && n > 0 ? n : null;
16
+ }
17
+
18
+ function resolveServerComponentFromStackEnv(env) {
19
+ const v =
20
+ (env.HAPPY_STACKS_SERVER_COMPONENT ?? env.HAPPY_LOCAL_SERVER_COMPONENT ?? '').toString().trim() || 'happy-server-light';
21
+ return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
22
+ }
23
+
24
+ async function daemonControlPost({ httpPort, path, body = {} }) {
25
+ const ctl = new AbortController();
26
+ const t = setTimeout(() => ctl.abort(), 1500);
27
+ try {
28
+ const res = await fetch(`http://127.0.0.1:${httpPort}${path}`, {
29
+ method: 'POST',
30
+ headers: { 'content-type': 'application/json' },
31
+ body: JSON.stringify(body),
32
+ signal: ctl.signal,
33
+ });
34
+ const text = await res.text();
35
+ if (!res.ok) {
36
+ throw new Error(`daemon control ${path} failed (http ${res.status}): ${text.trim()}`);
37
+ }
38
+ return text.trim() ? JSON.parse(text) : null;
39
+ } finally {
40
+ clearTimeout(t);
41
+ }
42
+ }
43
+
44
+ async function stopDaemonTrackedSessions({ cliHomeDir, json }) {
45
+ // Read daemon state file written by happy-cli; needed to call control server (/list, /stop-session).
46
+ const statePath = join(cliHomeDir, 'daemon.state.json');
47
+ if (!existsSync(statePath)) {
48
+ return { ok: true, skipped: true, reason: 'missing_state', stoppedSessionIds: [] };
49
+ }
50
+
51
+ let state = null;
52
+ try {
53
+ state = JSON.parse(await readFile(statePath, 'utf-8'));
54
+ } catch {
55
+ return { ok: false, skipped: true, reason: 'bad_state', stoppedSessionIds: [] };
56
+ }
57
+
58
+ const httpPort = Number(state?.httpPort);
59
+ const pid = Number(state?.pid);
60
+ if (!Number.isFinite(httpPort) || httpPort <= 0) {
61
+ return { ok: false, skipped: true, reason: 'missing_http_port', stoppedSessionIds: [] };
62
+ }
63
+ if (!Number.isFinite(pid) || pid <= 1) {
64
+ return { ok: false, skipped: true, reason: 'missing_pid', stoppedSessionIds: [] };
65
+ }
66
+ try {
67
+ process.kill(pid, 0);
68
+ } catch {
69
+ return { ok: true, skipped: true, reason: 'daemon_not_running', stoppedSessionIds: [] };
70
+ }
71
+
72
+ const listed = await daemonControlPost({ httpPort, path: '/list' }).catch((e) => {
73
+ if (!json) console.warn(`[stack] failed to list daemon sessions: ${e instanceof Error ? e.message : String(e)}`);
74
+ return null;
75
+ });
76
+ const children = Array.isArray(listed?.children) ? listed.children : [];
77
+
78
+ const stoppedSessionIds = [];
79
+ for (const child of children) {
80
+ const sid = String(child?.happySessionId ?? '').trim();
81
+ if (!sid) continue;
82
+ // eslint-disable-next-line no-await-in-loop
83
+ const res = await daemonControlPost({ httpPort, path: '/stop-session', body: { sessionId: sid } }).catch(() => null);
84
+ if (res?.success) {
85
+ stoppedSessionIds.push(sid);
86
+ }
87
+ }
88
+
89
+ return { ok: true, skipped: false, stoppedSessionIds };
90
+ }
91
+
92
+ async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, json }) {
93
+ const root = join(baseDir, kind);
94
+ let entries = [];
95
+ try {
96
+ entries = await readdir(root, { withFileTypes: true });
97
+ } catch {
98
+ entries = [];
99
+ }
100
+
101
+ const killed = [];
102
+ for (const e of entries) {
103
+ if (!e.isDirectory()) continue;
104
+ const statePath = join(root, e.name, stateFileName);
105
+ // eslint-disable-next-line no-await-in-loop
106
+ const state = await readPidState(statePath);
107
+ if (!state) continue;
108
+ const pid = Number(state.pid);
109
+ const port = parseIntOrNull(state.port);
110
+
111
+ if (!Number.isFinite(pid) || pid <= 1) continue;
112
+ if (!isPidAlive(pid)) continue;
113
+
114
+ if (!json) {
115
+ // eslint-disable-next-line no-console
116
+ console.log(`[stack] stopping ${kind} (pid=${pid}${port ? ` port=${port}` : ''}) for ${stackName}`);
117
+ }
118
+ if (port) {
119
+ // eslint-disable-next-line no-await-in-loop
120
+ await killPortListeners(port, { label: `${stackName} ${kind}` });
121
+ }
122
+ // eslint-disable-next-line no-await-in-loop
123
+ await killPid(pid);
124
+ killed.push({ pid, port, statePath });
125
+ }
126
+ return killed;
127
+ }
128
+
129
+ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker = false, aggressive = false }) {
130
+ const actions = {
131
+ stackName,
132
+ baseDir,
133
+ aggressive,
134
+ daemonSessionsStopped: null,
135
+ daemonStopped: false,
136
+ killedPorts: [],
137
+ uiDev: [],
138
+ mobile: [],
139
+ infra: null,
140
+ errors: [],
141
+ };
142
+
143
+ const serverComponent = resolveServerComponentFromStackEnv(env);
144
+ const port = parseIntOrNull(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
145
+ const backendPort = parseIntOrNull(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
146
+ const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
147
+ const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
148
+
149
+ if (aggressive) {
150
+ try {
151
+ actions.daemonSessionsStopped = await stopDaemonTrackedSessions({ cliHomeDir, json });
152
+ } catch (e) {
153
+ actions.errors.push({ step: 'daemon-sessions', error: e instanceof Error ? e.message : String(e) });
154
+ }
155
+ }
156
+
157
+ try {
158
+ const internalServerUrl = port ? `http://127.0.0.1:${port}` : 'http://127.0.0.1:3005';
159
+ await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
160
+ actions.daemonStopped = true;
161
+ } catch (e) {
162
+ actions.errors.push({ step: 'daemon', error: e instanceof Error ? e.message : String(e) });
163
+ }
164
+
165
+ try {
166
+ actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', json });
167
+ } catch (e) {
168
+ actions.errors.push({ step: 'expo-ui', error: e instanceof Error ? e.message : String(e) });
169
+ }
170
+ try {
171
+ actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', json });
172
+ } catch (e) {
173
+ actions.errors.push({ step: 'expo-mobile', error: e instanceof Error ? e.message : String(e) });
174
+ }
175
+
176
+ if (backendPort) {
177
+ try {
178
+ const pids = await killPortListeners(backendPort, { label: `${stackName} happy-server-backend` });
179
+ actions.killedPorts.push({ port: backendPort, pids, label: 'happy-server-backend' });
180
+ } catch (e) {
181
+ actions.errors.push({ step: 'backend-port', error: e instanceof Error ? e.message : String(e) });
182
+ }
183
+ }
184
+ if (port) {
185
+ try {
186
+ const pids = await killPortListeners(port, { label: `${stackName} server` });
187
+ actions.killedPorts.push({ port, pids, label: 'server' });
188
+ } catch (e) {
189
+ actions.errors.push({ step: 'server-port', error: e instanceof Error ? e.message : String(e) });
190
+ }
191
+ }
192
+
193
+ const managed = (env.HAPPY_STACKS_MANAGED_INFRA ?? env.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0';
194
+ if (!noDocker && serverComponent === 'happy-server' && managed) {
195
+ try {
196
+ actions.infra = await stopHappyServerManagedInfra({ stackName, baseDir, removeVolumes: false });
197
+ } catch (e) {
198
+ actions.errors.push({ step: 'infra', error: e instanceof Error ? e.message : String(e) });
199
+ }
200
+ } else {
201
+ actions.infra = { ok: true, skipped: true, reason: noDocker ? 'no_docker' : 'not_managed_or_not_happy_server' };
202
+ }
203
+
204
+ return actions;
205
+ }
206
+
@@ -1,4 +1,5 @@
1
- import { resolve, sep } from 'node:path';
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
2
3
  import { getComponentsDir } from './paths.mjs';
3
4
 
4
5
  function isInside(path, dir) {
@@ -45,3 +46,43 @@ export function assertServerComponentDirMatches({ rootDir, serverComponentName,
45
46
  );
46
47
  }
47
48
 
49
+ function detectPrismaProvider(schemaText) {
50
+ // Best-effort parse of:
51
+ // datasource db { provider = "sqlite" ... }
52
+ const m = schemaText.match(/datasource\s+db\s*\{[\s\S]*?\bprovider\s*=\s*\"([a-zA-Z0-9_-]+)\"/m);
53
+ return m?.[1] ?? '';
54
+ }
55
+
56
+ export function assertServerPrismaProviderMatches({ serverComponentName, serverDir }) {
57
+ const schemaPath = join(serverDir, 'prisma', 'schema.prisma');
58
+ let schemaText = '';
59
+ try {
60
+ schemaText = readFileSync(schemaPath, 'utf-8');
61
+ } catch {
62
+ // If it doesn't exist, skip validation; not every server component necessarily uses Prisma.
63
+ return;
64
+ }
65
+
66
+ const provider = detectPrismaProvider(schemaText);
67
+ if (!provider) {
68
+ return;
69
+ }
70
+
71
+ if (serverComponentName === 'happy-server-light' && provider !== 'sqlite') {
72
+ throw new Error(
73
+ `[server] happy-server-light expects Prisma datasource provider \"sqlite\", but found \"${provider}\" in:\n` +
74
+ `- ${schemaPath}\n` +
75
+ `This usually means you're pointing happy-server-light at an upstream happy-server checkout/PR (Postgres).\n` +
76
+ `Fix: either switch server flavor to happy-server, or point happy-server-light at a fork checkout that keeps sqlite support.`
77
+ );
78
+ }
79
+
80
+ if (serverComponentName === 'happy-server' && provider === 'sqlite') {
81
+ throw new Error(
82
+ `[server] happy-server expects Prisma datasource provider \"postgresql\", but found \"sqlite\" in:\n` +
83
+ `- ${schemaPath}\n` +
84
+ `Fix: either switch server flavor to happy-server-light, or point happy-server at the full-server checkout.`
85
+ );
86
+ }
87
+ }
88
+
@@ -14,6 +14,14 @@ import { existsSync } from 'node:fs';
14
14
  import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/config.mjs';
15
15
  import { detectServerComponentDirMismatch } from './utils/validate.mjs';
16
16
 
17
+ function getActiveStackName() {
18
+ return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
19
+ }
20
+
21
+ function isMainStack() {
22
+ return getActiveStackName() === 'main';
23
+ }
24
+
17
25
  function getWorktreesRoot(rootDir) {
18
26
  return join(getComponentsDir(rootDir), '.worktrees');
19
27
  }
@@ -142,6 +150,7 @@ async function ensureWorktreeExclude(worktreeDir, patterns) {
142
150
  const want = patterns.map((p) => p.trim()).filter(Boolean).filter((p) => !existingLines.has(p));
143
151
  if (!want.length) return;
144
152
  const next = (existing ? existing.replace(/\s*$/, '') + '\n' : '') + want.join('\n') + '\n';
153
+ await mkdir(dirname(excludePath), { recursive: true });
145
154
  await writeFile(excludePath, next, 'utf-8');
146
155
  }
147
156
 
@@ -475,13 +484,33 @@ async function cmdMigrate({ rootDir }) {
475
484
  return { moved: totalMoved, branchesRenamed: totalRenamed };
476
485
  }
477
486
 
478
- async function cmdUse({ rootDir, args }) {
487
+ async function cmdUse({ rootDir, args, flags }) {
479
488
  const component = args[0];
480
489
  const spec = args[1];
481
490
  if (!component || !spec) {
482
491
  throw new Error('[wt] usage: happys wt use <component> <owner/branch|path|default>');
483
492
  }
484
493
 
494
+ // Safety: main stack should not be repointed to arbitrary worktrees by default.
495
+ // This is the most common “oops, the main stack now runs my PR checkout” footgun (especially for agents).
496
+ const force = Boolean(flags?.has('--force'));
497
+ if (!force && isMainStack() && spec !== 'default' && spec !== 'main') {
498
+ throw new Error(
499
+ `[wt] refusing to change main stack component override by default.\n` +
500
+ `- stack: main\n` +
501
+ `- component: ${component}\n` +
502
+ `- requested: ${spec}\n` +
503
+ `\n` +
504
+ `Recommendation:\n` +
505
+ `- Create a new isolated stack and switch that stack instead:\n` +
506
+ ` happys stack new exp1 --interactive\n` +
507
+ ` happys stack wt exp1 -- use ${component} ${spec}\n` +
508
+ `\n` +
509
+ `If you really intend to repoint the main stack, re-run with --force:\n` +
510
+ ` happys wt use ${component} ${spec} --force\n`
511
+ );
512
+ }
513
+
485
514
  const key = componentDirEnvKey(component);
486
515
  const worktreesRoot = getWorktreesRoot(rootDir);
487
516
  const envPath = process.env.HAPPY_STACKS_ENV_FILE?.trim()
@@ -577,10 +606,10 @@ async function cmdUseInteractive({ rootDir }) {
577
606
  options: specs.map((s) => ({ label: s, value: s })),
578
607
  defaultIndex: 0,
579
608
  });
580
- await cmdUse({ rootDir, args: [component, picked] });
609
+ await cmdUse({ rootDir, args: [component, picked], flags: new Set(['--force']) });
581
610
  return;
582
611
  }
583
- await cmdUse({ rootDir, args: [component, 'default'] });
612
+ await cmdUse({ rootDir, args: [component, 'default'], flags: new Set(['--force']) });
584
613
  });
585
614
  }
586
615
 
@@ -652,7 +681,24 @@ async function cmdNew({ rootDir, argv }) {
652
681
  const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
653
682
 
654
683
  const shouldUse = flags.has('--use');
684
+ const force = flags.has('--force');
655
685
  if (shouldUse) {
686
+ if (isMainStack() && !force) {
687
+ throw new Error(
688
+ `[wt] refusing to set main stack component override via --use by default.\n` +
689
+ `- stack: main\n` +
690
+ `- component: ${component}\n` +
691
+ `- new worktree: ${destPath}\n` +
692
+ `\n` +
693
+ `Recommendation:\n` +
694
+ `- Use an isolated stack instead:\n` +
695
+ ` happys stack new exp1 --interactive\n` +
696
+ ` happys stack wt exp1 -- use ${component} ${owner}/${slug}\n` +
697
+ `\n` +
698
+ `If you really intend to repoint the main stack, re-run with --force:\n` +
699
+ ` happys wt new ${component} ${slug} --use --force\n`
700
+ );
701
+ }
656
702
  const key = componentDirEnvKey(component);
657
703
  await ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: destPath }] });
658
704
  }
@@ -776,7 +822,7 @@ async function cmdPr({ rootDir, argv }) {
776
822
  const shouldUse = flags.has('--use');
777
823
  if (shouldUse) {
778
824
  // Reuse cmdUse so it writes to env.local or stack env file depending on context.
779
- await cmdUse({ rootDir, args: [component, destPath] });
825
+ await cmdUse({ rootDir, args: [component, destPath], flags });
780
826
  }
781
827
 
782
828
  const newHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
@@ -1535,9 +1581,9 @@ async function main() {
1535
1581
  ' happys wt sync <component> [--remote=<name>] [--json]',
1536
1582
  ' happys wt sync-all [--remote=<name>] [--json]',
1537
1583
  ' happys wt list <component> [--json]',
1538
- ' happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--interactive|-i] [--json]',
1584
+ ' happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--force] [--interactive|-i] [--json]',
1539
1585
  ' happys wt pr <component> <pr-url|number> [--remote=upstream] [--slug=<name>] [--deps=none|link|install|link-or-install] [--use] [--update] [--stash|--stash-keep] [--force] [--json]',
1540
- ' happys wt use <component> <owner/branch|path|default|main> [--interactive|-i] [--json]',
1586
+ ' happys wt use <component> <owner/branch|path|default|main> [--force] [--interactive|-i] [--json]',
1541
1587
  ' happys wt status <component> [worktreeSpec|default|path] [--json]',
1542
1588
  ' happys wt update <component> [worktreeSpec|default|path] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
1543
1589
  ' happys wt update-all [component] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
@@ -1569,7 +1615,7 @@ async function main() {
1569
1615
  if (interactive && isTty()) {
1570
1616
  await cmdUseInteractive({ rootDir });
1571
1617
  } else {
1572
- const res = await cmdUse({ rootDir, args: positionals.slice(1) });
1618
+ const res = await cmdUse({ rootDir, args: positionals.slice(1), flags });
1573
1619
  printResult({ json, data: res, text: `[wt] ${res.component}: active dir -> ${res.activeDir}` });
1574
1620
  }
1575
1621
  return;