happy-stacks 0.3.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 +93 -40
- package/bin/happys.mjs +158 -16
- 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 +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- 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/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -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 +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- 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/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- 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/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- 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 +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -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 +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- 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/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -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/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -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/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- 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
- package/scripts/utils/dev/expo_web.mjs +0 -112
|
@@ -2,41 +2,32 @@ 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';
|
|
8
9
|
import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
|
|
9
10
|
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
|
-
}
|
|
11
|
+
import { pickMetroPort, resolveStablePortStart } from '../expo/metro_ports.mjs';
|
|
19
12
|
|
|
20
13
|
export function resolveStackUiDevPortStart({ env = process.env, stackName }) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
return resolveStablePortStart({
|
|
15
|
+
env: {
|
|
16
|
+
...env,
|
|
17
|
+
HAPPY_STACKS_UI_DEV_PORT_BASE: (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString(),
|
|
18
|
+
HAPPY_STACKS_UI_DEV_PORT_RANGE: (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString(),
|
|
19
|
+
},
|
|
20
|
+
stackName,
|
|
21
|
+
baseKey: 'HAPPY_STACKS_UI_DEV_PORT_BASE',
|
|
22
|
+
rangeKey: 'HAPPY_STACKS_UI_DEV_PORT_RANGE',
|
|
23
|
+
defaultBase: 8081,
|
|
24
|
+
defaultRange: 1000,
|
|
25
|
+
});
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
export async function pickDevMetroPort({ startPort, reservedPorts = new Set(), host = '127.0.0.1' } = {}) {
|
|
31
|
-
const
|
|
32
|
-
|
|
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 });
|
|
29
|
+
const forcedPort = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
|
|
30
|
+
return await pickMetroPort({ startPort, forcedPort, reservedPorts, host });
|
|
40
31
|
}
|
|
41
32
|
|
|
42
33
|
export async function startDevServer({
|
|
@@ -97,12 +88,7 @@ export async function startDevServer({
|
|
|
97
88
|
await ensureDepsInstalled(serverDir, serverComponentName);
|
|
98
89
|
|
|
99
90
|
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';
|
|
91
|
+
const serverScript = resolveServerDevScript({ serverComponentName, serverDir, prismaPush });
|
|
106
92
|
|
|
107
93
|
// Restart behavior (stack-safe): only kill when we can prove ownership via runtime state.
|
|
108
94
|
if (restart && stackMode && runtimeStatePath && serverAlreadyRunning) {
|
|
@@ -165,19 +151,27 @@ export function watchDevServerAndRestart({
|
|
|
165
151
|
const pid = Number(serverProcRef?.current?.pid);
|
|
166
152
|
if (!Number.isFinite(pid) || pid <= 1) return;
|
|
167
153
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 });
|
|
171
158
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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);
|
|
177
174
|
}
|
|
178
|
-
await waitForServerReady(internalServerUrl);
|
|
179
|
-
// eslint-disable-next-line no-console
|
|
180
|
-
console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
|
|
181
175
|
},
|
|
182
176
|
});
|
|
183
177
|
}
|
|
@@ -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 './paths/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,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
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ensureDepsInstalled, pmExecBin, pmSpawnBin } from '../proc/pm.mjs';
|
|
2
|
+
import { ensureExpoIsolationEnv, getExpoStatePaths, wantsExpoClearCache } from './expo.mjs';
|
|
3
|
+
|
|
4
|
+
export async function prepareExpoCommandEnv({
|
|
5
|
+
baseDir,
|
|
6
|
+
kind,
|
|
7
|
+
projectDir,
|
|
8
|
+
baseEnv,
|
|
9
|
+
stateFileName,
|
|
10
|
+
}) {
|
|
11
|
+
const env = { ...(baseEnv ?? process.env) };
|
|
12
|
+
const paths = getExpoStatePaths({ baseDir, kind, projectDir, stateFileName });
|
|
13
|
+
await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
|
|
14
|
+
return { env, paths };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function maybeAddExpoClear({ args, env }) {
|
|
18
|
+
const next = [...(args ?? [])];
|
|
19
|
+
if (wantsExpoClearCache({ env: env ?? process.env })) {
|
|
20
|
+
// Expo supports `--clear` for start, and `-c` for export.
|
|
21
|
+
// Callers should pass the right flag for their subcommand; we only add when missing.
|
|
22
|
+
if (!next.includes('--clear') && !next.includes('-c')) {
|
|
23
|
+
// Prefer `--clear` as a safe default; callers can override per-command.
|
|
24
|
+
next.push('--clear');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function expoExec({
|
|
31
|
+
dir,
|
|
32
|
+
args,
|
|
33
|
+
env,
|
|
34
|
+
ensureDepsLabel = 'happy',
|
|
35
|
+
quiet = false,
|
|
36
|
+
}) {
|
|
37
|
+
await ensureDepsInstalled(dir, ensureDepsLabel, { quiet });
|
|
38
|
+
await pmExecBin({ dir, bin: 'expo', args, env, quiet });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function expoSpawn({
|
|
42
|
+
label,
|
|
43
|
+
dir,
|
|
44
|
+
args,
|
|
45
|
+
env,
|
|
46
|
+
ensureDepsLabel = 'happy',
|
|
47
|
+
options,
|
|
48
|
+
}) {
|
|
49
|
+
await ensureDepsInstalled(dir, ensureDepsLabel);
|
|
50
|
+
return await pmSpawnBin({ label, dir, bin: 'expo', args, env, options });
|
|
51
|
+
}
|
|
52
|
+
|
|
@@ -4,6 +4,7 @@ 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
6
|
import { isPidAlive } from '../proc/pids.mjs';
|
|
7
|
+
import { isTcpPortFree } from '../net/ports.mjs';
|
|
7
8
|
|
|
8
9
|
export { isPidAlive };
|
|
9
10
|
|
|
@@ -63,7 +64,25 @@ export async function isStateProcessRunning(statePath) {
|
|
|
63
64
|
const state = await readPidState(statePath);
|
|
64
65
|
if (!state) return { running: false, state: null };
|
|
65
66
|
const pid = Number(state.pid);
|
|
66
|
-
|
|
67
|
+
if (isPidAlive(pid)) {
|
|
68
|
+
return { running: true, state, reason: 'pid' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Expo/Metro can sometimes be “up” even if the original wrapper pid exited (pm/yarn layers).
|
|
72
|
+
// If we have a port and something is listening on it, treat it as running.
|
|
73
|
+
const port = Number(state?.port);
|
|
74
|
+
if (Number.isFinite(port) && port > 0) {
|
|
75
|
+
try {
|
|
76
|
+
const free = await isTcpPortFree(port, { host: '127.0.0.1' });
|
|
77
|
+
if (!free) {
|
|
78
|
+
return { running: true, state, reason: 'port' };
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { running: false, state };
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
export async function writePidState(statePath, state) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
|
|
2
|
+
|
|
3
|
+
function hashStringToInt(s) {
|
|
4
|
+
let h = 0;
|
|
5
|
+
const str = String(s ?? '');
|
|
6
|
+
for (let i = 0; i < str.length; i++) {
|
|
7
|
+
h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
|
8
|
+
}
|
|
9
|
+
return h >>> 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function coercePositiveInt(v) {
|
|
13
|
+
const n = Number(v);
|
|
14
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveStablePortStart({
|
|
18
|
+
env = process.env,
|
|
19
|
+
stackName,
|
|
20
|
+
baseKey,
|
|
21
|
+
rangeKey,
|
|
22
|
+
defaultBase,
|
|
23
|
+
defaultRange,
|
|
24
|
+
}) {
|
|
25
|
+
const baseRaw = (env[baseKey] ?? '').toString().trim();
|
|
26
|
+
const rangeRaw = (env[rangeKey] ?? '').toString().trim();
|
|
27
|
+
const base = coercePositiveInt(baseRaw) ?? defaultBase;
|
|
28
|
+
const range = coercePositiveInt(rangeRaw) ?? defaultRange;
|
|
29
|
+
return base + (hashStringToInt(stackName) % range);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function pickMetroPort({
|
|
33
|
+
startPort,
|
|
34
|
+
forcedPort,
|
|
35
|
+
reservedPorts = new Set(),
|
|
36
|
+
host = '127.0.0.1',
|
|
37
|
+
} = {}) {
|
|
38
|
+
const forced = coercePositiveInt(forcedPort);
|
|
39
|
+
if (forced) {
|
|
40
|
+
const ok = await isTcpPortFree(forced, { host });
|
|
41
|
+
if (ok) return forced;
|
|
42
|
+
}
|
|
43
|
+
return await pickNextFreeTcpPort(startPort, { reservedPorts, host });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function wantsStablePortStrategy({ env = process.env, strategyKey, legacyStrategyKey } = {}) {
|
|
47
|
+
const raw = (env[strategyKey] ?? env[legacyStrategyKey] ?? 'ephemeral').toString().trim() || 'ephemeral';
|
|
48
|
+
return raw === 'stable';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function pickUiDevMetroPort({
|
|
52
|
+
env = process.env,
|
|
53
|
+
stackMode,
|
|
54
|
+
stackName,
|
|
55
|
+
reservedPorts = new Set(),
|
|
56
|
+
host = '127.0.0.1',
|
|
57
|
+
} = {}) {
|
|
58
|
+
// Legacy alias: UI dev Metro is now the unified Expo dev server port.
|
|
59
|
+
return await pickExpoDevMetroPort({ env, stackMode, stackName, reservedPorts, host });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function pickMobileDevMetroPort({
|
|
63
|
+
env = process.env,
|
|
64
|
+
stackMode,
|
|
65
|
+
stackName,
|
|
66
|
+
reservedPorts = new Set(),
|
|
67
|
+
host = '127.0.0.1',
|
|
68
|
+
} = {}) {
|
|
69
|
+
// Legacy alias: mobile dev Metro is now the unified Expo dev server port.
|
|
70
|
+
return await pickExpoDevMetroPort({ env, stackMode, stackName, reservedPorts, host });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function pickExpoDevMetroPort({
|
|
74
|
+
env = process.env,
|
|
75
|
+
stackMode,
|
|
76
|
+
stackName,
|
|
77
|
+
reservedPorts = new Set(),
|
|
78
|
+
host = '127.0.0.1',
|
|
79
|
+
} = {}) {
|
|
80
|
+
const forcedPort =
|
|
81
|
+
(env.HAPPY_STACKS_EXPO_DEV_PORT ??
|
|
82
|
+
env.HAPPY_LOCAL_EXPO_DEV_PORT ??
|
|
83
|
+
// Back-compat: older knobs.
|
|
84
|
+
env.HAPPY_STACKS_UI_DEV_PORT ??
|
|
85
|
+
env.HAPPY_LOCAL_UI_DEV_PORT ??
|
|
86
|
+
env.HAPPY_STACKS_MOBILE_DEV_PORT ??
|
|
87
|
+
env.HAPPY_LOCAL_MOBILE_DEV_PORT ??
|
|
88
|
+
env.HAPPY_STACKS_MOBILE_PORT ??
|
|
89
|
+
env.HAPPY_LOCAL_MOBILE_PORT ??
|
|
90
|
+
'')
|
|
91
|
+
.toString()
|
|
92
|
+
.trim() || '';
|
|
93
|
+
|
|
94
|
+
const stable =
|
|
95
|
+
stackMode &&
|
|
96
|
+
wantsStablePortStrategy({
|
|
97
|
+
env,
|
|
98
|
+
strategyKey: 'HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY',
|
|
99
|
+
legacyStrategyKey: 'HAPPY_LOCAL_EXPO_DEV_PORT_STRATEGY',
|
|
100
|
+
});
|
|
101
|
+
const startPort = stable
|
|
102
|
+
? resolveStablePortStart({
|
|
103
|
+
env,
|
|
104
|
+
stackName,
|
|
105
|
+
baseKey: 'HAPPY_STACKS_EXPO_DEV_PORT_BASE',
|
|
106
|
+
rangeKey: 'HAPPY_STACKS_EXPO_DEV_PORT_RANGE',
|
|
107
|
+
defaultBase: 8081,
|
|
108
|
+
defaultRange: 1000,
|
|
109
|
+
})
|
|
110
|
+
: 8081;
|
|
111
|
+
|
|
112
|
+
return await pickMetroPort({ startPort, forcedPort, reservedPorts, host });
|
|
113
|
+
}
|
|
114
|
+
|