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.
- package/README.md +121 -83
- package/bin/happys.mjs +70 -10
- package/docs/edison.md +381 -0
- package/docs/happy-development.md +733 -0
- package/docs/menubar.md +54 -0
- package/docs/paths-and-env.md +141 -0
- package/docs/stacks.md +39 -0
- package/extras/swiftbar/auth-login.sh +5 -2
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +131 -81
- package/extras/swiftbar/happys-term.sh +15 -38
- package/extras/swiftbar/happys.sh +15 -32
- package/extras/swiftbar/install.sh +99 -13
- package/extras/swiftbar/lib/git.sh +309 -1
- package/extras/swiftbar/lib/icons.sh +2 -2
- package/extras/swiftbar/lib/render.sh +209 -80
- package/extras/swiftbar/lib/system.sh +27 -4
- package/extras/swiftbar/lib/utils.sh +311 -28
- package/extras/swiftbar/pnpm.sh +2 -1
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +11 -2
- package/extras/swiftbar/wt-pr.sh +9 -2
- package/package.json +2 -1
- package/scripts/auth.mjs +560 -112
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +130 -20
- package/scripts/dev.mjs +201 -133
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +43 -20
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +25 -15
- package/scripts/mobile.mjs +13 -7
- package/scripts/run.mjs +114 -27
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +15 -2
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +1792 -254
- package/scripts/stop.mjs +6 -3
- package/scripts/tailscale.mjs +17 -2
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +2 -2
- package/scripts/ui_gateway.mjs +2 -2
- package/scripts/uninstall.mjs +18 -10
- package/scripts/utils/auth_files.mjs +58 -0
- package/scripts/utils/auth_login_ux.mjs +76 -0
- package/scripts/utils/auth_sources.mjs +12 -0
- package/scripts/utils/browser.mjs +22 -0
- package/scripts/utils/canonical_home.mjs +20 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +6 -2
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/dev_daemon.mjs +104 -0
- package/scripts/utils/dev_expo_web.mjs +112 -0
- package/scripts/utils/dev_server.mjs +183 -0
- package/scripts/utils/env.mjs +60 -11
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +4 -2
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +100 -46
- package/scripts/utils/localhost_host.mjs +17 -0
- package/scripts/utils/ownership.mjs +135 -0
- package/scripts/utils/paths.mjs +5 -2
- package/scripts/utils/pm.mjs +121 -20
- package/scripts/utils/proc.mjs +29 -2
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +24 -0
- package/scripts/utils/server_port.mjs +9 -0
- package/scripts/utils/server_urls.mjs +54 -0
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stack_startup.mjs +208 -0
- package/scripts/utils/stack_stop.mjs +79 -30
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +82 -8
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/utils/proc.mjs
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
+
|
package/scripts/utils/server.mjs
CHANGED
|
@@ -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 {
|
|
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}
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|