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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
function normalizePath(p) {
|
|
2
|
+
return String(p ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function commonPrefixParts(partsList) {
|
|
6
|
+
if (!partsList.length) return [];
|
|
7
|
+
const first = partsList[0];
|
|
8
|
+
let n = first.length;
|
|
9
|
+
for (const parts of partsList.slice(1)) {
|
|
10
|
+
n = Math.min(n, parts.length, n);
|
|
11
|
+
for (let i = 0; i < n; i += 1) {
|
|
12
|
+
if (parts[i] !== first[i]) {
|
|
13
|
+
n = i;
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return first.slice(0, n);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pathPrefixLabel(parts, { maxDepth = 4 } = {}) {
|
|
22
|
+
const depth = Math.min(parts.length, Math.max(1, maxDepth));
|
|
23
|
+
return parts.slice(0, depth).join('/');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function groupByPrefix(paths, depth) {
|
|
27
|
+
const groups = new Map();
|
|
28
|
+
for (const p of paths) {
|
|
29
|
+
const parts = normalizePath(p).split('/').filter(Boolean);
|
|
30
|
+
const key = parts.slice(0, Math.max(1, Math.min(depth, parts.length))).join('/');
|
|
31
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
32
|
+
groups.get(key).push(p);
|
|
33
|
+
}
|
|
34
|
+
return groups;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Plan review slices that:
|
|
39
|
+
* - cover every changed path exactly once
|
|
40
|
+
* - keep each slice at <= maxFiles where possible
|
|
41
|
+
* - prefer directory-prefix grouping (better reviewer context) over raw batching
|
|
42
|
+
*
|
|
43
|
+
* The output is intended for "HEAD-sliced" review: the reviewer gets a focused diff
|
|
44
|
+
* while still having access to the full repo code at HEAD.
|
|
45
|
+
*/
|
|
46
|
+
export function planPathSlices({ changedPaths, maxFiles = 300, maxPrefixDepth = 6 } = {}) {
|
|
47
|
+
const unique = Array.from(new Set((Array.isArray(changedPaths) ? changedPaths : []).map(normalizePath))).filter(Boolean);
|
|
48
|
+
unique.sort();
|
|
49
|
+
if (!unique.length) return [];
|
|
50
|
+
|
|
51
|
+
const limit = Number.isFinite(maxFiles) && maxFiles > 0 ? Math.floor(maxFiles) : 300;
|
|
52
|
+
if (unique.length <= limit) {
|
|
53
|
+
const parts = unique.map((p) => p.split('/').filter(Boolean));
|
|
54
|
+
const prefix = commonPrefixParts(parts);
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
label: prefix.length ? `${pathPrefixLabel(prefix, { maxDepth: 3 })}/` : 'repo/',
|
|
58
|
+
paths: unique,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// First pass: top-level directories (plus root files).
|
|
64
|
+
const topGroups = groupByPrefix(unique, 1);
|
|
65
|
+
|
|
66
|
+
const slices = [];
|
|
67
|
+
const pushSlice = (label, paths) => {
|
|
68
|
+
const normalized = Array.from(new Set(paths.map(normalizePath))).filter(Boolean).sort();
|
|
69
|
+
if (!normalized.length) return;
|
|
70
|
+
slices.push({ label, paths: normalized });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (const [top, paths] of topGroups.entries()) {
|
|
74
|
+
if (paths.length <= limit) {
|
|
75
|
+
pushSlice(top.includes('/') ? top : `${top}/`, paths);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Iteratively refine prefix depth within this group until all chunks are <= limit.
|
|
80
|
+
let pending = [{ label: top, paths }];
|
|
81
|
+
for (let depth = 2; depth <= maxPrefixDepth && pending.some((x) => x.paths.length > limit); depth += 1) {
|
|
82
|
+
const next = [];
|
|
83
|
+
for (const item of pending) {
|
|
84
|
+
if (item.paths.length <= limit) {
|
|
85
|
+
next.push(item);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const groups = groupByPrefix(item.paths, depth);
|
|
89
|
+
if (groups.size <= 1) {
|
|
90
|
+
next.push(item);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const [k, v] of groups.entries()) {
|
|
94
|
+
next.push({ label: k, paths: v });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
pending = next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Final pass: pack refined groups into <=limit windows (greedy, stable order).
|
|
101
|
+
pending.sort((a, b) => a.label.localeCompare(b.label));
|
|
102
|
+
let bucket = [];
|
|
103
|
+
let bucketCount = 0;
|
|
104
|
+
let bucketLabelParts = [];
|
|
105
|
+
const flush = () => {
|
|
106
|
+
if (!bucket.length) return;
|
|
107
|
+
const parts = commonPrefixParts(bucketLabelParts);
|
|
108
|
+
const label = parts.length ? `${pathPrefixLabel(parts, { maxDepth: 4 })}/` : `${top}/`;
|
|
109
|
+
pushSlice(label, bucket);
|
|
110
|
+
bucket = [];
|
|
111
|
+
bucketCount = 0;
|
|
112
|
+
bucketLabelParts = [];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
for (const g of pending) {
|
|
116
|
+
const n = g.paths.length;
|
|
117
|
+
if (n > limit) {
|
|
118
|
+
// Fall back to raw batching for truly massive groups (rare).
|
|
119
|
+
flush();
|
|
120
|
+
for (let i = 0; i < g.paths.length; i += limit) {
|
|
121
|
+
const batch = g.paths.slice(i, i + limit);
|
|
122
|
+
pushSlice(`${g.label}/`, batch);
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (bucketCount + n > limit) {
|
|
127
|
+
flush();
|
|
128
|
+
}
|
|
129
|
+
bucket.push(...g.paths);
|
|
130
|
+
bucketCount += n;
|
|
131
|
+
bucketLabelParts.push(g.label.split('/').filter(Boolean));
|
|
132
|
+
}
|
|
133
|
+
flush();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Stable ordering helps humans follow progress.
|
|
137
|
+
slices.sort((a, b) => a.label.localeCompare(b.label));
|
|
138
|
+
return slices;
|
|
139
|
+
}
|
|
140
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { planPathSlices } from './slices.mjs';
|
|
4
|
+
|
|
5
|
+
test('planPathSlices returns empty for no paths', () => {
|
|
6
|
+
assert.deepEqual(planPathSlices({ changedPaths: [], maxFiles: 3 }), []);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('planPathSlices creates a single slice when under maxFiles', () => {
|
|
10
|
+
const slices = planPathSlices({
|
|
11
|
+
changedPaths: ['expo-app/a.txt', 'cli/b.txt', 'server/c.txt'],
|
|
12
|
+
maxFiles: 10,
|
|
13
|
+
});
|
|
14
|
+
assert.equal(slices.length, 1);
|
|
15
|
+
assert.deepEqual(slices[0].paths, ['cli/b.txt', 'expo-app/a.txt', 'server/c.txt']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('planPathSlices splits large groups by prefix depth and respects maxFiles', () => {
|
|
19
|
+
const changedPaths = [
|
|
20
|
+
...Array.from({ length: 6 }, (_, i) => `expo-app/sources/a${i}.ts`),
|
|
21
|
+
...Array.from({ length: 6 }, (_, i) => `expo-app/sources/b${i}.ts`),
|
|
22
|
+
...Array.from({ length: 2 }, (_, i) => `cli/src/x${i}.ts`),
|
|
23
|
+
];
|
|
24
|
+
const slices = planPathSlices({ changedPaths, maxFiles: 5, maxPrefixDepth: 4 });
|
|
25
|
+
assert.ok(slices.length > 1);
|
|
26
|
+
for (const s of slices) {
|
|
27
|
+
assert.ok(s.paths.length <= 5, `slice ${s.label} exceeded maxFiles`);
|
|
28
|
+
}
|
|
29
|
+
const all = slices.flatMap((s) => s.paths).sort();
|
|
30
|
+
assert.deepEqual(all, Array.from(new Set(changedPaths)).sort());
|
|
31
|
+
});
|
|
32
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getComponentsDir, getComponentDir } from '../paths/paths.mjs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function isStackMode(env = process.env) {
|
|
5
|
+
const stack = String(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').trim();
|
|
6
|
+
const envFile = String(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
|
|
7
|
+
return Boolean(stack && envFile);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function defaultComponentCheckoutDir(rootDir, component) {
|
|
11
|
+
return join(getComponentsDir(rootDir), component);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveDefaultStackReviewComponents({ rootDir, components }) {
|
|
15
|
+
const list = Array.isArray(components) ? components : [];
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const c of list) {
|
|
18
|
+
const effective = getComponentDir(rootDir, c);
|
|
19
|
+
const def = defaultComponentCheckoutDir(rootDir, c);
|
|
20
|
+
if (effective !== def) out.push(c);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolveDefaultStackReviewComponents } from './targets.mjs';
|
|
4
|
+
|
|
5
|
+
test('resolveDefaultStackReviewComponents returns only non-default pinned components', () => {
|
|
6
|
+
const rootDir = '/tmp/hs-root';
|
|
7
|
+
const keys = [
|
|
8
|
+
'HAPPY_STACKS_WORKSPACE_DIR',
|
|
9
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY',
|
|
10
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT',
|
|
11
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI',
|
|
12
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER',
|
|
13
|
+
];
|
|
14
|
+
const old = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
15
|
+
try {
|
|
16
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = '/tmp/hs-root';
|
|
17
|
+
// Default checkouts
|
|
18
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY = '';
|
|
19
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = '';
|
|
20
|
+
// Pinned overrides
|
|
21
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = '/tmp/custom/happy-cli';
|
|
22
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = '/tmp/custom/happy-server';
|
|
23
|
+
|
|
24
|
+
const comps = resolveDefaultStackReviewComponents({
|
|
25
|
+
rootDir,
|
|
26
|
+
components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
|
|
27
|
+
});
|
|
28
|
+
assert.deepEqual(comps.sort(), ['happy-cli', 'happy-server'].sort());
|
|
29
|
+
} finally {
|
|
30
|
+
for (const k of keys) {
|
|
31
|
+
if (old[k] == null) delete process.env[k];
|
|
32
|
+
else process.env[k] = old[k];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const REVIEW_PR_MARKER_FILENAME = '.happy-stacks-sandbox-marker';
|
|
7
|
+
export const REVIEW_PR_META_FILENAME = '.happy-stacks-review-pr.json';
|
|
8
|
+
|
|
9
|
+
export function reviewPrSandboxPrefixBase(baseStackName) {
|
|
10
|
+
const base = String(baseStackName ?? '').trim() || 'pr';
|
|
11
|
+
// Keep prefix stable for listing/reuse; mkdtemp adds a random suffix.
|
|
12
|
+
return `happy-stacks-review-pr-${base}-`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function reviewPrSandboxPrefixPath(baseStackName) {
|
|
16
|
+
return join(tmpdir(), reviewPrSandboxPrefixBase(baseStackName));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readJsonBestEffort(path) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(path, 'utf-8');
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function listReviewPrSandboxes({ baseStackName }) {
|
|
29
|
+
const prefixBase = reviewPrSandboxPrefixBase(baseStackName);
|
|
30
|
+
const root = tmpdir();
|
|
31
|
+
let entries = [];
|
|
32
|
+
try {
|
|
33
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
34
|
+
} catch {
|
|
35
|
+
entries = [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
if (!e.isDirectory()) continue;
|
|
41
|
+
if (!e.name.startsWith(prefixBase)) continue;
|
|
42
|
+
const dir = resolve(join(root, e.name));
|
|
43
|
+
const markerPath = join(dir, REVIEW_PR_MARKER_FILENAME);
|
|
44
|
+
if (!existsSync(markerPath)) continue;
|
|
45
|
+
|
|
46
|
+
let markerOk = false;
|
|
47
|
+
try {
|
|
48
|
+
const marker = await readFile(markerPath, 'utf-8');
|
|
49
|
+
markerOk = marker.trim().startsWith('review-pr');
|
|
50
|
+
} catch {
|
|
51
|
+
markerOk = false;
|
|
52
|
+
}
|
|
53
|
+
if (!markerOk) continue;
|
|
54
|
+
|
|
55
|
+
const metaPath = join(dir, REVIEW_PR_META_FILENAME);
|
|
56
|
+
const meta = await readJsonBestEffort(metaPath);
|
|
57
|
+
|
|
58
|
+
let mtimeMs = 0;
|
|
59
|
+
try {
|
|
60
|
+
mtimeMs = (await stat(dir)).mtimeMs;
|
|
61
|
+
} catch {
|
|
62
|
+
mtimeMs = 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
out.push({
|
|
66
|
+
dir,
|
|
67
|
+
name: basename(dir),
|
|
68
|
+
markerPath,
|
|
69
|
+
metaPath,
|
|
70
|
+
baseStackName: String(meta?.baseStackName ?? '').trim() || String(baseStackName ?? '').trim() || null,
|
|
71
|
+
stackName: String(meta?.stackName ?? '').trim() || null,
|
|
72
|
+
createdAtMs: typeof meta?.createdAtMs === 'number' ? meta.createdAtMs : null,
|
|
73
|
+
lastTouchedAtMs: Number.isFinite(mtimeMs) ? mtimeMs : null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
out.sort((a, b) => (b.lastTouchedAtMs ?? 0) - (a.lastTouchedAtMs ?? 0));
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function writeReviewPrSandboxMeta({ sandboxDir, baseStackName, stackName, argv }) {
|
|
82
|
+
const dir = resolve(String(sandboxDir ?? '').trim());
|
|
83
|
+
const markerPath = join(dir, REVIEW_PR_MARKER_FILENAME);
|
|
84
|
+
const metaPath = join(dir, REVIEW_PR_META_FILENAME);
|
|
85
|
+
|
|
86
|
+
// Marker (for safe deletion) + meta (for reuse menu).
|
|
87
|
+
await writeFile(markerPath, 'review-pr\n', 'utf-8');
|
|
88
|
+
await writeFile(
|
|
89
|
+
metaPath,
|
|
90
|
+
JSON.stringify(
|
|
91
|
+
{
|
|
92
|
+
kind: 'review-pr',
|
|
93
|
+
createdAtMs: Date.now(),
|
|
94
|
+
baseStackName: String(baseStackName ?? '').trim() || null,
|
|
95
|
+
stackName: String(stackName ?? '').trim() || null,
|
|
96
|
+
argv: Array.isArray(argv) ? argv : null,
|
|
97
|
+
},
|
|
98
|
+
null,
|
|
99
|
+
2
|
|
100
|
+
) + '\n',
|
|
101
|
+
'utf-8'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return { markerPath, metaPath };
|
|
105
|
+
}
|
|
106
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
function readScripts(serverDir) {
|
|
6
|
+
try {
|
|
7
|
+
const pkgPath = join(serverDir, 'package.json');
|
|
8
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
9
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
10
|
+
return scripts;
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasScript(scripts, name) {
|
|
17
|
+
return typeof scripts?.[name] === 'string' && scripts[name].trim().length > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isUnifiedHappyServerLight({ serverDir }) {
|
|
21
|
+
return (
|
|
22
|
+
existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma')) ||
|
|
23
|
+
existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma'))
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveServerLightPrismaSchemaArgs({ serverDir }) {
|
|
28
|
+
if (existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))) {
|
|
29
|
+
return ['--schema', 'prisma/sqlite/schema.prisma'];
|
|
30
|
+
}
|
|
31
|
+
if (existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma'))) {
|
|
32
|
+
return ['--schema', 'prisma/schema.sqlite.prisma'];
|
|
33
|
+
}
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveServerLightPrismaMigrateDeployArgs({ serverDir }) {
|
|
38
|
+
return ['migrate', 'deploy', ...resolveServerLightPrismaSchemaArgs({ serverDir })];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveServerLightPrismaClientImport({ serverDir }) {
|
|
42
|
+
if (!isUnifiedHappyServerLight({ serverDir })) {
|
|
43
|
+
return '@prisma/client';
|
|
44
|
+
}
|
|
45
|
+
const clientPath = join(serverDir, 'generated', 'sqlite-client', 'index.js');
|
|
46
|
+
return pathToFileURL(clientPath).href;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolvePrismaClientImportForServerComponent({ serverComponentName, serverComponent, serverDir }) {
|
|
50
|
+
const name = serverComponentName ?? serverComponent;
|
|
51
|
+
if (name === 'happy-server-light') {
|
|
52
|
+
return resolveServerLightPrismaClientImport({ serverDir });
|
|
53
|
+
}
|
|
54
|
+
return '@prisma/client';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveServerDevScript({ serverComponentName, serverDir, prismaPush }) {
|
|
58
|
+
const scripts = readScripts(serverDir);
|
|
59
|
+
|
|
60
|
+
if (serverComponentName === 'happy-server') {
|
|
61
|
+
return 'start';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (serverComponentName === 'happy-server-light') {
|
|
65
|
+
const unified = isUnifiedHappyServerLight({ serverDir });
|
|
66
|
+
if (unified) {
|
|
67
|
+
// Server-light now relies on deterministic migrations (not db push).
|
|
68
|
+
// Prefer the dedicated dev script that runs migrate deploy before starting.
|
|
69
|
+
if (hasScript(scripts, 'dev:light')) {
|
|
70
|
+
return 'dev:light';
|
|
71
|
+
}
|
|
72
|
+
// Fallback: no dev script, run the light start script.
|
|
73
|
+
return hasScript(scripts, 'start:light') ? 'start:light' : 'start';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Legacy behavior: prefer `dev` for older happy-server-light checkouts.
|
|
77
|
+
if (prismaPush) {
|
|
78
|
+
return hasScript(scripts, 'dev') ? 'dev' : 'start';
|
|
79
|
+
}
|
|
80
|
+
return hasScript(scripts, 'start') ? 'start' : 'dev';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Unknown: be conservative.
|
|
84
|
+
return 'start';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resolveServerStartScript({ serverComponentName, serverDir }) {
|
|
88
|
+
const scripts = readScripts(serverDir);
|
|
89
|
+
|
|
90
|
+
if (serverComponentName === 'happy-server-light') {
|
|
91
|
+
const unified = isUnifiedHappyServerLight({ serverDir });
|
|
92
|
+
if (unified && hasScript(scripts, 'start:light')) {
|
|
93
|
+
return 'start:light';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return 'start';
|
|
98
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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 {
|
|
8
|
+
resolvePrismaClientImportForServerComponent,
|
|
9
|
+
resolveServerDevScript,
|
|
10
|
+
resolveServerLightPrismaClientImport,
|
|
11
|
+
resolveServerLightPrismaMigrateDeployArgs,
|
|
12
|
+
resolveServerStartScript,
|
|
13
|
+
} from './flavor_scripts.mjs';
|
|
14
|
+
|
|
15
|
+
async function writeJson(path, obj) {
|
|
16
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('resolveServer*Script uses light scripts when unified light flavor is detected', async () => {
|
|
20
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
21
|
+
try {
|
|
22
|
+
await mkdir(join(dir, 'prisma', 'sqlite'), { recursive: true });
|
|
23
|
+
await writeFile(join(dir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
24
|
+
await writeJson(join(dir, 'package.json'), { scripts: { 'start:light': 'node x', 'dev:light': 'node y' } });
|
|
25
|
+
|
|
26
|
+
assert.equal(resolveServerDevScript({ serverComponentName: 'happy-server-light', serverDir: dir, prismaPush: true }), 'dev:light');
|
|
27
|
+
assert.equal(resolveServerDevScript({ serverComponentName: 'happy-server-light', serverDir: dir, prismaPush: false }), 'dev:light');
|
|
28
|
+
assert.equal(resolveServerStartScript({ serverComponentName: 'happy-server-light', serverDir: dir }), 'start:light');
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('resolveServer*Script falls back to legacy scripts for non-unified happy-server-light', async () => {
|
|
35
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
36
|
+
try {
|
|
37
|
+
await writeJson(join(dir, 'package.json'), { scripts: { start: 'node start', dev: 'node dev' } });
|
|
38
|
+
|
|
39
|
+
assert.equal(resolveServerDevScript({ serverComponentName: 'happy-server-light', serverDir: dir, prismaPush: true }), 'dev');
|
|
40
|
+
assert.equal(resolveServerDevScript({ serverComponentName: 'happy-server-light', serverDir: dir, prismaPush: false }), 'start');
|
|
41
|
+
assert.equal(resolveServerStartScript({ serverComponentName: 'happy-server-light', serverDir: dir }), 'start');
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(dir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('resolveServer*Script returns start for happy-server', async () => {
|
|
48
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
49
|
+
try {
|
|
50
|
+
await writeJson(join(dir, 'package.json'), { scripts: { start: 'node start', dev: 'node dev' } });
|
|
51
|
+
|
|
52
|
+
assert.equal(resolveServerDevScript({ serverComponentName: 'happy-server', serverDir: dir, prismaPush: true }), 'start');
|
|
53
|
+
assert.equal(resolveServerDevScript({ serverComponentName: 'happy-server', serverDir: dir, prismaPush: false }), 'start');
|
|
54
|
+
assert.equal(resolveServerStartScript({ serverComponentName: 'happy-server', serverDir: dir }), 'start');
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('resolveServerLightPrismaMigrateDeployArgs adds --schema when unified light flavor is detected', async () => {
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
62
|
+
try {
|
|
63
|
+
await mkdir(join(dir, 'prisma', 'sqlite'), { recursive: true });
|
|
64
|
+
await writeFile(join(dir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(resolveServerLightPrismaMigrateDeployArgs({ serverDir: dir }), ['migrate', 'deploy', '--schema', 'prisma/sqlite/schema.prisma']);
|
|
67
|
+
} finally {
|
|
68
|
+
await rm(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('resolveServerLightPrismaMigrateDeployArgs supports legacy schema.sqlite.prisma', async () => {
|
|
73
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
74
|
+
try {
|
|
75
|
+
await mkdir(join(dir, 'prisma'), { recursive: true });
|
|
76
|
+
await writeFile(join(dir, 'prisma', 'schema.sqlite.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
77
|
+
|
|
78
|
+
assert.deepEqual(resolveServerLightPrismaMigrateDeployArgs({ serverDir: dir }), ['migrate', 'deploy', '--schema', 'prisma/schema.sqlite.prisma']);
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('resolveServerLightPrismaClientImport returns file URL when unified light flavor is detected', async () => {
|
|
85
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
86
|
+
try {
|
|
87
|
+
await mkdir(join(dir, 'prisma', 'sqlite'), { recursive: true });
|
|
88
|
+
await writeFile(join(dir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
89
|
+
|
|
90
|
+
const spec = resolveServerLightPrismaClientImport({ serverDir: dir });
|
|
91
|
+
assert.equal(typeof spec, 'string');
|
|
92
|
+
assert.ok(spec.startsWith('file:'), `expected file: URL import spec, got: ${spec}`);
|
|
93
|
+
assert.ok(spec.endsWith('/generated/sqlite-client/index.js'), `unexpected import spec: ${spec}`);
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('resolveServerLightPrismaClientImport returns @prisma/client for legacy happy-server-light', async () => {
|
|
100
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
101
|
+
try {
|
|
102
|
+
assert.equal(resolveServerLightPrismaClientImport({ serverDir: dir }), '@prisma/client');
|
|
103
|
+
assert.deepEqual(resolveServerLightPrismaMigrateDeployArgs({ serverDir: dir }), ['migrate', 'deploy']);
|
|
104
|
+
} finally {
|
|
105
|
+
await rm(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('resolvePrismaClientImportForServerComponent returns sqlite client file URL for unified server-light', async () => {
|
|
110
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
111
|
+
try {
|
|
112
|
+
await mkdir(join(dir, 'prisma', 'sqlite'), { recursive: true });
|
|
113
|
+
await writeFile(join(dir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
114
|
+
|
|
115
|
+
const spec = resolvePrismaClientImportForServerComponent({ serverComponentName: 'happy-server-light', serverDir: dir });
|
|
116
|
+
assert.equal(typeof spec, 'string');
|
|
117
|
+
assert.ok(spec.startsWith('file:'), `expected file: URL import spec, got: ${spec}`);
|
|
118
|
+
assert.ok(spec.endsWith('/generated/sqlite-client/index.js'), `unexpected import spec: ${spec}`);
|
|
119
|
+
} finally {
|
|
120
|
+
await rm(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('resolvePrismaClientImportForServerComponent accepts serverComponent alias (back-compat)', async () => {
|
|
125
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
126
|
+
try {
|
|
127
|
+
await mkdir(join(dir, 'prisma', 'sqlite'), { recursive: true });
|
|
128
|
+
await writeFile(join(dir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
|
|
129
|
+
|
|
130
|
+
const spec = resolvePrismaClientImportForServerComponent({ serverComponent: 'happy-server-light', serverDir: dir });
|
|
131
|
+
assert.equal(typeof spec, 'string');
|
|
132
|
+
assert.ok(spec.startsWith('file:'), `expected file: URL import spec, got: ${spec}`);
|
|
133
|
+
assert.ok(spec.endsWith('/generated/sqlite-client/index.js'), `unexpected import spec: ${spec}`);
|
|
134
|
+
} finally {
|
|
135
|
+
await rm(dir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('resolvePrismaClientImportForServerComponent returns @prisma/client for happy-server', async () => {
|
|
140
|
+
const dir = await mkdtemp(join(tmpdir(), 'hs-flavor-scripts-'));
|
|
141
|
+
try {
|
|
142
|
+
assert.equal(resolvePrismaClientImportForServerComponent({ serverComponentName: 'happy-server', serverDir: dir }), '@prisma/client');
|
|
143
|
+
} finally {
|
|
144
|
+
await rm(dir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { pickLanIpv4 } from '../net/lan_ip.mjs';
|
|
2
|
+
import { normalizeUrlNoTrailingSlash } from '../net/url.mjs';
|
|
3
|
+
|
|
4
|
+
function resolveLanIp({ env = process.env } = {}) {
|
|
5
|
+
const raw = (env.HAPPY_STACKS_LAN_IP ?? env.HAPPY_LOCAL_LAN_IP ?? '').toString().trim();
|
|
6
|
+
return raw || pickLanIpv4() || '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isLocalHostName(hostname) {
|
|
10
|
+
const h = String(hostname ?? '').trim().toLowerCase();
|
|
11
|
+
if (!h) return false;
|
|
12
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') return true;
|
|
13
|
+
if (h.endsWith('.localhost')) return true;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* For mobile devices, `localhost` and `*.localhost` are not reachable.
|
|
19
|
+
*
|
|
20
|
+
* This helper rewrites any local server URL to a LAN-reachable URL using the machine's LAN IPv4.
|
|
21
|
+
* It preserves protocol, port, and path/query.
|
|
22
|
+
*
|
|
23
|
+
* Notes:
|
|
24
|
+
* - If the URL is already non-local (e.g. Tailscale HTTPS), it is returned unchanged.
|
|
25
|
+
* - If LAN IP cannot be determined, it returns the original URL unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveMobileReachableServerUrl({
|
|
28
|
+
env = process.env,
|
|
29
|
+
serverUrl,
|
|
30
|
+
serverPort,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const raw = String(serverUrl ?? '').trim();
|
|
33
|
+
const fallbackPort = Number(serverPort);
|
|
34
|
+
const fallback = Number.isFinite(fallbackPort) && fallbackPort > 0 ? `http://localhost:${fallbackPort}` : '';
|
|
35
|
+
const base = raw || fallback;
|
|
36
|
+
if (!base) return '';
|
|
37
|
+
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = new URL(base);
|
|
41
|
+
} catch {
|
|
42
|
+
return base;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
46
|
+
return base;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!isLocalHostName(parsed.hostname)) {
|
|
50
|
+
return normalizeUrlNoTrailingSlash(parsed.toString());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lanIp = resolveLanIp({ env });
|
|
54
|
+
if (!lanIp) {
|
|
55
|
+
return normalizeUrlNoTrailingSlash(parsed.toString());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
parsed.hostname = lanIp;
|
|
59
|
+
return normalizeUrlNoTrailingSlash(parsed.toString());
|
|
60
|
+
}
|
|
61
|
+
|