happy-stacks 0.0.0 → 0.1.2

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 (42) hide show
  1. package/README.md +22 -4
  2. package/bin/happys.mjs +76 -5
  3. package/docs/server-flavors.md +61 -2
  4. package/docs/stacks.md +16 -4
  5. package/extras/swiftbar/auth-login.sh +5 -5
  6. package/extras/swiftbar/happy-stacks.5s.sh +83 -41
  7. package/extras/swiftbar/happys-term.sh +151 -0
  8. package/extras/swiftbar/happys.sh +52 -0
  9. package/extras/swiftbar/lib/render.sh +74 -56
  10. package/extras/swiftbar/lib/system.sh +37 -6
  11. package/extras/swiftbar/lib/utils.sh +180 -4
  12. package/extras/swiftbar/pnpm-term.sh +2 -122
  13. package/extras/swiftbar/pnpm.sh +2 -13
  14. package/extras/swiftbar/set-server-flavor.sh +8 -8
  15. package/extras/swiftbar/wt-pr.sh +1 -1
  16. package/package.json +1 -1
  17. package/scripts/auth.mjs +374 -3
  18. package/scripts/daemon.mjs +78 -11
  19. package/scripts/dev.mjs +122 -17
  20. package/scripts/init.mjs +238 -32
  21. package/scripts/migrate.mjs +292 -0
  22. package/scripts/mobile.mjs +51 -19
  23. package/scripts/run.mjs +118 -26
  24. package/scripts/service.mjs +176 -37
  25. package/scripts/stack.mjs +665 -22
  26. package/scripts/stop.mjs +157 -0
  27. package/scripts/tailscale.mjs +147 -21
  28. package/scripts/typecheck.mjs +145 -0
  29. package/scripts/ui_gateway.mjs +248 -0
  30. package/scripts/uninstall.mjs +3 -3
  31. package/scripts/utils/cli_registry.mjs +23 -0
  32. package/scripts/utils/config.mjs +9 -1
  33. package/scripts/utils/env.mjs +37 -15
  34. package/scripts/utils/expo.mjs +94 -0
  35. package/scripts/utils/happy_server_infra.mjs +430 -0
  36. package/scripts/utils/pm.mjs +11 -2
  37. package/scripts/utils/ports.mjs +51 -13
  38. package/scripts/utils/proc.mjs +46 -5
  39. package/scripts/utils/server.mjs +37 -0
  40. package/scripts/utils/stack_stop.mjs +206 -0
  41. package/scripts/utils/validate.mjs +42 -1
  42. package/scripts/worktrees.mjs +53 -7
