happy-stacks 0.3.0 → 0.4.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.
Files changed (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,76 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildExpoStartArgs, resolveExpoDevHost } from './expo_dev.mjs';
5
+
6
+ test('resolveExpoDevHost defaults to lan and normalizes values', () => {
7
+ assert.equal(resolveExpoDevHost({ env: {} }), 'lan');
8
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'LAN' } }), 'lan');
9
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'localhost' } }), 'localhost');
10
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'tunnel' } }), 'tunnel');
11
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'nope' } }), 'lan');
12
+ });
13
+
14
+ test('buildExpoStartArgs builds dev-client args (preferred when mobile enabled)', () => {
15
+ const args = buildExpoStartArgs({
16
+ port: 8081,
17
+ host: 'lan',
18
+ wantWeb: true,
19
+ wantDevClient: true,
20
+ scheme: 'happy',
21
+ clearCache: true,
22
+ });
23
+ assert.deepEqual(args, ['start', '--dev-client', '--host', 'lan', '--port', '8081', '--scheme', 'happy', '--clear']);
24
+ });
25
+
26
+ test('buildExpoStartArgs builds web args when dev-client is not requested', () => {
27
+ const args = buildExpoStartArgs({
28
+ port: 8081,
29
+ host: 'lan',
30
+ wantWeb: true,
31
+ wantDevClient: false,
32
+ scheme: '',
33
+ clearCache: false,
34
+ });
35
+ assert.deepEqual(args, ['start', '--web', '--host', 'lan', '--port', '8081']);
36
+ });
37
+
38
+ test('buildExpoStartArgs omits --scheme when empty', () => {
39
+ const args = buildExpoStartArgs({
40
+ port: 8081,
41
+ host: 'lan',
42
+ wantWeb: false,
43
+ wantDevClient: true,
44
+ scheme: '',
45
+ clearCache: false,
46
+ });
47
+ assert.deepEqual(args, ['start', '--dev-client', '--host', 'lan', '--port', '8081']);
48
+ });
49
+
50
+ test('buildExpoStartArgs throws on invalid requests', () => {
51
+ assert.throws(
52
+ () =>
53
+ buildExpoStartArgs({
54
+ port: 0,
55
+ host: 'lan',
56
+ wantWeb: true,
57
+ wantDevClient: false,
58
+ scheme: '',
59
+ clearCache: false,
60
+ }),
61
+ /invalid Metro port/i
62
+ );
63
+ assert.throws(
64
+ () =>
65
+ buildExpoStartArgs({
66
+ port: 8081,
67
+ host: 'lan',
68
+ wantWeb: false,
69
+ wantDevClient: false,
70
+ scheme: '',
71
+ clearCache: false,
72
+ }),
73
+ /neither web nor dev-client requested/i
74
+ );
75
+ });
76
+
@@ -7,36 +7,26 @@ import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
7
7
  import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
8
8
  import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
9
9
  import { watchDebounced } from '../proc/watch.mjs';
10
-
11
- function hashStringToInt(s) {
12
- let h = 0;
13
- const str = String(s ?? '');
14
- for (let i = 0; i < str.length; i++) {
15
- h = (h * 31 + str.charCodeAt(i)) >>> 0;
16
- }
17
- return h >>> 0;
18
- }
10
+ import { pickMetroPort, resolveStablePortStart } from '../expo/metro_ports.mjs';
19
11
 
20
12
  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);
13
+ return resolveStablePortStart({
14
+ env: {
15
+ ...env,
16
+ HAPPY_STACKS_UI_DEV_PORT_BASE: (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString(),
17
+ HAPPY_STACKS_UI_DEV_PORT_RANGE: (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString(),
18
+ },
19
+ stackName,
20
+ baseKey: 'HAPPY_STACKS_UI_DEV_PORT_BASE',
21
+ rangeKey: 'HAPPY_STACKS_UI_DEV_PORT_RANGE',
22
+ defaultBase: 8081,
23
+ defaultRange: 1000,
24
+ });
28
25
  }
29
26
 
30
27
  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 });
28
+ const forcedPort = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
29
+ return await pickMetroPort({ startPort, forcedPort, reservedPorts, host });
40
30
  }
41
31
 
