happy-stacks 0.4.0 → 0.5.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 +64 -33
- package/bin/happys.mjs +44 -1
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +1 -2
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +1 -1
- package/scripts/auth.mjs +21 -3
- package/scripts/build.mjs +1 -1
- package/scripts/dev.mjs +20 -7
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +2 -2
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +5 -2
- package/scripts/install.mjs +99 -57
- package/scripts/migrate.mjs +3 -12
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/review.mjs +715 -24
- package/scripts/review_pr.mjs +5 -20
- package/scripts/run.mjs +21 -15
- package/scripts/setup.mjs +147 -25
- package/scripts/setup_pr.mjs +19 -28
- package/scripts/stack.mjs +493 -157
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tui.mjs +88 -2
- package/scripts/utils/cli/cli_registry.mjs +20 -5
- package/scripts/utils/cli/cwd_scope.mjs +56 -2
- package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
- package/scripts/utils/cli/prereqs.mjs +8 -5
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +14 -1
- package/scripts/utils/dev/expo_dev.mjs +188 -4
- package/scripts/utils/dev/server.mjs +21 -17
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/git/worktrees.mjs +63 -12
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/paths/paths.mjs +118 -3
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/pm.mjs +113 -16
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +68 -10
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +56 -14
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +32 -22
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/stack/editor_workspace.mjs +4 -4
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/startup.mjs +113 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +627 -137
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { promptWorktreeSource } from './wizard.mjs';
|
|
5
|
+
|
|
6
|
+
test('promptWorktreeSource does not list worktrees unless user selects "pick"', async () => {
|
|
7
|
+
let listed = 0;
|
|
8
|
+
const listWorktreeSpecs = async () => {
|
|
9
|
+
listed++;
|
|
10
|
+
return ['slopus/pr/123'];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const promptSelect = async () => 'default';
|
|
14
|
+
const prompt = async () => '';
|
|
15
|
+
|
|
16
|
+
const res = await promptWorktreeSource({
|
|
17
|
+
rl: {},
|
|
18
|
+
rootDir: '/tmp',
|
|
19
|
+
component: 'happy',
|
|
20
|
+
stackName: 'exp1',
|
|
21
|
+
createRemote: 'upstream',
|
|
22
|
+
deps: { listWorktreeSpecs, promptSelect, prompt },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
assert.equal(res, 'default');
|
|
26
|
+
assert.equal(listed, 0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('promptWorktreeSource lists worktrees when user selects "pick"', async () => {
|
|
30
|
+
let listed = 0;
|
|
31
|
+
const listWorktreeSpecs = async () => {
|
|
32
|
+
listed++;
|
|
33
|
+
return ['slopus/pr/123', 'slopus/pr/456'];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let selectCount = 0;
|
|
37
|
+
const promptSelect = async (_rl, { title }) => {
|
|
38
|
+
selectCount++;
|
|
39
|
+
if (selectCount === 1) {
|
|
40
|
+
assert.ok(title.startsWith('Select '));
|
|
41
|
+
return 'pick';
|
|
42
|
+
}
|
|
43
|
+
assert.ok(title.startsWith('Available '));
|
|
44
|
+
return 'slopus/pr/456';
|
|
45
|
+
};
|
|
46
|
+
const prompt = async () => '';
|
|
47
|
+
|
|
48
|
+
const res = await promptWorktreeSource({
|
|
49
|
+
rl: {},
|
|
50
|
+
rootDir: '/tmp',
|
|
51
|
+
component: 'happy',
|
|
52
|
+
stackName: 'exp1',
|
|
53
|
+
createRemote: 'upstream',
|
|
54
|
+
deps: { listWorktreeSpecs, promptSelect, prompt },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.equal(res, 'slopus/pr/456');
|
|
58
|
+
assert.equal(listed, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
@@ -124,7 +124,20 @@ export function watchHappyCliAndRestartDaemon({
|
|
|
124
124
|
try {
|
|
125
125
|
// eslint-disable-next-line no-console
|
|
126
126
|
console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
|
|
127
|
-
|
|
127
|
+
try {
|
|
128
|
+
await ensureCliBuilt(cliDir, { buildCli });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// IMPORTANT:
|
|
131
|
+
// - A rebuild can legitimately fail while an agent is mid-edit (e.g. TS errors).
|
|
132
|
+
// - In that case we must NOT restart the daemon (we'd just restart into a broken build),
|
|
133
|
+
// and we must NOT crash the parent dev process. Keep watching for the next change.
|
|
134
|
+
const msg = e instanceof Error ? e.stack || e.message : String(e);
|
|
135
|
+
// eslint-disable-next-line no-console
|
|
136
|
+
console.error('[local] watch: happy-cli rebuild failed; keeping daemon running (will retry on next change).');
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.error(msg);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
128
141
|
const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
|
|
129
142
|
if (!existsSync(distEntrypoint)) {
|
|
130
143
|
console.warn(
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
1
5
|
import {
|
|
2
6
|
ensureExpoIsolationEnv,
|
|
3
7
|
getExpoStatePaths,
|
|
@@ -11,6 +15,11 @@ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
|
|
|
11
15
|
import { expoSpawn } from '../expo/command.mjs';
|
|
12
16
|
import { resolveMobileExpoConfig } from '../mobile/config.mjs';
|
|
13
17
|
import { resolveMobileReachableServerUrl } from '../server/mobile_api_url.mjs';
|
|
18
|
+
import { getTailscaleStatus } from '../tailscale/ip.mjs';
|
|
19
|
+
import { pickLanIpv4 } from '../net/lan_ip.mjs';
|
|
20
|
+
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
14
23
|
|
|
15
24
|
function normalizeExpoHost(raw) {
|
|
16
25
|
const v = String(raw ?? '').trim().toLowerCase();
|
|
@@ -18,6 +27,140 @@ function normalizeExpoHost(raw) {
|
|
|
18
27
|
return 'lan';
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Resolve whether Tailscale forwarding for Expo is enabled.
|
|
32
|
+
*
|
|
33
|
+
* Can be enabled via:
|
|
34
|
+
* - --expo-tailscale flag (passed as expoTailscale option)
|
|
35
|
+
* - HAPPY_STACKS_EXPO_TAILSCALE=1 env var
|
|
36
|
+
* - HAPPY_LOCAL_EXPO_TAILSCALE=1 env var (legacy)
|
|
37
|
+
*/
|
|
38
|
+
export function resolveExpoTailscaleEnabled({ env = process.env, expoTailscale = false } = {}) {
|
|
39
|
+
if (expoTailscale) return true;
|
|
40
|
+
const envVal = (env.HAPPY_STACKS_EXPO_TAILSCALE ?? env.HAPPY_LOCAL_EXPO_TAILSCALE ?? '').toString().trim();
|
|
41
|
+
return envVal === '1' || envVal.toLowerCase() === 'true';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start a TCP forwarder process for Expo Tailscale access.
|
|
46
|
+
*
|
|
47
|
+
* Forwards from Tailscale IP:port to the LAN IP:port where Expo actually binds.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* @param {number} options.metroPort - The Metro bundler port
|
|
51
|
+
* @param {Object} options.baseEnv - Base environment variables
|
|
52
|
+
* @param {string} options.stackName - Stack name for logging
|
|
53
|
+
* @param {Array} options.children - Array to track child processes
|
|
54
|
+
* @returns {Promise<{ ok: boolean, pid?: number, tailscaleIp?: string, lanIp?: string, error?: string }>}
|
|
55
|
+
*/
|
|
56
|
+
export async function startExpoTailscaleForwarder({ metroPort, baseEnv, stackName, children }) {
|
|
57
|
+
const ts = await getTailscaleStatus();
|
|
58
|
+
if (!ts.available || !ts.ip) {
|
|
59
|
+
// Common case: Tailscale app installed but toggle is off / not connected.
|
|
60
|
+
// This must never fail stack startup; just skip with a clear message.
|
|
61
|
+
return { ok: false, error: ts.error || 'Tailscale is not connected' };
|
|
62
|
+
}
|
|
63
|
+
const tailscaleIp = ts.ip;
|
|
64
|
+
|
|
65
|
+
// Some platforms / Tailscale variants report an IP but do not allow binding to it (EADDRNOTAVAIL).
|
|
66
|
+
// If we can't bind *at all*, don't spawn the forwarder process (it will just error noisily).
|
|
67
|
+
const canBind = await new Promise((resolve) => {
|
|
68
|
+
const srv = net.createServer();
|
|
69
|
+
const done = (ok, err) => {
|
|
70
|
+
try {
|
|
71
|
+
srv.close(() => resolve({ ok, err }));
|
|
72
|
+
} catch {
|
|
73
|
+
resolve({ ok, err });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
srv.once('error', (err) => done(false, err));
|
|
77
|
+
srv.listen(0, tailscaleIp, () => done(true, null));
|
|
78
|
+
});
|
|
79
|
+
if (!canBind.ok) {
|
|
80
|
+
const code = canBind.err && typeof canBind.err === 'object' ? canBind.err.code : '';
|
|
81
|
+
const msg = canBind.err instanceof Error ? canBind.err.message : String(canBind.err ?? '');
|
|
82
|
+
const hint =
|
|
83
|
+
code === 'EADDRNOTAVAIL'
|
|
84
|
+
? `Tailscale IP ${tailscaleIp} is not bindable on this machine (EADDRNOTAVAIL).`
|
|
85
|
+
: `Tailscale IP ${tailscaleIp} is not bindable (${code || 'error'}).`;
|
|
86
|
+
return { ok: false, error: `${hint}${msg ? ` ${msg}` : ''}`.trim() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Determine where Expo binds (LAN IP when host=lan, localhost otherwise)
|
|
90
|
+
const host = resolveExpoDevHost({ env: baseEnv });
|
|
91
|
+
let targetHost = '127.0.0.1';
|
|
92
|
+
if (host === 'lan') {
|
|
93
|
+
const lanIp = pickLanIpv4();
|
|
94
|
+
if (lanIp) targetHost = lanIp;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const label = `expo-ts-fwd${stackName ? `-${stackName}` : ''}`;
|
|
98
|
+
const forwarderScript = join(__dirname, '..', 'net', 'tcp_forward.mjs');
|
|
99
|
+
|
|
100
|
+
// Fork the forwarder as a child process
|
|
101
|
+
// Note: fork() requires 'ipc' in stdio array
|
|
102
|
+
const forwarderProc = fork(forwarderScript, [
|
|
103
|
+
`--listen-host=${tailscaleIp}`,
|
|
104
|
+
`--listen-port=${metroPort}`,
|
|
105
|
+
`--target-host=${targetHost}`,
|
|
106
|
+
`--target-port=${metroPort}`,
|
|
107
|
+
`--label=${label}`,
|
|
108
|
+
], {
|
|
109
|
+
env: { ...baseEnv },
|
|
110
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
111
|
+
detached: process.platform !== 'win32',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Prefix forwarder output
|
|
115
|
+
const outPrefix = `[${label}] `;
|
|
116
|
+
forwarderProc.stdout?.on('data', (d) => process.stdout.write(outPrefix + d.toString()));
|
|
117
|
+
forwarderProc.stderr?.on('data', (d) => process.stderr.write(outPrefix + d.toString()));
|
|
118
|
+
|
|
119
|
+
// Wait until the forwarder actually starts listening (or fails) before declaring success.
|
|
120
|
+
const ready = await new Promise((resolve) => {
|
|
121
|
+
const t = setTimeout(() => resolve({ ok: false, error: 'forwarder startup timed out' }), 2000);
|
|
122
|
+
const done = (res) => {
|
|
123
|
+
clearTimeout(t);
|
|
124
|
+
resolve(res);
|
|
125
|
+
};
|
|
126
|
+
forwarderProc.once('message', (m) => {
|
|
127
|
+
if (m && typeof m === 'object' && m.type === 'ready') {
|
|
128
|
+
done({ ok: true });
|
|
129
|
+
} else if (m && typeof m === 'object' && m.type === 'error') {
|
|
130
|
+
done({ ok: false, error: m.message ? String(m.message) : 'failed to start' });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
forwarderProc.once('exit', (code, sig) => {
|
|
134
|
+
done({ ok: false, error: `exited (code=${code}, sig=${sig})` });
|
|
135
|
+
});
|
|
136
|
+
forwarderProc.once('error', (e) => {
|
|
137
|
+
done({ ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!ready.ok) {
|
|
142
|
+
try {
|
|
143
|
+
forwarderProc.kill('SIGKILL');
|
|
144
|
+
} catch {
|
|
145
|
+
// ignore
|
|
146
|
+
}
|
|
147
|
+
return { ok: false, error: ready.error || 'failed to start forwarder' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
children.push(forwarderProc);
|
|
151
|
+
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.log(`[local] expo: Tailscale forwarder started (${tailscaleIp}:${metroPort} -> ${targetHost}:${metroPort})`);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
pid: forwarderProc.pid,
|
|
158
|
+
tailscaleIp,
|
|
159
|
+
lanIp: targetHost,
|
|
160
|
+
proc: forwarderProc,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
21
164
|
export function resolveExpoDevHost({ env = process.env } = {}) {
|
|
22
165
|
// Always prefer LAN by default so phones can reach Metro.
|
|
23
166
|
const raw = (env.HAPPY_STACKS_EXPO_HOST ?? env.HAPPY_LOCAL_EXPO_HOST ?? '').toString();
|
|
@@ -76,6 +219,7 @@ export async function ensureDevExpoServer({
|
|
|
76
219
|
envPath,
|
|
77
220
|
children,
|
|
78
221
|
spawnOptions = {},
|
|
222
|
+
expoTailscale = false,
|
|
79
223
|
} = {}) {
|
|
80
224
|
const wantWeb = Boolean(startUi);
|
|
81
225
|
const wantDevClient = Boolean(startMobile);
|
|
@@ -144,13 +288,20 @@ export async function ensureDevExpoServer({
|
|
|
144
288
|
const running = await isStateProcessRunning(paths.statePath);
|
|
145
289
|
const alreadyRunning = Boolean(running.running);
|
|
146
290
|
|
|
291
|
+
// Resolve Tailscale forwarding preference
|
|
292
|
+
const wantTailscale = resolveExpoTailscaleEnabled({ env: baseEnv, expoTailscale });
|
|
293
|
+
|
|
147
294
|
// Always publish runtime metadata when we can.
|
|
148
|
-
const publishRuntime = async ({ pid, port }) => {
|
|
295
|
+
const publishRuntime = async ({ pid, port, tailscaleForwarderPid = null, tailscaleIp = null }) => {
|
|
149
296
|
if (!stackMode || !runtimeStatePath) return;
|
|
150
297
|
const nPid = Number(pid);
|
|
151
298
|
const nPort = Number(port);
|
|
299
|
+
const nTsPid = Number(tailscaleForwarderPid);
|
|
152
300
|
await recordStackRuntimeUpdate(runtimeStatePath, {
|
|
153
|
-
processes: {
|
|
301
|
+
processes: {
|
|
302
|
+
expoPid: Number.isFinite(nPid) && nPid > 1 ? nPid : null,
|
|
303
|
+
expoTailscaleForwarderPid: Number.isFinite(nTsPid) && nTsPid > 1 ? nTsPid : null,
|
|
304
|
+
},
|
|
154
305
|
expo: {
|
|
155
306
|
port: Number.isFinite(nPort) && nPort > 0 ? nPort : null,
|
|
156
307
|
// For now keep these populated for callers that still expect webPort/mobilePort.
|
|
@@ -160,6 +311,8 @@ export async function ensureDevExpoServer({
|
|
|
160
311
|
devClientEnabled: wantDevClient,
|
|
161
312
|
host: resolveExpoDevHost({ env }),
|
|
162
313
|
scheme: wantDevClient ? scheme : null,
|
|
314
|
+
tailscaleEnabled: wantTailscale,
|
|
315
|
+
tailscaleIp: tailscaleIp ?? null,
|
|
163
316
|
},
|
|
164
317
|
}).catch(() => {});
|
|
165
318
|
};
|
|
@@ -224,7 +377,27 @@ export async function ensureDevExpoServer({
|
|
|
224
377
|
const proc = await expoSpawn({ label: 'expo', dir: uiDir, args, env, options: spawnOptions });
|
|
225
378
|
children.push(proc);
|
|
226
379
|
|
|
227
|
-
|
|
380
|
+
// Start Tailscale forwarder if enabled
|
|
381
|
+
let tailscaleResult = null;
|
|
382
|
+
if (wantTailscale) {
|
|
383
|
+
tailscaleResult = await startExpoTailscaleForwarder({
|
|
384
|
+
metroPort,
|
|
385
|
+
baseEnv,
|
|
386
|
+
stackName,
|
|
387
|
+
children,
|
|
388
|
+
});
|
|
389
|
+
if (!tailscaleResult.ok) {
|
|
390
|
+
// eslint-disable-next-line no-console
|
|
391
|
+
console.warn(`[local] expo: Tailscale forwarder not started: ${tailscaleResult.error}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await publishRuntime({
|
|
396
|
+
pid: proc.pid,
|
|
397
|
+
port: metroPort,
|
|
398
|
+
tailscaleForwarderPid: tailscaleResult?.pid ?? null,
|
|
399
|
+
tailscaleIp: tailscaleResult?.tailscaleIp ?? null,
|
|
400
|
+
});
|
|
228
401
|
|
|
229
402
|
try {
|
|
230
403
|
await writePidState(paths.statePath, {
|
|
@@ -236,11 +409,22 @@ export async function ensureDevExpoServer({
|
|
|
236
409
|
devClientEnabled: wantDevClient,
|
|
237
410
|
host,
|
|
238
411
|
scheme: wantDevClient ? scheme : null,
|
|
412
|
+
tailscaleEnabled: wantTailscale,
|
|
413
|
+
tailscaleForwarderPid: tailscaleResult?.pid ?? null,
|
|
414
|
+
tailscaleIp: tailscaleResult?.tailscaleIp ?? null,
|
|
239
415
|
});
|
|
240
416
|
} catch {
|
|
241
417
|
// ignore
|
|
242
418
|
}
|
|
243
419
|
|
|
244
|
-
return {
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
skipped: false,
|
|
423
|
+
pid: proc.pid,
|
|
424
|
+
port: metroPort,
|
|
425
|
+
proc,
|
|
426
|
+
mode: expoModeLabel({ wantWeb, wantDevClient }),
|
|
427
|
+
tailscale: tailscaleResult ?? null,
|
|
428
|
+
};
|
|
245
429
|
}
|
|
246
430
|
|
|
@@ -2,6 +2,7 @@ import { join, resolve } from 'node:path';
|
|
|
2
2
|
|
|
3
3
|
import { ensureDepsInstalled, pmSpawnScript } from '../proc/pm.mjs';
|
|
4
4
|
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
|
|
5
|
+
import { resolveServerDevScript } from '../server/flavor_scripts.mjs';
|
|
5
6
|
import { waitForServerReady } from '../server/server.mjs';
|
|
6
7
|
import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
|
|
7
8
|
import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
|
|
@@ -87,12 +88,7 @@ export async function startDevServer({
|
|
|
87
88
|
await ensureDepsInstalled(serverDir, serverComponentName);
|
|
88
89
|
|
|
89
90
|
const prismaPush = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '1').toString().trim() !== '0';
|
|
90
|
-
const serverScript =
|
|
91
|
-
serverComponentName === 'happy-server'
|
|
92
|
-
? 'start'
|
|
93
|
-
: serverComponentName === 'happy-server-light' && !prismaPush
|
|
94
|
-
? 'start'
|
|
95
|
-
: 'dev';
|
|
91
|
+
const serverScript = resolveServerDevScript({ serverComponentName, serverDir, prismaPush });
|
|
96
92
|
|
|
97
93
|
// Restart behavior (stack-safe): only kill when we can prove ownership via runtime state.
|
|
98
94
|
if (restart && stackMode && runtimeStatePath && serverAlreadyRunning) {
|
|
@@ -155,19 +151,27 @@ export function watchDevServerAndRestart({
|
|
|
155
151
|
const pid = Number(serverProcRef?.current?.pid);
|
|
156
152
|
if (!Number.isFinite(pid) || pid <= 1) return;
|
|
157
153
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
154
|
+
try {
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.log('[local] watch: server changed → restarting...');
|
|
157
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: 'server', json: false });
|
|
161
158
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
159
|
+
const next = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
|
|
160
|
+
children.push(next);
|
|
161
|
+
serverProcRef.current = next;
|
|
162
|
+
if (stackMode && runtimeStatePath) {
|
|
163
|
+
await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: next.pid } }).catch(() => {});
|
|
164
|
+
}
|
|
165
|
+
await waitForServerReady(internalServerUrl);
|
|
166
|
+
// eslint-disable-next-line no-console
|
|
167
|
+
console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
const msg = e instanceof Error ? e.stack || e.message : String(e);
|
|
170
|
+
// eslint-disable-next-line no-console
|
|
171
|
+
console.error('[local] watch: server restart failed; keeping existing process as-is (will retry on next change).');
|
|
172
|
+
// eslint-disable-next-line no-console
|
|
173
|
+
console.error(msg);
|
|
167
174
|
}
|
|
168
|
-
await waitForServerReady(internalServerUrl);
|
|
169
|
-
// eslint-disable-next-line no-console
|
|
170
|
-
console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
|
|
171
175
|
},
|
|
172
176
|
});
|
|
173
177
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export function findGitRootForPath(dir) {
|
|
5
|
+
let cur = resolve(String(dir ?? '').trim());
|
|
6
|
+
if (!cur) return '';
|
|
7
|
+
while (true) {
|
|
8
|
+
try {
|
|
9
|
+
if (existsSync(join(cur, '.git'))) {
|
|
10
|
+
return cur;
|
|
11
|
+
}
|
|
12
|
+
} catch {
|
|
13
|
+
// ignore
|
|
14
|
+
}
|
|
15
|
+
const parent = dirname(cur);
|
|
16
|
+
if (parent === cur) return '';
|
|
17
|
+
cur = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeGitRoots(paths) {
|
|
22
|
+
const list = Array.isArray(paths) ? paths : [];
|
|
23
|
+
const normalized = list
|
|
24
|
+
.map((d) => findGitRootForPath(d) || String(d ?? '').trim())
|
|
25
|
+
.map((d) => resolve(d))
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
return Array.from(new Set(normalized));
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { findGitRootForPath, normalizeGitRoots } from './git_roots.mjs';
|
|
8
|
+
|
|
9
|
+
async function withTempRoot(t) {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-edison-git-roots-'));
|
|
11
|
+
t.after(async () => {
|
|
12
|
+
await rm(dir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('findGitRootForPath returns nearest ancestor containing .git marker', async (t) => {
|
|
18
|
+
const root = await withTempRoot(t);
|
|
19
|
+
const repoRoot = join(root, 'repo');
|
|
20
|
+
await mkdir(join(repoRoot, 'a', 'b'), { recursive: true });
|
|
21
|
+
await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
|
|
22
|
+
|
|
23
|
+
assert.equal(findGitRootForPath(join(repoRoot, 'a', 'b')), repoRoot);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('normalizeGitRoots de-duplicates multiple paths inside the same repo', async (t) => {
|
|
27
|
+
const root = await withTempRoot(t);
|
|
28
|
+
const repoRoot = join(root, 'repo');
|
|
29
|
+
await mkdir(join(repoRoot, 'expo-app'), { recursive: true });
|
|
30
|
+
await mkdir(join(repoRoot, 'cli'), { recursive: true });
|
|
31
|
+
await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
|
|
32
|
+
|
|
33
|
+
const roots = normalizeGitRoots([join(repoRoot, 'expo-app'), join(repoRoot, 'cli')]);
|
|
34
|
+
assert.deepEqual(roots, [repoRoot]);
|
|
35
|
+
});
|
|
36
|
+
|
|
@@ -162,7 +162,12 @@ if (hasHomeConfig) {
|
|
|
162
162
|
const stacksStorageRootRaw = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
|
|
163
163
|
const stacksStorageRoot = stacksStorageRootRaw ? expandHome(stacksStorageRootRaw) : join(homedir(), '.happy', 'stacks');
|
|
164
164
|
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
165
|
-
|
|
165
|
+
// If the user explicitly overrides the stacks storage root, do not auto-discover a legacy env file from the real home dir.
|
|
166
|
+
// This keeps isolated runs (tests, sandboxes, custom dirs) from accidentally loading a "real" machine stack env file.
|
|
167
|
+
const legacyStacksRoot =
|
|
168
|
+
allowLegacy && !stacksStorageRootRaw
|
|
169
|
+
? join(homedir(), '.happy', 'local', 'stacks')
|
|
170
|
+
: join(stacksStorageRoot, '__legacy_disabled__');
|
|
166
171
|
|
|
167
172
|
const candidates = [
|
|
168
173
|
join(stacksStorageRoot, stackName, 'env'),
|
|
@@ -204,8 +209,7 @@ process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_M
|
|
|
204
209
|
const delimiter = process.platform === 'win32' ? ';' : ':';
|
|
205
210
|
const current = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
206
211
|
const nodeBinDir = dirname(process.execPath);
|
|
207
|
-
const want = [nodeBinDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
|
|
212
|
+
const want = [nodeBinDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin'];
|
|
208
213
|
const next = [...want.filter((p) => p && !current.includes(p)), ...current];
|
|
209
214
|
process.env.PATH = next.join(delimiter);
|
|
210
215
|
})();
|
|
211
|
-
|
|
@@ -7,7 +7,9 @@ export async function ensureEnvFileUpdated({ envPath, updates }) {
|
|
|
7
7
|
return;
|
|
8
8
|
}
|
|
9
9
|
await mkdir(dirname(envPath), { recursive: true });
|
|
10
|
-
|
|
10
|
+
const existing = await readText(envPath);
|
|
11
|
+
const next = applyEnvUpdates(existing, updates);
|
|
12
|
+
await writeFileIfChanged(existing, next, envPath);
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export async function ensureEnvFilePruned({ envPath, removeKeys }) {
|
|
@@ -30,7 +32,7 @@ async function readText(path) {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function applyEnvUpdates(existing, updates) {
|
|
33
|
-
const lines = existing.split('\n');
|
|
35
|
+
const lines = existing ? existing.split('\n') : [];
|
|
34
36
|
const next = [...lines];
|
|
35
37
|
|
|
36
38
|
const upsert = (key, value) => {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
|
|
8
|
+
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './env_file.mjs';
|
|
9
|
+
|
|
10
|
+
test('ensureEnvFileUpdated appends new key and ensures trailing newline', async () => {
|
|
11
|
+
const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-env-file-'));
|
|
12
|
+
const envPath = join(dir, 'env');
|
|
13
|
+
|
|
14
|
+
await ensureEnvFileUpdated({ envPath, updates: [{ key: 'OPENAI_API_KEY', value: 'sk-test' }] });
|
|
15
|
+
const next = await readFile(envPath, 'utf-8');
|
|
16
|
+
assert.equal(next, 'OPENAI_API_KEY=sk-test\n');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('ensureEnvFileUpdated does not touch file when no content changes', async () => {
|
|
20
|
+
const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-env-file-'));
|
|
21
|
+
const envPath = join(dir, 'env');
|
|
22
|
+
|
|
23
|
+
await writeFile(envPath, 'FOO=bar\n', 'utf-8');
|
|
24
|
+
const before = await stat(envPath);
|
|
25
|
+
|
|
26
|
+
// Ensure filesystem mtime resolution won't hide unintended writes.
|
|
27
|
+
await delay(25);
|
|
28
|
+
|
|
29
|
+
await ensureEnvFileUpdated({ envPath, updates: [{ key: 'FOO', value: 'bar' }] });
|
|
30
|
+
const after = await stat(envPath);
|
|
31
|
+
assert.equal(after.mtimeMs, before.mtimeMs);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('ensureEnvFilePruned removes a key but keeps comments/blank lines', async () => {
|
|
35
|
+
const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-env-file-'));
|
|
36
|
+
const envPath = join(dir, 'env');
|
|
37
|
+
|
|
38
|
+
await writeFile(envPath, '# header\nFOO=bar\n\nBAZ=qux\n', 'utf-8');
|
|
39
|
+
await ensureEnvFilePruned({ envPath, removeKeys: ['FOO'] });
|
|
40
|
+
|
|
41
|
+
const next = await readFile(envPath, 'utf-8');
|
|
42
|
+
assert.equal(next, '# header\n\nBAZ=qux\n');
|
|
43
|
+
});
|
|
44
|
+
|