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/config.mjs
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { ensureEnvFileUpdated } from './env_file.mjs';
|
|
3
3
|
import { getHappyStacksHomeDir, resolveStackEnvPath } from './paths.mjs';
|
|
4
|
+
import { getCanonicalHomeDirFromEnv } from './canonical_home.mjs';
|
|
4
5
|
|
|
5
6
|
export function getHomeEnvPath() {
|
|
6
7
|
return join(getHappyStacksHomeDir(), '.env');
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
export function getCanonicalHomeDir() {
|
|
11
|
+
return getCanonicalHomeDirFromEnv(process.env);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getCanonicalHomeEnvPath() {
|
|
15
|
+
return join(getCanonicalHomeDir(), '.env');
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
export function getHomeEnvLocalPath() {
|
|
10
19
|
return join(getHappyStacksHomeDir(), 'env.local');
|
|
11
20
|
}
|
|
@@ -28,6 +37,10 @@ export async function ensureHomeEnvUpdated({ updates }) {
|
|
|
28
37
|
await ensureEnvFileUpdated({ envPath: getHomeEnvPath(), updates });
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
export async function ensureCanonicalHomeEnvUpdated({ updates }) {
|
|
41
|
+
await ensureEnvFileUpdated({ envPath: getCanonicalHomeEnvPath(), updates });
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export async function ensureHomeEnvLocalUpdated({ updates }) {
|
|
32
45
|
await ensureEnvFileUpdated({ envPath: getHomeEnvLocalPath(), updates });
|
|
33
46
|
}
|
|
@@ -37,4 +50,3 @@ export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
|
|
|
37
50
|
await ensureEnvFileUpdated({ envPath, updates });
|
|
38
51
|
return envPath;
|
|
39
52
|
}
|
|
40
|
-
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { expandHome } from './canonical_home.mjs';
|
|
7
|
+
|
|
8
|
+
export function resolveHappyStacksHomeDir(env = process.env) {
|
|
9
|
+
const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').toString().trim();
|
|
10
|
+
return fromEnv ? expandHome(fromEnv) : join(homedir(), '.happy-stacks');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getDevAuthKeyPath(env = process.env) {
|
|
14
|
+
return join(resolveHappyStacksHomeDir(env), 'keys', 'dev-auth.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function base64UrlToBytes(s) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = String(s ?? '').trim();
|
|
20
|
+
if (!raw) return null;
|
|
21
|
+
const b64 = raw.replace(/-/g, '+').replace(/_/g, '/');
|
|
22
|
+
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4));
|
|
23
|
+
return new Uint8Array(Buffer.from(b64 + pad, 'base64'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bytesToBase64Url(bytes) {
|
|
30
|
+
const b64 = Buffer.from(bytes).toString('base64');
|
|
31
|
+
return b64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Base32 alphabet (RFC 4648)
|
|
35
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
36
|
+
|
|
37
|
+
function bytesToBase32(bytes) {
|
|
38
|
+
let result = '';
|
|
39
|
+
let buffer = 0;
|
|
40
|
+
let bufferLength = 0;
|
|
41
|
+
|
|
42
|
+
for (const byte of bytes) {
|
|
43
|
+
buffer = (buffer << 8) | byte;
|
|
44
|
+
bufferLength += 8;
|
|
45
|
+
while (bufferLength >= 5) {
|
|
46
|
+
bufferLength -= 5;
|
|
47
|
+
result += BASE32_ALPHABET[(buffer >> bufferLength) & 0x1f];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (bufferLength > 0) {
|
|
51
|
+
result += BASE32_ALPHABET[(buffer << (5 - bufferLength)) & 0x1f];
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function base32ToBytes(base32) {
|
|
57
|
+
let normalized = String(base32 ?? '')
|
|
58
|
+
.toUpperCase()
|
|
59
|
+
.replace(/0/g, 'O')
|
|
60
|
+
.replace(/1/g, 'I')
|
|
61
|
+
.replace(/8/g, 'B')
|
|
62
|
+
.replace(/9/g, 'G');
|
|
63
|
+
const cleaned = normalized.replace(/[^A-Z2-7]/g, '');
|
|
64
|
+
if (!cleaned) throw new Error('no valid base32 characters');
|
|
65
|
+
|
|
66
|
+
const bytes = [];
|
|
67
|
+
let buffer = 0;
|
|
68
|
+
let bufferLength = 0;
|
|
69
|
+
for (const char of cleaned) {
|
|
70
|
+
const value = BASE32_ALPHABET.indexOf(char);
|
|
71
|
+
if (value === -1) throw new Error('invalid base32 character');
|
|
72
|
+
buffer = (buffer << 5) | value;
|
|
73
|
+
bufferLength += 5;
|
|
74
|
+
if (bufferLength >= 8) {
|
|
75
|
+
bufferLength -= 8;
|
|
76
|
+
bytes.push((buffer >> bufferLength) & 0xff);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return new Uint8Array(bytes);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeDevAuthKeyInputToBytes(input) {
|
|
83
|
+
const raw = String(input ?? '').trim();
|
|
84
|
+
if (!raw) return null;
|
|
85
|
+
|
|
86
|
+
// Match Happy UI behavior:
|
|
87
|
+
// - backup format is base32 and is long (usually grouped with '-' / spaces)
|
|
88
|
+
// - base64url is short (~43 chars) and may contain '-' / '_' legitimately
|
|
89
|
+
//
|
|
90
|
+
// Key point: avoid mis-parsing backup base32 as base64.
|
|
91
|
+
if (raw.length > 50) {
|
|
92
|
+
try {
|
|
93
|
+
const b32 = base32ToBytes(raw);
|
|
94
|
+
return b32.length === 32 ? b32 : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const b64 = base64UrlToBytes(raw);
|
|
101
|
+
if (b64 && b64.length === 32) return b64;
|
|
102
|
+
try {
|
|
103
|
+
const b32 = base32ToBytes(raw);
|
|
104
|
+
return b32.length === 32 ? b32 : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatDevAuthKeyBackup(secretKeyBase64Url) {
|
|
111
|
+
const bytes = base64UrlToBytes(secretKeyBase64Url);
|
|
112
|
+
if (!bytes || bytes.length !== 32) throw new Error('invalid secret key (expected base64url 32 bytes)');
|
|
113
|
+
const base32 = bytesToBase32(bytes);
|
|
114
|
+
const groups = [];
|
|
115
|
+
for (let i = 0; i < base32.length; i += 5) groups.push(base32.slice(i, i + 5));
|
|
116
|
+
return groups.join('-');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readDevAuthKey({ env = process.env } = {}) {
|
|
120
|
+
if ((env.HAPPY_STACKS_DEV_AUTH_SECRET_KEY ?? '').toString().trim()) {
|
|
121
|
+
const bytes = normalizeDevAuthKeyInputToBytes(env.HAPPY_STACKS_DEV_AUTH_SECRET_KEY);
|
|
122
|
+
if (!bytes) return { ok: false, error: 'invalid_env_key', source: 'env', secretKeyBase64Url: null, backup: null };
|
|
123
|
+
const base64url = bytesToBase64Url(bytes);
|
|
124
|
+
return { ok: true, source: 'env:HAPPY_STACKS_DEV_AUTH_SECRET_KEY', secretKeyBase64Url: base64url, backup: formatDevAuthKeyBackup(base64url) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const path = getDevAuthKeyPath(env);
|
|
128
|
+
try {
|
|
129
|
+
if (!existsSync(path)) return { ok: true, source: `file:${path}`, secretKeyBase64Url: null, backup: null, path };
|
|
130
|
+
const raw = await readFile(path, 'utf-8');
|
|
131
|
+
const parsed = JSON.parse(raw);
|
|
132
|
+
const input = parsed?.secretKeyBase64Url ?? parsed?.secretKey ?? parsed?.key ?? null;
|
|
133
|
+
const bytes = normalizeDevAuthKeyInputToBytes(input);
|
|
134
|
+
if (!bytes) return { ok: false, error: 'invalid_file_key', source: `file:${path}`, secretKeyBase64Url: null, backup: null, path };
|
|
135
|
+
const base64url = bytesToBase64Url(bytes);
|
|
136
|
+
return { ok: true, source: `file:${path}`, secretKeyBase64Url: base64url, backup: formatDevAuthKeyBackup(base64url), path };
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { ok: false, error: 'failed_to_read', source: `file:${path}`, secretKeyBase64Url: null, backup: null, path, details: e instanceof Error ? e.message : String(e) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function writeDevAuthKey({ env = process.env, input } = {}) {
|
|
143
|
+
const bytes = normalizeDevAuthKeyInputToBytes(input);
|
|
144
|
+
if (!bytes || bytes.length !== 32) {
|
|
145
|
+
throw new Error('invalid secret key (expected 32 bytes; accept base64url or backup format)');
|
|
146
|
+
}
|
|
147
|
+
const secretKeyBase64Url = bytesToBase64Url(bytes);
|
|
148
|
+
const path = getDevAuthKeyPath(env);
|
|
149
|
+
await mkdir(dirname(path), { recursive: true });
|
|
150
|
+
const payload = {
|
|
151
|
+
v: 1,
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
secretKeyBase64Url,
|
|
154
|
+
};
|
|
155
|
+
await writeFile(path, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
156
|
+
await chmod(path, 0o600).catch(() => {});
|
|
157
|
+
return { ok: true, path, secretKeyBase64Url, backup: formatDevAuthKeyBackup(secretKeyBase64Url) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function clearDevAuthKey({ env = process.env } = {}) {
|
|
161
|
+
const path = getDevAuthKeyPath(env);
|
|
162
|
+
try {
|
|
163
|
+
if (!existsSync(path)) return { ok: true, deleted: false, path };
|
|
164
|
+
await unlink(path);
|
|
165
|
+
return { ok: true, deleted: true, path };
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return { ok: false, deleted: false, path, error: e instanceof Error ? e.message : String(e) };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { ensureCliBuilt, ensureDepsInstalled } from './pm.mjs';
|
|
4
|
+
import { watchDebounced } from './watch.mjs';
|
|
5
|
+
import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './stack_startup.mjs';
|
|
6
|
+
import { startLocalDaemonWithAuth } from '../daemon.mjs';
|
|
7
|
+
|
|
8
|
+
export async function ensureDevCliReady({ cliDir, buildCli }) {
|
|
9
|
+
await ensureDepsInstalled(cliDir, 'happy-cli');
|
|
10
|
+
return await ensureCliBuilt(cliDir, { buildCli });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function prepareDaemonAuthSeed({
|
|
14
|
+
rootDir,
|
|
15
|
+
env,
|
|
16
|
+
stackName,
|
|
17
|
+
cliHomeDir,
|
|
18
|
+
startDaemon,
|
|
19
|
+
isInteractive,
|
|
20
|
+
serverComponentName,
|
|
21
|
+
serverDir,
|
|
22
|
+
serverEnv,
|
|
23
|
+
quiet = false,
|
|
24
|
+
}) {
|
|
25
|
+
if (!startDaemon) return { ok: true, skipped: true, reason: 'no_daemon' };
|
|
26
|
+
const acct = await getAccountCountForServerComponent({
|
|
27
|
+
serverComponentName,
|
|
28
|
+
serverDir,
|
|
29
|
+
env: serverEnv,
|
|
30
|
+
bestEffort: serverComponentName === 'happy-server',
|
|
31
|
+
});
|
|
32
|
+
return await prepareDaemonAuthSeedIfNeeded({
|
|
33
|
+
rootDir,
|
|
34
|
+
env,
|
|
35
|
+
stackName,
|
|
36
|
+
cliHomeDir,
|
|
37
|
+
startDaemon,
|
|
38
|
+
isInteractive,
|
|
39
|
+
accountCount: typeof acct.accountCount === 'number' ? acct.accountCount : null,
|
|
40
|
+
quiet,
|
|
41
|
+
// IMPORTANT: run auth seeding under the same env used for server probes (includes DATABASE_URL).
|
|
42
|
+
authEnv: serverEnv,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function startDevDaemon({
|
|
47
|
+
startDaemon,
|
|
48
|
+
cliBin,
|
|
49
|
+
cliHomeDir,
|
|
50
|
+
internalServerUrl,
|
|
51
|
+
publicServerUrl,
|
|
52
|
+
restart,
|
|
53
|
+
isShuttingDown,
|
|
54
|
+
}) {
|
|
55
|
+
if (!startDaemon) return;
|
|
56
|
+
await startLocalDaemonWithAuth({
|
|
57
|
+
cliBin,
|
|
58
|
+
cliHomeDir,
|
|
59
|
+
internalServerUrl,
|
|
60
|
+
publicServerUrl,
|
|
61
|
+
isShuttingDown,
|
|
62
|
+
forceRestart: Boolean(restart),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function watchHappyCliAndRestartDaemon({
|
|
67
|
+
enabled,
|
|
68
|
+
startDaemon,
|
|
69
|
+
buildCli,
|
|
70
|
+
cliDir,
|
|
71
|
+
cliBin,
|
|
72
|
+
cliHomeDir,
|
|
73
|
+
internalServerUrl,
|
|
74
|
+
publicServerUrl,
|
|
75
|
+
isShuttingDown,
|
|
76
|
+
}) {
|
|
77
|
+
if (!enabled || !startDaemon) return null;
|
|
78
|
+
|
|
79
|
+
let inFlight = false;
|
|
80
|
+
return watchDebounced({
|
|
81
|
+
paths: [resolve(cliDir)],
|
|
82
|
+
debounceMs: 500,
|
|
83
|
+
onChange: async () => {
|
|
84
|
+
if (isShuttingDown?.()) return;
|
|
85
|
+
if (inFlight) return;
|
|
86
|
+
inFlight = true;
|
|
87
|
+
try {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
|
|
90
|
+
await ensureCliBuilt(cliDir, { buildCli });
|
|
91
|
+
await startLocalDaemonWithAuth({
|
|
92
|
+
cliBin,
|
|
93
|
+
cliHomeDir,
|
|
94
|
+
internalServerUrl,
|
|
95
|
+
publicServerUrl,
|
|
96
|
+
isShuttingDown,
|
|
97
|
+
forceRestart: true,
|
|
98
|
+
});
|
|
99
|
+
} finally {
|
|
100
|
+
inFlight = false;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ensureDepsInstalled, pmSpawnBin } from './pm.mjs';
|
|
2
|
+
import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from './expo.mjs';
|
|
3
|
+
import { pickDevMetroPort, resolveStackUiDevPortStart } from './dev_server.mjs';
|
|
4
|
+
import { recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
|
|
5
|
+
import { killProcessGroupOwnedByStack } from './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 './pm.mjs';
|
|
4
|
+
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './happy_server_infra.mjs';
|
|
5
|
+
import { waitForServerReady } from './server.mjs';
|
|
6
|
+
import { isTcpPortFree, pickNextFreeTcpPort } from './ports.mjs';
|
|
7
|
+
import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
|
|
8
|
+
import { killProcessGroupOwnedByStack } from './ownership.mjs';
|
|
9
|
+
import { watchDebounced } from './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
|
+
}
|