happy-stacks 0.1.0 → 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 +130 -74
- package/bin/happys.mjs +140 -9
- 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/server-flavors.md +61 -2
- package/docs/stacks.md +55 -4
- package/extras/swiftbar/auth-login.sh +10 -7
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +175 -83
- package/extras/swiftbar/happys-term.sh +128 -0
- package/extras/swiftbar/happys.sh +35 -0
- 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 +279 -132
- package/extras/swiftbar/lib/system.sh +64 -10
- package/extras/swiftbar/lib/utils.sh +469 -10
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +4 -14
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +19 -10
- package/extras/swiftbar/wt-pr.sh +10 -3
- package/package.json +2 -1
- package/scripts/auth.mjs +833 -14
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +200 -23
- package/scripts/dev.mjs +230 -57
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +275 -46
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +302 -0
- package/scripts/mobile.mjs +59 -21
- package/scripts/run.mjs +222 -43
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +190 -38
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +2273 -92
- package/scripts/stop.mjs +160 -0
- package/scripts/tailscale.mjs +164 -23
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +21 -13
- 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} +71 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +13 -1
- 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 +94 -23
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +96 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +484 -0
- 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 +132 -22
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +75 -7
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +61 -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 +255 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +135 -15
- /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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
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 { isPidAlive, readPidState } from './expo.mjs';
|
|
7
|
+
import { stopLocalDaemon } from '../daemon.mjs';
|
|
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';
|
|
11
|
+
|
|
12
|
+
function parseIntOrNull(raw) {
|
|
13
|
+
const s = String(raw ?? '').trim();
|
|
14
|
+
if (!s) return null;
|
|
15
|
+
const n = Number(s);
|
|
16
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveServerComponentFromStackEnv(env) {
|
|
20
|
+
const v =
|
|
21
|
+
(env.HAPPY_STACKS_SERVER_COMPONENT ?? env.HAPPY_LOCAL_SERVER_COMPONENT ?? '').toString().trim() || 'happy-server-light';
|
|
22
|
+
return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function daemonControlPost({ httpPort, path, body = {} }) {
|
|
26
|
+
const ctl = new AbortController();
|
|
27
|
+
const t = setTimeout(() => ctl.abort(), 1500);
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`http://127.0.0.1:${httpPort}${path}`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'content-type': 'application/json' },
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
signal: ctl.signal,
|
|
34
|
+
});
|
|
35
|
+
const text = await res.text();
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
throw new Error(`daemon control ${path} failed (http ${res.status}): ${text.trim()}`);
|
|
38
|
+
}
|
|
39
|
+
return text.trim() ? JSON.parse(text) : null;
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(t);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function stopDaemonTrackedSessions({ cliHomeDir, json }) {
|
|
46
|
+
// Read daemon state file written by happy-cli; needed to call control server (/list, /stop-session).
|
|
47
|
+
const statePath = join(cliHomeDir, 'daemon.state.json');
|
|
48
|
+
if (!existsSync(statePath)) {
|
|
49
|
+
return { ok: true, skipped: true, reason: 'missing_state', stoppedSessionIds: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let state = null;
|
|
53
|
+
try {
|
|
54
|
+
state = JSON.parse(await readFile(statePath, 'utf-8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, skipped: true, reason: 'bad_state', stoppedSessionIds: [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const httpPort = Number(state?.httpPort);
|
|
60
|
+
const pid = Number(state?.pid);
|
|
61
|
+
if (!Number.isFinite(httpPort) || httpPort <= 0) {
|
|
62
|
+
return { ok: false, skipped: true, reason: 'missing_http_port', stoppedSessionIds: [] };
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isFinite(pid) || pid <= 1) {
|
|
65
|
+
return { ok: false, skipped: true, reason: 'missing_pid', stoppedSessionIds: [] };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
} catch {
|
|
70
|
+
return { ok: true, skipped: true, reason: 'daemon_not_running', stoppedSessionIds: [] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const listed = await daemonControlPost({ httpPort, path: '/list' }).catch((e) => {
|
|
74
|
+
if (!json) console.warn(`[stack] failed to list daemon sessions: ${e instanceof Error ? e.message : String(e)}`);
|
|
75
|
+
return null;
|
|
76
|
+
});
|
|
77
|
+
const children = Array.isArray(listed?.children) ? listed.children : [];
|
|
78
|
+
|
|
79
|
+
const stoppedSessionIds = [];
|
|
80
|
+
for (const child of children) {
|
|
81
|
+
const sid = String(child?.happySessionId ?? '').trim();
|
|
82
|
+
if (!sid) continue;
|
|
83
|
+
// eslint-disable-next-line no-await-in-loop
|
|
84
|
+
const res = await daemonControlPost({ httpPort, path: '/stop-session', body: { sessionId: sid } }).catch(() => null);
|
|
85
|
+
if (res?.success) {
|
|
86
|
+
stoppedSessionIds.push(sid);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { ok: true, skipped: false, stoppedSessionIds };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, envPath, json }) {
|
|
94
|
+
const root = join(baseDir, kind);
|
|
95
|
+
let entries = [];
|
|
96
|
+
try {
|
|
97
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
entries = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const killed = [];
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
if (!e.isDirectory()) continue;
|
|
105
|
+
const statePath = join(root, e.name, stateFileName);
|
|
106
|
+
// eslint-disable-next-line no-await-in-loop
|
|
107
|
+
const state = await readPidState(statePath);
|
|
108
|
+
if (!state) continue;
|
|
109
|
+
const pid = Number(state.pid);
|
|
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}) for ${stackName}`);
|
|
117
|
+
}
|
|
118
|
+
// eslint-disable-next-line no-await-in-loop
|
|
119
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: kind, json });
|
|
120
|
+
killed.push({ pid, port: null, statePath });
|
|
121
|
+
}
|
|
122
|
+
return killed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker = false, aggressive = false, sweepOwned = false }) {
|
|
126
|
+
const actions = {
|
|
127
|
+
stackName,
|
|
128
|
+
baseDir,
|
|
129
|
+
aggressive,
|
|
130
|
+
sweepOwned,
|
|
131
|
+
runner: null,
|
|
132
|
+
daemonSessionsStopped: null,
|
|
133
|
+
daemonStopped: false,
|
|
134
|
+
killedPorts: [],
|
|
135
|
+
uiDev: [],
|
|
136
|
+
mobile: [],
|
|
137
|
+
infra: null,
|
|
138
|
+
errors: [],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const serverComponent = resolveServerComponentFromStackEnv(env);
|
|
142
|
+
const port = parseIntOrNull(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
|
|
143
|
+
const backendPort = parseIntOrNull(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
|
|
144
|
+
const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
|
|
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 };
|
|
170
|
+
|
|
171
|
+
if (aggressive) {
|
|
172
|
+
try {
|
|
173
|
+
actions.daemonSessionsStopped = await stopDaemonTrackedSessions({ cliHomeDir, json });
|
|
174
|
+
} catch (e) {
|
|
175
|
+
actions.errors.push({ step: 'daemon-sessions', error: e instanceof Error ? e.message : String(e) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const internalServerUrl = port ? `http://127.0.0.1:${port}` : 'http://127.0.0.1:3005';
|
|
181
|
+
await stopLocalDaemon({ cliBin, internalServerUrl, cliHomeDir });
|
|
182
|
+
actions.daemonStopped = true;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
actions.errors.push({ step: 'daemon', error: e instanceof Error ? e.message : String(e) });
|
|
185
|
+
}
|
|
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
|
+
|
|
202
|
+
try {
|
|
203
|
+
actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
|
|
204
|
+
} catch (e) {
|
|
205
|
+
actions.errors.push({ step: 'expo-ui', error: e instanceof Error ? e.message : String(e) });
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
|
|
209
|
+
} catch (e) {
|
|
210
|
+
actions.errors.push({ step: 'expo-mobile', error: e instanceof Error ? e.message : String(e) });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// IMPORTANT:
|
|
214
|
+
// Never kill "whatever is listening on a port" in stack mode.
|
|
215
|
+
void backendPort;
|
|
216
|
+
void port;
|
|
217
|
+
|
|
218
|
+
const managed = (env.HAPPY_STACKS_MANAGED_INFRA ?? env.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0';
|
|
219
|
+
if (!noDocker && serverComponent === 'happy-server' && managed) {
|
|
220
|
+
try {
|
|
221
|
+
actions.infra = await stopHappyServerManagedInfra({ stackName, baseDir, removeVolumes: false });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
actions.errors.push({ step: 'infra', error: e instanceof Error ? e.message : String(e) });
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
actions.infra = { ok: true, skipped: true, reason: noDocker ? 'no_docker' : 'not_managed_or_not_happy_server' };
|
|
227
|
+
}
|
|
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
|
+
|
|
253
|
+
return actions;
|
|
254
|
+
}
|
|
255
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { getLegacyStorageRoot, getStacksStorageRoot } from './paths.mjs';
|
|
6
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
|
|
7
|
+
|
|
8
|
+
export async function listAllStackNames() {
|
|
9
|
+
const names = new Set(['main']);
|
|
10
|
+
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
11
|
+
const roots = [
|
|
12
|
+
// New layout: ~/.happy/stacks/<name>/env
|
|
13
|
+
getStacksStorageRoot(),
|
|
14
|
+
// Legacy layout: ~/.happy/local/stacks/<name>/env
|
|
15
|
+
...(allowLegacy ? [join(getLegacyStorageRoot(), 'stacks')] : []),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
for (const root of roots) {
|
|
19
|
+
let entries = [];
|
|
20
|
+
try {
|
|
21
|
+
// eslint-disable-next-line no-await-in-loop
|
|
22
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
23
|
+
} catch {
|
|
24
|
+
entries = [];
|
|
25
|
+
}
|
|
26
|
+
for (const ent of entries) {
|
|
27
|
+
if (!ent.isDirectory()) continue;
|
|
28
|
+
const name = ent.name;
|
|
29
|
+
if (!name || name.startsWith('.')) continue;
|
|
30
|
+
const envPath = join(root, name, 'env');
|
|
31
|
+
if (existsSync(envPath)) {
|
|
32
|
+
names.add(name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Array.from(names).sort();
|
|
38
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
+
|