happy-stacks 0.2.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.
- package/README.md +84 -25
- package/bin/happys.mjs +116 -17
- package/docs/happy-development.md +2 -2
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/package.json +5 -1
- package/scripts/auth.mjs +59 -208
- package/scripts/build.mjs +58 -12
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +5 -5
- package/scripts/daemon.mjs +168 -20
- package/scripts/dev.mjs +196 -70
- package/scripts/doctor.mjs +20 -36
- package/scripts/edison.mjs +105 -78
- package/scripts/happy.mjs +8 -19
- package/scripts/init.mjs +8 -14
- package/scripts/install.mjs +119 -23
- package/scripts/lint.mjs +31 -32
- package/scripts/menubar.mjs +6 -13
- package/scripts/migrate.mjs +11 -21
- package/scripts/mobile.mjs +93 -108
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +217 -0
- package/scripts/review_pr.mjs +368 -0
- package/scripts/run.mjs +95 -21
- package/scripts/self.mjs +11 -29
- package/scripts/server_flavor.mjs +4 -4
- package/scripts/service.mjs +19 -29
- package/scripts/setup.mjs +63 -160
- package/scripts/setup_pr.mjs +592 -52
- package/scripts/stack.mjs +608 -200
- package/scripts/stop.mjs +3 -3
- package/scripts/tailscale.mjs +44 -11
- package/scripts/test.mjs +52 -36
- package/scripts/tui.mjs +314 -74
- package/scripts/typecheck.mjs +31 -32
- package/scripts/ui_gateway.mjs +1 -1
- package/scripts/uninstall.mjs +6 -6
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
- 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/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
- package/scripts/utils/auth/sources.mjs +38 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +24 -0
- package/scripts/utils/cli/cwd_scope.mjs +82 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/smoke_help.mjs +2 -2
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
- package/scripts/utils/dev/expo_dev.mjs +246 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
- package/scripts/utils/dev_auth_key.mjs +1 -1
- package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
- package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
- package/scripts/utils/handy_master_secret.mjs +2 -2
- 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/dns.mjs +10 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +56 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/runners/coderabbit.mjs +19 -0
- package/scripts/utils/review/runners/codex.mjs +51 -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/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
- 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/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
- package/scripts/utils/server/urls.mjs +101 -0
- package/scripts/utils/server/validate.mjs +88 -0
- package/scripts/utils/service/autostart_darwin.mjs +182 -0
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +23 -0
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +88 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
- package/scripts/utils/stack_context.mjs +3 -3
- package/scripts/utils/stack_runtime_state.mjs +1 -1
- package/scripts/utils/stacks.mjs +2 -2
- package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/utils/validate.mjs +1 -1
- package/scripts/where.mjs +6 -6
- package/scripts/worktrees.mjs +171 -113
- package/scripts/utils/auth_sources.mjs +0 -12
- package/scripts/utils/dev_expo_web.mjs +0 -112
- package/scripts/utils/localhost_host.mjs +0 -17
- package/scripts/utils/server_port.mjs +0 -9
- package/scripts/utils/server_urls.mjs +0 -54
- /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
- /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
- /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
|
@@ -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,25 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
export async function readJsonIfExists(path, { defaultValue = null } = {}) {
|
|
6
|
+
try {
|
|
7
|
+
const p = String(path ?? '').trim();
|
|
8
|
+
if (!p || !existsSync(p)) return defaultValue;
|
|
9
|
+
const raw = await readFile(p, 'utf-8');
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
} catch {
|
|
12
|
+
return defaultValue;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeJsonAtomic(path, value) {
|
|
17
|
+
const p = String(path ?? '').trim();
|
|
18
|
+
if (!p) throw new Error('writeJsonAtomic: path is required');
|
|
19
|
+
const dir = dirname(p);
|
|
20
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
21
|
+
const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
|
|
22
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
|
23
|
+
await rename(tmp, p);
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
export async function ensureDir(path) {
|
|
5
|
+
await mkdir(path, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function readTextIfExists(path) {
|
|
9
|
+
try {
|
|
10
|
+
const p = String(path ?? '').trim();
|
|
11
|
+
if (!p || !existsSync(p)) return null;
|
|
12
|
+
const raw = await readFile(p, 'utf-8');
|
|
13
|
+
const t = raw.trim();
|
|
14
|
+
return t ? t : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readTextOrEmpty(path) {
|
|
21
|
+
try {
|
|
22
|
+
const p = String(path ?? '').trim();
|
|
23
|
+
if (!p || !existsSync(p)) return '';
|
|
24
|
+
return await readFile(p, 'utf-8');
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export async function readLastLines(path, lines = 60) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = await readFile(path, 'utf-8');
|
|
6
|
+
const parts = raw.split('\n');
|
|
7
|
+
return parts.slice(Math.max(0, parts.length - lines)).join('\n');
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function parseGithubPullRequest(input) {
|
|
2
|
+
const raw = (input ?? '').trim();
|
|
3
|
+
if (!raw) return null;
|
|
4
|
+
if (/^\d+$/.test(raw)) {
|
|
5
|
+
return { number: Number(raw), owner: null, repo: null };
|
|
6
|
+
}
|
|
7
|
+
// https://github.com/<owner>/<repo>/pull/<num>
|
|
8
|
+
const m = raw.match(/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<num>\d+)/);
|
|
9
|
+
if (!m?.groups?.num) return null;
|
|
10
|
+
return {
|
|
11
|
+
number: Number(m.groups.num),
|
|
12
|
+
owner: m.groups.owner ?? null,
|
|
13
|
+
repo: m.groups.repo ?? null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sanitizeSlugPart(s) {
|
|
18
|
+
return (s ?? '')
|
|
19
|
+
.toString()
|
|
20
|
+
.trim()
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9._/-]+/g, '-')
|
|
23
|
+
.replace(/-+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
2
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
-
import { getComponentsDir } from '
|
|
4
|
-
import { pathExists } from '
|
|
5
|
-
import { run, runCapture } from '
|
|
3
|
+
import { getComponentsDir } from '../paths/paths.mjs';
|
|
4
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
5
|
+
import { run, runCapture } from '../proc/proc.mjs';
|
|
6
6
|
|
|
7
7
|
export function parseGithubOwner(remoteUrl) {
|
|
8
8
|
const raw = (remoteUrl ?? '').trim();
|
|
@@ -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}`], {
|
|
131
|
-
|
|
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
|
}
|
|
@@ -3,8 +3,8 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import { parseDotenv } from './dotenv.mjs';
|
|
7
|
-
import { resolveStackEnvPath } from './paths.mjs';
|
|
6
|
+
import { parseDotenv } from './env/dotenv.mjs';
|
|
7
|
+
import { resolveStackEnvPath } from './paths/paths.mjs';
|
|
8
8
|
import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './auth_sources.mjs';
|
|
9
9
|
|
|
10
10
|
async function readTextIfExists(path) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sanitizeBundleIdSegment, sanitizeUrlScheme } from './identifiers.mjs';
|
|
2
|
+
|
|
3
|
+
export function resolveMobileExpoConfig({ env = process.env } = {}) {
|
|
4
|
+
const user = sanitizeBundleIdSegment(env.USER ?? env.USERNAME ?? 'user');
|
|
5
|
+
const defaultLocalBundleId = `com.happy.local.${user}.dev`;
|
|
6
|
+
|
|
7
|
+
const appEnv = env.APP_ENV ?? env.HAPPY_STACKS_APP_ENV ?? env.HAPPY_LOCAL_APP_ENV ?? 'development';
|
|
8
|
+
const iosAppName = (env.HAPPY_STACKS_IOS_APP_NAME ?? env.HAPPY_LOCAL_IOS_APP_NAME ?? '').toString();
|
|
9
|
+
const iosBundleId = (env.HAPPY_STACKS_IOS_BUNDLE_ID ?? env.HAPPY_LOCAL_IOS_BUNDLE_ID ?? defaultLocalBundleId).toString();
|
|
10
|
+
// Happy Stacks convention:
|
|
11
|
+
// - dev-client QR should open a dedicated "Happy Stacks Dev" app (not a per-stack release build)
|
|
12
|
+
// - so default to a stable happy-stacks-specific scheme unless explicitly overridden.
|
|
13
|
+
const scheme = sanitizeUrlScheme(
|
|
14
|
+
(env.HAPPY_STACKS_MOBILE_SCHEME ??
|
|
15
|
+
env.HAPPY_LOCAL_MOBILE_SCHEME ??
|
|
16
|
+
env.HAPPY_STACKS_DEV_CLIENT_SCHEME ??
|
|
17
|
+
env.HAPPY_LOCAL_DEV_CLIENT_SCHEME ??
|
|
18
|
+
'happystacks-dev')
|
|
19
|
+
.toString()
|
|
20
|
+
);
|
|
21
|
+
const host = (env.HAPPY_STACKS_MOBILE_HOST ?? env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan').toString();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
appEnv,
|
|
25
|
+
iosAppName,
|
|
26
|
+
iosBundleId,
|
|
27
|
+
scheme,
|
|
28
|
+
host,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getEnvValueAny } from '../env/values.mjs';
|
|
2
|
+
import { pickLanIpv4 } from '../net/lan_ip.mjs';
|
|
3
|
+
import { resolveMobileExpoConfig } from './config.mjs';
|
|
4
|
+
|
|
5
|
+
function normalizeHostMode(raw) {
|
|
6
|
+
const v = String(raw ?? '').trim().toLowerCase();
|
|
7
|
+
if (v === 'localhost' || v === 'local') return 'localhost';
|
|
8
|
+
if (v === 'lan' || v === 'ip') return 'lan';
|
|
9
|
+
if (v === 'tunnel') return 'tunnel';
|
|
10
|
+
return v || 'lan';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveMobileHostMode(env = process.env) {
|
|
14
|
+
// Prefer explicit host vars (so TUI/setup-pr match the same knobs Expo uses).
|
|
15
|
+
const raw =
|
|
16
|
+
getEnvValueAny(env, ['HAPPY_STACKS_MOBILE_HOST', 'HAPPY_LOCAL_MOBILE_HOST']) ||
|
|
17
|
+
resolveMobileExpoConfig({ env }).host ||
|
|
18
|
+
'lan';
|
|
19
|
+
return normalizeHostMode(raw);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveMobileScheme(env = process.env) {
|
|
23
|
+
return String(resolveMobileExpoConfig({ env }).scheme || '').trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveMetroUrlForMobile({ env = process.env, port }) {
|
|
27
|
+
const p = Number(port);
|
|
28
|
+
if (!Number.isFinite(p) || p <= 0) return '';
|
|
29
|
+
|
|
30
|
+
const mode = resolveMobileHostMode(env);
|
|
31
|
+
if (mode === 'localhost') {
|
|
32
|
+
return `http://localhost:${p}`;
|
|
33
|
+
}
|
|
34
|
+
if (mode === 'lan') {
|
|
35
|
+
const ip = pickLanIpv4();
|
|
36
|
+
return `http://${ip || 'localhost'}:${p}`;
|
|
37
|
+
}
|
|
38
|
+
// Tunnel URLs are controlled by Expo; we can't reliably derive them locally.
|
|
39
|
+
// Fall back to localhost so the URL is at least correct for the host machine.
|
|
40
|
+
return `http://localhost:${p}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveDevClientDeepLink({ scheme, metroUrl }) {
|
|
44
|
+
const s = String(scheme ?? '').trim();
|
|
45
|
+
const url = String(metroUrl ?? '').trim();
|
|
46
|
+
if (!url) return '';
|
|
47
|
+
if (!s) return url;
|
|
48
|
+
return `${s}://expo-development-client/?url=${encodeURIComponent(url)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveMobileQrPayload({ env = process.env, port }) {
|
|
52
|
+
const metroUrl = resolveMetroUrlForMobile({ env, port });
|
|
53
|
+
const scheme = resolveMobileScheme(env);
|
|
54
|
+
const deepLink = resolveDevClientDeepLink({ scheme, metroUrl });
|
|
55
|
+
// Match Expo CLI / @expo/cli UrlCreator: QR encodes the dev-client deep link.
|
|
56
|
+
// Note: iOS Camera will still offer to open custom schemes when the app is installed.
|
|
57
|
+
const payload = deepLink || metroUrl;
|
|
58
|
+
return { scheme, metroUrl, deepLink, payload };
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function sanitizeToken(raw, { allowDots = false } = {}) {
|
|
2
|
+
const s = (raw ?? '').toString().trim().toLowerCase();
|
|
3
|
+
const re = allowDots ? /[^a-z0-9.-]+/g : /[^a-z0-9-]+/g;
|
|
4
|
+
const out = s.replace(re, '-').replace(/^-+|-+$/g, '').replace(/-+/g, '-');
|
|
5
|
+
return out;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sanitizeBundleIdSegment(s) {
|
|
9
|
+
const seg = sanitizeToken(s, { allowDots: false });
|
|
10
|
+
if (!seg) return 'app';
|
|
11
|
+
// Bundle id segments should not start with a digit; prefix if needed.
|
|
12
|
+
return /^[a-z]/.test(seg) ? seg : `s${seg}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sanitizeUrlScheme(s) {
|
|
16
|
+
// iOS URL schemes must start with a letter and may contain letters/digits/+.-.
|
|
17
|
+
const raw = (s ?? '').toString().trim().toLowerCase();
|
|
18
|
+
const cleaned = raw.replace(/[^a-z0-9+.-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
19
|
+
if (!cleaned) return 'happystacks-dev';
|
|
20
|
+
return /^[a-z]/.test(cleaned) ? cleaned : `h${cleaned}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stackSlugForMobileIds(stackName) {
|
|
24
|
+
const raw = (stackName ?? '').toString().trim();
|
|
25
|
+
return sanitizeBundleIdSegment(raw || 'stack');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function defaultDevClientIdentity({ user = null } = {}) {
|
|
29
|
+
const u = sanitizeBundleIdSegment(user ?? 'user');
|
|
30
|
+
return {
|
|
31
|
+
iosAppName: 'Happy Stacks Dev',
|
|
32
|
+
iosBundleId: `com.happystacks.dev.${u}`,
|
|
33
|
+
scheme: 'happystacks-dev',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function defaultStackReleaseIdentity({ stackName, user = null, appName = null } = {}) {
|
|
38
|
+
const slug = stackSlugForMobileIds(stackName);
|
|
39
|
+
const u = sanitizeBundleIdSegment(user ?? 'user');
|
|
40
|
+
const label = (appName ?? '').toString().trim();
|
|
41
|
+
return {
|
|
42
|
+
iosAppName: label || `Happy (${stackName})`,
|
|
43
|
+
iosBundleId: `com.happystacks.stack.${u}.${slug}`,
|
|
44
|
+
scheme: sanitizeUrlScheme(`happystacks-${slug}`),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
defaultDevClientIdentity,
|
|
6
|
+
defaultStackReleaseIdentity,
|
|
7
|
+
sanitizeBundleIdSegment,
|
|
8
|
+
sanitizeUrlScheme,
|
|
9
|
+
stackSlugForMobileIds,
|
|
10
|
+
} from './identifiers.mjs';
|
|
11
|
+
|
|
12
|
+
test('sanitizeBundleIdSegment produces a safe segment', () => {
|
|
13
|
+
assert.equal(sanitizeBundleIdSegment(' PR272-107 '), 'pr272-107');
|
|
14
|
+
assert.equal(sanitizeBundleIdSegment('---'), 'app');
|
|
15
|
+
assert.equal(sanitizeBundleIdSegment('123'), 's123');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('sanitizeUrlScheme produces a safe scheme', () => {
|
|
19
|
+
assert.equal(sanitizeUrlScheme('HappyStacks-Dev'), 'happystacks-dev');
|
|
20
|
+
assert.equal(sanitizeUrlScheme('123bad'), 'h123bad');
|
|
21
|
+
assert.equal(sanitizeUrlScheme(''), 'happystacks-dev');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('stackSlugForMobileIds derives a stable slug', () => {
|
|
25
|
+
assert.equal(stackSlugForMobileIds('pr272-107-fixes-2026-01-15'), 'pr272-107-fixes-2026-01-15');
|
|
26
|
+
assert.equal(stackSlugForMobileIds(' Weird Name '), 'weird-name');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('defaultDevClientIdentity is stable and safe', () => {
|
|
30
|
+
const id = defaultDevClientIdentity({ user: 'Leeroy' });
|
|
31
|
+
assert.equal(id.iosAppName, 'Happy Stacks Dev');
|
|
32
|
+
assert.equal(id.scheme, 'happystacks-dev');
|
|
33
|
+
assert.equal(id.iosBundleId, 'com.happystacks.dev.leeroy');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('defaultStackReleaseIdentity is per-stack', () => {
|
|
37
|
+
const id = defaultStackReleaseIdentity({ stackName: 'pr272-107', user: 'Leeroy' });
|
|
38
|
+
assert.equal(id.iosBundleId, 'com.happystacks.stack.leeroy.pr272-107');
|
|
39
|
+
assert.equal(id.scheme, 'happystacks-pr272-107');
|
|
40
|
+
assert.equal(id.iosAppName, 'Happy (pr272-107)');
|
|
41
|
+
});
|
|
42
|
+
|