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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
export async function ensureDir(path) {
|
|
5
|
+
await mkdir(path, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function readTextIfExists(path) {
|
|
9
|
+
try {
|
|
10
|
+
const p = String(path ?? '').trim();
|
|
11
|
+
if (!p || !existsSync(p)) return null;
|
|
12
|
+
const raw = await readFile(p, 'utf-8');
|
|
13
|
+
const t = raw.trim();
|
|
14
|
+
return t ? t : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readTextOrEmpty(path) {
|
|
21
|
+
try {
|
|
22
|
+
const p = String(path ?? '').trim();
|
|
23
|
+
if (!p || !existsSync(p)) return '';
|
|
24
|
+
return await readFile(p, 'utf-8');
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export async function readLastLines(path, lines = 60) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = await readFile(path, 'utf-8');
|
|
6
|
+
const parts = raw.split('\n');
|
|
7
|
+
return parts.slice(Math.max(0, parts.length - lines)).join('\n');
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function parseGithubPullRequest(input) {
|
|
2
|
+
const raw = (input ?? '').trim();
|
|
3
|
+
if (!raw) return null;
|
|
4
|
+
if (/^\d+$/.test(raw)) {
|
|
5
|
+
return { number: Number(raw), owner: null, repo: null };
|
|
6
|
+
}
|
|
7
|
+
// https://github.com/<owner>/<repo>/pull/<num>
|
|
8
|
+
const m = raw.match(/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<num>\d+)/);
|
|
9
|
+
if (!m?.groups?.num) return null;
|
|
10
|
+
return {
|
|
11
|
+
number: Number(m.groups.num),
|
|
12
|
+
owner: m.groups.owner ?? null,
|
|
13
|
+
repo: m.groups.repo ?? null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sanitizeSlugPart(s) {
|
|
18
|
+
return (s ?? '')
|
|
19
|
+
.toString()
|
|
20
|
+
.trim()
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9._/-]+/g, '-')
|
|
23
|
+
.replace(/-+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
|
-
import { isAbsolute, join } from 'node:path';
|
|
3
|
-
import { getComponentsDir } from '
|
|
4
|
-
import { pathExists } from '
|
|
5
|
-
import { run, runCapture } from '
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { getComponentsDir } from '../paths/paths.mjs';
|
|
4
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
5
|
+
import { run, runCapture } from '../proc/proc.mjs';
|
|
6
6
|
|
|
7
7
|
export function parseGithubOwner(remoteUrl) {
|
|
8
8
|
const raw = (remoteUrl ?? '').trim();
|
|
@@ -23,6 +23,62 @@ export function componentRepoDir(rootDir, component) {
|
|
|
23
23
|
return join(getComponentsDir(rootDir), component);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function isComponentWorktreePath({ rootDir, component, dir }) {
|
|
27
|
+
const raw = String(dir ?? '').trim();
|
|
28
|
+
if (!raw) return false;
|
|
29
|
+
const abs = resolve(raw);
|
|
30
|
+
const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
|
|
31
|
+
return abs.startsWith(root);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function worktreeSpecFromDir({ rootDir, component, dir }) {
|
|
35
|
+
const raw = String(dir ?? '').trim();
|
|
36
|
+
if (!raw) return null;
|
|
37
|
+
if (!isComponentWorktreePath({ rootDir, component, dir: raw })) return null;
|
|
38
|
+
const abs = resolve(raw);
|
|
39
|
+
const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
|
|
40
|
+
const rel = abs.slice(root.length).split('/').filter(Boolean);
|
|
41
|
+
if (rel.length < 2) return null;
|
|
42
|
+
// rel = [owner, ...branchParts]
|
|
43
|
+
return rel.join('/');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function inferRemoteNameForOwner({ repoDir, owner }) {
|
|
47
|
+
const want = String(owner ?? '').trim();
|
|
48
|
+
if (!want) return 'upstream';
|
|
49
|
+
|
|
50
|
+
const candidates = ['upstream', 'origin', 'fork'];
|
|
51
|
+
for (const remoteName of candidates) {
|
|
52
|
+
try {
|
|
53
|
+
const url = (await runCapture('git', ['remote', 'get-url', remoteName], { cwd: repoDir })).trim();
|
|
54
|
+
const o = parseGithubOwner(url);
|
|
55
|
+
if (o && o === want) {
|
|
56
|
+
return remoteName;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore missing remote
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return 'upstream';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function createWorktreeFromBaseWorktree({
|
|
66
|
+
rootDir,
|
|
67
|
+
component,
|
|
68
|
+
slug,
|
|
69
|
+
baseWorktreeSpec,
|
|
70
|
+
remoteName = 'upstream',
|
|
71
|
+
depsMode = '',
|
|
72
|
+
}) {
|
|
73
|
+
const args = ['wt', 'new', component, slug, `--remote=${remoteName}`, `--base-worktree=${baseWorktreeSpec}`];
|
|
74
|
+
if (depsMode) args.push(`--deps=${depsMode}`);
|
|
75
|
+
await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), ...args], { cwd: rootDir });
|
|
76
|
+
|
|
77
|
+
const repoDir = componentRepoDir(rootDir, component);
|
|
78
|
+
const owner = await getRemoteOwner({ repoDir, remoteName });
|
|
79
|
+
return join(getWorktreesRoot(rootDir), component, owner, ...slug.split('/'));
|
|
80
|
+
}
|
|
81
|
+
|
|
26
82
|
export function resolveComponentSpecToDir({ rootDir, component, spec }) {
|
|
27
83
|
const raw = (spec ?? '').trim();
|
|
28
84
|
if (!raw || raw === 'default') {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
2
|
import net from 'node:net';
|
|
3
|
-
import {
|
|
3
|
+
import { runCaptureIfCommandExists } from '../proc/commands.mjs';
|
|
4
4
|
|
|
5
5
|
async function listListenPids(port) {
|
|
6
6
|
if (!Number.isFinite(port) || port <= 0) return [];
|
|
@@ -9,10 +9,7 @@ async function listListenPids(port) {
|
|
|
9
9
|
let raw = '';
|
|
10
10
|
try {
|
|
11
11
|
// `lsof` exits non-zero if no matches; normalize to empty output.
|
|
12
|
-
raw = await
|
|
13
|
-
'-lc',
|
|
14
|
-
`command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`,
|
|
15
|
-
]);
|
|
12
|
+
raw = await runCaptureIfCommandExists('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
|
16
13
|
} catch {
|
|
17
14
|
raw = '';
|
|
18
15
|
}
|
|
@@ -102,3 +99,4 @@ export async function pickNextFreeTcpPort(startPort, { reservedPorts = new Set()
|
|
|
102
99
|
}
|
|
103
100
|
throw new Error(`[local] unable to find a free TCP port starting at ${startPort}`);
|
|
104
101
|
}
|
|
102
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function expandHome(p) {
|
|
5
|
+
return String(p ?? '').replace(/^~(?=\/)/, homedir());
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getCanonicalHomeDirFromEnv(env = process.env) {
|
|
9
|
+
const fromEnv = (
|
|
10
|
+
(env.HAPPY_STACKS_CANONICAL_HOME_DIR ?? '').trim() ||
|
|
11
|
+
(env.HAPPY_LOCAL_CANONICAL_HOME_DIR ?? '').trim() ||
|
|
12
|
+
''
|
|
13
|
+
);
|
|
14
|
+
return fromEnv ? expandHome(fromEnv) : join(homedir(), '.happy-stacks');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getCanonicalHomeEnvPathFromEnv(env = process.env) {
|
|
18
|
+
return join(getCanonicalHomeDirFromEnv(env), '.env');
|
|
19
|
+
}
|
|
20
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getStackName } from './paths.mjs';
|
|
2
|
+
import { sanitizeDnsLabel } from '../net/dns.mjs';
|
|
3
|
+
|
|
4
|
+
export function resolveLocalhostHost({ stackMode, stackName = getStackName() } = {}) {
|
|
5
|
+
if (!stackMode) return 'localhost';
|
|
6
|
+
if (!stackName || stackName === 'main') return 'localhost';
|
|
7
|
+
return `happy-${sanitizeDnsLabel(stackName)}.localhost`;
|
|
8
|
+
}
|
|
9
|
+
|
|
@@ -3,6 +3,9 @@ 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';
|
|
8
|
+
|
|
6
9
|
const PRIMARY_APP_SLUG = 'happy-stacks';
|
|
7
10
|
const LEGACY_APP_SLUG = 'happy-local';
|
|
8
11
|
const PRIMARY_LABEL_BASE = 'com.happy.stacks';
|
|
@@ -15,10 +18,10 @@ export function getRootDir(importMetaUrl) {
|
|
|
15
18
|
return dirname(dirname(fileURLToPath(importMetaUrl)));
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
export function getHappyStacksHomeDir() {
|
|
19
|
-
const fromEnv = (
|
|
21
|
+
export function getHappyStacksHomeDir(env = process.env) {
|
|
22
|
+
const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').trim();
|
|
20
23
|
if (fromEnv) {
|
|
21
|
-
return fromEnv
|
|
24
|
+
return expandHome(fromEnv);
|
|
22
25
|
}
|
|
23
26
|
return PRIMARY_HOME_DIR;
|
|
24
27
|
}
|
|
@@ -26,7 +29,7 @@ export function getHappyStacksHomeDir() {
|
|
|
26
29
|
export function getWorkspaceDir(cliRootDir = null) {
|
|
27
30
|
const fromEnv = (process.env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
|
|
28
31
|
if (fromEnv) {
|
|
29
|
-
return fromEnv
|
|
32
|
+
return expandHome(fromEnv);
|
|
30
33
|
}
|
|
31
34
|
const homeDir = getHappyStacksHomeDir();
|
|
32
35
|
const defaultWorkspace = join(homeDir, 'workspace');
|
|
@@ -52,7 +55,7 @@ function normalizePathForEnv(rootDir, raw) {
|
|
|
52
55
|
if (!trimmed) {
|
|
53
56
|
return '';
|
|
54
57
|
}
|
|
55
|
-
const expanded = trimmed
|
|
58
|
+
const expanded = expandHome(trimmed);
|
|
56
59
|
// If the path is relative, treat it as relative to the workspace root (default: repo root).
|
|
57
60
|
const workspaceDir = getWorkspaceDir(rootDir);
|
|
58
61
|
return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
|
|
@@ -88,7 +91,7 @@ export function getLegacyStackLabel(stackName = getStackName()) {
|
|
|
88
91
|
export function getStacksStorageRoot() {
|
|
89
92
|
const fromEnv = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
|
|
90
93
|
if (fromEnv) {
|
|
91
|
-
return fromEnv
|
|
94
|
+
return expandHome(fromEnv);
|
|
92
95
|
}
|
|
93
96
|
return PRIMARY_STORAGE_ROOT;
|
|
94
97
|
}
|
|
@@ -101,12 +104,13 @@ export function resolveStackBaseDir(stackName = getStackName()) {
|
|
|
101
104
|
const preferredRoot = getStacksStorageRoot();
|
|
102
105
|
const newBase = join(preferredRoot, stackName);
|
|
103
106
|
const legacyBase = stackName === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', stackName);
|
|
107
|
+
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
104
108
|
|
|
105
109
|
// Prefer the new layout by default.
|
|
106
110
|
//
|
|
107
111
|
// For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
|
|
108
112
|
// This avoids breaking existing stacks until `happys stack migrate` is run.
|
|
109
|
-
if (stackName !== 'main') {
|
|
113
|
+
if (allowLegacy && stackName !== 'main') {
|
|
110
114
|
const newEnv = join(preferredRoot, stackName, 'env');
|
|
111
115
|
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
|
|
112
116
|
if (!existsSync(newEnv) && existsSync(legacyEnv)) {
|
|
@@ -123,11 +127,12 @@ export function resolveStackEnvPath(stackName = getStackName()) {
|
|
|
123
127
|
const newEnv = join(getStacksStorageRoot(), stackName, 'env');
|
|
124
128
|
// Legacy layout: ~/.happy/local/stacks/<name>/env
|
|
125
129
|
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', stackName, 'env');
|
|
130
|
+
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
126
131
|
|
|
127
132
|
if (existsSync(newEnv)) {
|
|
128
133
|
return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(), stackName) };
|
|
129
134
|
}
|
|
130
|
-
if (existsSync(legacyEnv)) {
|
|
135
|
+
if (allowLegacy && existsSync(legacyEnv)) {
|
|
131
136
|
return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', stackName) };
|
|
132
137
|
}
|
|
133
138
|
return { envPath: newEnv, isLegacy, baseDir: activeBase };
|
|
@@ -182,3 +187,4 @@ export function getDefaultAutostartPaths() {
|
|
|
182
187
|
legacyStderrPath,
|
|
183
188
|
};
|
|
184
189
|
}
|
|
190
|
+
|
|
@@ -2,16 +2,16 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
return p.replace(/^~(?=\/)/, homedir());
|
|
7
|
-
}
|
|
5
|
+
import { expandHome } from './canonical_home.mjs';
|
|
8
6
|
|
|
9
7
|
export function getRuntimeDir() {
|
|
10
8
|
const fromEnv = (process.env.HAPPY_STACKS_RUNTIME_DIR ?? '').trim();
|
|
11
9
|
if (fromEnv) {
|
|
12
10
|
return expandHome(fromEnv);
|
|
13
11
|
}
|
|
14
|
-
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');
|
|
15
15
|
return join(homeDir, 'runtime');
|
|
16
16
|
}
|
|
17
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,135 @@
|
|
|
1
|
+
import { runCapture } from './proc.mjs';
|
|
2
|
+
import { killPid } from '../expo/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
|
+
|
|
@@ -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
|
+
|