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,484 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
|
|
7
|
+
import { parseDotenv } from './dotenv.mjs';
|
|
8
|
+
import { ensureEnvFileUpdated } from './env_file.mjs';
|
|
9
|
+
import { pickNextFreeTcpPort } from './ports.mjs';
|
|
10
|
+
import { pmExecBin } from './pm.mjs';
|
|
11
|
+
import { run, runCapture } from './proc.mjs';
|
|
12
|
+
|
|
13
|
+
function base64Url(buf) {
|
|
14
|
+
return Buffer.from(buf)
|
|
15
|
+
.toString('base64')
|
|
16
|
+
.replaceAll('+', '-')
|
|
17
|
+
.replaceAll('/', '_')
|
|
18
|
+
.replaceAll('=', '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function randomToken(lenBytes = 24) {
|
|
22
|
+
return base64Url(randomBytes(lenBytes));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
|
|
26
|
+
const s = String(raw ?? '')
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
29
|
+
.replace(/-+/g, '-')
|
|
30
|
+
.replace(/^-+/, '')
|
|
31
|
+
.replace(/-+$/, '');
|
|
32
|
+
return s || fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function coercePort(v) {
|
|
36
|
+
const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN;
|
|
37
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readEnvObject(envPath) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(envPath, 'utf-8');
|
|
43
|
+
return Object.fromEntries(parseDotenv(raw).entries());
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function ensureTextFile({ path, generate }) {
|
|
50
|
+
if (existsSync(path)) {
|
|
51
|
+
const v = (await readFile(path, 'utf-8')).trim();
|
|
52
|
+
if (v) return v;
|
|
53
|
+
}
|
|
54
|
+
const next = String(generate()).trim();
|
|
55
|
+
await mkdir(join(path, '..'), { recursive: true }).catch(() => {});
|
|
56
|
+
await writeFile(path, next + '\n', 'utf-8');
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function composeProjectName(stackName) {
|
|
61
|
+
return sanitizeDnsLabel(`happy-stacks-${stackName}-happy-server`, { fallback: 'happy-stacks-happy-server' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function stopHappyServerManagedInfra({ stackName, baseDir, removeVolumes = false }) {
|
|
65
|
+
const infraDir = join(baseDir, 'happy-server', 'infra');
|
|
66
|
+
const composePath = join(infraDir, 'docker-compose.yml');
|
|
67
|
+
if (!existsSync(composePath)) {
|
|
68
|
+
return { ok: true, skipped: true, reason: 'missing_compose', composePath };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await ensureDockerCompose();
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
skipped: true,
|
|
77
|
+
reason: 'docker_unavailable',
|
|
78
|
+
error: e instanceof Error ? e.message : String(e),
|
|
79
|
+
composePath,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const projectName = composeProjectName(stackName);
|
|
84
|
+
const args = ['down', '--remove-orphans', ...(removeVolumes ? ['--volumes'] : [])];
|
|
85
|
+
await dockerCompose({ composePath, projectName, args, options: { cwd: baseDir } });
|
|
86
|
+
return { ok: true, skipped: false, projectName, composePath };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildComposeYaml({
|
|
90
|
+
infraDir,
|
|
91
|
+
pgPort,
|
|
92
|
+
pgUser,
|
|
93
|
+
pgPassword,
|
|
94
|
+
pgDb,
|
|
95
|
+
redisPort,
|
|
96
|
+
minioPort,
|
|
97
|
+
minioConsolePort,
|
|
98
|
+
s3AccessKey,
|
|
99
|
+
s3SecretKey,
|
|
100
|
+
s3Bucket,
|
|
101
|
+
}) {
|
|
102
|
+
// Keep it explicit (no env substitution); we generate this file per stack.
|
|
103
|
+
return `services:
|
|
104
|
+
postgres:
|
|
105
|
+
image: postgres:16-alpine
|
|
106
|
+
environment:
|
|
107
|
+
POSTGRES_USER: ${pgUser}
|
|
108
|
+
POSTGRES_PASSWORD: ${pgPassword}
|
|
109
|
+
POSTGRES_DB: ${pgDb}
|
|
110
|
+
ports:
|
|
111
|
+
- "127.0.0.1:${pgPort}:5432"
|
|
112
|
+
volumes:
|
|
113
|
+
- "${join(infraDir, 'pgdata')}:/var/lib/postgresql/data"
|
|
114
|
+
healthcheck:
|
|
115
|
+
test: ["CMD-SHELL", "pg_isready -U ${pgUser} -d ${pgDb}"]
|
|
116
|
+
interval: 2s
|
|
117
|
+
timeout: 3s
|
|
118
|
+
retries: 30
|
|
119
|
+
|
|
120
|
+
redis:
|
|
121
|
+
image: redis:7-alpine
|
|
122
|
+
command: ["redis-server", "--appendonly", "yes"]
|
|
123
|
+
ports:
|
|
124
|
+
- "127.0.0.1:${redisPort}:6379"
|
|
125
|
+
volumes:
|
|
126
|
+
- "${join(infraDir, 'redis')}:/data"
|
|
127
|
+
healthcheck:
|
|
128
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
129
|
+
interval: 2s
|
|
130
|
+
timeout: 3s
|
|
131
|
+
retries: 30
|
|
132
|
+
|
|
133
|
+
minio:
|
|
134
|
+
image: minio/minio:latest
|
|
135
|
+
command: ["server", "/data", "--console-address", ":9001"]
|
|
136
|
+
environment:
|
|
137
|
+
MINIO_ROOT_USER: ${s3AccessKey}
|
|
138
|
+
MINIO_ROOT_PASSWORD: ${s3SecretKey}
|
|
139
|
+
ports:
|
|
140
|
+
- "127.0.0.1:${minioPort}:9000"
|
|
141
|
+
- "127.0.0.1:${minioConsolePort}:9001"
|
|
142
|
+
volumes:
|
|
143
|
+
- "${join(infraDir, 'minio')}:/data"
|
|
144
|
+
|
|
145
|
+
minio-init:
|
|
146
|
+
image: minio/mc:latest
|
|
147
|
+
depends_on:
|
|
148
|
+
- minio
|
|
149
|
+
entrypoint: ["/bin/sh", "-lc"]
|
|
150
|
+
command: >
|
|
151
|
+
mc alias set local http://minio:9000 ${s3AccessKey} ${s3SecretKey} &&
|
|
152
|
+
mc mb -p local/${s3Bucket} || true &&
|
|
153
|
+
mc anonymous set download local/${s3Bucket} || true
|
|
154
|
+
restart: "no"
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function ensureDockerCompose() {
|
|
159
|
+
const waitMsRaw = (process.env.HAPPY_STACKS_DOCKER_WAIT_MS ?? process.env.HAPPY_LOCAL_DOCKER_WAIT_MS ?? '').trim();
|
|
160
|
+
const waitMs = waitMsRaw ? Number(waitMsRaw) : process.stdout.isTTY ? 0 : 60_000;
|
|
161
|
+
const deadline = waitMs > 0 ? Date.now() + waitMs : Date.now();
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await runCapture('docker', ['compose', 'version'], { timeoutMs: 10_000 });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
const msg = e?.message ? String(e.message) : String(e);
|
|
167
|
+
throw new Error(
|
|
168
|
+
`[infra] docker compose is required for managed happy-server stacks.\n` +
|
|
169
|
+
`Fix: install Docker Desktop and ensure \`docker compose\` works.\n` +
|
|
170
|
+
`Details: ${msg}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const autostartRaw = (process.env.HAPPY_STACKS_DOCKER_AUTOSTART ?? process.env.HAPPY_LOCAL_DOCKER_AUTOSTART ?? '').trim();
|
|
175
|
+
const autostart = autostartRaw ? autostartRaw !== '0' : !process.stdout.isTTY;
|
|
176
|
+
|
|
177
|
+
// Ensure the Docker daemon is ready (launchd/SwiftBar often runs before Docker Desktop starts).
|
|
178
|
+
// If not ready, wait up to waitMs (non-interactive default: 60s) to avoid restart loops.
|
|
179
|
+
while (true) {
|
|
180
|
+
try {
|
|
181
|
+
await runCapture('docker', ['info'], { timeoutMs: 10_000 });
|
|
182
|
+
return;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (autostart) {
|
|
185
|
+
await maybeStartDockerDaemon().catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
if (Date.now() >= deadline) {
|
|
188
|
+
const msg = e?.message ? String(e.message) : String(e);
|
|
189
|
+
throw new Error(
|
|
190
|
+
`[infra] docker is installed but the daemon is not ready.\n` +
|
|
191
|
+
`Fix: start Docker Desktop, or disable managed infra (HAPPY_STACKS_MANAGED_INFRA=0).\n` +
|
|
192
|
+
`You can also increase wait time with HAPPY_STACKS_DOCKER_WAIT_MS, or disable auto-start with HAPPY_STACKS_DOCKER_AUTOSTART=0.\n` +
|
|
193
|
+
`Details: ${msg}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
// eslint-disable-next-line no-await-in-loop
|
|
197
|
+
await delay(1000);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function maybeStartDockerDaemon() {
|
|
203
|
+
// Best-effort. This may be a no-op depending on platform/permissions.
|
|
204
|
+
if (process.platform === 'darwin') {
|
|
205
|
+
const app = (process.env.HAPPY_STACKS_DOCKER_APP ?? process.env.HAPPY_LOCAL_DOCKER_APP ?? '/Applications/Docker.app').trim();
|
|
206
|
+
// `open` exits quickly; Docker Desktop will start in the background.
|
|
207
|
+
await runCapture('open', ['-gj', '-a', app], { timeoutMs: 5_000 }).catch(() => {});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (process.platform === 'linux') {
|
|
212
|
+
// Rootless / Docker Desktop / system Docker can differ. Try a few user-scope units first.
|
|
213
|
+
const candidates = ['docker.service', 'docker.socket', 'docker-desktop.service', 'docker-desktop'];
|
|
214
|
+
for (const unit of candidates) {
|
|
215
|
+
// eslint-disable-next-line no-await-in-loop
|
|
216
|
+
await runCapture('systemctl', ['--user', 'start', unit], { timeoutMs: 5_000 }).catch(() => {});
|
|
217
|
+
}
|
|
218
|
+
// As a last resort, try system scope (may fail without sudo; ignore).
|
|
219
|
+
await runCapture('systemctl', ['start', 'docker'], { timeoutMs: 5_000 }).catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function dockerCompose({ composePath, projectName, args, options = {}, quiet = false, retries = 0 }) {
|
|
224
|
+
const cmdArgs = ['compose', '-f', composePath, '-p', projectName, ...args];
|
|
225
|
+
let attempt = 0;
|
|
226
|
+
// eslint-disable-next-line no-constant-condition
|
|
227
|
+
while (true) {
|
|
228
|
+
try {
|
|
229
|
+
if (quiet) {
|
|
230
|
+
// Capture stderr so callers can surface it in structured JSON errors.
|
|
231
|
+
await runCapture('docker', cmdArgs, { timeoutMs: 120_000, ...options });
|
|
232
|
+
} else {
|
|
233
|
+
await run('docker', cmdArgs, { ...options, stdio: options?.stdio ?? 'inherit' });
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
if (attempt >= retries) throw e;
|
|
238
|
+
attempt += 1;
|
|
239
|
+
// eslint-disable-next-line no-await-in-loop
|
|
240
|
+
await delay(800);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb }) {
|
|
246
|
+
const deadline = Date.now() + 60_000;
|
|
247
|
+
while (Date.now() < deadline) {
|
|
248
|
+
try {
|
|
249
|
+
await runCapture(
|
|
250
|
+
'docker',
|
|
251
|
+
['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'postgres', 'pg_isready', '-U', pgUser, '-d', pgDb],
|
|
252
|
+
{ timeoutMs: 5_000 }
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
} catch {
|
|
256
|
+
// ignore
|
|
257
|
+
}
|
|
258
|
+
// eslint-disable-next-line no-await-in-loop
|
|
259
|
+
await delay(800);
|
|
260
|
+
}
|
|
261
|
+
throw new Error('[infra] timed out waiting for postgres to become ready');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function waitForHealthyRedis({ composePath, projectName }) {
|
|
265
|
+
const deadline = Date.now() + 30_000;
|
|
266
|
+
while (Date.now() < deadline) {
|
|
267
|
+
try {
|
|
268
|
+
const out = await runCapture('docker', ['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'redis', 'redis-cli', 'ping'], {
|
|
269
|
+
timeoutMs: 5_000,
|
|
270
|
+
});
|
|
271
|
+
if (out.trim().toUpperCase().includes('PONG')) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// ignore
|
|
276
|
+
}
|
|
277
|
+
// eslint-disable-next-line no-await-in-loop
|
|
278
|
+
await delay(600);
|
|
279
|
+
}
|
|
280
|
+
throw new Error('[infra] timed out waiting for redis to become ready');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function waitForMinioReady({ composePath, projectName }) {
|
|
284
|
+
const deadline = Date.now() + 30_000;
|
|
285
|
+
while (Date.now() < deadline) {
|
|
286
|
+
try {
|
|
287
|
+
// Minio doesn't ship a healthcheck in our compose; exec'ing a trivial command is a good enough
|
|
288
|
+
// readiness proxy for running/accepting execs before we run minio-init.
|
|
289
|
+
await runCapture('docker', ['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'minio', 'sh', '-lc', 'echo ok'], {
|
|
290
|
+
timeoutMs: 5_000,
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
} catch {
|
|
294
|
+
// ignore
|
|
295
|
+
}
|
|
296
|
+
// eslint-disable-next-line no-await-in-loop
|
|
297
|
+
await delay(600);
|
|
298
|
+
}
|
|
299
|
+
throw new Error('[infra] timed out waiting for minio to become ready');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function ensureHappyServerManagedInfra({
|
|
303
|
+
stackName,
|
|
304
|
+
baseDir,
|
|
305
|
+
serverPort,
|
|
306
|
+
publicServerUrl,
|
|
307
|
+
envPath,
|
|
308
|
+
env = process.env,
|
|
309
|
+
quiet = false,
|
|
310
|
+
skipMinioInit = false,
|
|
311
|
+
}) {
|
|
312
|
+
await ensureDockerCompose();
|
|
313
|
+
|
|
314
|
+
const infraDir = join(baseDir, 'happy-server', 'infra');
|
|
315
|
+
await mkdir(infraDir, { recursive: true });
|
|
316
|
+
|
|
317
|
+
const existingEnv = envPath ? await readEnvObject(envPath) : {};
|
|
318
|
+
const reservedPorts = new Set();
|
|
319
|
+
|
|
320
|
+
// Reserve known ports (if present) to avoid picking duplicates when auto-filling.
|
|
321
|
+
for (const key of [
|
|
322
|
+
'HAPPY_STACKS_SERVER_PORT',
|
|
323
|
+
'HAPPY_LOCAL_SERVER_PORT',
|
|
324
|
+
'HAPPY_STACKS_PG_PORT',
|
|
325
|
+
'HAPPY_STACKS_REDIS_PORT',
|
|
326
|
+
'HAPPY_STACKS_MINIO_PORT',
|
|
327
|
+
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
328
|
+
]) {
|
|
329
|
+
const p = coercePort(existingEnv[key] ?? env[key]);
|
|
330
|
+
if (p) reservedPorts.add(p);
|
|
331
|
+
}
|
|
332
|
+
if (Number.isFinite(serverPort) && serverPort > 0) reservedPorts.add(serverPort);
|
|
333
|
+
|
|
334
|
+
const pgPort =
|
|
335
|
+
coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ??
|
|
336
|
+
(await pickNextFreeTcpPort(serverPort + 1000, { reservedPorts }));
|
|
337
|
+
reservedPorts.add(pgPort);
|
|
338
|
+
const redisPort =
|
|
339
|
+
coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ??
|
|
340
|
+
(await pickNextFreeTcpPort(pgPort + 1, { reservedPorts }));
|
|
341
|
+
reservedPorts.add(redisPort);
|
|
342
|
+
const minioPort =
|
|
343
|
+
coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ??
|
|
344
|
+
(await pickNextFreeTcpPort(redisPort + 1, { reservedPorts }));
|
|
345
|
+
reservedPorts.add(minioPort);
|
|
346
|
+
const minioConsolePort =
|
|
347
|
+
coercePort(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? env.HAPPY_STACKS_MINIO_CONSOLE_PORT) ??
|
|
348
|
+
(await pickNextFreeTcpPort(minioPort + 1, { reservedPorts }));
|
|
349
|
+
reservedPorts.add(minioConsolePort);
|
|
350
|
+
|
|
351
|
+
const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? env.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
|
|
352
|
+
const pgPassword = (existingEnv.HAPPY_STACKS_PG_PASSWORD ?? env.HAPPY_STACKS_PG_PASSWORD ?? '').trim() || randomToken(24);
|
|
353
|
+
const pgDb = (existingEnv.HAPPY_STACKS_PG_DATABASE ?? env.HAPPY_STACKS_PG_DATABASE ?? 'handy').trim() || 'handy';
|
|
354
|
+
|
|
355
|
+
const s3Bucket =
|
|
356
|
+
(existingEnv.S3_BUCKET ?? env.S3_BUCKET ?? '').trim() || sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
|
|
357
|
+
const s3AccessKey = (existingEnv.S3_ACCESS_KEY ?? env.S3_ACCESS_KEY ?? '').trim() || randomToken(12);
|
|
358
|
+
const s3SecretKey = (existingEnv.S3_SECRET_KEY ?? env.S3_SECRET_KEY ?? '').trim() || randomToken(24);
|
|
359
|
+
|
|
360
|
+
const secretFile = (existingEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim()
|
|
361
|
+
? (existingEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE).trim()
|
|
362
|
+
: join(baseDir, 'happy-server', 'handy-master-secret.txt');
|
|
363
|
+
const handyMasterSecret = (existingEnv.HANDY_MASTER_SECRET ?? env.HANDY_MASTER_SECRET ?? '').trim()
|
|
364
|
+
? (existingEnv.HANDY_MASTER_SECRET ?? env.HANDY_MASTER_SECRET).trim()
|
|
365
|
+
: await ensureTextFile({ path: secretFile, generate: () => randomToken(32) });
|
|
366
|
+
|
|
367
|
+
const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
|
|
368
|
+
const redisUrl = `redis://127.0.0.1:${redisPort}`;
|
|
369
|
+
const s3Host = '127.0.0.1';
|
|
370
|
+
const s3UseSsl = 'false';
|
|
371
|
+
const pub = String(publicServerUrl ?? '').trim().replace(/\/+$/, '');
|
|
372
|
+
if (!pub) {
|
|
373
|
+
throw new Error('[infra] publicServerUrl is required for managed infra (to set S3_PUBLIC_URL)');
|
|
374
|
+
}
|
|
375
|
+
const s3PublicUrl = `${pub}/files`;
|
|
376
|
+
|
|
377
|
+
if (envPath) {
|
|
378
|
+
// Ephemeral stacks should not pin ports in env files. In stack runtime, callers set
|
|
379
|
+
// HAPPY_STACKS_EPHEMERAL_PORTS=1 (via stack.runtime.json overlay) while the stack owner is alive.
|
|
380
|
+
//
|
|
381
|
+
// For offline tooling (e.g. auth seeding) we still want to preserve the invariant:
|
|
382
|
+
// - non-main stacks are ephemeral-by-default unless the user explicitly pinned ports already.
|
|
383
|
+
const runtimeEphemeral = (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
|
|
384
|
+
const alreadyPinnedPorts =
|
|
385
|
+
Boolean((existingEnv.HAPPY_STACKS_PG_PORT ?? '').trim()) ||
|
|
386
|
+
Boolean((existingEnv.HAPPY_STACKS_REDIS_PORT ?? '').trim()) ||
|
|
387
|
+
Boolean((existingEnv.HAPPY_STACKS_MINIO_PORT ?? '').trim()) ||
|
|
388
|
+
Boolean((existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? '').trim());
|
|
389
|
+
const ephemeralPorts = runtimeEphemeral || (stackName !== 'main' && !alreadyPinnedPorts);
|
|
390
|
+
await ensureEnvFileUpdated({
|
|
391
|
+
envPath,
|
|
392
|
+
updates: [
|
|
393
|
+
// Stable credentials/files: persist these so restarts keep the same DB/user and S3 creds.
|
|
394
|
+
{ key: 'HAPPY_STACKS_PG_USER', value: pgUser },
|
|
395
|
+
{ key: 'HAPPY_STACKS_PG_PASSWORD', value: pgPassword },
|
|
396
|
+
{ key: 'HAPPY_STACKS_PG_DATABASE', value: pgDb },
|
|
397
|
+
{ key: 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE', value: secretFile },
|
|
398
|
+
{ key: 'S3_ACCESS_KEY', value: s3AccessKey },
|
|
399
|
+
{ key: 'S3_SECRET_KEY', value: s3SecretKey },
|
|
400
|
+
{ key: 'S3_BUCKET', value: s3Bucket },
|
|
401
|
+
// Ports + derived URLs: persist only when ports are explicitly pinned (non-ephemeral mode).
|
|
402
|
+
...(ephemeralPorts
|
|
403
|
+
? []
|
|
404
|
+
: [
|
|
405
|
+
{ key: 'HAPPY_STACKS_PG_PORT', value: String(pgPort) },
|
|
406
|
+
{ key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) },
|
|
407
|
+
{ key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) },
|
|
408
|
+
{ key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) },
|
|
409
|
+
// Vars consumed by happy-server:
|
|
410
|
+
{ key: 'DATABASE_URL', value: databaseUrl },
|
|
411
|
+
{ key: 'REDIS_URL', value: redisUrl },
|
|
412
|
+
{ key: 'S3_HOST', value: s3Host },
|
|
413
|
+
{ key: 'S3_PORT', value: String(minioPort) },
|
|
414
|
+
{ key: 'S3_USE_SSL', value: s3UseSsl },
|
|
415
|
+
{ key: 'S3_PUBLIC_URL', value: s3PublicUrl },
|
|
416
|
+
]),
|
|
417
|
+
],
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const composePath = join(infraDir, 'docker-compose.yml');
|
|
422
|
+
const projectName = composeProjectName(stackName);
|
|
423
|
+
const yaml = buildComposeYaml({
|
|
424
|
+
infraDir,
|
|
425
|
+
pgPort,
|
|
426
|
+
pgUser,
|
|
427
|
+
pgPassword,
|
|
428
|
+
pgDb,
|
|
429
|
+
redisPort,
|
|
430
|
+
minioPort,
|
|
431
|
+
minioConsolePort,
|
|
432
|
+
s3AccessKey,
|
|
433
|
+
s3SecretKey,
|
|
434
|
+
s3Bucket,
|
|
435
|
+
});
|
|
436
|
+
await writeFile(composePath, yaml, 'utf-8');
|
|
437
|
+
|
|
438
|
+
await dockerCompose({
|
|
439
|
+
composePath,
|
|
440
|
+
projectName,
|
|
441
|
+
args: ['up', '-d', '--remove-orphans'],
|
|
442
|
+
options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
|
|
443
|
+
quiet,
|
|
444
|
+
});
|
|
445
|
+
await waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb });
|
|
446
|
+
await waitForHealthyRedis({ composePath, projectName });
|
|
447
|
+
|
|
448
|
+
if (!skipMinioInit) {
|
|
449
|
+
// Ensure bucket exists (idempotent). This can race with Minio startup; retry a few times.
|
|
450
|
+
await waitForMinioReady({ composePath, projectName });
|
|
451
|
+
await dockerCompose({
|
|
452
|
+
composePath,
|
|
453
|
+
projectName,
|
|
454
|
+
args: ['run', '--rm', '--no-deps', 'minio-init'],
|
|
455
|
+
options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
|
|
456
|
+
quiet,
|
|
457
|
+
retries: 3,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
composePath,
|
|
463
|
+
projectName,
|
|
464
|
+
infraDir,
|
|
465
|
+
env: {
|
|
466
|
+
DATABASE_URL: databaseUrl,
|
|
467
|
+
REDIS_URL: redisUrl,
|
|
468
|
+
S3_HOST: s3Host,
|
|
469
|
+
S3_PORT: String(minioPort),
|
|
470
|
+
S3_USE_SSL: s3UseSsl,
|
|
471
|
+
S3_ACCESS_KEY: s3AccessKey,
|
|
472
|
+
S3_SECRET_KEY: s3SecretKey,
|
|
473
|
+
S3_BUCKET: s3Bucket,
|
|
474
|
+
S3_PUBLIC_URL: s3PublicUrl,
|
|
475
|
+
HANDY_MASTER_SECRET: handyMasterSecret,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function applyHappyServerMigrations({ serverDir, env, quiet = false }) {
|
|
481
|
+
// Non-interactive + idempotent. Safe for dev; also safe for managed stacks on start.
|
|
482
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env, quiet });
|
|
483
|
+
}
|
|
484
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getStackName } from './paths.mjs';
|
|
2
|
+
|
|
3
|
+
function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
|
|
4
|
+
const s = String(raw ?? '')
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
7
|
+
.replace(/-+/g, '-')
|
|
8
|
+
.replace(/^-+/, '')
|
|
9
|
+
.replace(/-+$/, '');
|
|
10
|
+
return s || fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveLocalhostHost({ stackMode, stackName = getStackName() } = {}) {
|
|
14
|
+
if (!stackMode) return 'localhost';
|
|
15
|
+
if (!stackName || stackName === 'main') return 'localhost';
|
|
16
|
+
return `happy-${sanitizeDnsLabel(stackName)}.localhost`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { runCapture } from './proc.mjs';
|
|
2
|
+
import { killPid } from './expo.mjs';
|
|
3
|
+
|
|
4
|
+
export async function getPsEnvLine(pid) {
|
|
5
|
+
const n = Number(pid);
|
|
6
|
+
if (!Number.isFinite(n) || n <= 1) return null;
|
|
7
|
+
if (process.platform === 'win32') return null;
|
|
8
|
+
try {
|
|
9
|
+
const out = await runCapture('ps', ['eww', '-p', String(n)]);
|
|
10
|
+
// Output usually includes a header line and then a single process line.
|
|
11
|
+
const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
12
|
+
if (lines.length >= 2) return lines[1];
|
|
13
|
+
if (lines.length === 1) return lines[0];
|
|
14
|
+
return null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function listPidsWithEnvNeedle(needle) {
|
|
21
|
+
const n = String(needle ?? '').trim();
|
|
22
|
+
if (!n) return [];
|
|
23
|
+
if (process.platform === 'win32') return [];
|
|
24
|
+
try {
|
|
25
|
+
// Include environment variables (eww) so we can match on HAPPY_STACKS_ENV_FILE=/.../env safely.
|
|
26
|
+
const out = await runCapture('ps', ['eww', '-ax', '-o', 'pid=,command=']);
|
|
27
|
+
const pids = [];
|
|
28
|
+
for (const line of out.split('\n')) {
|
|
29
|
+
if (!line.includes(n)) continue;
|
|
30
|
+
const m = line.trim().match(/^(\d+)\s+/);
|
|
31
|
+
if (!m) continue;
|
|
32
|
+
const pid = Number(m[1]);
|
|
33
|
+
if (Number.isFinite(pid) && pid > 1) {
|
|
34
|
+
pids.push(pid);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return Array.from(new Set(pids));
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function getProcessGroupId(pid) {
|
|
44
|
+
const n = Number(pid);
|
|
45
|
+
if (!Number.isFinite(n) || n <= 1) return null;
|
|
46
|
+
if (process.platform === 'win32') return null;
|
|
47
|
+
try {
|
|
48
|
+
const out = await runCapture('ps', ['-o', 'pgid=', '-p', String(n)]);
|
|
49
|
+
const raw = out.trim();
|
|
50
|
+
const pgid = raw ? Number(raw) : NaN;
|
|
51
|
+
return Number.isFinite(pgid) && pgid > 1 ? pgid : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function isPidOwnedByStack(pid, { stackName, envPath, cliHomeDir } = {}) {
|
|
58
|
+
const line = await getPsEnvLine(pid);
|
|
59
|
+
if (!line) return false;
|
|
60
|
+
const sn = String(stackName ?? '').trim();
|
|
61
|
+
const ep = String(envPath ?? '').trim();
|
|
62
|
+
const ch = String(cliHomeDir ?? '').trim();
|
|
63
|
+
|
|
64
|
+
// Require at least one stack identifier.
|
|
65
|
+
const hasStack =
|
|
66
|
+
(sn && (line.includes(`HAPPY_STACKS_STACK=${sn}`) || line.includes(`HAPPY_LOCAL_STACK=${sn}`))) ||
|
|
67
|
+
(!sn && (line.includes('HAPPY_STACKS_STACK=') || line.includes('HAPPY_LOCAL_STACK=')));
|
|
68
|
+
if (!hasStack) return false;
|
|
69
|
+
|
|
70
|
+
// Prefer env-file binding (strongest).
|
|
71
|
+
if (ep) {
|
|
72
|
+
if (line.includes(`HAPPY_STACKS_ENV_FILE=${ep}`) || line.includes(`HAPPY_LOCAL_ENV_FILE=${ep}`)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fallback: CLI home dir binding (useful for daemon-related processes).
|
|
78
|
+
if (ch) {
|
|
79
|
+
if (line.includes(`HAPPY_HOME_DIR=${ch}`) || line.includes(`HAPPY_STACKS_CLI_HOME_DIR=${ch}`) || line.includes(`HAPPY_LOCAL_CLI_HOME_DIR=${ch}`)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function killPidOwnedByStack(pid, { stackName, envPath, cliHomeDir, label = 'process', json = false } = {}) {
|
|
88
|
+
const ok = await isPidOwnedByStack(pid, { stackName, envPath, cliHomeDir });
|
|
89
|
+
if (!ok) {
|
|
90
|
+
if (!json) {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn(`[stack] refusing to kill ${label} pid=${pid} (cannot prove it belongs to stack ${stackName ?? ''})`);
|
|
93
|
+
}
|
|
94
|
+
return { killed: false, reason: 'not_owned' };
|
|
95
|
+
}
|
|
96
|
+
await killPid(pid);
|
|
97
|
+
return { killed: true, reason: 'killed' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function killProcessGroupOwnedByStack(
|
|
101
|
+
pid,
|
|
102
|
+
{ stackName, envPath, cliHomeDir, label = 'process-group', json = false, signal = 'SIGTERM' } = {}
|
|
103
|
+
) {
|
|
104
|
+
const ok = await isPidOwnedByStack(pid, { stackName, envPath, cliHomeDir });
|
|
105
|
+
if (!ok) {
|
|
106
|
+
if (!json) {
|
|
107
|
+
// eslint-disable-next-line no-console
|
|
108
|
+
console.warn(`[stack] refusing to kill ${label} pid=${pid} (cannot prove it belongs to stack ${stackName ?? ''})`);
|
|
109
|
+
}
|
|
110
|
+
return { killed: false, reason: 'not_owned' };
|
|
111
|
+
}
|
|
112
|
+
const pgid = await getProcessGroupId(pid);
|
|
113
|
+
if (!pgid) {
|
|
114
|
+
await killPid(pid);
|
|
115
|
+
return { killed: true, reason: 'killed_pid_only' };
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
process.kill(-pgid, signal);
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore
|
|
121
|
+
}
|
|
122
|
+
// Escalate if still alive.
|
|
123
|
+
try {
|
|
124
|
+
process.kill(pid, 0);
|
|
125
|
+
try {
|
|
126
|
+
process.kill(-pgid, 'SIGKILL');
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// exited
|
|
132
|
+
}
|
|
133
|
+
return { killed: true, reason: 'killed_pgid', pgid };
|
|
134
|
+
}
|
|
135
|
+
|
package/scripts/utils/paths.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
|
|
5
6
|
|
|
6
7
|
const PRIMARY_APP_SLUG = 'happy-stacks';
|
|
7
8
|
const LEGACY_APP_SLUG = 'happy-local';
|
|
@@ -101,12 +102,13 @@ export function resolveStackBaseDir(stackName = getStackName()) {
|
|
|
101
102
|
const preferredRoot = getStacksStorageRoot();
|
|
102
103
|
const newBase = join(preferredRoot, stackName);
|
|
103
104
|
const legacyBase = stackName === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', stackName);
|
|
105
|
+
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
104
106
|
|
|
105
107
|
// Prefer the new layout by default.
|
|
106
108
|
//
|
|
107
109
|
// For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
|
|
108
110
|
// This avoids breaking existing stacks until `happys stack migrate` is run.
|
|
109
|
-
if (stackName !== 'main') {
|
|
111
|
+
if (allowLegacy && stackName !== 'main') {
|
|
110
112
|
const newEnv = join(preferredRoot, stackName, 'env');
|
|
111
113
|
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
|
|
112
114
|
if (!existsSync(newEnv) && existsSync(legacyEnv)) {
|
|
@@ -123,11 +125,12 @@ export function resolveStackEnvPath(stackName = getStackName()) {
|
|
|
123
125
|
const newEnv = join(getStacksStorageRoot(), stackName, 'env');
|
|
124
126
|
// Legacy layout: ~/.happy/local/stacks/<name>/env
|
|
125
127
|
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
|
|
128
|
+
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
126
129
|
|
|
127
130
|
if (existsSync(newEnv)) {
|
|
128
131
|
return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(), stackName) };
|
|
129
132
|
}
|
|
130
|
-
if (existsSync(legacyEnv)) {
|
|
133
|
+
if (allowLegacy && existsSync(legacyEnv)) {
|
|
131
134
|
return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', stackName) };
|
|
132
135
|
}
|
|
133
136
|
return { envPath: newEnv, isLegacy, baseDir: activeBase };
|