happy-stacks 0.1.2 → 0.3.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 +164 -89
- 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 +521 -226
- package/scripts/build.mjs +29 -10
- package/scripts/cli-link.mjs +6 -6
- package/scripts/completion.mjs +18 -11
- package/scripts/daemon.mjs +133 -31
- package/scripts/dev.mjs +196 -137
- package/scripts/doctor.mjs +44 -55
- package/scripts/edison.mjs +1853 -0
- package/scripts/happy.mjs +10 -25
- package/scripts/init.mjs +46 -31
- package/scripts/install.mjs +21 -15
- package/scripts/lint.mjs +124 -0
- package/scripts/menubar.mjs +76 -10
- package/scripts/migrate.mjs +35 -35
- package/scripts/mobile.mjs +24 -17
- package/scripts/run.mjs +122 -35
- package/scripts/self.mjs +13 -35
- package/scripts/server_flavor.mjs +7 -7
- package/scripts/service.mjs +31 -28
- package/scripts/setup.mjs +694 -0
- package/scripts/setup_pr.mjs +165 -0
- package/scripts/stack.mjs +1851 -363
- package/scripts/stop.mjs +9 -6
- package/scripts/tailscale.mjs +23 -11
- package/scripts/test.mjs +123 -0
- package/scripts/tui.mjs +526 -0
- package/scripts/typecheck.mjs +10 -31
- package/scripts/ui_gateway.mjs +3 -3
- package/scripts/uninstall.mjs +21 -13
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/auth/files.mjs +56 -0
- package/scripts/utils/auth/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/login_ux.mjs +76 -0
- package/scripts/utils/auth/sources.mjs +12 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -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/{config.mjs → env/config.mjs} +8 -3
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
- package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/sandbox.mjs +14 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
- package/scripts/utils/paths/canonical_home.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +9 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/proc/ownership.mjs +135 -0
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/proc/pm.mjs +317 -0
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
- package/scripts/utils/proc/watch.mjs +63 -0
- package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
- package/scripts/utils/server/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
- package/scripts/utils/server/urls.mjs +91 -0
- package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
- package/scripts/utils/service/autostart_darwin.mjs +142 -0
- package/scripts/utils/stack/context.mjs +23 -0
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/stack/runtime_state.mjs +87 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/stack/startup.mjs +208 -0
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
- package/scripts/utils/ui/browser.mjs +22 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/where.mjs +17 -10
- package/scripts/worktrees.mjs +110 -64
- package/scripts/utils/pm.mjs +0 -303
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
|
@@ -1,73 +1,18 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import net from 'node:net';
|
|
5
3
|
import { join } from 'node:path';
|
|
6
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
5
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 isPortFree(port) {
|
|
41
|
-
return await new Promise((resolvePromise) => {
|
|
42
|
-
const srv = net.createServer();
|
|
43
|
-
srv.unref();
|
|
44
|
-
srv.on('error', () => resolvePromise(false));
|
|
45
|
-
srv.listen({ port, host: '127.0.0.1' }, () => {
|
|
46
|
-
srv.close(() => resolvePromise(true));
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
|
|
52
|
-
let port = startPort;
|
|
53
|
-
for (let i = 0; i < 200; i++) {
|
|
54
|
-
// eslint-disable-next-line no-await-in-loop
|
|
55
|
-
if (!reservedPorts.has(port) && (await isPortFree(port))) {
|
|
56
|
-
return port;
|
|
57
|
-
}
|
|
58
|
-
port += 1;
|
|
59
|
-
}
|
|
60
|
-
throw new Error(`[infra] unable to find a free port starting at ${startPort}`);
|
|
61
|
-
}
|
|
6
|
+
import { ensureEnvFileUpdated } from '../../env/env_file.mjs';
|
|
7
|
+
import { readEnvObjectFromFile } from '../../env/read.mjs';
|
|
8
|
+
import { sanitizeDnsLabel } from '../../net/dns.mjs';
|
|
9
|
+
import { pickNextFreeTcpPort } from '../../net/ports.mjs';
|
|
10
|
+
import { pmExecBin } from '../../proc/pm.mjs';
|
|
11
|
+
import { run, runCapture } from '../../proc/proc.mjs';
|
|
12
|
+
import { randomToken } from '../../crypto/tokens.mjs';
|
|
13
|
+
import { coercePort, INFRA_RESERVED_PORT_KEYS } from '../port.mjs';
|
|
62
14
|
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const raw = await readFile(envPath, 'utf-8');
|
|
66
|
-
return Object.fromEntries(parseDotenv(raw).entries());
|
|
67
|
-
} catch {
|
|
68
|
-
return {};
|
|
69
|
-
}
|
|
70
|
-
}
|
|
15
|
+
const readEnvObject = readEnvObjectFromFile;
|
|
71
16
|
|
|
72
17
|
async function ensureTextFile({ path, generate }) {
|
|
73
18
|
if (existsSync(path)) {
|
|
@@ -243,8 +188,26 @@ async function maybeStartDockerDaemon() {
|
|
|
243
188
|
}
|
|
244
189
|
}
|
|
245
190
|
|
|
246
|
-
async function dockerCompose({ composePath, projectName, args, options = {} }) {
|
|
247
|
-
|
|
191
|
+
async function dockerCompose({ composePath, projectName, args, options = {}, quiet = false, retries = 0 }) {
|
|
192
|
+
const cmdArgs = ['compose', '-f', composePath, '-p', projectName, ...args];
|
|
193
|
+
let attempt = 0;
|
|
194
|
+
// eslint-disable-next-line no-constant-condition
|
|
195
|
+
while (true) {
|
|
196
|
+
try {
|
|
197
|
+
if (quiet) {
|
|
198
|
+
// Capture stderr so callers can surface it in structured JSON errors.
|
|
199
|
+
await runCapture('docker', cmdArgs, { timeoutMs: 120_000, ...options });
|
|
200
|
+
} else {
|
|
201
|
+
await run('docker', cmdArgs, { ...options, stdio: options?.stdio ?? 'inherit' });
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
if (attempt >= retries) throw e;
|
|
206
|
+
attempt += 1;
|
|
207
|
+
// eslint-disable-next-line no-await-in-loop
|
|
208
|
+
await delay(800);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
248
211
|
}
|
|
249
212
|
|
|
250
213
|
async function waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb }) {
|
|
@@ -285,6 +248,25 @@ async function waitForHealthyRedis({ composePath, projectName }) {
|
|
|
285
248
|
throw new Error('[infra] timed out waiting for redis to become ready');
|
|
286
249
|
}
|
|
287
250
|
|
|
251
|
+
async function waitForMinioReady({ composePath, projectName }) {
|
|
252
|
+
const deadline = Date.now() + 30_000;
|
|
253
|
+
while (Date.now() < deadline) {
|
|
254
|
+
try {
|
|
255
|
+
// Minio doesn't ship a healthcheck in our compose; exec'ing a trivial command is a good enough
|
|
256
|
+
// readiness proxy for running/accepting execs before we run minio-init.
|
|
257
|
+
await runCapture('docker', ['compose', '-f', composePath, '-p', projectName, 'exec', '-T', 'minio', 'sh', '-lc', 'echo ok'], {
|
|
258
|
+
timeoutMs: 5_000,
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
} catch {
|
|
262
|
+
// ignore
|
|
263
|
+
}
|
|
264
|
+
// eslint-disable-next-line no-await-in-loop
|
|
265
|
+
await delay(600);
|
|
266
|
+
}
|
|
267
|
+
throw new Error('[infra] timed out waiting for minio to become ready');
|
|
268
|
+
}
|
|
269
|
+
|
|
288
270
|
export async function ensureHappyServerManagedInfra({
|
|
289
271
|
stackName,
|
|
290
272
|
baseDir,
|
|
@@ -292,6 +274,8 @@ export async function ensureHappyServerManagedInfra({
|
|
|
292
274
|
publicServerUrl,
|
|
293
275
|
envPath,
|
|
294
276
|
env = process.env,
|
|
277
|
+
quiet = false,
|
|
278
|
+
skipMinioInit = false,
|
|
295
279
|
}) {
|
|
296
280
|
await ensureDockerCompose();
|
|
297
281
|
|
|
@@ -302,30 +286,27 @@ export async function ensureHappyServerManagedInfra({
|
|
|
302
286
|
const reservedPorts = new Set();
|
|
303
287
|
|
|
304
288
|
// Reserve known ports (if present) to avoid picking duplicates when auto-filling.
|
|
305
|
-
for (const key of
|
|
306
|
-
'HAPPY_STACKS_SERVER_PORT',
|
|
307
|
-
'HAPPY_LOCAL_SERVER_PORT',
|
|
308
|
-
'HAPPY_STACKS_PG_PORT',
|
|
309
|
-
'HAPPY_STACKS_REDIS_PORT',
|
|
310
|
-
'HAPPY_STACKS_MINIO_PORT',
|
|
311
|
-
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
312
|
-
]) {
|
|
289
|
+
for (const key of INFRA_RESERVED_PORT_KEYS) {
|
|
313
290
|
const p = coercePort(existingEnv[key] ?? env[key]);
|
|
314
291
|
if (p) reservedPorts.add(p);
|
|
315
292
|
}
|
|
316
293
|
if (Number.isFinite(serverPort) && serverPort > 0) reservedPorts.add(serverPort);
|
|
317
294
|
|
|
318
|
-
const pgPort =
|
|
295
|
+
const pgPort =
|
|
296
|
+
coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ??
|
|
297
|
+
(await pickNextFreeTcpPort(serverPort + 1000, { reservedPorts }));
|
|
319
298
|
reservedPorts.add(pgPort);
|
|
320
299
|
const redisPort =
|
|
321
|
-
coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ??
|
|
300
|
+
coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ??
|
|
301
|
+
(await pickNextFreeTcpPort(pgPort + 1, { reservedPorts }));
|
|
322
302
|
reservedPorts.add(redisPort);
|
|
323
303
|
const minioPort =
|
|
324
|
-
coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ??
|
|
304
|
+
coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ??
|
|
305
|
+
(await pickNextFreeTcpPort(redisPort + 1, { reservedPorts }));
|
|
325
306
|
reservedPorts.add(minioPort);
|
|
326
307
|
const minioConsolePort =
|
|
327
308
|
coercePort(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? env.HAPPY_STACKS_MINIO_CONSOLE_PORT) ??
|
|
328
|
-
(await
|
|
309
|
+
(await pickNextFreeTcpPort(minioPort + 1, { reservedPorts }));
|
|
329
310
|
reservedPorts.add(minioConsolePort);
|
|
330
311
|
|
|
331
312
|
const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? env.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
|
|
@@ -355,27 +336,45 @@ export async function ensureHappyServerManagedInfra({
|
|
|
355
336
|
const s3PublicUrl = `${pub}/files`;
|
|
356
337
|
|
|
357
338
|
if (envPath) {
|
|
339
|
+
// Ephemeral stacks should not pin ports in env files. In stack runtime, callers set
|
|
340
|
+
// HAPPY_STACKS_EPHEMERAL_PORTS=1 (via stack.runtime.json overlay) while the stack owner is alive.
|
|
341
|
+
//
|
|
342
|
+
// For offline tooling (e.g. auth seeding) we still want to preserve the invariant:
|
|
343
|
+
// - non-main stacks are ephemeral-by-default unless the user explicitly pinned ports already.
|
|
344
|
+
const runtimeEphemeral = (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
|
|
345
|
+
const alreadyPinnedPorts =
|
|
346
|
+
Boolean((existingEnv.HAPPY_STACKS_PG_PORT ?? '').trim()) ||
|
|
347
|
+
Boolean((existingEnv.HAPPY_STACKS_REDIS_PORT ?? '').trim()) ||
|
|
348
|
+
Boolean((existingEnv.HAPPY_STACKS_MINIO_PORT ?? '').trim()) ||
|
|
349
|
+
Boolean((existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? '').trim());
|
|
350
|
+
const ephemeralPorts = runtimeEphemeral || (stackName !== 'main' && !alreadyPinnedPorts);
|
|
358
351
|
await ensureEnvFileUpdated({
|
|
359
352
|
envPath,
|
|
360
353
|
updates: [
|
|
361
|
-
|
|
362
|
-
{ key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) },
|
|
363
|
-
{ key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) },
|
|
364
|
-
{ key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) },
|
|
354
|
+
// Stable credentials/files: persist these so restarts keep the same DB/user and S3 creds.
|
|
365
355
|
{ key: 'HAPPY_STACKS_PG_USER', value: pgUser },
|
|
366
356
|
{ key: 'HAPPY_STACKS_PG_PASSWORD', value: pgPassword },
|
|
367
357
|
{ key: 'HAPPY_STACKS_PG_DATABASE', value: pgDb },
|
|
368
358
|
{ key: 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE', value: secretFile },
|
|
369
|
-
// Vars consumed by happy-server:
|
|
370
|
-
{ key: 'DATABASE_URL', value: databaseUrl },
|
|
371
|
-
{ key: 'REDIS_URL', value: redisUrl },
|
|
372
|
-
{ key: 'S3_HOST', value: s3Host },
|
|
373
|
-
{ key: 'S3_PORT', value: String(minioPort) },
|
|
374
|
-
{ key: 'S3_USE_SSL', value: s3UseSsl },
|
|
375
359
|
{ key: 'S3_ACCESS_KEY', value: s3AccessKey },
|
|
376
360
|
{ key: 'S3_SECRET_KEY', value: s3SecretKey },
|
|
377
361
|
{ key: 'S3_BUCKET', value: s3Bucket },
|
|
378
|
-
|
|
362
|
+
// Ports + derived URLs: persist only when ports are explicitly pinned (non-ephemeral mode).
|
|
363
|
+
...(ephemeralPorts
|
|
364
|
+
? []
|
|
365
|
+
: [
|
|
366
|
+
{ key: 'HAPPY_STACKS_PG_PORT', value: String(pgPort) },
|
|
367
|
+
{ key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) },
|
|
368
|
+
{ key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) },
|
|
369
|
+
{ key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) },
|
|
370
|
+
// Vars consumed by happy-server:
|
|
371
|
+
{ key: 'DATABASE_URL', value: databaseUrl },
|
|
372
|
+
{ key: 'REDIS_URL', value: redisUrl },
|
|
373
|
+
{ key: 'S3_HOST', value: s3Host },
|
|
374
|
+
{ key: 'S3_PORT', value: String(minioPort) },
|
|
375
|
+
{ key: 'S3_USE_SSL', value: s3UseSsl },
|
|
376
|
+
{ key: 'S3_PUBLIC_URL', value: s3PublicUrl },
|
|
377
|
+
]),
|
|
379
378
|
],
|
|
380
379
|
});
|
|
381
380
|
}
|
|
@@ -397,12 +396,28 @@ export async function ensureHappyServerManagedInfra({
|
|
|
397
396
|
});
|
|
398
397
|
await writeFile(composePath, yaml, 'utf-8');
|
|
399
398
|
|
|
400
|
-
await dockerCompose({
|
|
399
|
+
await dockerCompose({
|
|
400
|
+
composePath,
|
|
401
|
+
projectName,
|
|
402
|
+
args: ['up', '-d', '--remove-orphans'],
|
|
403
|
+
options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
|
|
404
|
+
quiet,
|
|
405
|
+
});
|
|
401
406
|
await waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb });
|
|
402
407
|
await waitForHealthyRedis({ composePath, projectName });
|
|
403
408
|
|
|
404
|
-
|
|
405
|
-
|
|
409
|
+
if (!skipMinioInit) {
|
|
410
|
+
// Ensure bucket exists (idempotent). This can race with Minio startup; retry a few times.
|
|
411
|
+
await waitForMinioReady({ composePath, projectName });
|
|
412
|
+
await dockerCompose({
|
|
413
|
+
composePath,
|
|
414
|
+
projectName,
|
|
415
|
+
args: ['run', '--rm', '--no-deps', 'minio-init'],
|
|
416
|
+
options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
|
|
417
|
+
quiet,
|
|
418
|
+
retries: 3,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
406
421
|
|
|
407
422
|
return {
|
|
408
423
|
composePath,
|
|
@@ -423,8 +438,8 @@ export async function ensureHappyServerManagedInfra({
|
|
|
423
438
|
};
|
|
424
439
|
}
|
|
425
440
|
|
|
426
|
-
export async function applyHappyServerMigrations({ serverDir, env }) {
|
|
441
|
+
export async function applyHappyServerMigrations({ serverDir, env, quiet = false }) {
|
|
427
442
|
// Non-interactive + idempotent. Safe for dev; also safe for managed stacks on start.
|
|
428
|
-
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
|
|
443
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env, quiet });
|
|
429
444
|
}
|
|
430
445
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readEnvValueFromFile } from '../env/read.mjs';
|
|
2
|
+
|
|
3
|
+
export const STACK_RESERVED_PORT_KEYS = [
|
|
4
|
+
'HAPPY_STACKS_SERVER_PORT',
|
|
5
|
+
'HAPPY_LOCAL_SERVER_PORT',
|
|
6
|
+
'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
|
|
7
|
+
'HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT',
|
|
8
|
+
'HAPPY_STACKS_PG_PORT',
|
|
9
|
+
'HAPPY_STACKS_REDIS_PORT',
|
|
10
|
+
'HAPPY_STACKS_MINIO_PORT',
|
|
11
|
+
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const INFRA_RESERVED_PORT_KEYS = [
|
|
15
|
+
'HAPPY_STACKS_SERVER_PORT',
|
|
16
|
+
'HAPPY_LOCAL_SERVER_PORT',
|
|
17
|
+
'HAPPY_STACKS_PG_PORT',
|
|
18
|
+
'HAPPY_STACKS_REDIS_PORT',
|
|
19
|
+
'HAPPY_STACKS_MINIO_PORT',
|
|
20
|
+
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function coercePort(v) {
|
|
24
|
+
const s = String(v ?? '').trim();
|
|
25
|
+
if (!s) return null;
|
|
26
|
+
const n = Number(s);
|
|
27
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
|
|
31
|
+
const raw =
|
|
32
|
+
(env.HAPPY_STACKS_SERVER_PORT ?? '').toString().trim() ||
|
|
33
|
+
(env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim() ||
|
|
34
|
+
'';
|
|
35
|
+
const n = raw ? Number(raw) : Number(defaultPort);
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function listPortsFromEnvObject(env, keys) {
|
|
40
|
+
const obj = env && typeof env === 'object' ? env : {};
|
|
41
|
+
const list = Array.isArray(keys) ? keys : [];
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const k of list) {
|
|
44
|
+
const p = coercePort(obj[k]);
|
|
45
|
+
if (p) out.push(p);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readServerPortFromEnvFile(envPath, { defaultPort = 3005 } = {}) {
|
|
51
|
+
const v =
|
|
52
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_STACKS_SERVER_PORT')) ||
|
|
53
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_LOCAL_SERVER_PORT')) ||
|
|
54
|
+
'';
|
|
55
|
+
const n = v ? Number(String(v).trim()) : Number(defaultPort);
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// For stack env files, "missing" means "ephemeral stack" (no pinned port).
|
|
60
|
+
export async function readPinnedServerPortFromEnvFile(envPath) {
|
|
61
|
+
const v =
|
|
62
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_STACKS_SERVER_PORT')) ||
|
|
63
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_LOCAL_SERVER_PORT')) ||
|
|
64
|
+
'';
|
|
65
|
+
const n = v ? Number(String(v).trim()) : NaN;
|
|
66
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -59,6 +59,18 @@ export async function isHappyServerRunning(baseUrl) {
|
|
|
59
59
|
return true;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export async function waitForHappyHealthOk(baseUrl, { timeoutMs = 60_000, intervalMs = 300 } = {}) {
|
|
63
|
+
const deadline = Date.now() + timeoutMs;
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
// eslint-disable-next-line no-await-in-loop
|
|
66
|
+
const health = await fetchHappyHealth(baseUrl);
|
|
67
|
+
if (health.ok) return true;
|
|
68
|
+
// eslint-disable-next-line no-await-in-loop
|
|
69
|
+
await delay(intervalMs);
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
export async function waitForServerReady(url) {
|
|
63
75
|
const deadline = Date.now() + 60_000;
|
|
64
76
|
while (Date.now() < deadline) {
|
|
@@ -76,3 +88,27 @@ export async function waitForServerReady(url) {
|
|
|
76
88
|
throw new Error(`Timed out waiting for server at ${url}`);
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
// Used for UI readiness checks (Expo / gateway / server). Treat any HTTP response as "up".
|
|
92
|
+
export async function waitForHttpOk(url, { timeoutMs = 15_000, intervalMs = 250 } = {}) {
|
|
93
|
+
const deadline = Date.now() + timeoutMs;
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
try {
|
|
96
|
+
const ctl = new AbortController();
|
|
97
|
+
const t = setTimeout(() => ctl.abort(), Math.min(2500, Math.max(250, intervalMs)));
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(url, { method: 'GET', signal: ctl.signal });
|
|
100
|
+
if (res.status >= 100 && res.status < 600) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
clearTimeout(t);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
// eslint-disable-next-line no-await-in-loop
|
|
110
|
+
await delay(intervalMs);
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Timed out waiting for HTTP response from ${url} after ${timeoutMs}ms`);
|
|
113
|
+
}
|
|
114
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
4
|
+
import { resolvePublicServerUrl } from '../../tailscale.mjs';
|
|
5
|
+
import { resolveServerPortFromEnv } from './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
|
+
function stackEnvExplicitlySetsWebappUrl({ env, stackName }) {
|
|
21
|
+
try {
|
|
22
|
+
const envPath =
|
|
23
|
+
(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
|
|
24
|
+
resolveStackEnvPath(stackName).envPath;
|
|
25
|
+
if (!envPath || !existsSync(envPath)) return false;
|
|
26
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
27
|
+
return /^HAPPY_WEBAPP_URL=/m.test(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getPublicServerUrlEnvOverride({ env = process.env, serverPort, stackName = null } = {}) {
|
|
34
|
+
const defaultPublicUrl = `http://localhost:${serverPort}`;
|
|
35
|
+
const name =
|
|
36
|
+
(stackName ?? '').toString().trim() ||
|
|
37
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
38
|
+
getStackName();
|
|
39
|
+
|
|
40
|
+
let envPublicUrl =
|
|
41
|
+
(env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
|
|
42
|
+
|
|
43
|
+
// Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
|
|
44
|
+
if (name !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName: name })) {
|
|
45
|
+
envPublicUrl = '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { defaultPublicUrl, envPublicUrl, publicServerUrl: envPublicUrl || defaultPublicUrl };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getWebappUrlEnvOverride({ env = process.env, stackName = null } = {}) {
|
|
52
|
+
const name =
|
|
53
|
+
(stackName ?? '').toString().trim() ||
|
|
54
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
55
|
+
getStackName();
|
|
56
|
+
|
|
57
|
+
let envWebappUrl = (env.HAPPY_WEBAPP_URL ?? '').toString().trim() || '';
|
|
58
|
+
|
|
59
|
+
// Safety: for non-main stacks, ignore a global HAPPY_WEBAPP_URL unless it was explicitly set in the stack env file.
|
|
60
|
+
if (name !== 'main' && envWebappUrl && !stackEnvExplicitlySetsWebappUrl({ env, stackName: name })) {
|
|
61
|
+
envWebappUrl = '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { envWebappUrl };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
|
|
68
|
+
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
69
|
+
const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
|
|
70
|
+
const resolved = await resolvePublicServerUrl({
|
|
71
|
+
internalServerUrl,
|
|
72
|
+
defaultPublicUrl,
|
|
73
|
+
envPublicUrl,
|
|
74
|
+
allowEnable,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
internalServerUrl,
|
|
78
|
+
defaultPublicUrl,
|
|
79
|
+
envPublicUrl,
|
|
80
|
+
publicServerUrl: resolved.publicServerUrl,
|
|
81
|
+
publicServerUrlSource: resolved.source,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getInternalServerUrl({ env = process.env, defaultPort = 3005 } = {}) {
|
|
86
|
+
const port = resolveServerPortFromEnv({ env, defaultPort });
|
|
87
|
+
return { port, internalServerUrl: `http://127.0.0.1:${port}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export { resolveServerPortFromEnv };
|
|
91
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import { runCapture } from '../proc/proc.mjs';
|
|
6
|
+
import { getDefaultAutostartPaths } from '../paths/paths.mjs';
|
|
7
|
+
import { resolveInstalledCliRoot, resolveInstalledPath } from '../paths/runtime.mjs';
|
|
8
|
+
|
|
9
|
+
function plistPathForLabel(label) {
|
|
10
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function xmlEscape(s) {
|
|
14
|
+
return String(s ?? '')
|
|
15
|
+
.replaceAll('&', '&')
|
|
16
|
+
.replaceAll('<', '<')
|
|
17
|
+
.replaceAll('>', '>')
|
|
18
|
+
.replaceAll('"', '"')
|
|
19
|
+
.replaceAll("'", ''');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function plistXml({ label, programArgs, env = {}, stdoutPath, stderrPath, workingDirectory }) {
|
|
23
|
+
const envEntries = Object.entries(env ?? {}).filter(([k, v]) => String(k).trim() && String(v ?? '').trim());
|
|
24
|
+
const programArgsXml = programArgs.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
25
|
+
const envXml = envEntries
|
|
26
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
|
27
|
+
.join('\n');
|
|
28
|
+
const workingDirXml = workingDirectory
|
|
29
|
+
? `\n <key>WorkingDirectory</key>\n <string>${xmlEscape(workingDirectory)}</string>\n`
|
|
30
|
+
: '\n';
|
|
31
|
+
|
|
32
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
34
|
+
<plist version="1.0">
|
|
35
|
+
<dict>
|
|
36
|
+
<key>Label</key>
|
|
37
|
+
<string>${xmlEscape(label)}</string>
|
|
38
|
+
|
|
39
|
+
<key>ProgramArguments</key>
|
|
40
|
+
<array>
|
|
41
|
+
${programArgsXml}
|
|
42
|
+
</array>
|
|
43
|
+
|
|
44
|
+
<key>RunAtLoad</key>
|
|
45
|
+
<true/>
|
|
46
|
+
<key>KeepAlive</key>
|
|
47
|
+
<true/>
|
|
48
|
+
${workingDirXml} <key>StandardOutPath</key>
|
|
49
|
+
<string>${xmlEscape(stdoutPath)}</string>
|
|
50
|
+
<key>StandardErrorPath</key>
|
|
51
|
+
<string>${xmlEscape(stderrPath)}</string>
|
|
52
|
+
|
|
53
|
+
<key>EnvironmentVariables</key>
|
|
54
|
+
<dict>
|
|
55
|
+
${envXml}
|
|
56
|
+
</dict>
|
|
57
|
+
</dict>
|
|
58
|
+
</plist>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function ensureMacAutostartEnabled({ rootDir, label, env }) {
|
|
63
|
+
if (process.platform !== 'darwin') {
|
|
64
|
+
throw new Error('[local] macOS autostart is only supported on Darwin');
|
|
65
|
+
}
|
|
66
|
+
const l = String(label ?? '').trim();
|
|
67
|
+
if (!l) throw new Error('[local] missing launchd label');
|
|
68
|
+
|
|
69
|
+
const plistPath = plistPathForLabel(l);
|
|
70
|
+
const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
|
|
71
|
+
await mkdir(dirname(plistPath), { recursive: true }).catch(() => {});
|
|
72
|
+
await mkdir(dirname(stdoutPath), { recursive: true }).catch(() => {});
|
|
73
|
+
await mkdir(dirname(stderrPath), { recursive: true }).catch(() => {});
|
|
74
|
+
|
|
75
|
+
const programArgs = [process.execPath, resolveInstalledPath(rootDir, 'bin/happys.mjs'), 'start'];
|
|
76
|
+
const mergedEnv = {
|
|
77
|
+
...(env ?? {}),
|
|
78
|
+
// Ensure a reasonable PATH for subprocesses (git/docker/etc) in launchd’s minimal environment.
|
|
79
|
+
PATH: (process.env.PATH ?? '').trim() || '/usr/bin:/bin:/usr/sbin:/sbin',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const xml = plistXml({
|
|
83
|
+
label: l,
|
|
84
|
+
programArgs,
|
|
85
|
+
env: mergedEnv,
|
|
86
|
+
stdoutPath,
|
|
87
|
+
stderrPath,
|
|
88
|
+
workingDirectory: resolveInstalledCliRoot(rootDir),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const tmp = join(dirname(plistPath), `.tmp.${l}.${Date.now()}.plist`);
|
|
92
|
+
await writeFile(tmp, xml, 'utf-8');
|
|
93
|
+
await rename(tmp, plistPath);
|
|
94
|
+
|
|
95
|
+
// Best-effort load/enable; `scripts/service.mjs` has a more robust bootstrap fallback.
|
|
96
|
+
try {
|
|
97
|
+
await runCapture('launchctl', ['load', '-w', plistPath]);
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function ensureMacAutostartDisabled({ label }) {
|
|
104
|
+
if (process.platform !== 'darwin') {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const l = String(label ?? '').trim();
|
|
108
|
+
if (!l) return;
|
|
109
|
+
const plistPath = plistPathForLabel(l);
|
|
110
|
+
|
|
111
|
+
const uidRaw = Number(process.env.UID);
|
|
112
|
+
const uid = Number.isFinite(uidRaw) ? uidRaw : null;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await runCapture('launchctl', ['unload', '-w', plistPath]);
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await runCapture('launchctl', ['unload', plistPath]);
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
if (uid != null) {
|
|
125
|
+
try {
|
|
126
|
+
await runCapture('launchctl', ['disable', `gui/${uid}/${l}`]);
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await runCapture('launchctl', ['bootout', `gui/${uid}`, plistPath]);
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await runCapture('launchctl', ['remove', l]);
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
2
|
+
import { getStackRuntimeStatePath } from './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
|
+
|