happy-stacks 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -83
- package/bin/happys.mjs +70 -10
- package/docs/edison.md +381 -0
- package/docs/happy-development.md +733 -0
- package/docs/menubar.md +54 -0
- package/docs/paths-and-env.md +141 -0
- package/docs/stacks.md +39 -0
- package/extras/swiftbar/auth-login.sh +5 -2
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +131 -81
- package/extras/swiftbar/happys-term.sh +15 -38
- package/extras/swiftbar/happys.sh +15 -32
- package/extras/swiftbar/install.sh +99 -13
- package/extras/swiftbar/lib/git.sh +309 -1
- package/extras/swiftbar/lib/icons.sh +2 -2
- package/extras/swiftbar/lib/render.sh +209 -80
- package/extras/swiftbar/lib/system.sh +27 -4
- package/extras/swiftbar/lib/utils.sh +311 -28
- package/extras/swiftbar/pnpm.sh +2 -1
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +11 -2
- package/extras/swiftbar/wt-pr.sh +9 -2
- package/package.json +2 -1
- package/scripts/auth.mjs +560 -112
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +130 -20
- package/scripts/dev.mjs +201 -133
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +43 -20
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +25 -15
- package/scripts/mobile.mjs +13 -7
- package/scripts/run.mjs +114 -27
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +15 -2
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +1792 -254
- package/scripts/stop.mjs +6 -3
- package/scripts/tailscale.mjs +17 -2
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +2 -2
- package/scripts/ui_gateway.mjs +2 -2
- package/scripts/uninstall.mjs +18 -10
- package/scripts/utils/auth_files.mjs +58 -0
- package/scripts/utils/auth_login_ux.mjs +76 -0
- package/scripts/utils/auth_sources.mjs +12 -0
- package/scripts/utils/browser.mjs +22 -0
- package/scripts/utils/canonical_home.mjs +20 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +6 -2
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/dev_daemon.mjs +104 -0
- package/scripts/utils/dev_expo_web.mjs +112 -0
- package/scripts/utils/dev_server.mjs +183 -0
- package/scripts/utils/env.mjs +60 -11
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +4 -2
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +100 -46
- package/scripts/utils/localhost_host.mjs +17 -0
- package/scripts/utils/ownership.mjs +135 -0
- package/scripts/utils/paths.mjs +5 -2
- package/scripts/utils/pm.mjs +121 -20
- package/scripts/utils/proc.mjs +29 -2
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +24 -0
- package/scripts/utils/server_port.mjs +9 -0
- package/scripts/utils/server_urls.mjs +54 -0
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stack_startup.mjs +208 -0
- package/scripts/utils/stack_stop.mjs +79 -30
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +82 -8
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import net from 'node:net';
|
|
5
4
|
import { join } from 'node:path';
|
|
6
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
6
|
|
|
8
7
|
import { parseDotenv } from './dotenv.mjs';
|
|
9
8
|
import { ensureEnvFileUpdated } from './env_file.mjs';
|
|
9
|
+
import { pickNextFreeTcpPort } from './ports.mjs';
|
|
10
10
|
import { pmExecBin } from './pm.mjs';
|
|
11
11
|
import { run, runCapture } from './proc.mjs';
|
|
12
12
|
|
|
@@ -37,29 +37,6 @@ function coercePort(v) {
|
|
|
37
37
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
38
38
|
}
|
|
39
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
|
-
}
|
|
62
|
-
|
|
63
40
|
async function readEnvObject(envPath) {
|
|
64
41
|
try {
|
|
65
42
|
const raw = await readFile(envPath, 'utf-8');
|
|
@@ -243,8 +220,26 @@ async function maybeStartDockerDaemon() {
|
|
|
243
220
|
}
|
|
244
221
|
}
|
|
245
222
|
|
|
246
|
-
async function dockerCompose({ composePath, projectName, args, options = {} }) {
|
|
247
|
-
|
|
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
|
+
}
|
|
248
243
|
}
|
|
249
244
|
|
|
250
245
|
async function waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb }) {
|
|
@@ -285,6 +280,25 @@ async function waitForHealthyRedis({ composePath, projectName }) {
|
|
|
285
280
|
throw new Error('[infra] timed out waiting for redis to become ready');
|
|
286
281
|
}
|
|
287
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
|
+
|
|
288
302
|
export async function ensureHappyServerManagedInfra({
|
|
289
303
|
stackName,
|
|
290
304
|
baseDir,
|
|
@@ -292,6 +306,8 @@ export async function ensureHappyServerManagedInfra({
|
|
|
292
306
|
publicServerUrl,
|
|
293
307
|
envPath,
|
|
294
308
|
env = process.env,
|
|
309
|
+
quiet = false,
|
|
310
|
+
skipMinioInit = false,
|
|
295
311
|
}) {
|
|
296
312
|
await ensureDockerCompose();
|
|
297
313
|
|
|
@@ -315,17 +331,21 @@ export async function ensureHappyServerManagedInfra({
|
|
|
315
331
|
}
|
|
316
332
|
if (Number.isFinite(serverPort) && serverPort > 0) reservedPorts.add(serverPort);
|
|
317
333
|
|
|
318
|
-
const pgPort =
|
|
334
|
+
const pgPort =
|
|
335
|
+
coercePort(existingEnv.HAPPY_STACKS_PG_PORT ?? env.HAPPY_STACKS_PG_PORT) ??
|
|
336
|
+
(await pickNextFreeTcpPort(serverPort + 1000, { reservedPorts }));
|
|
319
337
|
reservedPorts.add(pgPort);
|
|
320
338
|
const redisPort =
|
|
321
|
-
coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ??
|
|
339
|
+
coercePort(existingEnv.HAPPY_STACKS_REDIS_PORT ?? env.HAPPY_STACKS_REDIS_PORT) ??
|
|
340
|
+
(await pickNextFreeTcpPort(pgPort + 1, { reservedPorts }));
|
|
322
341
|
reservedPorts.add(redisPort);
|
|
323
342
|
const minioPort =
|
|
324
|
-
coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ??
|
|
343
|
+
coercePort(existingEnv.HAPPY_STACKS_MINIO_PORT ?? env.HAPPY_STACKS_MINIO_PORT) ??
|
|
344
|
+
(await pickNextFreeTcpPort(redisPort + 1, { reservedPorts }));
|
|
325
345
|
reservedPorts.add(minioPort);
|
|
326
346
|
const minioConsolePort =
|
|
327
347
|
coercePort(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT ?? env.HAPPY_STACKS_MINIO_CONSOLE_PORT) ??
|
|
328
|
-
(await
|
|
348
|
+
(await pickNextFreeTcpPort(minioPort + 1, { reservedPorts }));
|
|
329
349
|
reservedPorts.add(minioConsolePort);
|
|
330
350
|
|
|
331
351
|
const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? env.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
|
|
@@ -355,27 +375,45 @@ export async function ensureHappyServerManagedInfra({
|
|
|
355
375
|
const s3PublicUrl = `${pub}/files`;
|
|
356
376
|
|
|
357
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);
|
|
358
390
|
await ensureEnvFileUpdated({
|
|
359
391
|
envPath,
|
|
360
392
|
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) },
|
|
393
|
+
// Stable credentials/files: persist these so restarts keep the same DB/user and S3 creds.
|
|
365
394
|
{ key: 'HAPPY_STACKS_PG_USER', value: pgUser },
|
|
366
395
|
{ key: 'HAPPY_STACKS_PG_PASSWORD', value: pgPassword },
|
|
367
396
|
{ key: 'HAPPY_STACKS_PG_DATABASE', value: pgDb },
|
|
368
397
|
{ 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
398
|
{ key: 'S3_ACCESS_KEY', value: s3AccessKey },
|
|
376
399
|
{ key: 'S3_SECRET_KEY', value: s3SecretKey },
|
|
377
400
|
{ key: 'S3_BUCKET', value: s3Bucket },
|
|
378
|
-
|
|
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
|
+
]),
|
|
379
417
|
],
|
|
380
418
|
});
|
|
381
419
|
}
|
|
@@ -397,12 +435,28 @@ export async function ensureHappyServerManagedInfra({
|
|
|
397
435
|
});
|
|
398
436
|
await writeFile(composePath, yaml, 'utf-8');
|
|
399
437
|
|
|
400
|
-
await dockerCompose({
|
|
438
|
+
await dockerCompose({
|
|
439
|
+
composePath,
|
|
440
|
+
projectName,
|
|
441
|
+
args: ['up', '-d', '--remove-orphans'],
|
|
442
|
+
options: { cwd: baseDir, stdio: quiet ? 'ignore' : 'inherit' },
|
|
443
|
+
quiet,
|
|
444
|
+
});
|
|
401
445
|
await waitForHealthyPostgres({ composePath, projectName, pgUser, pgDb });
|
|
402
446
|
await waitForHealthyRedis({ composePath, projectName });
|
|
403
447
|
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
}
|
|
406
460
|
|
|
407
461
|
return {
|
|
408
462
|
composePath,
|
|
@@ -423,8 +477,8 @@ export async function ensureHappyServerManagedInfra({
|
|
|
423
477
|
};
|
|
424
478
|
}
|
|
425
479
|
|
|
426
|
-
export async function applyHappyServerMigrations({ serverDir, env }) {
|
|
480
|
+
export async function applyHappyServerMigrations({ serverDir, env, quiet = false }) {
|
|
427
481
|
// Non-interactive + idempotent. Safe for dev; also safe for managed stacks on start.
|
|
428
|
-
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
|
|
482
|
+
await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env, quiet });
|
|
429
483
|
}
|
|
430
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 };
|
package/scripts/utils/pm.mjs
CHANGED
|
@@ -1,13 +1,61 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { chmod, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
|
|
6
7
|
import { pathExists } from './fs.mjs';
|
|
7
8
|
import { run, runCapture, spawnProc } from './proc.mjs';
|
|
8
9
|
import { getDefaultAutostartPaths, getHappyStacksHomeDir } from './paths.mjs';
|
|
9
10
|
import { resolveInstalledPath, resolveInstalledCliRoot } from './runtime.mjs';
|
|
10
11
|
|
|
12
|
+
function sha256Hex(s) {
|
|
13
|
+
return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function readJsonIfExists(path) {
|
|
17
|
+
try {
|
|
18
|
+
if (!path || !existsSync(path)) return null;
|
|
19
|
+
const raw = await readFile(path, 'utf-8');
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function writeJsonAtomic(path, value) {
|
|
27
|
+
const dir = dirname(path);
|
|
28
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
29
|
+
const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
|
|
30
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
|
31
|
+
await rename(tmp, path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveBuildStatePath({ label, dir }) {
|
|
35
|
+
const homeDir = getHappyStacksHomeDir();
|
|
36
|
+
const key = sha256Hex(resolve(dir));
|
|
37
|
+
return join(homeDir, 'cache', 'build', label, `${key}.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function computeGitWorktreeSignature(dir) {
|
|
41
|
+
try {
|
|
42
|
+
// Fast path: only if this is a git worktree.
|
|
43
|
+
const inside = (await runCapture('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'])).trim();
|
|
44
|
+
if (inside !== 'true') return null;
|
|
45
|
+
const head = (await runCapture('git', ['-C', dir, 'rev-parse', 'HEAD'])).trim();
|
|
46
|
+
// Includes staged + unstaged + untracked changes; captures “dirty” vs “clean”.
|
|
47
|
+
const status = await runCapture('git', ['-C', dir, 'status', '--porcelain=v1']);
|
|
48
|
+
return {
|
|
49
|
+
kind: 'git',
|
|
50
|
+
head,
|
|
51
|
+
statusHash: sha256Hex(status),
|
|
52
|
+
signature: sha256Hex(`${head}\n${status}`),
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
11
59
|
async function commandExists(cmd, options = {}) {
|
|
12
60
|
try {
|
|
13
61
|
await runCapture(cmd, ['--version'], options);
|
|
@@ -53,7 +101,7 @@ export async function requireDir(label, dir) {
|
|
|
53
101
|
);
|
|
54
102
|
}
|
|
55
103
|
|
|
56
|
-
export async function ensureDepsInstalled(dir, label) {
|
|
104
|
+
export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
57
105
|
const pkgJson = join(dir, 'package.json');
|
|
58
106
|
if (!(await pathExists(pkgJson))) {
|
|
59
107
|
return;
|
|
@@ -62,6 +110,7 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
62
110
|
const nodeModules = join(dir, 'node_modules');
|
|
63
111
|
const pnpmModulesMeta = join(dir, 'node_modules', '.modules.yaml');
|
|
64
112
|
const pm = await getComponentPm(dir);
|
|
113
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
65
114
|
|
|
66
115
|
if (await pathExists(nodeModules)) {
|
|
67
116
|
const yarnLock = join(dir, 'yarn.lock');
|
|
@@ -71,10 +120,12 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
71
120
|
// If this repo is Yarn-managed (yarn.lock present) but node_modules was created by pnpm,
|
|
72
121
|
// reinstall with Yarn to restore upstream-locked dependency versions.
|
|
73
122
|
if (pm.name === 'yarn' && (await pathExists(pnpmModulesMeta))) {
|
|
74
|
-
|
|
75
|
-
|
|
123
|
+
if (!quiet) {
|
|
124
|
+
// eslint-disable-next-line no-console
|
|
125
|
+
console.log(`[local] converting ${label} dependencies back to yarn (reinstalling node_modules)...`);
|
|
126
|
+
}
|
|
76
127
|
await rm(nodeModules, { recursive: true, force: true });
|
|
77
|
-
await run(pm.cmd, ['install'], { cwd: dir });
|
|
128
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
78
129
|
}
|
|
79
130
|
|
|
80
131
|
// If dependencies changed since the last install, re-run install even if node_modules exists.
|
|
@@ -92,9 +143,11 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
92
143
|
const pkgM = await mtimeMs(pkgJson);
|
|
93
144
|
const intM = await mtimeMs(yarnIntegrity);
|
|
94
145
|
if (!intM || lockM > intM || pkgM > intM) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
if (!quiet) {
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
|
|
149
|
+
}
|
|
150
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
98
151
|
}
|
|
99
152
|
}
|
|
100
153
|
|
|
@@ -102,29 +155,75 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
102
155
|
const lockM = await mtimeMs(pnpmLock);
|
|
103
156
|
const metaM = await mtimeMs(pnpmModulesMeta);
|
|
104
157
|
if (!metaM || lockM > metaM) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
if (!quiet) {
|
|
159
|
+
// eslint-disable-next-line no-console
|
|
160
|
+
console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
|
|
161
|
+
}
|
|
162
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
108
163
|
}
|
|
109
164
|
}
|
|
110
165
|
|
|
111
166
|
return;
|
|
112
167
|
}
|
|
113
168
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
169
|
+
if (!quiet) {
|
|
170
|
+
// eslint-disable-next-line no-console
|
|
171
|
+
console.log(`[local] installing ${label} dependencies (first run)...`);
|
|
172
|
+
}
|
|
173
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
117
174
|
}
|
|
118
175
|
|
|
119
176
|
export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
120
177
|
await ensureDepsInstalled(cliDir, 'happy-cli');
|
|
121
178
|
if (!buildCli) {
|
|
122
|
-
return;
|
|
179
|
+
return { built: false, reason: 'disabled' };
|
|
180
|
+
}
|
|
181
|
+
// Default: build only when needed (fast + reliable for worktrees that haven't been built yet).
|
|
182
|
+
//
|
|
183
|
+
// You can force always-build by setting:
|
|
184
|
+
// - HAPPY_STACKS_CLI_BUILD_MODE=always (legacy: HAPPY_LOCAL_CLI_BUILD_MODE=always)
|
|
185
|
+
// Or disable via:
|
|
186
|
+
// - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
|
|
187
|
+
const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
|
|
188
|
+
const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
|
|
189
|
+
if (mode === 'never') {
|
|
190
|
+
return { built: false, reason: 'mode_never' };
|
|
191
|
+
}
|
|
192
|
+
const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
|
|
193
|
+
const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
|
|
194
|
+
const gitSig = await computeGitWorktreeSignature(cliDir);
|
|
195
|
+
const prev = await readJsonIfExists(buildStatePath);
|
|
196
|
+
|
|
197
|
+
if (mode === 'auto') {
|
|
198
|
+
// If dist doesn't exist, we must build.
|
|
199
|
+
if (!(await pathExists(distEntrypoint))) {
|
|
200
|
+
// fallthrough to build
|
|
201
|
+
} else if (gitSig && prev?.signature && prev.signature === gitSig.signature) {
|
|
202
|
+
return { built: false, reason: 'up_to_date' };
|
|
203
|
+
} else if (!gitSig) {
|
|
204
|
+
// No git info: best-effort skip if dist exists (keeps this fast outside git worktrees).
|
|
205
|
+
return { built: false, reason: 'no_git_info' };
|
|
206
|
+
}
|
|
123
207
|
}
|
|
208
|
+
|
|
124
209
|
// eslint-disable-next-line no-console
|
|
125
210
|
console.log('[local] building happy-cli...');
|
|
126
211
|
const pm = await getComponentPm(cliDir);
|
|
127
212
|
await run(pm.cmd, ['build'], { cwd: cliDir });
|
|
213
|
+
|
|
214
|
+
// Persist new build state (best-effort).
|
|
215
|
+
const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
|
|
216
|
+
if (nowSig) {
|
|
217
|
+
await writeJsonAtomic(buildStatePath, {
|
|
218
|
+
label: 'happy-cli',
|
|
219
|
+
dir: resolve(cliDir),
|
|
220
|
+
signature: nowSig.signature,
|
|
221
|
+
head: nowSig.head,
|
|
222
|
+
statusHash: nowSig.statusHash,
|
|
223
|
+
builtAt: new Date().toISOString(),
|
|
224
|
+
}).catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
return { built: true, reason: mode === 'always' ? 'mode_always' : 'changed' };
|
|
128
227
|
}
|
|
129
228
|
|
|
130
229
|
function getPathEntries() {
|
|
@@ -153,8 +252,9 @@ export async function ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli }) {
|
|
|
153
252
|
|
|
154
253
|
const shim = `#!/bin/bash
|
|
155
254
|
set -euo pipefail
|
|
156
|
-
|
|
157
|
-
|
|
255
|
+
# Prefer the sibling happys shim (works for sandbox installs too).
|
|
256
|
+
BIN_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
257
|
+
HAPPYS="$BIN_DIR/happys"
|
|
158
258
|
if [[ -x "$HAPPYS" ]]; then
|
|
159
259
|
exec "$HAPPYS" happy "$@"
|
|
160
260
|
fi
|
|
@@ -188,13 +288,14 @@ export async function pmSpawnBin({ label, dir, bin, args, env, options = {} }) {
|
|
|
188
288
|
return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
|
|
189
289
|
}
|
|
190
290
|
|
|
191
|
-
export async function pmExecBin({ dir, bin, args, env }) {
|
|
291
|
+
export async function pmExecBin({ dir, bin, args, env, quiet = false }) {
|
|
192
292
|
const pm = await getComponentPm(dir);
|
|
293
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
193
294
|
if (pm.name === 'yarn') {
|
|
194
|
-
await run(pm.cmd, [bin, ...args], { env, cwd: dir });
|
|
295
|
+
await run(pm.cmd, [bin, ...args], { env, cwd: dir, stdio });
|
|
195
296
|
return;
|
|
196
297
|
}
|
|
197
|
-
await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir });
|
|
298
|
+
await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir, stdio });
|
|
198
299
|
}
|
|
199
300
|
|
|
200
301
|
export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {
|