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,112 @@
|
|
|
1
|
+
import { ensureDepsInstalled, pmSpawnBin } from '../proc/pm.mjs';
|
|
2
|
+
import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from '../expo/expo.mjs';
|
|
3
|
+
import { pickDevMetroPort, resolveStackUiDevPortStart } from './server.mjs';
|
|
4
|
+
import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
|
|
5
|
+
import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
|
|
6
|
+
|
|
7
|
+
export async function startDevExpoWebUi({
|
|
8
|
+
startUi,
|
|
9
|
+
uiDir,
|
|
10
|
+
autostart,
|
|
11
|
+
baseEnv,
|
|
12
|
+
apiServerUrl,
|
|
13
|
+
restart,
|
|
14
|
+
stackMode,
|
|
15
|
+
runtimeStatePath,
|
|
16
|
+
stackName,
|
|
17
|
+
envPath,
|
|
18
|
+
children,
|
|
19
|
+
spawnOptions = {},
|
|
20
|
+
}) {
|
|
21
|
+
if (!startUi) return { ok: true, skipped: true, reason: 'disabled' };
|
|
22
|
+
|
|
23
|
+
await ensureDepsInstalled(uiDir, 'happy');
|
|
24
|
+
const uiEnv = { ...baseEnv };
|
|
25
|
+
delete uiEnv.CI;
|
|
26
|
+
uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = apiServerUrl;
|
|
27
|
+
uiEnv.EXPO_PUBLIC_DEBUG = uiEnv.EXPO_PUBLIC_DEBUG ?? '1';
|
|
28
|
+
|
|
29
|
+
// We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
|
|
30
|
+
uiEnv.EXPO_NO_BROWSER = '1';
|
|
31
|
+
uiEnv.BROWSER = 'none';
|
|
32
|
+
|
|
33
|
+
const uiPaths = getExpoStatePaths({
|
|
34
|
+
baseDir: autostart.baseDir,
|
|
35
|
+
kind: 'ui-dev',
|
|
36
|
+
projectDir: uiDir,
|
|
37
|
+
stateFileName: 'ui.state.json',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await ensureExpoIsolationEnv({
|
|
41
|
+
env: uiEnv,
|
|
42
|
+
stateDir: uiPaths.stateDir,
|
|
43
|
+
expoHomeDir: uiPaths.expoHomeDir,
|
|
44
|
+
tmpDir: uiPaths.tmpDir,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const uiRunning = await isStateProcessRunning(uiPaths.statePath);
|
|
48
|
+
const uiAlreadyRunning = Boolean(uiRunning.running);
|
|
49
|
+
|
|
50
|
+
if (uiAlreadyRunning && !restart) {
|
|
51
|
+
const pid = Number(uiRunning.state?.pid);
|
|
52
|
+
const port = Number(uiRunning.state?.port);
|
|
53
|
+
if (stackMode && runtimeStatePath && Number.isFinite(pid) && pid > 1) {
|
|
54
|
+
await recordStackRuntimeUpdate(runtimeStatePath, {
|
|
55
|
+
processes: { expoWebPid: pid },
|
|
56
|
+
expo: { webPort: Number.isFinite(port) && port > 0 ? port : null },
|
|
57
|
+
}).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
skipped: true,
|
|
62
|
+
reason: 'already_running',
|
|
63
|
+
pid: Number.isFinite(pid) ? pid : null,
|
|
64
|
+
port: Number.isFinite(port) ? port : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const strategy =
|
|
69
|
+
(baseEnv.HAPPY_STACKS_UI_DEV_PORT_STRATEGY ?? baseEnv.HAPPY_LOCAL_UI_DEV_PORT_STRATEGY ?? 'ephemeral').toString().trim() ||
|
|
70
|
+
'ephemeral';
|
|
71
|
+
const stable = strategy === 'stable';
|
|
72
|
+
const startPort = stackMode && stable ? resolveStackUiDevPortStart({ env: baseEnv, stackName }) : 8081;
|
|
73
|
+
const metroPort = await pickDevMetroPort({ startPort });
|
|
74
|
+
uiEnv.RCT_METRO_PORT = String(metroPort);
|
|
75
|
+
|
|
76
|
+
const uiArgs = ['start', '--web', '--port', String(metroPort)];
|
|
77
|
+
if (wantsExpoClearCache({ env: baseEnv })) {
|
|
78
|
+
uiArgs.push('--clear');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (restart && uiRunning.state?.pid) {
|
|
82
|
+
const prevPid = Number(uiRunning.state.pid);
|
|
83
|
+
const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-web', json: true });
|
|
84
|
+
if (!res.killed) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.warn(
|
|
87
|
+
`[local] ui: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
|
|
88
|
+
`[local] ui: continuing by starting a new Expo process on a free port.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.log(`[local] ui: starting Expo web (metro port=${metroPort})`);
|
|
95
|
+
const ui = await pmSpawnBin({ label: 'ui', dir: uiDir, bin: 'expo', args: uiArgs, env: uiEnv, options: spawnOptions });
|
|
96
|
+
children.push(ui);
|
|
97
|
+
|
|
98
|
+
if (stackMode && runtimeStatePath) {
|
|
99
|
+
await recordStackRuntimeUpdate(runtimeStatePath, {
|
|
100
|
+
processes: { expoWebPid: ui.pid },
|
|
101
|
+
expo: { webPort: metroPort },
|
|
102
|
+
}).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await writePidState(uiPaths.statePath, { pid: ui.pid, port: metroPort, uiDir, startedAt: new Date().toISOString() });
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { ok: true, skipped: false, pid: ui.pid, port: metroPort, proc: ui };
|
|
112
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { ensureDepsInstalled, pmSpawnScript } from '../proc/pm.mjs';
|
|
4
|
+
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
|
|
5
|
+
import { waitForServerReady } from '../server/server.mjs';
|
|
6
|
+
import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
|
|
7
|
+
import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
|
|
8
|
+
import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
|
|
9
|
+
import { watchDebounced } from '../proc/watch.mjs';
|
|
10
|
+
|
|
11
|
+
function hashStringToInt(s) {
|
|
12
|
+
let h = 0;
|
|
13
|
+
const str = String(s ?? '');
|
|
14
|
+
for (let i = 0; i < str.length; i++) {
|
|
15
|
+
h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
|
16
|
+
}
|
|
17
|
+
return h >>> 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveStackUiDevPortStart({ env = process.env, stackName }) {
|
|
21
|
+
const baseRaw = (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString().trim();
|
|
22
|
+
const rangeRaw = (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString().trim();
|
|
23
|
+
const base = Number(baseRaw);
|
|
24
|
+
const range = Number(rangeRaw);
|
|
25
|
+
const b = Number.isFinite(base) ? base : 8081;
|
|
26
|
+
const r = Number.isFinite(range) && range > 0 ? range : 1000;
|
|
27
|
+
return b + (hashStringToInt(stackName) % r);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function pickDevMetroPort({ startPort, reservedPorts = new Set(), host = '127.0.0.1' } = {}) {
|
|
31
|
+
const forcedRaw = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
|
|
32
|
+
if (forcedRaw) {
|
|
33
|
+
const forced = Number(forcedRaw);
|
|
34
|
+
if (Number.isFinite(forced) && forced > 0) {
|
|
35
|
+
const ok = await isTcpPortFree(forced, { host });
|
|
36
|
+
if (ok) return forced;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return await pickNextFreeTcpPort(startPort, { reservedPorts, host });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startDevServer({
|
|
43
|
+
serverComponentName,
|
|
44
|
+
serverDir,
|
|
45
|
+
autostart,
|
|
46
|
+
baseEnv,
|
|
47
|
+
serverPort,
|
|
48
|
+
internalServerUrl,
|
|
49
|
+
publicServerUrl,
|
|
50
|
+
envPath,
|
|
51
|
+
stackMode,
|
|
52
|
+
runtimeStatePath,
|
|
53
|
+
serverAlreadyRunning,
|
|
54
|
+
restart,
|
|
55
|
+
children,
|
|
56
|
+
}) {
|
|
57
|
+
const serverEnv = {
|
|
58
|
+
...baseEnv,
|
|
59
|
+
PORT: String(serverPort),
|
|
60
|
+
PUBLIC_URL: publicServerUrl,
|
|
61
|
+
// Avoid noisy failures if a previous run left the metrics port busy.
|
|
62
|
+
METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (serverComponentName === 'happy-server-light') {
|
|
66
|
+
const dataDir = baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR?.trim()
|
|
67
|
+
? baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR.trim()
|
|
68
|
+
: join(autostart.baseDir, 'server-light');
|
|
69
|
+
serverEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
|
|
70
|
+
serverEnv.HAPPY_SERVER_LIGHT_FILES_DIR = baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR?.trim()
|
|
71
|
+
? baseEnv.HAPPY_SERVER_LIGHT_FILES_DIR.trim()
|
|
72
|
+
: join(dataDir, 'files');
|
|
73
|
+
serverEnv.DATABASE_URL = baseEnv.DATABASE_URL?.trim() ? baseEnv.DATABASE_URL.trim() : `file:${join(dataDir, 'happy-server-light.sqlite')}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (serverComponentName === 'happy-server') {
|
|
77
|
+
const managed = (baseEnv.HAPPY_STACKS_MANAGED_INFRA ?? baseEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1') !== '0';
|
|
78
|
+
if (managed) {
|
|
79
|
+
const infra = await ensureHappyServerManagedInfra({
|
|
80
|
+
stackName: autostart.stackName,
|
|
81
|
+
baseDir: autostart.baseDir,
|
|
82
|
+
serverPort,
|
|
83
|
+
publicServerUrl,
|
|
84
|
+
envPath,
|
|
85
|
+
env: baseEnv,
|
|
86
|
+
});
|
|
87
|
+
Object.assign(serverEnv, infra.env);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const autoMigrate = (baseEnv.HAPPY_STACKS_PRISMA_MIGRATE ?? baseEnv.HAPPY_LOCAL_PRISMA_MIGRATE ?? '1') !== '0';
|
|
91
|
+
if (autoMigrate) {
|
|
92
|
+
await applyHappyServerMigrations({ serverDir, env: serverEnv });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Ensure server deps exist before any Prisma/docker work.
|
|
97
|
+
await ensureDepsInstalled(serverDir, serverComponentName);
|
|
98
|
+
|
|
99
|
+
const prismaPush = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '1').toString().trim() !== '0';
|
|
100
|
+
const serverScript =
|
|
101
|
+
serverComponentName === 'happy-server'
|
|
102
|
+
? 'start'
|
|
103
|
+
: serverComponentName === 'happy-server-light' && !prismaPush
|
|
104
|
+
? 'start'
|
|
105
|
+
: 'dev';
|
|
106
|
+
|
|
107
|
+
// Restart behavior (stack-safe): only kill when we can prove ownership via runtime state.
|
|
108
|
+
if (restart && stackMode && runtimeStatePath && serverAlreadyRunning) {
|
|
109
|
+
const st = await readStackRuntimeStateFile(runtimeStatePath);
|
|
110
|
+
const pid = Number(st?.processes?.serverPid);
|
|
111
|
+
if (pid > 1) {
|
|
112
|
+
const res = await killProcessGroupOwnedByStack(pid, { stackName: autostart.stackName, envPath, label: 'server', json: true });
|
|
113
|
+
if (!res.killed) {
|
|
114
|
+
// Fail-closed if the port is still occupied.
|
|
115
|
+
const free = await isTcpPortFree(serverPort, { host: '127.0.0.1' });
|
|
116
|
+
if (!free) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[local] restart refused: server port ${serverPort} is occupied and the PID is not provably stack-owned.\n` +
|
|
119
|
+
`[local] Fix: run 'happys stack stop ${autostart.stackName}' then re-run, or re-run without --restart.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (serverAlreadyRunning && !restart) {
|
|
127
|
+
return { serverEnv, serverScript, serverProc: null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
|
|
131
|
+
children.push(server);
|
|
132
|
+
if (stackMode && runtimeStatePath) {
|
|
133
|
+
await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: server.pid } }).catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
await waitForServerReady(internalServerUrl);
|
|
136
|
+
return { serverEnv, serverScript, serverProc: server };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function watchDevServerAndRestart({
|
|
140
|
+
enabled,
|
|
141
|
+
stackMode,
|
|
142
|
+
serverComponentName,
|
|
143
|
+
serverDir,
|
|
144
|
+
serverPort,
|
|
145
|
+
internalServerUrl,
|
|
146
|
+
serverScript,
|
|
147
|
+
serverEnv,
|
|
148
|
+
runtimeStatePath,
|
|
149
|
+
stackName,
|
|
150
|
+
envPath,
|
|
151
|
+
children,
|
|
152
|
+
serverProcRef,
|
|
153
|
+
isShuttingDown,
|
|
154
|
+
}) {
|
|
155
|
+
if (!enabled) return null;
|
|
156
|
+
|
|
157
|
+
// Only watch full server by default; happy-server-light already has a good upstream dev loop.
|
|
158
|
+
if (serverComponentName !== 'happy-server') return null;
|
|
159
|
+
|
|
160
|
+
return watchDebounced({
|
|
161
|
+
paths: [resolve(serverDir)],
|
|
162
|
+
debounceMs: 600,
|
|
163
|
+
onChange: async () => {
|
|
164
|
+
if (isShuttingDown?.()) return;
|
|
165
|
+
const pid = Number(serverProcRef?.current?.pid);
|
|
166
|
+
if (!Number.isFinite(pid) || pid <= 1) return;
|
|
167
|
+
|
|
168
|
+
// eslint-disable-next-line no-console
|
|
169
|
+
console.log('[local] watch: server changed → restarting...');
|
|
170
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: 'server', json: false });
|
|
171
|
+
|
|
172
|
+
const next = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
|
|
173
|
+
children.push(next);
|
|
174
|
+
serverProcRef.current = next;
|
|
175
|
+
if (stackMode && runtimeStatePath) {
|
|
176
|
+
await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: next.pid } }).catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
await waitForServerReady(internalServerUrl);
|
|
179
|
+
// eslint-disable-next-line no-console
|
|
180
|
+
console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
2
|
import { ensureEnvFileUpdated } from './env_file.mjs';
|
|
4
|
-
import { getHappyStacksHomeDir, resolveStackEnvPath } from '
|
|
3
|
+
import { getHappyStacksHomeDir, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
4
|
+
import { getCanonicalHomeDirFromEnv } from '../paths/canonical_home.mjs';
|
|
5
5
|
|
|
6
6
|
export function getHomeEnvPath() {
|
|
7
7
|
return join(getHappyStacksHomeDir(), '.env');
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export function getCanonicalHomeDir() {
|
|
11
|
+
return getCanonicalHomeDirFromEnv(process.env);
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
export function getCanonicalHomeEnvPath() {
|
|
11
|
-
return join(
|
|
15
|
+
return join(getCanonicalHomeDir(), '.env');
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export function getHomeEnvLocalPath() {
|
|
@@ -46,3 +50,4 @@ export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
|
|
|
46
50
|
await ensureEnvFileUpdated({ envPath, updates });
|
|
47
51
|
return envPath;
|
|
48
52
|
}
|
|
53
|
+
|
|
@@ -4,13 +4,32 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parseDotenv } from './dotenv.mjs';
|
|
7
|
+
import { expandHome, getCanonicalHomeEnvPathFromEnv } from '../paths/canonical_home.mjs';
|
|
8
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
|
|
7
9
|
|
|
8
10
|
async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
|
|
9
11
|
try {
|
|
10
12
|
const contents = await readFile(path, 'utf-8');
|
|
11
13
|
const parsed = parseDotenv(contents);
|
|
14
|
+
const allowTransientComponentDirOverrides =
|
|
15
|
+
!overridePrefix &&
|
|
16
|
+
override &&
|
|
17
|
+
((process.env.HAPPY_STACKS_TRANSIENT_COMPONENT_OVERRIDES ?? '').trim() === '1' ||
|
|
18
|
+
(process.env.HAPPY_LOCAL_TRANSIENT_COMPONENT_OVERRIDES ?? '').trim() === '1');
|
|
12
19
|
for (const [k, v] of parsed.entries()) {
|
|
13
20
|
const allowOverride = override && (!overridePrefix || k.startsWith(overridePrefix));
|
|
21
|
+
// Special-case: allow one-shot CLI overrides (e.g. `happys stack typecheck <stack> --happy-cli=...`)
|
|
22
|
+
// to win over stack env files for component directories.
|
|
23
|
+
//
|
|
24
|
+
// This keeps stack env files authoritative by default (we also scrub HAPPY_STACKS_* from the parent
|
|
25
|
+
// environment in `withStackEnv()`), but lets the stack wrappers inject a temporary override when explicitly requested.
|
|
26
|
+
if (
|
|
27
|
+
allowTransientComponentDirOverrides &&
|
|
28
|
+
(k.startsWith('HAPPY_STACKS_COMPONENT_DIR_') || k.startsWith('HAPPY_LOCAL_COMPONENT_DIR_')) &&
|
|
29
|
+
(process.env[k] ?? '').trim()
|
|
30
|
+
) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
14
33
|
if (allowOverride || process.env[k] == null || process.env[k] === '') {
|
|
15
34
|
process.env[k] = v;
|
|
16
35
|
}
|
|
@@ -20,16 +39,30 @@ async function loadEnvFile(path, { override = false, overridePrefix = null } = {
|
|
|
20
39
|
}
|
|
21
40
|
}
|
|
22
41
|
|
|
42
|
+
async function loadEnvFileIgnoringPrefixes(path, { ignorePrefixes = [] } = {}) {
|
|
43
|
+
try {
|
|
44
|
+
const contents = await readFile(path, 'utf-8');
|
|
45
|
+
const parsed = parseDotenv(contents);
|
|
46
|
+
for (const [k, v] of parsed.entries()) {
|
|
47
|
+
if (ignorePrefixes.some((p) => k.startsWith(p))) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (process.env[k] == null || process.env[k] === '') {
|
|
51
|
+
process.env[k] = v;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore missing/invalid env file
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
23
59
|
// Load happy-stacks env (optional). This is intentionally lightweight and does not require extra deps.
|
|
24
|
-
// This file lives under scripts/utils
|
|
25
|
-
const
|
|
60
|
+
// This file lives under scripts/utils/env, so repo root is three directories up.
|
|
61
|
+
const __envDir = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const __utilsDir = dirname(__envDir);
|
|
26
63
|
const __scriptsDir = dirname(__utilsDir);
|
|
27
64
|
const __cliRootDir = dirname(__scriptsDir);
|
|
28
65
|
|
|
29
|
-
function expandHome(p) {
|
|
30
|
-
return p.replace(/^~(?=\/)/, homedir());
|
|
31
|
-
}
|
|
32
|
-
|
|
33
66
|
function resolveHomeDir() {
|
|
34
67
|
const fromEnv = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim();
|
|
35
68
|
if (fromEnv) {
|
|
@@ -65,11 +98,11 @@ function applyStacksPrefixMapping() {
|
|
|
65
98
|
}
|
|
66
99
|
}
|
|
67
100
|
|
|
68
|
-
// If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at
|
|
101
|
+
// If HAPPY_STACKS_HOME_DIR isn't set, try the canonical pointer file at <canonicalHomeDir>/.env first.
|
|
69
102
|
//
|
|
70
103
|
// This allows installs where the "real" home/workspace/runtime are elsewhere, while still
|
|
71
104
|
// giving us a stable discovery location for launchd/SwiftBar/minimal shells.
|
|
72
|
-
const canonicalEnvPath =
|
|
105
|
+
const canonicalEnvPath = getCanonicalHomeEnvPathFromEnv(process.env);
|
|
73
106
|
if (!(process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() && existsSync(canonicalEnvPath)) {
|
|
74
107
|
await loadEnvFile(canonicalEnvPath, { override: false });
|
|
75
108
|
await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
|
|
@@ -83,12 +116,15 @@ process.env.HAPPY_STACKS_HOME_DIR = process.env.HAPPY_STACKS_HOME_DIR ?? __homeD
|
|
|
83
116
|
// ~/.happy-stacks/.env
|
|
84
117
|
// ~/.happy-stacks/env.local
|
|
85
118
|
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
119
|
+
// Additionally: when running from a cloned repo, load <repo>/.env as a *fallback* even if home config exists.
|
|
120
|
+
// This helps keep repo-local dev settings (e.g. custom Codex binaries) working without requiring users to
|
|
121
|
+
// duplicate them into ~/.happy-stacks/env.local.
|
|
89
122
|
const homeEnv = join(__homeDir, '.env');
|
|
90
123
|
const homeLocal = join(__homeDir, 'env.local');
|
|
91
|
-
|
|
124
|
+
// In sandbox mode, never load repo env.local (it can contain "real" machine paths/URLs).
|
|
125
|
+
// Treat sandbox runs as having home config even if the sandbox home env files don't exist yet.
|
|
126
|
+
const hasHomeConfig = isSandboxed() || existsSync(homeEnv) || existsSync(homeLocal);
|
|
127
|
+
const repoEnv = join(__cliRootDir, '.env');
|
|
92
128
|
|
|
93
129
|
// 1) Load defaults first (lowest precedence)
|
|
94
130
|
if (hasHomeConfig) {
|
|
@@ -101,6 +137,19 @@ if (hasHomeConfig) {
|
|
|
101
137
|
await loadEnvFile(join(__cliRootDir, 'env.local'), { override: true, overridePrefix: 'HAPPY_STACKS_' });
|
|
102
138
|
}
|
|
103
139
|
|
|
140
|
+
// Repo-local fallback (dev convenience):
|
|
141
|
+
// If the repo has a .env, load it without overriding anything already set by the environment or home config.
|
|
142
|
+
// Note: we intentionally do NOT load repo env.local here, because env.local is treated as higher-precedence
|
|
143
|
+
// overrides and could unexpectedly fight with stack/home configuration when present.
|
|
144
|
+
if (hasHomeConfig) {
|
|
145
|
+
// IMPORTANT:
|
|
146
|
+
// When home config exists, do not let repo-local .env set HAPPY_STACKS_* / HAPPY_LOCAL_* keys.
|
|
147
|
+
// Otherwise a cloned repo's .env can accidentally leak global URLs/ports into every stack.
|
|
148
|
+
await loadEnvFileIgnoringPrefixes(repoEnv, { ignorePrefixes: ['HAPPY_STACKS_', 'HAPPY_LOCAL_'] });
|
|
149
|
+
} else {
|
|
150
|
+
await loadEnvFile(repoEnv, { override: false });
|
|
151
|
+
}
|
|
152
|
+
|
|
104
153
|
// If no explicit env file is set, and we're on the default "main" stack, prefer the stack-scoped env file
|
|
105
154
|
// if it exists: ~/.happy/stacks/main/env
|
|
106
155
|
(() => {
|
|
@@ -112,7 +161,8 @@ if (hasHomeConfig) {
|
|
|
112
161
|
const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
113
162
|
const stacksStorageRootRaw = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
|
|
114
163
|
const stacksStorageRoot = stacksStorageRootRaw ? expandHome(stacksStorageRootRaw) : join(homedir(), '.happy', 'stacks');
|
|
115
|
-
const
|
|
164
|
+
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
165
|
+
const legacyStacksRoot = allowLegacy ? join(homedir(), '.happy', 'local', 'stacks') : join(stacksStorageRoot, '__legacy_disabled__');
|
|
116
166
|
|
|
117
167
|
const candidates = [
|
|
118
168
|
join(stacksStorageRoot, stackName, 'env'),
|
|
@@ -158,3 +208,4 @@ process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_M
|
|
|
158
208
|
const next = [...want.filter((p) => p && !current.includes(p)), ...current];
|
|
159
209
|
process.env.PATH = next.join(delimiter);
|
|
160
210
|
})();
|
|
211
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { pathExists } from '
|
|
3
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
4
4
|
|
|
5
5
|
export async function ensureEnvFileUpdated({ envPath, updates }) {
|
|
6
6
|
if (!updates.length) {
|
|
@@ -10,6 +10,17 @@ export async function ensureEnvFileUpdated({ envPath, updates }) {
|
|
|
10
10
|
await writeFileIfChanged(envPath, applyEnvUpdates(await readText(envPath), updates), envPath);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export async function ensureEnvFilePruned({ envPath, removeKeys }) {
|
|
14
|
+
const keys = Array.from(new Set((removeKeys ?? []).map((k) => String(k).trim()).filter(Boolean)));
|
|
15
|
+
if (!keys.length) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
19
|
+
const existing = await readText(envPath);
|
|
20
|
+
const next = pruneEnvKeys(existing, keys);
|
|
21
|
+
await writeFileIfChanged(existing, next, envPath);
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
async function readText(path) {
|
|
14
25
|
try {
|
|
15
26
|
return (await pathExists(path)) ? await readFile(path, 'utf-8') : '';
|
|
@@ -42,6 +53,31 @@ function applyEnvUpdates(existing, updates) {
|
|
|
42
53
|
return next.join('\n');
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
function pruneEnvKeys(existing, removeKeys) {
|
|
57
|
+
const keys = new Set(removeKeys);
|
|
58
|
+
const lines = (existing ?? '').split('\n');
|
|
59
|
+
const kept = [];
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
63
|
+
kept.push(line);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Remove any "KEY=..." line for keys in removeKeys.
|
|
67
|
+
const eq = trimmed.indexOf('=');
|
|
68
|
+
if (eq <= 0) {
|
|
69
|
+
kept.push(line);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const key = trimmed.slice(0, eq).trim();
|
|
73
|
+
if (keys.has(key)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
kept.push(line);
|
|
77
|
+
}
|
|
78
|
+
return kept.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
45
81
|
async function writeFileIfChanged(existingContent, nextContent, path) {
|
|
46
82
|
const normalizedNext = nextContent.endsWith('\n') ? nextContent : nextContent + '\n';
|
|
47
83
|
const normalizedExisting = existingContent.endsWith('\n') ? existingContent : existingContent + (existingContent ? '\n' : '');
|
|
@@ -57,3 +93,4 @@ async function writeFileIfChanged(existingContent, nextContent, path) {
|
|
|
57
93
|
}
|
|
58
94
|
await writeFile(path, normalizedNext, 'utf-8');
|
|
59
95
|
}
|
|
96
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { parseDotenv } from './dotenv.mjs';
|
|
5
|
+
|
|
6
|
+
export async function readEnvValueFromFile(envPath, key, { defaultValue = '' } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
const p = String(envPath ?? '').trim();
|
|
9
|
+
const k = String(key ?? '').trim();
|
|
10
|
+
if (!p || !k) return defaultValue;
|
|
11
|
+
if (!existsSync(p)) return defaultValue;
|
|
12
|
+
const raw = await readFile(p, 'utf-8');
|
|
13
|
+
const parsed = parseDotenv(raw ?? '');
|
|
14
|
+
return String(parsed.get(k) ?? '').trim();
|
|
15
|
+
} catch {
|
|
16
|
+
return defaultValue;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readEnvObjectFromFile(envPath) {
|
|
21
|
+
try {
|
|
22
|
+
const p = String(envPath ?? '').trim();
|
|
23
|
+
if (!p || !existsSync(p)) return {};
|
|
24
|
+
const raw = await readFile(p, 'utf-8');
|
|
25
|
+
return Object.fromEntries(parseDotenv(raw ?? '').entries());
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function getEnvValue(obj, key) {
|
|
2
|
+
const v = (obj?.[key] ?? '').toString().trim();
|
|
3
|
+
return v || '';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function getEnvValueAny(obj, keys) {
|
|
7
|
+
for (const k of keys) {
|
|
8
|
+
const v = getEnvValue(obj, k);
|
|
9
|
+
if (v) return v;
|
|
10
|
+
}
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
@@ -3,6 +3,9 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { isPidAlive } from '../proc/pids.mjs';
|
|
7
|
+
|
|
8
|
+
export { isPidAlive };
|
|
6
9
|
|
|
7
10
|
function hashDir(dir) {
|
|
8
11
|
return createHash('sha1').update(String(dir ?? '')).digest('hex').slice(0, 12);
|
|
@@ -26,10 +29,12 @@ export async function ensureExpoIsolationEnv({ env, stateDir, expoHomeDir, tmpDi
|
|
|
26
29
|
await mkdir(tmpDir, { recursive: true });
|
|
27
30
|
|
|
28
31
|
// Expo CLI uses this to override ~/.expo.
|
|
29
|
-
|
|
32
|
+
// Always override: stack/worktree isolation must not fall back to the user's global ~/.expo.
|
|
33
|
+
env.__UNSAFE_EXPO_HOME_DIRECTORY = expoHomeDir;
|
|
30
34
|
|
|
31
35
|
// Metro default cache root is `path.join(os.tmpdir(), 'metro-cache')`, so TMPDIR isolates it.
|
|
32
|
-
|
|
36
|
+
// Always override: macOS sets TMPDIR by default, so a "set-if-missing" guard would not isolate Metro.
|
|
37
|
+
env.TMPDIR = tmpDir;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export function wantsExpoClearCache({ env }) {
|
|
@@ -54,15 +59,6 @@ export async function readPidState(statePath) {
|
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
export function isPidAlive(pid) {
|
|
58
|
-
try {
|
|
59
|
-
process.kill(pid, 0);
|
|
60
|
-
return true;
|
|
61
|
-
} catch {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
62
|
export async function isStateProcessRunning(statePath) {
|
|
67
63
|
const state = await readPidState(statePath);
|
|
68
64
|
if (!state) return { running: false, state: null };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
export async function readJsonIfExists(path, { defaultValue = null } = {}) {
|
|
6
|
+
try {
|
|
7
|
+
const p = String(path ?? '').trim();
|
|
8
|
+
if (!p || !existsSync(p)) return defaultValue;
|
|
9
|
+
const raw = await readFile(p, 'utf-8');
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
} catch {
|
|
12
|
+
return defaultValue;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeJsonAtomic(path, value) {
|
|
17
|
+
const p = String(path ?? '').trim();
|
|
18
|
+
if (!p) throw new Error('writeJsonAtomic: path is required');
|
|
19
|
+
const dir = dirname(p);
|
|
20
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
21
|
+
const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
|
|
22
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
|
23
|
+
await rename(tmp, p);
|
|
24
|
+
}
|
|
25
|
+
|