@@ -0,0 +1,248 @@
1
+ import './utils/env.mjs';
2
+ import http from 'node:http';
3
+ import net from 'node:net';
4
+ import { extname, resolve, sep } from 'node:path';
5
+ import { readFile, stat } from 'node:fs/promises';
6
+
7
+ import { parseArgs } from './utils/args.mjs';
8
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
9
+
10
+ function usage() {
11
+ return [
12
+ '[ui-gateway] usage:',
13
+ ' node scripts/ui_gateway.mjs --port=<port> --backend-url=<url> --minio-port=<port> --bucket=<name> [--ui-dir=<path>] [--no-ui]',
14
+ '',
15
+ 'Reverse-proxy gateway that can optionally serve the built Happy web UI at /.',
16
+ '',
17
+ 'Always proxies:',
18
+ '- /v1/* and /health to backend-url',
19
+ '- /v1/updates websocket upgrades to backend-url (socket.io)',
20
+ '- /files/* to local Minio (http://127.0.0.1:<minio-port>/<bucket>/...)',
21
+ ].join('\n');
22
+ }
23
+
24
+ function normalizePublicPath(path) {
25
+ const p = String(path ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
26
+ const parts = p.split('/').filter(Boolean);
27
+ if (parts.some((part) => part === '..')) {
28
+ throw new Error('Invalid path');
29
+ }
30
+ if (p.includes(':') || p.startsWith('/')) {
31
+ throw new Error('Invalid path');
32
+ }
33
+ return parts.join('/');
34
+ }
35
+
36
+ function contentTypeForExt(ext) {
37
+ const e = ext.toLowerCase();
38
+ if (e === '.html') return 'text/html; charset=utf-8';
39
+ if (e === '.js') return 'text/javascript; charset=utf-8';
40
+ if (e === '.css') return 'text/css; charset=utf-8';
41
+ if (e === '.json') return 'application/json; charset=utf-8';
42
+ if (e === '.svg') return 'image/svg+xml';
43
+ if (e === '.ico') return 'image/x-icon';
44
+ if (e === '.wasm') return 'application/wasm';
45
+ if (e === '.ttf') return 'font/ttf';
46
+ if (e === '.woff') return 'font/woff';
47
+ if (e === '.woff2') return 'font/woff2';
48
+ if (e === '.png') return 'image/png';
49
+ if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
50
+ if (e === '.webp') return 'image/webp';
51
+ if (e === '.gif') return 'image/gif';
52
+ return 'application/octet-stream';
53
+ }
54
+
55
+ async function sendUiFile({ uiRoot, relPath, res }) {
56
+ const candidate = resolve(uiRoot, relPath);
57
+ if (!(candidate === uiRoot || candidate.startsWith(uiRoot + sep))) {
58
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
59
+ res.end('Not found');
60
+ return;
61
+ }
62
+ const bytes = await readFile(candidate);
63
+ const ext = extname(candidate);
64
+ const isHtml = ext.toLowerCase() === '.html';
65
+ res.setHeader('content-type', contentTypeForExt(ext));
66
+ if (isHtml) {
67
+ res.setHeader('cache-control', 'no-cache');
68
+ } else {
69
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable');
70
+ }
71
+ res.end(bytes);
72
+ }
73
+
74
+ async function sendIndex({ uiRoot, res }) {
75
+ const indexPath = resolve(uiRoot, 'index.html');
76
+ const html = (await readFile(indexPath, 'utf-8')) + '\n<!-- Welcome to Happy Server! -->\n';
77
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
78
+ res.end(html);
79
+ }
80
+
81
+ function proxyHttp({ target, req, res, rewritePath = (p) => p }) {
82
+ const url = new URL(target);
83
+ const method = req.method || 'GET';
84
+ const headers = { ...req.headers };
85
+ // Let Node compute correct host
86
+ delete headers.host;
87
+
88
+ const upstream = http.request(
89
+ {
90
+ protocol: url.protocol,
91
+ hostname: url.hostname,
92
+ port: url.port,
93
+ method,
94
+ path: rewritePath(req.url || '/'),
95
+ headers,
96
+ },
97
+ (up) => {
98
+ res.writeHead(up.statusCode || 502, up.headers);
99
+ up.pipe(res);
100
+ }
101
+ );
102
+ upstream.on('error', () => {
103
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
104
+ res.end('Bad gateway');
105
+ });
106
+ req.pipe(upstream);
107
+ }
108
+
109
+ function proxyUpgrade({ target, req, socket, head }) {
110
+ const url = new URL(target);
111
+ const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;
112
+
113
+ const upstream = net.connect(port, url.hostname, () => {
114
+ const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
115
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
116
+ lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
117
+ }
118
+ lines.push('', '');
119
+ upstream.write(lines.join('\r\n'));
120
+ if (head?.length) upstream.write(head);
121
+ socket.pipe(upstream).pipe(socket);
122
+ });
123
+ upstream.on('error', () => {
124
+ try {
125
+ socket.destroy();
126
+ } catch {
127
+ // ignore
128
+ }
129
+ });
130
+ }
131
+
132
+ async function main() {
133
+ const argv = process.argv.slice(2);
134
+ const { flags, kv } = parseArgs(argv);
135
+ const json = wantsJson(argv, { flags });
136
+ if (wantsHelp(argv, { flags })) {
137
+ printResult({ json, data: { ok: true }, text: usage() });
138
+ return;
139
+ }
140
+
141
+ const portRaw = (kv.get('--port') ?? '').trim();
142
+ const backendUrl = (kv.get('--backend-url') ?? '').trim();
143
+ const minioPortRaw = (kv.get('--minio-port') ?? '').trim();
144
+ const bucket = (kv.get('--bucket') ?? '').trim();
145
+ const serveUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
146
+ const uiDir = serveUi ? (kv.get('--ui-dir') ?? '').trim() : '';
147
+
148
+ const port = portRaw ? Number(portRaw) : NaN;
149
+ const minioPort = minioPortRaw ? Number(minioPortRaw) : NaN;
150
+
151
+ if (!backendUrl || !bucket || !Number.isFinite(port) || port <= 0 || !Number.isFinite(minioPort) || minioPort <= 0) {
152
+ throw new Error(usage());
153
+ }
154
+
155
+ const uiRoot = uiDir ? resolve(uiDir) : '';
156
+
157
+ const server = http.createServer(async (req, res) => {
158
+ try {
159
+ const url = req.url || '/';
160
+
161
+ // API + health proxy
162
+ if (url.startsWith('/v1/') || url === '/health' || url === '/metrics' || url.startsWith('/v2/')) {
163
+ proxyHttp({ target: backendUrl, req, res });
164
+ return;
165
+ }
166
+
167
+ // Public files proxy (Minio path-style)
168
+ if (url.startsWith('/files/')) {
169
+ proxyHttp({
170
+ target: `http://127.0.0.1:${minioPort}`,
171
+ req,
172
+ res,
173
+ rewritePath: (p) => {
174
+ const raw = p.replace(/^\/files\/?/, '');
175
+ const safe = normalizePublicPath(raw);
176
+ return `/${encodeURIComponent(bucket)}/${safe}`;
177
+ },
178
+ });
179
+ return;
180
+ }
181
+
182
+ // UI static
183
+ if (url === '/' || url === '/ui' || url === '/ui/') {
184
+ if (uiRoot) {
185
+ await sendIndex({ uiRoot, res });
186
+ return;
187
+ }
188
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-cache' });
189
+ res.end('Welcome to Happy Server!');
190
+ return;
191
+ }
192
+ if (url.startsWith('/ui/')) {
193
+ res.writeHead(302, { location: '/' });
194
+ res.end();
195
+ return;
196
+ }
197
+
198
+ if (uiRoot) {
199
+ const rel = normalizePublicPath(decodeURIComponent(url));
200
+ const candidate = resolve(uiRoot, rel);
201
+ const exists = candidate === uiRoot || candidate.startsWith(uiRoot + sep) ? await stat(candidate).then(() => true).catch(() => false) : false;
202
+ if (exists) {
203
+ await sendUiFile({ uiRoot, relPath: rel, res });
204
+ return;
205
+ }
206
+
207
+ // SPA fallback
208
+ await sendIndex({ uiRoot, res });
209
+ return;
210
+ }
211
+
212
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
213
+ res.end('Not found');
214
+ } catch {
215
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
216
+ res.end('Internal error');
217
+ }
218
+ });
219
+
220
+ server.on('upgrade', (req, socket, head) => {
221
+ try {
222
+ const url = req.url || '';
223
+ // socket.io upgrades for realtime updates
224
+ if (url.startsWith('/v1/updates')) {
225
+ proxyUpgrade({ target: backendUrl, req, socket, head });
226
+ return;
227
+ }
228
+ socket.destroy();
229
+ } catch {
230
+ try {
231
+ socket.destroy();
232
+ } catch {
233
+ // ignore
234
+ }
235
+ }
236
+ });
237
+
238
+ await new Promise((resolvePromise) => server.listen({ port, host: '0.0.0.0' }, resolvePromise));
239
+ // eslint-disable-next-line no-console
240
+ console.log(`[ui-gateway] ready on http://127.0.0.1:${port}`);
241
+ }
242
+
243
+ main().catch((err) => {
244
+ // eslint-disable-next-line no-console
245
+ console.error(err);
246
+ process.exit(1);
247
+ });
248
+
@@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process';
8
8
 
