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.
Files changed (116) hide show
  1. package/README.md +164 -89
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -0
  64. package/scripts/utils/dev/daemon.mjs +104 -0
  65. package/scripts/utils/dev/expo_web.mjs +112 -0
  66. package/scripts/utils/dev/server.mjs +183 -0
  67. package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /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,8 @@
1
+ import { readJsonIfExists } from './json.mjs';
2
+
3
+ export async function readPackageJsonVersion(path) {
4
+ const pkg = await readJsonIfExists(path, { defaultValue: null });
5
+ const v = String(pkg?.version ?? '').trim();
6
+ return v || null;
7
+ }
8
+
@@ -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 './paths.mjs';
4
- import { pathExists } from './fs.mjs';
5
- import { run, runCapture } from './proc.mjs';
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') {
@@ -0,0 +1,10 @@
1
+ export function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
2
+ const s = String(raw ?? '')
3
+ .toLowerCase()
4
+ .replace(/[^a-z0-9-]+/g, '-')
5
+ .replace(/-+/g, '-')
6
+ .replace(/^-+/, '')
7
+ .replace(/-+$/, '');
8
+ return s || fallback;
9
+ }
10
+
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import net from 'node:net';
3
- import { runCapture } from './proc.mjs';
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 runCapture('sh', [
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 = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
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.replace(/^~(?=\/)/, homedir());
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.replace(/^~(?=\/)/, homedir());
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.replace(/^~(?=\/)/, homedir());
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.replace(/^~(?=\/)/, homedir());
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
- function expandHome(p) {
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() ? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim()) : join(homedir(), '.happy-stacks');
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
+
@@ -0,0 +1,11 @@
1
+ export function isPidAlive(pid) {
2
+ const n = Number(pid);
3
+ if (!Number.isFinite(n) || n <= 1) return false;
4
+ try {
5
+ process.kill(n, 0);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+