happy-stacks 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -74
- package/bin/happys.mjs +140 -9
- 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/server-flavors.md +61 -2
- package/docs/stacks.md +55 -4
- package/extras/swiftbar/auth-login.sh +10 -7
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +175 -83
- package/extras/swiftbar/happys-term.sh +128 -0
- package/extras/swiftbar/happys.sh +35 -0
- 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 +279 -132
- package/extras/swiftbar/lib/system.sh +64 -10
- package/extras/swiftbar/lib/utils.sh +469 -10
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +4 -14
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +19 -10
- package/extras/swiftbar/wt-pr.sh +10 -3
- package/package.json +2 -1
- package/scripts/auth.mjs +833 -14
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +200 -23
- package/scripts/dev.mjs +230 -57
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +275 -46
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +302 -0
- package/scripts/mobile.mjs +59 -21
- package/scripts/run.mjs +222 -43
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +190 -38
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +2273 -92
- package/scripts/stop.mjs +160 -0
- package/scripts/tailscale.mjs +164 -23
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +21 -13
- package/scripts/utils/auth_files.mjs +58 -0
- package/scripts/utils/auth_login_ux.mjs +76 -0
- package/scripts/utils/auth_sources.mjs +12 -0
- package/scripts/utils/browser.mjs +22 -0
- package/scripts/utils/canonical_home.mjs +20 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +13 -1
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/dev_daemon.mjs +104 -0
- package/scripts/utils/dev_expo_web.mjs +112 -0
- package/scripts/utils/dev_server.mjs +183 -0
- package/scripts/utils/env.mjs +94 -23
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +96 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +484 -0
- package/scripts/utils/localhost_host.mjs +17 -0
- package/scripts/utils/ownership.mjs +135 -0
- package/scripts/utils/paths.mjs +5 -2
- package/scripts/utils/pm.mjs +132 -22
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +75 -7
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +61 -0
- package/scripts/utils/server_port.mjs +9 -0
- package/scripts/utils/server_urls.mjs +54 -0
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stack_startup.mjs +208 -0
- package/scripts/utils/stack_stop.mjs +255 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +135 -15
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/utils/pm.mjs
CHANGED
|
@@ -1,13 +1,61 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { chmod, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
|
|
6
7
|
import { pathExists } from './fs.mjs';
|
|
7
8
|
import { run, runCapture, spawnProc } from './proc.mjs';
|
|
8
9
|
import { getDefaultAutostartPaths, getHappyStacksHomeDir } from './paths.mjs';
|
|
9
10
|
import { resolveInstalledPath, resolveInstalledCliRoot } from './runtime.mjs';
|
|
10
11
|
|
|
12
|
+
function sha256Hex(s) {
|
|
13
|
+
return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function readJsonIfExists(path) {
|
|
17
|
+
try {
|
|
18
|
+
if (!path || !existsSync(path)) return null;
|
|
19
|
+
const raw = await readFile(path, 'utf-8');
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function writeJsonAtomic(path, value) {
|
|
27
|
+
const dir = dirname(path);
|
|
28
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
29
|
+
const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
|
|
30
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
|
31
|
+
await rename(tmp, path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveBuildStatePath({ label, dir }) {
|
|
35
|
+
const homeDir = getHappyStacksHomeDir();
|
|
36
|
+
const key = sha256Hex(resolve(dir));
|
|
37
|
+
return join(homeDir, 'cache', 'build', label, `${key}.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function computeGitWorktreeSignature(dir) {
|
|
41
|
+
try {
|
|
42
|
+
// Fast path: only if this is a git worktree.
|
|
43
|
+
const inside = (await runCapture('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'])).trim();
|
|
44
|
+
if (inside !== 'true') return null;
|
|
45
|
+
const head = (await runCapture('git', ['-C', dir, 'rev-parse', 'HEAD'])).trim();
|
|
46
|
+
// Includes staged + unstaged + untracked changes; captures “dirty” vs “clean”.
|
|
47
|
+
const status = await runCapture('git', ['-C', dir, 'status', '--porcelain=v1']);
|
|
48
|
+
return {
|
|
49
|
+
kind: 'git',
|
|
50
|
+
head,
|
|
51
|
+
statusHash: sha256Hex(status),
|
|
52
|
+
signature: sha256Hex(`${head}\n${status}`),
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
11
59
|
async function commandExists(cmd, options = {}) {
|
|
12
60
|
try {
|
|
13
61
|
await runCapture(cmd, ['--version'], options);
|
|
@@ -53,7 +101,7 @@ export async function requireDir(label, dir) {
|
|
|
53
101
|
);
|
|
54
102
|
}
|
|
55
103
|
|
|
56
|
-
export async function ensureDepsInstalled(dir, label) {
|
|
104
|
+
export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
57
105
|
const pkgJson = join(dir, 'package.json');
|
|
58
106
|
if (!(await pathExists(pkgJson))) {
|
|
59
107
|
return;
|
|
@@ -62,6 +110,7 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
62
110
|
const nodeModules = join(dir, 'node_modules');
|
|
63
111
|
const pnpmModulesMeta = join(dir, 'node_modules', '.modules.yaml');
|
|
64
112
|
const pm = await getComponentPm(dir);
|
|
113
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
65
114
|
|
|
66
115
|
if (await pathExists(nodeModules)) {
|
|
67
116
|
const yarnLock = join(dir, 'yarn.lock');
|
|
@@ -71,10 +120,12 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
71
120
|
// If this repo is Yarn-managed (yarn.lock present) but node_modules was created by pnpm,
|
|
72
121
|
// reinstall with Yarn to restore upstream-locked dependency versions.
|
|
73
122
|
if (pm.name === 'yarn' && (await pathExists(pnpmModulesMeta))) {
|
|
74
|
-
|
|
75
|
-
|
|
123
|
+
if (!quiet) {
|
|
124
|
+
// eslint-disable-next-line no-console
|
|
125
|
+
console.log(`[local] converting ${label} dependencies back to yarn (reinstalling node_modules)...`);
|
|
126
|
+
}
|
|
76
127
|
await rm(nodeModules, { recursive: true, force: true });
|
|
77
|
-
await run(pm.cmd, ['install'], { cwd: dir });
|
|
128
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
78
129
|
}
|
|
79
130
|
|
|
80
131
|
// If dependencies changed since the last install, re-run install even if node_modules exists.
|
|
@@ -92,9 +143,11 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
92
143
|
const pkgM = await mtimeMs(pkgJson);
|
|
93
144
|
const intM = await mtimeMs(yarnIntegrity);
|
|
94
145
|
if (!intM || lockM > intM || pkgM > intM) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
if (!quiet) {
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
|
|
149
|
+
}
|
|
150
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
98
151
|
}
|
|
99
152
|
}
|
|
100
153
|
|
|
@@ -102,29 +155,75 @@ export async function ensureDepsInstalled(dir, label) {
|
|
|
102
155
|
const lockM = await mtimeMs(pnpmLock);
|
|
103
156
|
const metaM = await mtimeMs(pnpmModulesMeta);
|
|
104
157
|
if (!metaM || lockM > metaM) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
if (!quiet) {
|
|
159
|
+
// eslint-disable-next-line no-console
|
|
160
|
+
console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
|
|
161
|
+
}
|
|
162
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
108
163
|
}
|
|
109
164
|
}
|
|
110
165
|
|
|
111
166
|
return;
|
|
112
167
|
}
|
|
113
168
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
169
|
+
if (!quiet) {
|
|
170
|
+
// eslint-disable-next-line no-console
|
|
171
|
+
console.log(`[local] installing ${label} dependencies (first run)...`);
|
|
172
|
+
}
|
|
173
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
117
174
|
}
|
|
118
175
|
|
|
119
176
|
export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
120
177
|
await ensureDepsInstalled(cliDir, 'happy-cli');
|
|
121
178
|
if (!buildCli) {
|
|
122
|
-
return;
|
|
179
|
+
return { built: false, reason: 'disabled' };
|
|
180
|
+
}
|
|
181
|
+
// Default: build only when needed (fast + reliable for worktrees that haven't been built yet).
|
|
182
|
+
//
|
|
183
|
+
// You can force always-build by setting:
|
|
184
|
+
// - HAPPY_STACKS_CLI_BUILD_MODE=always (legacy: HAPPY_LOCAL_CLI_BUILD_MODE=always)
|
|
185
|
+
// Or disable via:
|
|
186
|
+
// - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
|
|
187
|
+
const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
|
|
188
|
+
const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
|
|
189
|
+
if (mode === 'never') {
|
|
190
|
+
return { built: false, reason: 'mode_never' };
|
|
191
|
+
}
|
|
192
|
+
const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
|
|
193
|
+
const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
|
|
194
|
+
const gitSig = await computeGitWorktreeSignature(cliDir);
|
|
195
|
+
const prev = await readJsonIfExists(buildStatePath);
|
|
196
|
+
|
|
197
|
+
if (mode === 'auto') {
|
|
198
|
+
// If dist doesn't exist, we must build.
|
|
199
|
+
if (!(await pathExists(distEntrypoint))) {
|
|
200
|
+
// fallthrough to build
|
|
201
|
+
} else if (gitSig && prev?.signature && prev.signature === gitSig.signature) {
|
|
202
|
+
return { built: false, reason: 'up_to_date' };
|
|
203
|
+
} else if (!gitSig) {
|
|
204
|
+
// No git info: best-effort skip if dist exists (keeps this fast outside git worktrees).
|
|
205
|
+
return { built: false, reason: 'no_git_info' };
|
|
206
|
+
}
|
|
123
207
|
}
|
|
208
|
+
|
|
124
209
|
// eslint-disable-next-line no-console
|
|
125
210
|
console.log('[local] building happy-cli...');
|
|
126
211
|
const pm = await getComponentPm(cliDir);
|
|
127
212
|
await run(pm.cmd, ['build'], { cwd: cliDir });
|
|
213
|
+
|
|
214
|
+
// Persist new build state (best-effort).
|
|
215
|
+
const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
|
|
216
|
+
if (nowSig) {
|
|
217
|
+
await writeJsonAtomic(buildStatePath, {
|
|
218
|
+
label: 'happy-cli',
|
|
219
|
+
dir: resolve(cliDir),
|
|
220
|
+
signature: nowSig.signature,
|
|
221
|
+
head: nowSig.head,
|
|
222
|
+
statusHash: nowSig.statusHash,
|
|
223
|
+
builtAt: new Date().toISOString(),
|
|
224
|
+
}).catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
return { built: true, reason: mode === 'always' ? 'mode_always' : 'changed' };
|
|
128
227
|
}
|
|
129
228
|
|
|
130
229
|
function getPathEntries() {
|
|
@@ -153,8 +252,9 @@ export async function ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli }) {
|
|
|
153
252
|
|
|
154
253
|
const shim = `#!/bin/bash
|
|
155
254
|
set -euo pipefail
|
|
156
|
-
|
|
157
|
-
|
|
255
|
+
# Prefer the sibling happys shim (works for sandbox installs too).
|
|
256
|
+
BIN_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
257
|
+
HAPPYS="$BIN_DIR/happys"
|
|
158
258
|
if [[ -x "$HAPPYS" ]]; then
|
|
159
259
|
exec "$HAPPYS" happy "$@"
|
|
160
260
|
fi
|
|
@@ -180,13 +280,22 @@ export async function pmSpawnScript({ label, dir, script, env, options = {} }) {
|
|
|
180
280
|
return spawnProc(label, pm.cmd, ['--silent', script], env, { ...options, cwd: dir });
|
|
181
281
|
}
|
|
182
282
|
|
|
183
|
-
export async function
|
|
283
|
+
export async function pmSpawnBin({ label, dir, bin, args, env, options = {} }) {
|
|
284
|
+
const pm = await getComponentPm(dir);
|
|
285
|
+
if (pm.name === 'yarn') {
|
|
286
|
+
return spawnProc(label, pm.cmd, [bin, ...args], env, { ...options, cwd: dir });
|
|
287
|
+
}
|
|
288
|
+
return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function pmExecBin({ dir, bin, args, env, quiet = false }) {
|
|
184
292
|
const pm = await getComponentPm(dir);
|
|
293
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
185
294
|
if (pm.name === 'yarn') {
|
|
186
|
-
await run(pm.cmd, [bin, ...args], { env, cwd: dir });
|
|
295
|
+
await run(pm.cmd, [bin, ...args], { env, cwd: dir, stdio });
|
|
187
296
|
return;
|
|
188
297
|
}
|
|
189
|
-
await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir });
|
|
298
|
+
await run(pm.cmd, ['exec', bin, ...args], { env, cwd: dir, stdio });
|
|
190
299
|
}
|
|
191
300
|
|
|
192
301
|
export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.local', env = {} }) {
|
|
@@ -217,6 +326,8 @@ export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.lo
|
|
|
217
326
|
: process.execPath;
|
|
218
327
|
const installedRoot = resolveInstalledCliRoot(rootDir);
|
|
219
328
|
const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
|
|
329
|
+
const happysShim = join(getHappyStacksHomeDir(), 'bin', 'happys');
|
|
330
|
+
const useShim = existsSync(happysShim);
|
|
220
331
|
|
|
221
332
|
// Ensure we write to the plist path that matches the label we're installing, instead of the
|
|
222
333
|
// "active" plist path (which might be legacy and cause filename/label mismatches).
|
|
@@ -233,8 +344,7 @@ export async function ensureMacAutostartEnabled({ rootDir, label = 'com.happy.lo
|
|
|
233
344
|
<string>${label}</string>
|
|
234
345
|
<key>ProgramArguments</key>
|
|
235
346
|
<array>
|
|
236
|
-
|
|
237
|
-
<string>${happysEntrypoint}</string>
|
|
347
|
+
${useShim ? `<string>${happysShim}</string>` : `<string>${nodePath}</string>\n <string>${happysEntrypoint}</string>`}
|
|
238
348
|
<string>start</string>
|
|
239
349
|
</array>
|
|
240
350
|
<key>WorkingDirectory</key>
|
package/scripts/utils/ports.mjs
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import net from 'node:net';
|
|
2
3
|
import { runCapture } from './proc.mjs';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
export async function killPortListeners(port, { label = 'port' } = {}) {
|
|
9
|
-
if (!Number.isFinite(port) || port <= 0) {
|
|
10
|
-
return [];
|
|
11
|
-
}
|
|
12
|
-
if (process.platform === 'win32') {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
5
|
+
async function listListenPids(port) {
|
|
6
|
+
if (!Number.isFinite(port) || port <= 0) return [];
|
|
7
|
+
if (process.platform === 'win32') return [];
|
|
15
8
|
|
|
16
9
|
let raw = '';
|
|
17
10
|
try {
|
|
@@ -21,10 +14,10 @@ export async function killPortListeners(port, { label = 'port' } = {}) {
|
|
|
21
14
|
`command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`,
|
|
22
15
|
]);
|
|
23
16
|
} catch {
|
|
24
|
-
|
|
17
|
+
raw = '';
|
|
25
18
|
}
|
|
26
19
|
|
|
27
|
-
|
|
20
|
+
return Array.from(
|
|
28
21
|
new Set(
|
|
29
22
|
raw
|
|
30
23
|
.split(/\s+/g)
|
|
@@ -34,6 +27,21 @@ export async function killPortListeners(port, { label = 'port' } = {}) {
|
|
|
34
27
|
.filter((n) => Number.isInteger(n) && n > 1)
|
|
35
28
|
)
|
|
36
29
|
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort: kill any processes LISTENing on a TCP port.
|
|
34
|
+
* Used to avoid EADDRINUSE when a previous run left a server behind.
|
|
35
|
+
*/
|
|
36
|
+
export async function killPortListeners(port, { label = 'port' } = {}) {
|
|
37
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
if (process.platform === 'win32') {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pids = await listListenPids(port);
|
|
37
45
|
|
|
38
46
|
if (!pids.length) {
|
|
39
47
|
return [];
|
|
@@ -64,3 +72,33 @@ export async function killPortListeners(port, { label = 'port' } = {}) {
|
|
|
64
72
|
return pids;
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
export async function isTcpPortFree(port, { host = '127.0.0.1' } = {}) {
|
|
76
|
+
if (!Number.isFinite(port) || port <= 0) return false;
|
|
77
|
+
|
|
78
|
+
// Prefer lsof-based detection to catch IPv6 listeners (e.g. TCP *:8081 (LISTEN))
|
|
79
|
+
// which can make a "bind 127.0.0.1" probe incorrectly report "free" on macOS.
|
|
80
|
+
const pids = await listListenPids(port);
|
|
81
|
+
if (pids.length) return false;
|
|
82
|
+
|
|
83
|
+
// Fallback: attempt to bind.
|
|
84
|
+
return await new Promise((resolvePromise) => {
|
|
85
|
+
const srv = net.createServer();
|
|
86
|
+
srv.unref();
|
|
87
|
+
srv.on('error', () => resolvePromise(false));
|
|
88
|
+
srv.listen({ port, host }, () => {
|
|
89
|
+
srv.close(() => resolvePromise(true));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function pickNextFreeTcpPort(startPort, { reservedPorts = new Set(), host = '127.0.0.1', tries = 200 } = {}) {
|
|
95
|
+
let port = startPort;
|
|
96
|
+
for (let i = 0; i < tries; i++) {
|
|
97
|
+
// eslint-disable-next-line no-await-in-loop
|
|
98
|
+
if (!reservedPorts.has(port) && (await isTcpPortFree(port, { host }))) {
|
|
99
|
+
return port;
|
|
100
|
+
}
|
|
101
|
+
port += 1;
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`[local] unable to find a free TCP port starting at ${startPort}`);
|
|
104
|
+
}
|
package/scripts/utils/proc.mjs
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
function writeWithPrefix(stream, prefix, bufState, chunk) {
|
|
4
|
+
const s = chunk.toString();
|
|
5
|
+
bufState.buf += s;
|
|
6
|
+
while (true) {
|
|
7
|
+
const idx = bufState.buf.indexOf('\n');
|
|
8
|
+
if (idx < 0) break;
|
|
9
|
+
const line = bufState.buf.slice(0, idx);
|
|
10
|
+
bufState.buf = bufState.buf.slice(idx + 1);
|
|
11
|
+
stream.write(`${prefix}${line}\n`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function flushPrefixed(stream, prefix, bufState) {
|
|
16
|
+
if (!bufState.buf) return;
|
|
17
|
+
stream.write(`${prefix}${bufState.buf}\n`);
|
|
18
|
+
bufState.buf = '';
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
export function spawnProc(label, cmd, args, env, options = {}) {
|
|
4
22
|
const child = spawn(cmd, args, {
|
|
5
23
|
env,
|
|
@@ -10,8 +28,17 @@ export function spawnProc(label, cmd, args, env, options = {}) {
|
|
|
10
28
|
...options,
|
|
11
29
|
});
|
|
12
30
|
|
|
13
|
-
|
|
14
|
-
|
|
31
|
+
const outState = { buf: '' };
|
|
32
|
+
const errState = { buf: '' };
|
|
33
|
+
const outPrefix = `[${label}] `;
|
|
34
|
+
const errPrefix = `[${label}] `;
|
|
35
|
+
|
|
36
|
+
child.stdout?.on('data', (d) => writeWithPrefix(process.stdout, outPrefix, outState, d));
|
|
37
|
+
child.stderr?.on('data', (d) => writeWithPrefix(process.stderr, errPrefix, errState, d));
|
|
38
|
+
child.on('close', () => {
|
|
39
|
+
flushPrefixed(process.stdout, outPrefix, outState);
|
|
40
|
+
flushPrefixed(process.stderr, errPrefix, errState);
|
|
41
|
+
});
|
|
15
42
|
child.on('exit', (code, sig) => {
|
|
16
43
|
if (code !== 0) {
|
|
17
44
|
process.stderr.write(`[${label}] exited (code=${code}, sig=${sig})\n`);
|
|
@@ -39,28 +66,69 @@ export function killProcessTree(child, signal) {
|
|
|
39
66
|
}
|
|
40
67
|
|
|
41
68
|
export async function run(cmd, args, options = {}) {
|
|
69
|
+
const { timeoutMs, ...spawnOptions } = options ?? {};
|
|
42
70
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
43
|
-
const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...
|
|
71
|
+
const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...spawnOptions });
|
|
72
|
+
const t =
|
|
73
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
74
|
+
? setTimeout(() => {
|
|
75
|
+
try {
|
|
76
|
+
proc.kill('SIGKILL');
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore
|
|
79
|
+
}
|
|
80
|
+
const e = new Error(`${cmd} timed out after ${timeoutMs}ms`);
|
|
81
|
+
e.code = 'ETIMEDOUT';
|
|
82
|
+
rejectPromise(e);
|
|
83
|
+
}, timeoutMs)
|
|
84
|
+
: null;
|
|
44
85
|
proc.on('error', rejectPromise);
|
|
45
86
|
proc.on('exit', (code) => (code === 0 ? resolvePromise() : rejectPromise(new Error(`${cmd} failed (code=${code})`))));
|
|
87
|
+
proc.on('exit', () => {
|
|
88
|
+
if (t) clearTimeout(t);
|
|
89
|
+
});
|
|
46
90
|
});
|
|
47
91
|
}
|
|
48
92
|
|
|
49
93
|
export async function runCapture(cmd, args, options = {}) {
|
|
94
|
+
const { timeoutMs, ...spawnOptions } = options ?? {};
|
|
50
95
|
return await new Promise((resolvePromise, rejectPromise) => {
|
|
51
|
-
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...
|
|
96
|
+
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
|
|
52
97
|
let out = '';
|
|
53
98
|
let err = '';
|
|
99
|
+
const t =
|
|
100
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
101
|
+
? setTimeout(() => {
|
|
102
|
+
try {
|
|
103
|
+
proc.kill('SIGKILL');
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore
|
|
106
|
+
}
|
|
107
|
+
const e = new Error(`${cmd} ${args.join(' ')} timed out after ${timeoutMs}ms`);
|
|
108
|
+
e.code = 'ETIMEDOUT';
|
|
109
|
+
e.out = out;
|
|
110
|
+
e.err = err;
|
|
111
|
+
rejectPromise(e);
|
|
112
|
+
}, timeoutMs)
|
|
113
|
+
: null;
|
|
54
114
|
proc.stdout?.on('data', (d) => (out += d.toString()));
|
|
55
115
|
proc.stderr?.on('data', (d) => (err += d.toString()));
|
|
56
116
|
proc.on('error', rejectPromise);
|
|
57
|
-
proc.on('exit', (code) => {
|
|
117
|
+
proc.on('exit', (code, signal) => {
|
|
118
|
+
if (t) clearTimeout(t);
|
|
58
119
|
if (code === 0) {
|
|
59
120
|
resolvePromise(out);
|
|
60
121
|
} else {
|
|
61
|
-
|
|
122
|
+
const e = new Error(
|
|
123
|
+
`${cmd} ${args.join(' ')} failed (code=${code ?? 'null'}, sig=${signal ?? 'null'}): ${err.trim()}`
|
|
124
|
+
);
|
|
125
|
+
e.code = 'EEXIT';
|
|
126
|
+
e.exitCode = code;
|
|
127
|
+
e.signal = signal;
|
|
128
|
+
e.out = out;
|
|
129
|
+
e.err = err;
|
|
130
|
+
rejectPromise(e);
|
|
62
131
|
}
|
|
63
132
|
});
|
|
64
133
|
});
|
|
65
134
|
}
|
|
66
|
-
|
|
@@ -2,9 +2,7 @@ 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();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function getSandboxDir() {
|
|
2
|
+
const v = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
|
|
3
|
+
return v || '';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isSandboxed() {
|
|
7
|
+
return Boolean(getSandboxDir());
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function sandboxAllowsGlobalSideEffects() {
|
|
11
|
+
const raw = (process.env.HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL ?? '').trim().toLowerCase();
|
|
12
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y';
|
|
13
|
+
}
|
|
14
|
+
|
package/scripts/utils/server.mjs
CHANGED
|
@@ -22,6 +22,43 @@ export function getServerComponentName({ kv } = {}) {
|
|
|
22
22
|
return raw;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export async function fetchHappyHealth(baseUrl) {
|
|
26
|
+
const ctl = new AbortController();
|
|
27
|
+
const t = setTimeout(() => ctl.abort(), 1500);
|
|
28
|
+
try {
|
|
29
|
+
const url = baseUrl.replace(/\/+$/, '') + '/health';
|
|
30
|
+
const res = await fetch(url, { method: 'GET', signal: ctl.signal });
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
let json = null;
|
|
33
|
+
try {
|
|
34
|
+
json = text ? JSON.parse(text) : null;
|
|
35
|
+
} catch {
|
|
36
|
+
json = null;
|
|
37
|
+
}
|
|
38
|
+
return { ok: res.ok, status: res.status, json, text };
|
|
39
|
+
} catch {
|
|
40
|
+
return { ok: false, status: null, json: null, text: null };
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(t);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function isHappyServerRunning(baseUrl) {
|
|
47
|
+
const health = await fetchHappyHealth(baseUrl);
|
|
48
|
+
if (!health.ok) return false;
|
|
49
|
+
// Both happy-server and happy-server-light use `service: 'happy-server'` today.
|
|
50
|
+
// Treat any ok health response as "running" to avoid duplicate spawns.
|
|
51
|
+
const svc = typeof health.json?.service === 'string' ? health.json.service : '';
|
|
52
|
+
const status = typeof health.json?.status === 'string' ? health.json.status : '';
|
|
53
|
+
if (svc && svc !== 'happy-server') {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (status && status !== 'ok') {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
25
62
|
export async function waitForServerReady(url) {
|
|
26
63
|
const deadline = Date.now() + 60_000;
|
|
27
64
|
while (Date.now() < deadline) {
|
|
@@ -39,3 +76,27 @@ export async function waitForServerReady(url) {
|
|
|
39
76
|
throw new Error(`Timed out waiting for server at ${url}`);
|
|
40
77
|
}
|
|
41
78
|
|
|
79
|
+
// Used for UI readiness checks (Expo / gateway / server). Treat any HTTP response as "up".
|
|
80
|
+
export async function waitForHttpOk(url, { timeoutMs = 15_000, intervalMs = 250 } = {}) {
|
|
81
|
+
const deadline = Date.now() + timeoutMs;
|
|
82
|
+
while (Date.now() < deadline) {
|
|
83
|
+
try {
|
|
84
|
+
const ctl = new AbortController();
|
|
85
|
+
const t = setTimeout(() => ctl.abort(), Math.min(2500, Math.max(250, intervalMs)));
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(url, { method: 'GET', signal: ctl.signal });
|
|
88
|
+
if (res.status >= 100 && res.status < 600) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
clearTimeout(t);
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore
|
|
96
|
+
}
|
|
97
|
+
// eslint-disable-next-line no-await-in-loop
|
|
98
|
+
await delay(intervalMs);
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Timed out waiting for HTTP response from ${url} after ${timeoutMs}ms`);
|
|
101
|
+
}
|
|
102
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
|
|
2
|
+
const raw =
|
|
3
|
+
(env.HAPPY_STACKS_SERVER_PORT ?? '').toString().trim() ||
|
|
4
|
+
(env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim() ||
|
|
5
|
+
'';
|
|
6
|
+
const n = raw ? Number(raw) : Number(defaultPort);
|
|
7
|
+
return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
|
|
8
|
+
}
|
|
9
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { getStackName, resolveStackEnvPath } from './paths.mjs';
|
|
4
|
+
import { resolvePublicServerUrl } from '../tailscale.mjs';
|
|
5
|
+
import { resolveServerPortFromEnv } from './server_port.mjs';
|
|
6
|
+
|
|
7
|
+
function stackEnvExplicitlySetsPublicUrl({ env, stackName }) {
|
|
8
|
+
try {
|
|
9
|
+
const envPath =
|
|
10
|
+
(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
|
|
11
|
+
resolveStackEnvPath(stackName).envPath;
|
|
12
|
+
if (!envPath || !existsSync(envPath)) return false;
|
|
13
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
14
|
+
return /^(HAPPY_STACKS_SERVER_URL|HAPPY_LOCAL_SERVER_URL)=/m.test(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getPublicServerUrlEnvOverride({ env = process.env, serverPort } = {}) {
|
|
21
|
+
const defaultPublicUrl = `http://localhost:${serverPort}`;
|
|
22
|
+
const stackName = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() || getStackName();
|
|
23
|
+
|
|
24
|
+
let envPublicUrl =
|
|
25
|
+
(env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
|
|
26
|
+
|
|
27
|
+
// Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
|
|
28
|
+
if (stackName !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName })) {
|
|
29
|
+
envPublicUrl = '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { defaultPublicUrl, envPublicUrl, publicServerUrl: envPublicUrl || defaultPublicUrl };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
|
|
36
|
+
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
37
|
+
const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
|
|
38
|
+
const resolved = await resolvePublicServerUrl({
|
|
39
|
+
internalServerUrl,
|
|
40
|
+
defaultPublicUrl,
|
|
41
|
+
envPublicUrl,
|
|
42
|
+
allowEnable,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
internalServerUrl,
|
|
46
|
+
defaultPublicUrl,
|
|
47
|
+
envPublicUrl,
|
|
48
|
+
publicServerUrl: resolved.publicServerUrl,
|
|
49
|
+
publicServerUrlSource: resolved.source,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { resolveServerPortFromEnv };
|
|
54
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getStackName, resolveStackEnvPath } from './paths.mjs';
|
|
2
|
+
import { getStackRuntimeStatePath } from './stack_runtime_state.mjs';
|
|
3
|
+
|
|
4
|
+
export function resolveStackContext({ env = process.env, autostart = null } = {}) {
|
|
5
|
+
const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
|
|
6
|
+
const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName();
|
|
7
|
+
const stackMode = Boolean(explicitStack);
|
|
8
|
+
|
|
9
|
+
const envPath =
|
|
10
|
+
(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
|
|
11
|
+
resolveStackEnvPath(stackName).envPath;
|
|
12
|
+
|
|
13
|
+
const runtimeStatePath =
|
|
14
|
+
(env.HAPPY_STACKS_RUNTIME_STATE_PATH ?? env.HAPPY_LOCAL_RUNTIME_STATE_PATH ?? '').toString().trim() ||
|
|
15
|
+
getStackRuntimeStatePath(stackName);
|
|
16
|
+
|
|
17
|
+
const explicitEphemeral =
|
|
18
|
+
(env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
|
|
19
|
+
const ephemeral = explicitEphemeral || (stackMode && stackName !== 'main');
|
|
20
|
+
|
|
21
|
+
return { stackMode, stackName, envPath, runtimeStatePath, ephemeral };
|
|
22
|
+
}
|
|
23
|
+
|