happy-stacks 0.2.0 → 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 +59 -22
- package/bin/happys.mjs +2 -2
- package/package.json +1 -1
- package/scripts/auth.mjs +49 -202
- package/scripts/build.mjs +5 -6
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +5 -5
- package/scripts/daemon.mjs +9 -17
- package/scripts/dev.mjs +18 -27
- package/scripts/doctor.mjs +20 -36
- package/scripts/edison.mjs +102 -77
- package/scripts/happy.mjs +8 -19
- package/scripts/init.mjs +5 -13
- package/scripts/install.mjs +8 -8
- package/scripts/lint.mjs +8 -29
- package/scripts/menubar.mjs +6 -13
- package/scripts/migrate.mjs +11 -21
- package/scripts/mobile.mjs +13 -12
- package/scripts/run.mjs +15 -15
- package/scripts/self.mjs +11 -29
- package/scripts/server_flavor.mjs +4 -4
- package/scripts/service.mjs +18 -28
- package/scripts/setup.mjs +26 -122
- package/scripts/setup_pr.mjs +11 -28
- package/scripts/stack.mjs +111 -161
- package/scripts/stop.mjs +3 -3
- package/scripts/tailscale.mjs +7 -10
- package/scripts/test.mjs +8 -29
- package/scripts/tui.mjs +8 -38
- package/scripts/typecheck.mjs +8 -29
- package/scripts/ui_gateway.mjs +1 -1
- package/scripts/uninstall.mjs +6 -6
- package/scripts/utils/{dev_auth_key.mjs → auth/dev_key.mjs} +2 -8
- package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
- package/scripts/utils/{handy_master_secret.mjs → auth/handy_master_secret.mjs} +6 -32
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/smoke_help.mjs +2 -2
- package/scripts/utils/cli/wizard.mjs +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +4 -4
- package/scripts/utils/{dev_expo_web.mjs → dev/expo_web.mjs} +5 -5
- package/scripts/utils/{dev_server.mjs → dev/server.mjs} +7 -7
- package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -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/values.mjs +13 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +3 -9
- 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} +3 -3
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
- package/scripts/utils/{localhost_host.mjs → paths/localhost_host.mjs} +2 -10
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +10 -7
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/{pm.mjs → proc/pm.mjs} +65 -152
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +1 -0
- package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
- package/scripts/utils/server/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +12 -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 → stack/context.mjs} +2 -2
- 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 → stack/runtime_state.mjs} +10 -27
- package/scripts/utils/{stacks.mjs → stack/stacks.mjs} +9 -2
- package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +2 -2
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +9 -15
- package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/where.mjs +6 -6
- package/scripts/worktrees.mjs +30 -58
- package/scripts/utils/server_port.mjs +0 -9
- package/scripts/utils/server_urls.mjs +0 -54
- /package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +0 -0
- /package/scripts/utils/{auth_sources.mjs → auth/sources.mjs} +0 -0
- /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
- /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
- /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
|
@@ -2,7 +2,9 @@ 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
|
-
|
|
5
|
+
|
|
6
|
+
import { expandHome } from './canonical_home.mjs';
|
|
7
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
|
|
6
8
|
|
|
7
9
|
const PRIMARY_APP_SLUG = 'happy-stacks';
|
|
8
10
|
const LEGACY_APP_SLUG = 'happy-local';
|
|
@@ -16,10 +18,10 @@ export function getRootDir(importMetaUrl) {
|
|
|
16
18
|
return dirname(dirname(fileURLToPath(importMetaUrl)));
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export function getHappyStacksHomeDir() {
|
|
20
|
-
const fromEnv = (
|
|
21
|
+
export function getHappyStacksHomeDir(env = process.env) {
|
|
22
|
+
const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').trim();
|
|
21
23
|
if (fromEnv) {
|
|
22
|
-
return fromEnv
|
|
24
|
+
return expandHome(fromEnv);
|
|
23
25
|
}
|
|
24
26
|
return PRIMARY_HOME_DIR;
|
|
25
27
|
}
|
|
@@ -27,7 +29,7 @@ export function getHappyStacksHomeDir() {
|
|
|
27
29
|
export function getWorkspaceDir(cliRootDir = null) {
|
|
28
30
|
const fromEnv = (process.env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
|
|
29
31
|
if (fromEnv) {
|
|
30
|
-
return fromEnv
|
|
32
|
+
return expandHome(fromEnv);
|
|
31
33
|
}
|
|
32
34
|
const homeDir = getHappyStacksHomeDir();
|
|
33
35
|
const defaultWorkspace = join(homeDir, 'workspace');
|
|
@@ -53,7 +55,7 @@ function normalizePathForEnv(rootDir, raw) {
|
|
|
53
55
|
if (!trimmed) {
|
|
54
56
|
return '';
|
|
55
57
|
}
|
|
56
|
-
const expanded = trimmed
|
|
58
|
+
const expanded = expandHome(trimmed);
|
|
57
59
|
// If the path is relative, treat it as relative to the workspace root (default: repo root).
|
|
58
60
|
const workspaceDir = getWorkspaceDir(rootDir);
|
|
59
61
|
return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
|
|
@@ -89,7 +91,7 @@ export function getLegacyStackLabel(stackName = getStackName()) {
|
|
|
89
91
|
export function getStacksStorageRoot() {
|
|
90
92
|
const fromEnv = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
|
|
91
93
|
if (fromEnv) {
|
|
92
|
-
return fromEnv
|
|
94
|
+
return expandHome(fromEnv);
|
|
93
95
|
}
|
|
94
96
|
return PRIMARY_STORAGE_ROOT;
|
|
95
97
|
}
|
|
@@ -185,3 +187,4 @@ export function getDefaultAutostartPaths() {
|
|
|
185
187
|
legacyStderrPath,
|
|
186
188
|
};
|
|
187
189
|
}
|
|
190
|
+
|
|
@@ -9,7 +9,9 @@ export function getRuntimeDir() {
|
|
|
9
9
|
if (fromEnv) {
|
|
10
10
|
return expandHome(fromEnv);
|
|
11
11
|
}
|
|
12
|
-
const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim()
|
|
12
|
+
const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim()
|
|
13
|
+
? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim())
|
|
14
|
+
: join(homedir(), '.happy-stacks');
|
|
13
15
|
return join(homeDir, 'runtime');
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { runCapture } from './proc.mjs';
|
|
2
|
+
|
|
3
|
+
export async function resolveCommandPath(cmd, { cwd, env, timeoutMs } = {}) {
|
|
4
|
+
const c = String(cmd ?? '').trim();
|
|
5
|
+
if (!c) return '';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform === 'win32') {
|
|
9
|
+
const out = (await runCapture('where', [c], { cwd, env, timeoutMs })).trim();
|
|
10
|
+
const first = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
|
|
11
|
+
return first;
|
|
12
|
+
}
|
|
13
|
+
return (
|
|
14
|
+
await runCapture('sh', ['-lc', `command -v "${c}" 2>/dev/null || true`], { cwd, env, timeoutMs })
|
|
15
|
+
).trim();
|
|
16
|
+
} catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runCaptureIfCommandExists(cmd, args, { cwd, env, timeoutMs } = {}) {
|
|
22
|
+
const resolved = await resolveCommandPath(cmd, { cwd, env, timeoutMs });
|
|
23
|
+
if (!resolved) return '';
|
|
24
|
+
try {
|
|
25
|
+
return await runCapture(resolved, args, { cwd, env, timeoutMs });
|
|
26
|
+
} catch {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function commandExists(cmd, { cwd } = {}) {
|
|
32
|
+
return Boolean(await resolveCommandPath(cmd, { cwd }));
|
|
33
|
+
}
|
|
34
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
5
|
+
import { requirePnpm } from './pm.mjs';
|
|
6
|
+
|
|
7
|
+
export async function detectPackageManagerCmd(dir) {
|
|
8
|
+
if (await pathExists(join(dir, 'yarn.lock'))) {
|
|
9
|
+
return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
|
|
10
|
+
}
|
|
11
|
+
await requirePnpm();
|
|
12
|
+
return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readPackageJsonScripts(dir) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await readFile(join(dir, 'package.json'), 'utf-8');
|
|
18
|
+
const pkg = JSON.parse(raw);
|
|
19
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
20
|
+
return scripts;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pickFirstScript(scripts, candidates) {
|
|
27
|
+
if (!scripts) return null;
|
|
28
|
+
const list = Array.isArray(candidates) ? candidates : [];
|
|
29
|
+
return list.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -4,33 +4,17 @@ import { existsSync } from 'node:fs';
|
|
|
4
4
|
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
|
|
7
|
-
import { pathExists } from '
|
|
7
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
8
|
+
import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
|
|
8
9
|
import { run, runCapture, spawnProc } from './proc.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
10
|
+
import { commandExists } from './commands.mjs';
|
|
11
|
+
import { getDefaultAutostartPaths, getHappyStacksHomeDir } from '../paths/paths.mjs';
|
|
12
|
+
import { resolveInstalledPath, resolveInstalledCliRoot } from '../paths/runtime.mjs';
|
|
11
13
|
|
|
12
14
|
function sha256Hex(s) {
|
|
13
15
|
return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
|
|
14
16
|
}
|
|
15
17
|
|
|
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
18
|
function resolveBuildStatePath({ label, dir }) {
|
|
35
19
|
const homeDir = getHappyStacksHomeDir();
|
|
36
20
|
const key = sha256Hex(resolve(dir));
|
|
@@ -56,15 +40,6 @@ async function computeGitWorktreeSignature(dir) {
|
|
|
56
40
|
}
|
|
57
41
|
}
|
|
58
42
|
|
|
59
|
-
async function commandExists(cmd, options = {}) {
|
|
60
|
-
try {
|
|
61
|
-
await runCapture(cmd, ['--version'], options);
|
|
62
|
-
return true;
|
|
63
|
-
} catch {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
43
|
export async function requirePnpm() {
|
|
69
44
|
if (await commandExists('pnpm')) {
|
|
70
45
|
return;
|
|
@@ -258,147 +233,85 @@ HAPPYS="$BIN_DIR/happys"
|
|
|
258
233
|
if [[ -x "$HAPPYS" ]]; then
|
|
259
234
|
exec "$HAPPYS" happy "$@"
|
|
260
235
|
fi
|
|
261
|
-
|
|
236
|
+
|
|
237
|
+
# Fallback: run happy-stacks from runtime install if present.
|
|
238
|
+
HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-\${HAPPY_LOCAL_HOME_DIR:-$HOME/.happy-stacks}}"
|
|
239
|
+
RUNTIME="$HOME_DIR/runtime/node_modules/happy-stacks/bin/happys.mjs"
|
|
240
|
+
if [[ -f "$RUNTIME" ]]; then
|
|
241
|
+
exec node "$RUNTIME" happy "$@"
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
echo "error: cannot find happys shim or runtime install" >&2
|
|
245
|
+
exit 1
|
|
262
246
|
`;
|
|
263
247
|
|
|
264
|
-
|
|
248
|
+
const writeIfChanged = async (path, text) => {
|
|
249
|
+
let existing = '';
|
|
250
|
+
try {
|
|
251
|
+
existing = await readFile(path, 'utf-8');
|
|
252
|
+
} catch {
|
|
253
|
+
existing = '';
|
|
254
|
+
}
|
|
255
|
+
if (existing === text) return false;
|
|
256
|
+
await writeFile(path, text, 'utf-8');
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
await writeIfChanged(happyShim, shim);
|
|
265
261
|
await chmod(happyShim, 0o755).catch(() => {});
|
|
266
262
|
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
263
|
+
// happys shim: use node + CLI root; if runtime install exists, prefer it.
|
|
264
|
+
const cliRoot = resolveInstalledCliRoot(rootDir);
|
|
265
|
+
const happysShimText = `#!/bin/bash
|
|
266
|
+
set -euo pipefail
|
|
267
|
+
exec node "${resolveInstalledPath(rootDir, 'bin/happys.mjs')}" "$@"
|
|
268
|
+
`;
|
|
269
|
+
await writeIfChanged(happysShim, happysShimText);
|
|
270
|
+
await chmod(happysShim, 0o755).catch(() => {});
|
|
271
|
+
|
|
272
|
+
// If user’s PATH points at a legacy install path, try to make it sane (best-effort).
|
|
273
|
+
const entries = getPathEntries();
|
|
274
|
+
const legacyBin = join(homedir(), '.happy-stacks', 'bin');
|
|
275
|
+
const newBin = join(getDefaultAutostartPaths().baseDir, 'bin');
|
|
276
|
+
if (entries.some((p) => isPathInside(p, legacyBin)) && !entries.some((p) => isPathInside(p, newBin))) {
|
|
270
277
|
// eslint-disable-next-line no-console
|
|
271
|
-
console.log(`[local] note:
|
|
278
|
+
console.log(`[local] note: your PATH includes ${legacyBin}; recommended path is ${newBin}`);
|
|
272
279
|
}
|
|
280
|
+
|
|
281
|
+
return { ok: true, cliRoot, binDir, happyShim, happysShim };
|
|
273
282
|
}
|
|
274
283
|
|
|
275
|
-
export async function
|
|
284
|
+
export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
|
|
285
|
+
const usesObjectStyle = typeof dirOrOpts === 'object' && dirOrOpts !== null;
|
|
286
|
+
|
|
287
|
+
const dir = usesObjectStyle ? dirOrOpts.dir : dirOrOpts;
|
|
288
|
+
const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
|
|
289
|
+
const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
|
|
290
|
+
|
|
291
|
+
const env = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
|
|
292
|
+
const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
|
|
293
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
294
|
+
|
|
276
295
|
const pm = await getComponentPm(dir);
|
|
277
296
|
if (pm.name === 'yarn') {
|
|
278
|
-
|
|
297
|
+
await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
|
|
298
|
+
return;
|
|
279
299
|
}
|
|
280
|
-
|
|
300
|
+
await run(pm.cmd, ['exec', bin, ...args], { cwd: dir, env, stdio });
|
|
281
301
|
}
|
|
282
302
|
|
|
283
|
-
export async function pmSpawnBin(
|
|
303
|
+
export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
|
|
284
304
|
const pm = await getComponentPm(dir);
|
|
285
305
|
if (pm.name === 'yarn') {
|
|
286
|
-
return spawnProc(label, pm.cmd, [bin, ...args], env, {
|
|
306
|
+
return spawnProc(label, pm.cmd, ['run', bin, ...args], env, { cwd: dir });
|
|
287
307
|
}
|
|
288
|
-
return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, {
|
|
308
|
+
return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { cwd: dir });
|
|
289
309
|
}
|
|
290
310
|
|
|
291
|
-
export async function
|
|
311
|
+
export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
|
|
292
312
|
const pm = await getComponentPm(dir);
|
|
293
|
-
const stdio = quiet ? 'ignore' : 'inherit';
|
|
294
313
|
if (pm.name === 'yarn') {
|
|
295
|
-
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir, stdio });
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {
|
|
302
|
-
if (process.platform !== 'darwin') {
|
|
303
|
-
throw new Error('[local] autostart is currently only implemented for macOS (LaunchAgents).');
|
|
314
|
+
return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
|
|
304
315
|
}
|
|
305
|
-
|
|
306
|
-
const {
|
|
307
|
-
logsDir,
|
|
308
|
-
stdoutPath,
|
|
309
|
-
stderrPath,
|
|
310
|
-
plistPath,
|
|
311
|
-
primaryLabel,
|
|
312
|
-
legacyLabel,
|
|
313
|
-
primaryPlistPath,
|
|
314
|
-
legacyPlistPath,
|
|
315
|
-
primaryStdoutPath,
|
|
316
|
-
primaryStderrPath,
|
|
317
|
-
legacyStdoutPath,
|
|
318
|
-
legacyStderrPath,
|
|
319
|
-
} = getDefaultAutostartPaths();
|
|
320
|
-
await mkdir(logsDir, { recursive: true });
|
|
321
|
-
|
|
322
|
-
const nodePath = process.env.HAPPY_STACKS_NODE?.trim()
|
|
323
|
-
? process.env.HAPPY_STACKS_NODE.trim()
|
|
324
|
-
: process.env.HAPPY_LOCAL_NODE?.trim()
|
|
325
|
-
? process.env.HAPPY_LOCAL_NODE.trim()
|
|
326
|
-
: process.execPath;
|
|
327
|
-
const installedRoot = resolveInstalledCliRoot(rootDir);
|
|
328
|
-
const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
|
|
329
|
-
const happysShim = join(getHappyStacksHomeDir(), 'bin', 'happys');
|
|
330
|
-
const useShim = existsSync(happysShim);
|
|
331
|
-
|
|
332
|
-
// Ensure we write to the plist path that matches the label we're installing, instead of the
|
|
333
|
-
// "active" plist path (which might be legacy and cause filename/label mismatches).
|
|
334
|
-
const resolvedPlistPath =
|
|
335
|
-
label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
336
|
-
const resolvedStdoutPath = label === primaryLabel ? primaryStdoutPath : label === legacyLabel ? legacyStdoutPath : stdoutPath;
|
|
337
|
-
const resolvedStderrPath = label === primaryLabel ? primaryStderrPath : label === legacyLabel ? legacyStderrPath : stderrPath;
|
|
338
|
-
|
|
339
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
340
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
341
|
-
<plist version="1.0">
|
|
342
|
-
<dict>
|
|
343
|
-
<key>Label</key>
|
|
344
|
-
<string>${label}</string>
|
|
345
|
-
<key>ProgramArguments</key>
|
|
346
|
-
<array>
|
|
347
|
-
${useShim ? `<string>${happysShim}</string>` : `<string>${nodePath}</string>\n <string>${happysEntrypoint}</string>`}
|
|
348
|
-
<string>start</string>
|
|
349
|
-
</array>
|
|
350
|
-
<key>WorkingDirectory</key>
|
|
351
|
-
<string>${installedRoot}</string>
|
|
352
|
-
<key>RunAtLoad</key>
|
|
353
|
-
<true/>
|
|
354
|
-
<key>KeepAlive</key>
|
|
355
|
-
<true/>
|
|
356
|
-
<key>StandardOutPath</key>
|
|
357
|
-
<string>${resolvedStdoutPath}</string>
|
|
358
|
-
<key>StandardErrorPath</key>
|
|
359
|
-
<string>${resolvedStderrPath}</string>
|
|
360
|
-
<key>EnvironmentVariables</key>
|
|
361
|
-
<dict>
|
|
362
|
-
${Object.entries(env)
|
|
363
|
-
.map(([k, v]) => ` <key>${k}</key>\n <string>${String(v)}</string>`)
|
|
364
|
-
.join('\n')}
|
|
365
|
-
</dict>
|
|
366
|
-
</dict>
|
|
367
|
-
</plist>
|
|
368
|
-
`;
|
|
369
|
-
|
|
370
|
-
await mkdir(dirname(resolvedPlistPath), { recursive: true });
|
|
371
|
-
await writeFile(resolvedPlistPath, plist, 'utf-8');
|
|
372
|
-
|
|
373
|
-
// Best-effort (works on most macOS setups). If it fails, the plist still exists and can be loaded manually.
|
|
374
|
-
try {
|
|
375
|
-
await run('launchctl', ['unload', '-w', resolvedPlistPath]);
|
|
376
|
-
} catch {
|
|
377
|
-
// ignore
|
|
378
|
-
}
|
|
379
|
-
await run('launchctl', ['load', '-w', resolvedPlistPath]);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export async function ensureMacAutostartDisabled({ label = 'com.happy.local' }) {
|
|
383
|
-
if (process.platform !== 'darwin') {
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
const { primaryLabel, legacyLabel, primaryPlistPath, legacyPlistPath } = getDefaultAutostartPaths();
|
|
387
|
-
const resolvedPlistPath =
|
|
388
|
-
label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
389
|
-
try {
|
|
390
|
-
await run('launchctl', ['unload', '-w', resolvedPlistPath]);
|
|
391
|
-
} catch {
|
|
392
|
-
// Old-style unload can fail on newer macOS; fall back to modern bootout.
|
|
393
|
-
try {
|
|
394
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
395
|
-
if (uid != null) {
|
|
396
|
-
await run('launchctl', ['bootout', `gui/${uid}/${label}`]);
|
|
397
|
-
}
|
|
398
|
-
} catch {
|
|
399
|
-
// ignore
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
// eslint-disable-next-line no-console
|
|
403
|
-
console.log(`[local] autostart disabled (${label})`);
|
|
316
|
+
return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
|
|
404
317
|
}
|
|
@@ -1,50 +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
3
|
import { join } from 'node:path';
|
|
5
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
5
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
}
|
|
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';
|
|
34
14
|
|
|
35
|
-
|
|
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
|
-
}
|
|
15
|
+
const readEnvObject = readEnvObjectFromFile;
|
|
48
16
|
|
|
49
17
|
async function ensureTextFile({ path, generate }) {
|
|
50
18
|
if (existsSync(path)) {
|
|
@@ -318,14 +286,7 @@ export async function ensureHappyServerManagedInfra({
|
|
|
318
286
|
const reservedPorts = new Set();
|
|
319
287
|
|
|
320
288
|
// 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
|
-
]) {
|
|
289
|
+
for (const key of INFRA_RESERVED_PORT_KEYS) {
|
|
329
290
|
const p = coercePort(existingEnv[key] ?? env[key]);
|
|
330
291
|
if (p) reservedPorts.add(p);
|
|
331
292
|
}
|
|
@@ -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) {
|