42
32
  export async function startDevServer({
@@ -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,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
- return { running: isPidAlive(pid), state };
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
+
@@ -0,0 +1,67 @@
1
+ import { runCapture } from '../proc/proc.mjs';
2
+
3
+ export async function gitCapture({ cwd, args }) {
4
+ return String(await runCapture('git', args, { cwd }));
5
+ }
6
+
7
+ export async function gitOk({ cwd, args }) {
8
+ try {
9
+ await runCapture('git', args, { cwd });
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ export async function normalizeRemoteName({ cwd, remote }) {
17
+ const want = String(remote ?? '').trim();
18
+ if (!want) return want;
19
+
20
+ if (await gitOk({ cwd, args: ['remote', 'get-url', want] })) return want;
21
+
22
+ // Treat origin/fork as interchangeable if one exists.
23
+ if (want === 'origin' && (await gitOk({ cwd, args: ['remote', 'get-url', 'fork'] }))) return 'fork';
24
+ if (want === 'fork' && (await gitOk({ cwd, args: ['remote', 'get-url', 'origin'] }))) return 'origin';
25
+
26
+ return want;
27
+ }
28
+
29
+ export async function resolveRemoteDefaultBranch({ cwd, remote }) {
30
+ const r = String(remote ?? '').trim();
31
+ if (!r) return 'main';
32
+
33
+ // Prefer refs/remotes/<remote>/HEAD when available.
34
+ try {
35
+ const headRef = (await gitCapture({ cwd, args: ['symbolic-ref', '-q', '--short', `refs/remotes/${r}/HEAD`] })).trim();
36
+ if (headRef.startsWith(`${r}/`)) {
37
+ return headRef.slice(r.length + 1);
38
+ }
39
+ } catch {
40
+ // ignore
41
+ }
42
+
43
+ // Fallback: parse `git remote show` output.
44
+ try {
45
+ const out = await gitCapture({ cwd, args: ['remote', 'show', r] });
46
+ for (const line of out.split('\n')) {
47
+ const m = line.match(/^\s*HEAD branch:\s*(.+)\s*$/);
48
+ if (m?.[1]) return m[1].trim();
49
+ }
50
+ } catch {
51
+ // ignore
52
+ }
53
+
54
+ return 'main';
55
+ }
56
+
57
+ export async function ensureRemoteRefAvailable({ cwd, remote, branch }) {
58
+ const r = String(remote ?? '').trim();
59
+ const b = String(branch ?? '').trim();
60
+ if (!r || !b) return false;
61
+ const ref = `refs/remotes/${r}/${b}`;
62
+ if (await gitOk({ cwd, args: ['show-ref', '--verify', '--quiet', ref] })) return true;
63
+ // Best-effort fetch of the default branch.
64
+ await gitCapture({ cwd, args: ['fetch', '--quiet', r, b] }).catch(() => '');
65
+ return await gitOk({ cwd, args: ['show-ref', '--verify', '--quiet', ref] });
66
+ }
67
+
@@ -15,28 +15,28 @@ export function parseGithubOwner(remoteUrl) {
15
15
  return m?.groups?.owner ?? null;
16
16
  }
17
17
 
18
- export function getWorktreesRoot(rootDir) {
19
- return join(getComponentsDir(rootDir), '.worktrees');
18
+ export function getWorktreesRoot(rootDir, env = process.env) {
19
+ return join(getComponentsDir(rootDir, env), '.worktrees');
20
20
  }
21
21
 
22
- export function componentRepoDir(rootDir, component) {
23
- return join(getComponentsDir(rootDir), component);
22
+ export function componentRepoDir(rootDir, component, env = process.env) {
23
+ return join(getComponentsDir(rootDir, env), component);
24
24
  }
25
25
 
26
- export function isComponentWorktreePath({ rootDir, component, dir }) {
26
+ export function isComponentWorktreePath({ rootDir, component, dir, env = process.env }) {
27
27
  const raw = String(dir ?? '').trim();
28
28
  if (!raw) return false;
29
29
  const abs = resolve(raw);
30
- const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
30
+ const root = resolve(join(getWorktreesRoot(rootDir, env), component)) + '/';
31
31
  return abs.startsWith(root);
32
32
  }
33
33
 
34
- export function worktreeSpecFromDir({ rootDir, component, dir }) {
34
+ export function worktreeSpecFromDir({ rootDir, component, dir, env = process.env }) {
35
35
  const raw = String(dir ?? '').trim();
36
36
  if (!raw) return null;
37
- if (!isComponentWorktreePath({ rootDir, component, dir: raw })) return null;
37
+ if (!isComponentWorktreePath({ rootDir, component, dir: raw, env })) return null;
38
38
  const abs = resolve(raw);
39
- const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
39
+ const root = resolve(join(getWorktreesRoot(rootDir, env), component)) + '/';
40
40
  const rel = abs.slice(root.length).split('/').filter(Boolean);
41
41
  if (rel.length < 2) return null;
42
42
  // rel = [owner, ...branchParts]
@@ -69,17 +69,18 @@ export async function createWorktreeFromBaseWorktree({
69
69
  baseWorktreeSpec,
70
70
  remoteName = 'upstream',
71
71
  depsMode = '',
72
+ env = process.env,
72
73
  }) {
73
74
  const args = ['wt', 'new', component, slug, `--remote=${remoteName}`, `--base-worktree=${baseWorktreeSpec}`];
74
75
  if (depsMode) args.push(`--deps=${depsMode}`);
75
- await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), ...args], { cwd: rootDir });
76
+ await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), ...args], { cwd: rootDir, env });
76
77
 
77
- const repoDir = componentRepoDir(rootDir, component);
78
+ const repoDir = componentRepoDir(rootDir, component, env);
78
79
  const owner = await getRemoteOwner({ repoDir, remoteName });
79
- return join(getWorktreesRoot(rootDir), component, owner, ...slug.split('/'));
80
+ return join(getWorktreesRoot(rootDir, env), component, owner, ...slug.split('/'));
80
81
  }
81
82
 
82
- export function resolveComponentSpecToDir({ rootDir, component, spec }) {
83
+ export function resolveComponentSpecToDir({ rootDir, component, spec, env = process.env }) {
83
84
  const raw = (spec ?? '').trim();
84
85
  if (!raw || raw === 'default') {
85
86
  return null;
@@ -88,11 +89,11 @@ export function resolveComponentSpecToDir({ rootDir, component, spec }) {
88
89
  return raw;
89
90
  }
90
91
  // Treat as <owner>/<branch...> under components/.worktrees/<component>/...
91
- return join(getWorktreesRoot(rootDir), component, ...raw.split('/'));
92
+ return join(getWorktreesRoot(rootDir, env), component, ...raw.split('/'));
92
93
  }
93
94
 
94
- export async function listWorktreeSpecs({ rootDir, component }) {
95
- const dir = join(getWorktreesRoot(rootDir), component);
95
+ export async function listWorktreeSpecs({ rootDir, component, env = process.env }) {
96
+ const dir = join(getWorktreesRoot(rootDir, env), component);
96
97
  const specs = [];
97
98
  try {
98
99
  const walk = async (d, prefixParts) => {
@@ -125,10 +126,13 @@ export async function getRemoteOwner({ repoDir, remoteName = 'upstream' }) {
125
126
  return owner;
126
127
  }
127
128
 
128
- export async function createWorktree({ rootDir, component, slug, remoteName = 'upstream' }) {
129
+ export async function createWorktree({ rootDir, component, slug, remoteName = 'upstream', env = process.env }) {
129
130
  // Create without modifying env.local (unless caller passes --use elsewhere).
130
- await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), 'wt', 'new', component, slug, `--remote=${remoteName}`], { cwd: rootDir });
131
- const repoDir = componentRepoDir(rootDir, component);
131
+ await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), 'wt', 'new', component, slug, `--remote=${remoteName}`], {
132
+ cwd: rootDir,
133
+ env,
134
+ });
135
+ const repoDir = componentRepoDir(rootDir, component, env);
132
136
  const owner = await getRemoteOwner({ repoDir, remoteName });
133
- return join(getWorktreesRoot(rootDir), component, owner, ...slug.split('/'));
137
+ return join(getWorktreesRoot(rootDir, env), component, owner, ...slug.split('/'));
134
138
  }