9
9
  import { parseArgs } from './utils/args.mjs';
10
10
  import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
11
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
11
+ import { getHappyStacksHomeDir, getRootDir, getStacksStorageRoot } from './utils/paths.mjs';
12
12
  import { getRuntimeDir } from './utils/runtime.mjs';
13
13
 
14
14
  function expandHome(p) {
@@ -148,7 +148,7 @@ async function main() {
148
148
 
149
149
  // 5) Optionally remove stacks data.
150
150
  if (removeStacks) {
151
- const stacksRoot = join(homedir(), '.happy', 'stacks');
151
+ const stacksRoot = getStacksStorageRoot();
152
152
  if (existsSync(stacksRoot)) {
153
153
  if (!dryRun) {
154
154
  await rm(stacksRoot, { recursive: true, force: true });
@@ -177,7 +177,7 @@ async function main() {
177
177
  `[uninstall] removed: ${removedPaths.length ? removedPaths.join(', ') : '(nothing)'}`,
178
178
  menubar?.pluginsDir ? `[uninstall] SwiftBar plugins dir: ${menubar.pluginsDir}` : null,
179
179
  removeWorkspace ? `[uninstall] workspace removed: ${workspaceDir}` : `[uninstall] workspace kept: ${workspaceDir}`,
180
- removeStacks ? `[uninstall] stacks removed: ~/.happy/stacks` : `[uninstall] stacks kept: ~/.happy/stacks`,
180
+ removeStacks ? `[uninstall] stacks removed: ${getStacksStorageRoot()}` : `[uninstall] stacks kept: ${getStacksStorageRoot()}`,
181
181
  ]
182
182
  .filter(Boolean)
183
183
  .join('\n'),
@@ -57,6 +57,13 @@ export function getHappysRegistry() {
57
57
  rootUsage: 'happys dev [-- ...]',
58
58
  description: 'Start local stack (dev)',
59
59
  },
60
+ {
61
+ name: 'stop',
62
+ kind: 'node',
63
+ scriptRelPath: 'scripts/stop.mjs',
64
+ rootUsage: 'happys stop [--except-stacks=main,exp1] [--yes] [--aggressive] [--no-docker] [--no-service] [--json]',
65
+ description: 'Stop stacks and related local processes',
66
+ },
60
67
  {
61
68
  name: 'build',
62
69
  kind: 'node',
@@ -64,6 +71,21 @@ export function getHappysRegistry() {
64
71
  rootUsage: 'happys build [-- ...]',
65
72
  description: 'Build UI bundle',
66
73
  },
74
+ {
75
+ name: 'typecheck',
76
+ aliases: ['type-check', 'check-types'],
77
+ kind: 'node',
78
+ scriptRelPath: 'scripts/typecheck.mjs',
79
+ rootUsage: 'happys typecheck [component...] [--json]',
80
+ description: 'Run TypeScript typechecks for components',
81
+ },
82
+ {
83
+ name: 'migrate',
84
+ kind: 'node',
85
+ scriptRelPath: 'scripts/migrate.mjs',
86
+ rootUsage: 'happys migrate light-to-server --from-stack=<name> --to-stack=<name> [--include-files] [--force] [--json]',
87
+ description: 'Migrate data between server flavors (experimental)',
88
+ },
67
89
  {
68
90
  name: 'mobile',
69
91
  kind: 'node',
@@ -260,3 +282,4 @@ export function renderHappysRootHelp() {
260
282
  ' happys help [command]',
261
283
  ].join('\n');
262
284
  }
285
+
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
2
3
  import { ensureEnvFileUpdated } from './env_file.mjs';
3
4
  import { getHappyStacksHomeDir, resolveStackEnvPath } from './paths.mjs';
4
5
 
@@ -6,6 +7,10 @@ export function getHomeEnvPath() {
6
7
  return join(getHappyStacksHomeDir(), '.env');
7
8
  }
8
9
 
10
+ export function getCanonicalHomeEnvPath() {
11
+ return join(homedir(), '.happy-stacks', '.env');
12
+ }
13
+
9
14
  export function getHomeEnvLocalPath() {
10
15
  return join(getHappyStacksHomeDir(), 'env.local');
11
16
  }
@@ -28,6 +33,10 @@ export async function ensureHomeEnvUpdated({ updates }) {
28
33
  await ensureEnvFileUpdated({ envPath: getHomeEnvPath(), updates });
29
34
  }
30
35
 
36
+ export async function ensureCanonicalHomeEnvUpdated({ updates }) {
37
+ await ensureEnvFileUpdated({ envPath: getCanonicalHomeEnvPath(), updates });
38
+ }
39
+
31
40
  export async function ensureHomeEnvLocalUpdated({ updates }) {
32
41
  await ensureEnvFileUpdated({ envPath: getHomeEnvLocalPath(), updates });
33
42
  }
@@ -37,4 +46,3 @@ export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
37
46
  await ensureEnvFileUpdated({ envPath, updates });
38
47
  return envPath;
39
48
  }
40
-
@@ -65,6 +65,17 @@ function applyStacksPrefixMapping() {
65
65
  }
66
66
  }
67
67
 
68
+ // If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at ~/.happy-stacks/.env first.
69
+ //
70
+ // This allows installs where the "real" home/workspace/runtime are elsewhere, while still
71
+ // giving us a stable discovery location for launchd/SwiftBar/minimal shells.
72
+ const canonicalEnvPath = join(homedir(), '.happy-stacks', '.env');
73
+ if (!(process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() && existsSync(canonicalEnvPath)) {
74
+ await loadEnvFile(canonicalEnvPath, { override: false });
75
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
76
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_LOCAL_' });
77
+ }
78
+
68
79
  const __homeDir = resolveHomeDir();
69
80
  process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? __homeDir;
70
81
 
@@ -99,22 +110,33 @@ if (hasHomeConfig) {
99
110
  return;
100
111
  }
101
112
  const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
102
- if (stackName !== 'main') {
103
- return;
104
- }
105
- const mainEnv = join(homedir(), '.happy', 'stacks', 'main', 'env');
106
- if (!existsSync(mainEnv)) {
107
- return;
108
- }
109
- process.env.HAPPY_STACKS_ENV_FILE = mainEnv;
110
- process.env.HAPPY_LOCAL_ENV_FILE = mainEnv;
113
+ const stacksStorageRootRaw = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
114
+ const stacksStorageRoot = stacksStorageRootRaw ? expandHome(stacksStorageRootRaw) : join(homedir(), '.happy', 'stacks');
115
+ const legacyStacksRoot = join(homedir(), '.happy', 'local', 'stacks');
116
+
117
+ const candidates = [
118
+ join(stacksStorageRoot, stackName, 'env'),
119
+ join(legacyStacksRoot, stackName, 'env'),
120
+ ];
121
+ const envPath = candidates.find((p) => existsSync(p));
122
+ if (!envPath) return;
123
+
124
+ process.env.HAPPY_STACKS_ENV_FILE = envPath;
125
+ process.env.HAPPY_LOCAL_ENV_FILE = envPath;
111
126
  })();
112
- // 3) Load explicit env file overlay (stack env, or any caller-provided env file) last (highest precedence)
113
- if (process.env.HAPPY_STACKS_ENV_FILE?.trim()) {
114
- await loadEnvFile(process.env.HAPPY_STACKS_ENV_FILE.trim(), { override: true, overridePrefix: 'HAPPY_STACKS_' });
115
- }
116
- if (process.env.HAPPY_LOCAL_ENV_FILE?.trim()) {
117
- await loadEnvFile(process.env.HAPPY_LOCAL_ENV_FILE.trim(), { override: true, overridePrefix: 'HAPPY_LOCAL_' });
127
+ // 3) Load explicit env file overlay (stack env, or any caller-provided env file) last (highest precedence).
128
+ //
129
+ // IMPORTANT:
130
+ // Stack env files intentionally include some non-prefixed keys (e.g. DATABASE_URL, HAPPY_SERVER_LIGHT_DATA_DIR)
131
+ // that must apply for true per-stack isolation. Do not filter by prefix here.
132
+ {
133
+ const stacksEnv = process.env.HAPPY_STACKS_ENV_FILE?.trim() ? process.env.HAPPY_STACKS_ENV_FILE.trim() : '';
134
+ const localEnv = process.env.HAPPY_LOCAL_ENV_FILE?.trim() ? process.env.HAPPY_LOCAL_ENV_FILE.trim() : '';
135
+ const unique = Array.from(new Set([stacksEnv, localEnv].filter(Boolean)));
136
+ for (const p of unique) {
137
+ // eslint-disable-next-line no-await-in-loop
138
+ await loadEnvFile(p, { override: true });
139
+ }
118
140
  }
119
141
 
120
142
  // Make both prefixes available to the rest of the codebase.
@@ -0,0 +1,94 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+
7
+ function hashDir(dir) {
8
+ return createHash('sha1').update(String(dir ?? '')).digest('hex').slice(0, 12);
9
+ }
10
+
11
+ export function getExpoStatePaths({ baseDir, kind, projectDir, stateFileName = 'expo.state.json' }) {
12
+ const key = hashDir(projectDir);
13
+ const stateDir = join(baseDir, kind, key);
14
+ return {
15
+ key,
16
+ stateDir,
17
+ statePath: join(stateDir, stateFileName),
18
+ expoHomeDir: join(stateDir, 'expo-home'),
19
+ tmpDir: join(stateDir, 'tmp'),
20
+ };
21
+ }
22
+
23
+ export async function ensureExpoIsolationEnv({ env, stateDir, expoHomeDir, tmpDir }) {
24
+ await mkdir(stateDir, { recursive: true });
25
+ await mkdir(expoHomeDir, { recursive: true });
26
+ await mkdir(tmpDir, { recursive: true });
27
+
28
+ // Expo CLI uses this to override ~/.expo.
29
+ env.__UNSAFE_EXPO_HOME_DIRECTORY = env.__UNSAFE_EXPO_HOME_DIRECTORY ?? expoHomeDir;
30
+
31
+ // Metro default cache root is `path.join(os.tmpdir(), 'metro-cache')`, so TMPDIR isolates it.
32
+ env.TMPDIR = env.TMPDIR ?? tmpDir;
33
+ }
34
+
35
+ export function wantsExpoClearCache({ env }) {
36
+ const raw = (env.HAPPY_STACKS_EXPO_CLEAR_CACHE ?? env.HAPPY_LOCAL_EXPO_CLEAR_CACHE ?? '').trim();
37
+ if (raw) {
38
+ return raw !== '0';
39
+ }
40
+ // Default: clear cache when non-interactive (LLMs/services), keep fast iteration in TTY shells.
41
+ return !(process.stdin.isTTY && process.stdout.isTTY);
42
+ }
43
+
44
+ export async function readPidState(statePath) {
45
+ try {
46
+ if (!existsSync(statePath)) return null;
47
+ const raw = await readFile(statePath, 'utf-8');
48
+ const state = JSON.parse(raw);
49
+ const pid = Number(state?.pid);
50
+ if (!Number.isFinite(pid) || pid <= 0) return null;
51
+ return state;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export function isPidAlive(pid) {
58
+ try {
59
+ process.kill(pid, 0);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ export async function isStateProcessRunning(statePath) {
67
+ const state = await readPidState(statePath);
68
+ if (!state) return { running: false, state: null };
69
+ const pid = Number(state.pid);
70
+ return { running: isPidAlive(pid), state };
71
+ }
72
+
73
+ export async function writePidState(statePath, state) {
74
+ await mkdir(dirname(statePath), { recursive: true }).catch(() => {});
75
+ await writeFile(statePath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
76
+ }
77
+
78
+ export async function killPid(pid) {
79
+ const n = Number(pid);
80
+ if (!Number.isFinite(n) || n <= 1) return;
81
+ try {
82
+ process.kill(n, 'SIGTERM');
83
+ } catch {
84
+ return;
85
+ }
86
+ await delay(500);
87
+ try {
88
+ process.kill(n, 0);
89
+ process.kill(n, 'SIGKILL');
90
+ } catch {
91
+ // exited
92
+ }
93
+ }
94
+