happy-stacks 0.1.2 → 0.2.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 (91) hide show
  1. package/README.md +121 -83
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -1,5 +1,23 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
+ function writeWithPrefix(stream, prefix, bufState, chunk) {
4
+ const s = chunk.toString();
5
+ bufState.buf += s;
6
+ while (true) {
7
+ const idx = bufState.buf.indexOf('\n');
8
+ if (idx < 0) break;
9
+ const line = bufState.buf.slice(0, idx);
10
+ bufState.buf = bufState.buf.slice(idx + 1);
11
+ stream.write(`${prefix}${line}\n`);
12
+ }
13
+ }
14
+
15
+ function flushPrefixed(stream, prefix, bufState) {
16
+ if (!bufState.buf) return;
17
+ stream.write(`${prefix}${bufState.buf}\n`);
18
+ bufState.buf = '';
19
+ }
20
+
3
21
  export function spawnProc(label, cmd, args, env, options = {}) {
4
22
  const child = spawn(cmd, args, {
5
23
  env,
@@ -10,8 +28,17 @@ export function spawnProc(label, cmd, args, env, options = {}) {
10
28
  ...options,
11
29
  });
12
30
 
13
- child.stdout?.on('data', (d) => process.stdout.write(`[${label}] ${d.toString()}`));
14
- child.stderr?.on('data', (d) => process.stderr.write(`[${label}] ${d.toString()}`));
31
+ const outState = { buf: '' };
32
+ const errState = { buf: '' };
33
+ const outPrefix = `[${label}] `;
34
+ const errPrefix = `[${label}] `;
35
+
36
+ child.stdout?.on('data', (d) => writeWithPrefix(process.stdout, outPrefix, outState, d));
37
+ child.stderr?.on('data', (d) => writeWithPrefix(process.stderr, errPrefix, errState, d));
38
+ child.on('close', () => {
39
+ flushPrefixed(process.stdout, outPrefix, outState);
40
+ flushPrefixed(process.stderr, errPrefix, errState);
41
+ });
15
42
  child.on('exit', (code, sig) => {
16
43
  if (code !== 0) {
17
44
  process.stderr.write(`[${label}] exited (code=${code}, sig=${sig})\n`);
@@ -2,9 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
 
5
- function expandHome(p) {
6
- return p.replace(/^~(?=\/)/, homedir());
7
- }
5
+ import { expandHome } from './canonical_home.mjs';
8
6
 
9
7
  export function getRuntimeDir() {
10
8
  const fromEnv = (process.env.HAPPY_STACKS_RUNTIME_DIR ?? '').trim();
@@ -0,0 +1,14 @@
1
+ export function getSandboxDir() {
2
+ const v = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
3
+ return v || '';
4
+ }
5
+
6
+ export function isSandboxed() {
7
+ return Boolean(getSandboxDir());
8
+ }
9
+
10
+ export function sandboxAllowsGlobalSideEffects() {
11
+ const raw = (process.env.HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL ?? '').trim().toLowerCase();
12
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y';
13
+ }
14
+
@@ -76,3 +76,27 @@ export async function waitForServerReady(url) {
76
76
  throw new Error(`Timed out waiting for server at ${url}`);
77
77
  }
78
78
 
79
+ // Used for UI readiness checks (Expo / gateway / server). Treat any HTTP response as "up".
80
+ export async function waitForHttpOk(url, { timeoutMs = 15_000, intervalMs = 250 } = {}) {
81
+ const deadline = Date.now() + timeoutMs;
82
+ while (Date.now() < deadline) {
83
+ try {
84
+ const ctl = new AbortController();
85
+ const t = setTimeout(() => ctl.abort(), Math.min(2500, Math.max(250, intervalMs)));
86
+ try {
87
+ const res = await fetch(url, { method: 'GET', signal: ctl.signal });
88
+ if (res.status >= 100 && res.status < 600) {
89
+ return;
90
+ }
91
+ } finally {
92
+ clearTimeout(t);
93
+ }
94
+ } catch {
95
+ // ignore
96
+ }
97
+ // eslint-disable-next-line no-await-in-loop
98
+ await delay(intervalMs);
99
+ }
100
+ throw new Error(`Timed out waiting for HTTP response from ${url} after ${timeoutMs}ms`);
101
+ }
102
+
@@ -0,0 +1,9 @@
1
+ export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
2
+ const raw =
3
+ (env.HAPPY_STACKS_SERVER_PORT ?? '').toString().trim() ||
4
+ (env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim() ||
5
+ '';
6
+ const n = raw ? Number(raw) : Number(defaultPort);
7
+ return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
8
+ }
9
+
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+
3
+ import { getStackName, resolveStackEnvPath } from './paths.mjs';
4
+ import { resolvePublicServerUrl } from '../tailscale.mjs';
5
+ import { resolveServerPortFromEnv } from './server_port.mjs';
6
+
7
+ function stackEnvExplicitlySetsPublicUrl({ env, stackName }) {
8
+ try {
9
+ const envPath =
10
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
+ resolveStackEnvPath(stackName).envPath;
12
+ if (!envPath || !existsSync(envPath)) return false;
13
+ const raw = readFileSync(envPath, 'utf-8');
14
+ return /^(HAPPY_STACKS_SERVER_URL|HAPPY_LOCAL_SERVER_URL)=/m.test(raw);
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function getPublicServerUrlEnvOverride({ env = process.env, serverPort } = {}) {
21
+ const defaultPublicUrl = `http://localhost:${serverPort}`;
22
+ const stackName = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() || getStackName();
23
+
24
+ let envPublicUrl =
25
+ (env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
26
+
27
+ // Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
28
+ if (stackName !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName })) {
29
+ envPublicUrl = '';
30
+ }
31
+
32
+ return { defaultPublicUrl, envPublicUrl, publicServerUrl: envPublicUrl || defaultPublicUrl };
33
+ }
34
+
35
+ export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
36
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
37
+ const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
38
+ const resolved = await resolvePublicServerUrl({
39
+ internalServerUrl,
40
+ defaultPublicUrl,
41
+ envPublicUrl,
42
+ allowEnable,
43
+ });
44
+ return {
45
+ internalServerUrl,
46
+ defaultPublicUrl,
47
+ envPublicUrl,
48
+ publicServerUrl: resolved.publicServerUrl,
49
+ publicServerUrlSource: resolved.source,
50
+ };
51
+ }
52
+
53
+ export { resolveServerPortFromEnv };
54
+
@@ -0,0 +1,23 @@
1
+ import { getStackName, resolveStackEnvPath } from './paths.mjs';
2
+ import { getStackRuntimeStatePath } from './stack_runtime_state.mjs';
3
+
4
+ export function resolveStackContext({ env = process.env, autostart = null } = {}) {
5
+ const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
6
+ const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName();
7
+ const stackMode = Boolean(explicitStack);
8
+
9
+ const envPath =
10
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
+ resolveStackEnvPath(stackName).envPath;
12
+
13
+ const runtimeStatePath =
14
+ (env.HAPPY_STACKS_RUNTIME_STATE_PATH ?? env.HAPPY_LOCAL_RUNTIME_STATE_PATH ?? '').toString().trim() ||
15
+ getStackRuntimeStatePath(stackName);
16
+
17
+ const explicitEphemeral =
18
+ (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
19
+ const ephemeral = explicitEphemeral || (stackMode && stackName !== 'main');
20
+
21
+ return { stackMode, stackName, envPath, runtimeStatePath, ephemeral };
22
+ }
23
+
@@ -0,0 +1,104 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import { resolveStackEnvPath } from './paths.mjs';
6
+
7
+ export function getStackRuntimeStatePath(stackName) {
8
+ const { baseDir } = resolveStackEnvPath(stackName);
9
+ return join(baseDir, 'stack.runtime.json');
10
+ }
11
+
12
+ export function isPidAlive(pid) {
13
+ const n = Number(pid);
14
+ if (!Number.isFinite(n) || n <= 1) return false;
15
+ try {
16
+ process.kill(n, 0);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export async function readStackRuntimeStateFile(statePath) {
24
+ try {
25
+ if (!statePath || !existsSync(statePath)) return null;
26
+ const raw = await readFile(statePath, 'utf-8');
27
+ const parsed = JSON.parse(raw);
28
+ return parsed && typeof parsed === 'object' ? parsed : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export async function writeStackRuntimeStateFile(statePath, state) {
35
+ if (!statePath) {
36
+ throw new Error('[stack] missing runtime state path');
37
+ }
38
+ const dir = dirname(statePath);
39
+ await mkdir(dir, { recursive: true }).catch(() => {});
40
+ const tmp = join(dir, `.stack.runtime.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
41
+ await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
42
+ await rename(tmp, statePath);
43
+ }
44
+
45
+ function isPlainObject(v) {
46
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
47
+ }
48
+
49
+ function deepMerge(a, b) {
50
+ if (!isPlainObject(a) || !isPlainObject(b)) {
51
+ return b;
52
+ }
53
+ const out = { ...a };
54
+ for (const [k, v] of Object.entries(b)) {
55
+ if (isPlainObject(out[k]) && isPlainObject(v)) {
56
+ out[k] = deepMerge(out[k], v);
57
+ } else {
58
+ out[k] = v;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export async function updateStackRuntimeStateFile(statePath, patch) {
65
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
66
+ const next = deepMerge(existing, patch ?? {});
67
+ await writeStackRuntimeStateFile(statePath, next);
68
+ return next;
69
+ }
70
+
71
+ export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports } = {}) {
72
+ const now = new Date().toISOString();
73
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
74
+ const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
75
+ const next = deepMerge(existing, {
76
+ version: 1,
77
+ stackName,
78
+ script,
79
+ ephemeral: Boolean(ephemeral),
80
+ ownerPid,
81
+ ports: ports ?? {},
82
+ startedAt,
83
+ updatedAt: now,
84
+ });
85
+ await writeStackRuntimeStateFile(statePath, next);
86
+ return next;
87
+ }
88
+
89
+ export async function recordStackRuntimeUpdate(statePath, patch = {}) {
90
+ return await updateStackRuntimeStateFile(statePath, {
91
+ ...(patch ?? {}),
92
+ updatedAt: new Date().toISOString(),
93
+ });
94
+ }
95
+
96
+ export async function deleteStackRuntimeStateFile(statePath) {
97
+ try {
98
+ if (!statePath || !existsSync(statePath)) return;
99
+ await unlink(statePath);
100
+ } catch {
101
+ // ignore
102
+ }
103
+ }
104
+
@@ -0,0 +1,208 @@
1
+ import { runCapture } from './proc.mjs';
2
+ import { ensureDepsInstalled, pmExecBin } from './pm.mjs';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ function looksLikeMissingTableError(msg) {
7
+ const s = String(msg ?? '').toLowerCase();
8
+ return s.includes('does not exist') || s.includes('no such table');
9
+ }
10
+
11
+ async function probeAccountCount({ serverDir, env }) {
12
+ const probe = `
13
+ let db;
14
+ try {
15
+ const { PrismaClient } = await import('@prisma/client');
16
+ db = new PrismaClient();
17
+ const accountCount = await db.account.count();
18
+ console.log(JSON.stringify({ accountCount }));
19
+ } catch (e) {
20
+ console.log(
21
+ JSON.stringify({
22
+ error: {
23
+ name: e?.name,
24
+ message: e?.message,
25
+ code: e?.code,
26
+ },
27
+ })
28
+ );
29
+ } finally {
30
+ try {
31
+ await db?.$disconnect();
32
+ } catch {
33
+ // ignore
34
+ }
35
+ }
36
+ `.trim();
37
+
38
+ const out = await runCapture(process.execPath, ['--input-type=module', '-e', probe], { cwd: serverDir, env, timeoutMs: 15_000 });
39
+ const parsed = out.trim() ? JSON.parse(out.trim()) : {};
40
+ if (parsed?.error) {
41
+ const e = new Error(parsed.error.message || 'unknown prisma probe error');
42
+ if (typeof parsed.error.name === 'string' && parsed.error.name) e.name = parsed.error.name;
43
+ if (typeof parsed.error.code === 'string' && parsed.error.code) e.code = parsed.error.code;
44
+ throw e;
45
+ }
46
+ return Number(parsed.accountCount ?? 0);
47
+ }
48
+
49
+ export function resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive }) {
50
+ const raw = (env.HAPPY_STACKS_AUTO_AUTH_SEED ?? env.HAPPY_LOCAL_AUTO_AUTH_SEED ?? '').toString().trim();
51
+ if (raw) return raw !== '0';
52
+
53
+ // Legacy toggle (kept for existing setups):
54
+ // - if set, it only controls enable/disable; source stack remains configurable via HAPPY_STACKS_AUTH_SEED_FROM.
55
+ const legacy = (env.HAPPY_STACKS_AUTO_COPY_FROM_MAIN ?? env.HAPPY_LOCAL_AUTO_COPY_FROM_MAIN ?? '').toString().trim();
56
+ if (legacy) return legacy !== '0';
57
+
58
+ if (stackName === 'main') return false;
59
+
60
+ // Default:
61
+ // - always auto-seed in non-interactive contexts (agents/services)
62
+ // - in interactive shells, auto-seed only when the user explicitly configured a non-main seed stack
63
+ // (this avoids silently spreading main identity for users who haven't opted in yet).
64
+ if (!isInteractive) return true;
65
+ const seed = (env.HAPPY_STACKS_AUTH_SEED_FROM ?? env.HAPPY_LOCAL_AUTH_SEED_FROM ?? '').toString().trim();
66
+ return Boolean(seed && seed !== 'main');
67
+ }
68
+
69
+ export function resolveAuthSeedFromEnv(env) {
70
+ // Back-compat for an earlier experimental var name:
71
+ // - if set to a non-bool-ish stack name, treat it as the seed source
72
+ // - if set to "1"/"true", ignore (source comes from HAPPY_STACKS_AUTH_SEED_FROM)
73
+ const legacyAutoFrom = (env.HAPPY_STACKS_AUTO_AUTH_SEED_FROM ?? env.HAPPY_LOCAL_AUTO_AUTH_SEED_FROM ?? '').toString().trim();
74
+ if (legacyAutoFrom && legacyAutoFrom !== '0' && legacyAutoFrom !== '1' && legacyAutoFrom.toLowerCase() !== 'true') {
75
+ return legacyAutoFrom;
76
+ }
77
+ // Legacy toggle: "on" implies main (historical behavior).
78
+ const legacy = (env.HAPPY_STACKS_AUTO_COPY_FROM_MAIN ?? env.HAPPY_LOCAL_AUTO_COPY_FROM_MAIN ?? '').toString().trim();
79
+ if (legacy && legacy !== '0') return 'main';
80
+ // Otherwise, use the general default seed stack.
81
+ const seed = (env.HAPPY_STACKS_AUTH_SEED_FROM ?? env.HAPPY_LOCAL_AUTH_SEED_FROM ?? '').toString().trim();
82
+ return seed || 'main';
83
+ }
84
+
85
+ export async function ensureServerLightSchemaReady({ serverDir, env }) {
86
+ await ensureDepsInstalled(serverDir, 'happy-server-light');
87
+
88
+ try {
89
+ const accountCount = await probeAccountCount({ serverDir, env });
90
+ return { ok: true, pushed: false, accountCount };
91
+ } catch (e) {
92
+ const msg = e instanceof Error ? e.message : String(e);
93
+ if (!looksLikeMissingTableError(msg)) {
94
+ throw e;
95
+ }
96
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['db', 'push'], env });
97
+ const accountCount = await probeAccountCount({ serverDir, env });
98
+ return { ok: true, pushed: true, accountCount };
99
+ }
100
+ }
101
+
102
+ export async function ensureHappyServerSchemaReady({ serverDir, env }) {
103
+ await ensureDepsInstalled(serverDir, 'happy-server');
104
+
105
+ try {
106
+ const accountCount = await probeAccountCount({ serverDir, env });
107
+ return { ok: true, migrated: false, accountCount };
108
+ } catch (e) {
109
+ const msg = e instanceof Error ? e.message : String(e);
110
+ if (!looksLikeMissingTableError(msg)) {
111
+ throw e;
112
+ }
113
+ // If tables are missing, try migrations (safe for postgres). Then re-probe.
114
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
115
+ const accountCount = await probeAccountCount({ serverDir, env });
116
+ return { ok: true, migrated: true, accountCount };
117
+ }
118
+ }
119
+
120
+ export async function getAccountCountForServerComponent({ serverComponentName, serverDir, env, bestEffort = false }) {
121
+ if (serverComponentName === 'happy-server-light') {
122
+ const ready = await ensureServerLightSchemaReady({ serverDir, env });
123
+ return { ok: true, accountCount: Number.isFinite(ready.accountCount) ? ready.accountCount : 0 };
124
+ }
125
+ if (serverComponentName === 'happy-server') {
126
+ try {
127
+ const ready = await ensureHappyServerSchemaReady({ serverDir, env });
128
+ return { ok: true, accountCount: Number.isFinite(ready.accountCount) ? ready.accountCount : 0 };
129
+ } catch (e) {
130
+ if (!bestEffort) throw e;
131
+ return { ok: false, accountCount: null, error: e instanceof Error ? e.message : String(e) };
132
+ }
133
+ }
134
+ return { ok: false, accountCount: null, error: `unknown server component: ${serverComponentName}` };
135
+ }
136
+
137
+ export async function maybeAutoCopyAuthFromMainIfNeeded({
138
+ rootDir,
139
+ env,
140
+ enabled,
141
+ stackName,
142
+ cliHomeDir,
143
+ accountCount,
144
+ quiet = false,
145
+ authEnv = null,
146
+ }) {
147
+ const accessKeyPath = join(cliHomeDir, 'access.key');
148
+ const hasAccessKey = existsSync(accessKeyPath);
149
+
150
+ // "Initialized" heuristic:
151
+ // - if we have credentials AND (when known) at least one Account row, we don't need to seed from main.
152
+ const hasAccounts = typeof accountCount === 'number' ? accountCount > 0 : null;
153
+ const needsSeed = !hasAccessKey || hasAccounts === false;
154
+
155
+ if (!enabled || !needsSeed) {
156
+ return { ok: true, skipped: true, reason: !enabled ? 'disabled' : 'already_initialized' };
157
+ }
158
+
159
+ const reason = !hasAccessKey ? 'missing_credentials' : 'no_accounts';
160
+ const fromStackName = resolveAuthSeedFromEnv(env);
161
+ const linkAuth =
162
+ (env.HAPPY_STACKS_AUTH_LINK ?? env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
163
+ (env.HAPPY_STACKS_AUTH_MODE ?? env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
164
+ if (!quiet) {
165
+ console.log(`[local] auth: auto seed from ${fromStackName} for ${stackName} (${reason})`);
166
+ }
167
+
168
+ // Best-effort: copy credentials/master secret + seed accounts from the configured seed stack.
169
+ // Keep this non-fatal; the daemon will emit actionable errors if it still can't authenticate.
170
+ try {
171
+ const out = await runCapture(
172
+ process.execPath,
173
+ [`${rootDir}/scripts/auth.mjs`, 'copy-from', fromStackName, '--json', ...(linkAuth ? ['--link'] : [])],
174
+ {
175
+ cwd: rootDir,
176
+ env: authEnv && typeof authEnv === 'object' ? authEnv : env,
177
+ }
178
+ );
179
+ return { ok: true, skipped: false, reason, out: out.trim() ? JSON.parse(out) : null };
180
+ } catch (e) {
181
+ return { ok: false, skipped: false, reason, error: e instanceof Error ? e.message : String(e) };
182
+ }
183
+ }
184
+
185
+ export async function prepareDaemonAuthSeedIfNeeded({
186
+ rootDir,
187
+ env,
188
+ stackName,
189
+ cliHomeDir,
190
+ startDaemon,
191
+ isInteractive,
192
+ accountCount,
193
+ quiet = false,
194
+ authEnv = null,
195
+ }) {
196
+ if (!startDaemon) return { ok: true, skipped: true, reason: 'no_daemon' };
197
+ const enabled = resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive });
198
+ return await maybeAutoCopyAuthFromMainIfNeeded({
199
+ rootDir,
200
+ env,
201
+ enabled,
202
+ stackName,
203
+ cliHomeDir,
204
+ accountCount,
205
+ quiet,
206
+ authEnv,
207
+ });
208
+ }
@@ -3,10 +3,11 @@ import { readdir, readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
5
5
  import { getComponentDir } from './paths.mjs';
6
- import { killPortListeners } from './ports.mjs';
7
- import { isPidAlive, killPid, readPidState } from './expo.mjs';
6
+ import { isPidAlive, readPidState } from './expo.mjs';
8
7
  import { stopLocalDaemon } from '../daemon.mjs';
9
8
  import { stopHappyServerManagedInfra } from './happy_server_infra.mjs';
9
+ import { deleteStackRuntimeStateFile, getStackRuntimeStatePath, readStackRuntimeStateFile } from './stack_runtime_state.mjs';
10
+ import { killPidOwnedByStack, killProcessGroupOwnedByStack, listPidsWithEnvNeedle } from './ownership.mjs';
10
11
 
11
12
  function parseIntOrNull(raw) {
12
13
  const s = String(raw ?? '').trim();
@@ -89,7 +90,7 @@ async function stopDaemonTrackedSessions({ cliHomeDir, json }) {
89
90
  return { ok: true, skipped: false, stoppedSessionIds };
90
91
  }
91
92
 
92
- async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, json }) {
93
+ async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, envPath, json }) {
93
94
  const root = join(baseDir, kind);
94
95
  let entries = [];
95
96
  try {
@@ -106,31 +107,28 @@ async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, json
106
107
  const state = await readPidState(statePath);
107
108
  if (!state) continue;
108
109
  const pid = Number(state.pid);
109
- const port = parseIntOrNull(state.port);
110
110
 
111
111
  if (!Number.isFinite(pid) || pid <= 1) continue;
112
112
  if (!isPidAlive(pid)) continue;
113
113
 
114
114
  if (!json) {
115
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}` });
116
+ console.log(`[stack] stopping ${kind} (pid=${pid}) for ${stackName}`);
121
117
  }
122
118
  // eslint-disable-next-line no-await-in-loop
123
- await killPid(pid);
124
- killed.push({ pid, port, statePath });
119
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: kind, json });
120
+ killed.push({ pid, port: null, statePath });
125
121
  }
126
122
  return killed;
127
123
  }
128
124
 
129
- export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker = false, aggressive = false }) {
125
+ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker = false, aggressive = false, sweepOwned = false }) {
130
126
  const actions = {
131
127
  stackName,
132
128
  baseDir,
133
129
  aggressive,
130
+ sweepOwned,
131
+ runner: null,
134
132
  daemonSessionsStopped: null,
135
133
  daemonStopped: false,
136
134
  killedPorts: [],
@@ -145,6 +143,30 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
145
143
  const backendPort = parseIntOrNull(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
146
144
  const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
147
145
  const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
146
+ const envPath = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
147
+
148
+ // Preferred: stop stack-started processes (by PID) recorded in stack.runtime.json.
149
+ // This is safer than killing whatever happens to listen on a port, and doesn't rely on the runner's shutdown handler.
150
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
151
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
152
+ const runnerPid = Number(runtimeState?.ownerPid);
153
+ const processes = runtimeState?.processes && typeof runtimeState.processes === 'object' ? runtimeState.processes : {};
154
+
155
+ // Kill known child processes first (process groups), then stop daemon, then stop runner.
156
+ const killedProcessPids = [];
157
+ for (const [key, rawPid] of Object.entries(processes)) {
158
+ const pid = Number(rawPid);
159
+ if (!Number.isFinite(pid) || pid <= 1) continue;
160
+ if (!isPidAlive(pid)) continue;
161
+ // eslint-disable-next-line no-await-in-loop
162
+ const res = await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: key, json });
163
+ if (res.killed) {
164
+ killedProcessPids.push({ key, pid, reason: res.reason, pgid: res.pgid ?? null });
165
+ }
166
+ }
167
+ actions.runner = { stopped: false, pid: Number.isFinite(runnerPid) ? runnerPid : null, reason: runtimeState ? 'not_running_or_not_owned' : 'missing_state' };
168
+ actions.killedPorts = actions.killedPorts ?? [];
169
+ actions.processes = { killed: killedProcessPids };
148
170
 
149
171
  if (aggressive) {
150
172
  try {
@@ -162,33 +184,36 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
162
184
  actions.errors.push({ step: 'daemon', error: e instanceof Error ? e.message : String(e) });
163
185
  }
164
186
 
187
+ // Now stop the runner PID last (if it exists). This should clean up any remaining state files it owns.
188
+ if (Number.isFinite(runnerPid) && runnerPid > 1 && isPidAlive(runnerPid)) {
189
+ if (!json) {
190
+ // eslint-disable-next-line no-console
191
+ console.log(`[stack] stopping runner (pid=${runnerPid}) for ${stackName}`);
192
+ }
193
+ const res = await killPidOwnedByStack(runnerPid, { stackName, envPath, cliHomeDir, label: 'runner', json });
194
+ actions.runner = { stopped: res.killed, pid: runnerPid, reason: res.reason };
195
+ }
196
+
197
+ // Only delete runtime state if the runner is confirmed stopped (or not running).
198
+ if (!isPidAlive(runnerPid)) {
199
+ await deleteStackRuntimeStateFile(runtimeStatePath);
200
+ }
201
+
165
202
  try {
166
- actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', json });
203
+ actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
167
204
  } catch (e) {
168
205
  actions.errors.push({ step: 'expo-ui', error: e instanceof Error ? e.message : String(e) });
169
206
  }
170
207
  try {
171
- actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', json });
208
+ actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
172
209
  } catch (e) {
173
210
  actions.errors.push({ step: 'expo-mobile', error: e instanceof Error ? e.message : String(e) });
174
211
  }
175
212
 
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
- }
213
+ // IMPORTANT:
214
+ // Never kill "whatever is listening on a port" in stack mode.
215
+ void backendPort;
216
+ void port;
192
217
 
193
218
  const managed = (env.HAPPY_STACKS_MANAGED_INFRA ?? env.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0';
194
219
  if (!noDocker && serverComponent === 'happy-server' && managed) {
@@ -201,6 +226,30 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
201
226
  actions.infra = { ok: true, skipped: true, reason: noDocker ? 'no_docker' : 'not_managed_or_not_happy_server' };
202
227
  }
203
228
 
229
+ // Last resort: sweep any remaining processes that still carry this stack env file in their environment.
230
+ // This is still safe because envPath is unique per stack; we also exclude our own PID.
231
+ if (sweepOwned && envPath) {
232
+ const needle1 = `HAPPY_STACKS_ENV_FILE=${envPath}`;
233
+ const needle2 = `HAPPY_LOCAL_ENV_FILE=${envPath}`;
234
+ const pids = [
235
+ ...(await listPidsWithEnvNeedle(needle1)),
236
+ ...(await listPidsWithEnvNeedle(needle2)),
237
+ ]
238
+ .filter((pid) => pid !== process.pid)
239
+ .filter((pid) => Number.isFinite(pid) && pid > 1);
240
+
241
+ const swept = [];
242
+ for (const pid of Array.from(new Set(pids))) {
243
+ if (!isPidAlive(pid)) continue;
244
+ // eslint-disable-next-line no-await-in-loop
245
+ const res = await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: 'sweep', json });
246
+ if (res.killed) {
247
+ swept.push({ pid, reason: res.reason, pgid: res.pgid ?? null });
248
+ }
249
+ }
250
+ actions.sweep = { pids: swept };
251
+ }
252
+
204
253
  return actions;
205
254
  }
206
255