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,19 @@
|
|
|
1
|
+
import { runCaptureResult } from '../../proc/proc.mjs';
|
|
2
|
+
|
|
3
|
+
export async function runCodeRabbitReview({ repoDir, baseRef, env }) {
|
|
4
|
+
const args = [
|
|
5
|
+
'review',
|
|
6
|
+
'--plain',
|
|
7
|
+
'--no-color',
|
|
8
|
+
'--type',
|
|
9
|
+
'all',
|
|
10
|
+
'--cwd',
|
|
11
|
+
repoDir,
|
|
12
|
+
];
|
|
13
|
+
if (baseRef) {
|
|
14
|
+
args.push('--base', baseRef);
|
|
15
|
+
}
|
|
16
|
+
const res = await runCaptureResult('coderabbit', args, { cwd: repoDir, env });
|
|
17
|
+
return { ...res, stdout: res.out, stderr: res.err };
|
|
18
|
+
}
|
|
19
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { runCaptureResult } from '../../proc/proc.mjs';
|
|
2
|
+
|
|
3
|
+
export function extractCodexReviewFromJsonl(jsonlText) {
|
|
4
|
+
const lines = String(jsonlText ?? '')
|
|
5
|
+
.split('\n')
|
|
6
|
+
.map((l) => l.trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
|
|
9
|
+
// JSONL events typically look like: { "type": "...", "payload": {...} } or similar.
|
|
10
|
+
// We keep this resilient by searching for keys matching the exec output format.
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
let obj = null;
|
|
13
|
+
try {
|
|
14
|
+
obj = JSON.parse(line);
|
|
15
|
+
} catch {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const msg = obj?.msg ?? obj?.payload ?? obj;
|
|
19
|
+
// We’ve observed EventMsg names like "ExitedReviewMode" in Codex protocol events.
|
|
20
|
+
// Accept several shapes:
|
|
21
|
+
// - { msg: { ExitedReviewMode: { review_output: {...} } } }
|
|
22
|
+
// - { type: "ExitedReviewMode", review_output: {...} }
|
|
23
|
+
const exited =
|
|
24
|
+
msg?.ExitedReviewMode ??
|
|
25
|
+
(obj?.type === 'ExitedReviewMode' ? obj : null) ??
|
|
26
|
+
(msg?.type === 'ExitedReviewMode' ? msg : null);
|
|
27
|
+
|
|
28
|
+
const reviewOutput = exited?.review_output ?? exited?.reviewOutput ?? null;
|
|
29
|
+
if (reviewOutput) return reviewOutput;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runCodexReview({ repoDir, baseRef, env, jsonMode }) {
|
|
35
|
+
const args = ['review', '--cd', repoDir, '--color=never'];
|
|
36
|
+
|
|
37
|
+
if (baseRef) {
|
|
38
|
+
args.push('--base', baseRef);
|
|
39
|
+
} else {
|
|
40
|
+
// Codex requires one of --uncommitted/--base/--commit/prompt; baseRef should exist in our flow.
|
|
41
|
+
args.push('--uncommitted');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (jsonMode) {
|
|
45
|
+
args.push('--json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const res = await runCaptureResult('codex', args, { cwd: repoDir, env });
|
|
49
|
+
return { ...res, stdout: res.out, stderr: res.err };
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -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
|
+
|
|
@@ -1,50 +1,18 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
5
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
.toString('base64')
|
|
16
|
-
.replaceAll('+', '-')
|
|
17
|
-
.replaceAll('/', '_')
|
|
18
|
-
.replaceAll('=', '');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function randomToken(lenBytes = 24) {
|
|
22
|
-
return base64Url(randomBytes(lenBytes));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
|
|
26
|
-
const s = String(raw ?? '')
|
|
27
|
-
.toLowerCase()
|
|
28
|
-
.replace(/[^a-z0-9-]+/g, '-')
|
|
29
|
-
.replace(/-+/g, '-')
|
|
30
|
-
.replace(/^-+/, '')
|
|
31
|
-
.replace(/-+$/, '');
|
|
32
|
-
return s || fallback;
|
|
33
|
-
}
|
|
6
|
+
import { ensureEnvFileUpdated } from '../../env/env_file.mjs';
|
|
7
|
+
import { readEnvObjectFromFile } from '../../env/read.mjs';
|
|
8
|
+
import { sanitizeDnsLabel } from '../../net/dns.mjs';
|
|
9
|
+
import { pickNextFreeTcpPort } from '../../net/ports.mjs';
|
|
10
|
+
import { pmExecBin } from '../../proc/pm.mjs';
|
|
11
|
+
import { run, runCapture } from '../../proc/proc.mjs';
|
|
12
|
+
import { randomToken } from '../../crypto/tokens.mjs';
|
|
13
|
+
import { coercePort, INFRA_RESERVED_PORT_KEYS } from '../port.mjs';
|
|
34
14
|
|
|
35
|
-
|
|
36
|
-
const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN;
|
|
37
|
-
return Number.isFinite(n) && n > 0 ? n : null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function readEnvObject(envPath) {
|
|
41
|
-
try {
|
|
42
|
-
const raw = await readFile(envPath, 'utf-8');
|
|
43
|
-
return Object.fromEntries(parseDotenv(raw).entries());
|
|
44
|
-
} catch {
|
|
45
|
-
return {};
|
|
46
|
-
}
|
|
47
|
-
}
|
|
15
|
+
const readEnvObject = readEnvObjectFromFile;
|
|
48
16
|
|
|
49
17
|
async function ensureTextFile({ path, generate }) {
|
|
50
18
|
if (existsSync(path)) {
|
|
@@ -318,14 +286,7 @@ export async function ensureHappyServerManagedInfra({
|
|
|
318
286
|
const reservedPorts = new Set();
|
|
319
287
|
|
|
320
288
|
// Reserve known ports (if present) to avoid picking duplicates when auto-filling.
|
|
321
|
-
for (const key of
|
|
322
|
-
'HAPPY_STACKS_SERVER_PORT',
|
|
323
|
-
'HAPPY_LOCAL_SERVER_PORT',
|
|
324
|
-
'HAPPY_STACKS_PG_PORT',
|
|
325
|
-
'HAPPY_STACKS_REDIS_PORT',
|
|
326
|
-
'HAPPY_STACKS_MINIO_PORT',
|
|
327
|
-
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
328
|
-
]) {
|
|
289
|
+
for (const key of INFRA_RESERVED_PORT_KEYS) {
|
|
329
290
|
const p = coercePort(existingEnv[key] ?? env[key]);
|
|
330
291
|
if (p) reservedPorts.add(p);
|
|
331
292
|
}
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { resolveMobileReachableServerUrl } from './mobile_api_url.mjs';
|
|
5
|
+
|
|
6
|
+
test('resolveMobileReachableServerUrl rewrites localhost to LAN IP (env override)', () => {
|
|
7
|
+
const out = resolveMobileReachableServerUrl({
|
|
8
|
+
env: { HAPPY_STACKS_LAN_IP: '192.168.0.50' },
|
|
9
|
+
serverUrl: 'http://localhost:3005',
|
|
10
|
+
serverPort: 3005,
|
|
11
|
+
});
|
|
12
|
+
assert.equal(out, 'http://192.168.0.50:3005');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('resolveMobileReachableServerUrl rewrites *.localhost to LAN IP (env override)', () => {
|
|
16
|
+
const out = resolveMobileReachableServerUrl({
|
|
17
|
+
env: { HAPPY_STACKS_LAN_IP: '10.0.0.12' },
|
|
18
|
+
serverUrl: 'http://happy-exp1.localhost:3009/',
|
|
19
|
+
serverPort: 3009,
|
|
20
|
+
});
|
|
21
|
+
assert.equal(out, 'http://10.0.0.12:3009');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('resolveMobileReachableServerUrl preserves path and query', () => {
|
|
25
|
+
const out = resolveMobileReachableServerUrl({
|
|
26
|
+
env: { HAPPY_STACKS_LAN_IP: '10.0.0.12' },
|
|
27
|
+
serverUrl: 'http://127.0.0.1:3005/api?x=1',
|
|
28
|
+
serverPort: 3005,
|
|
29
|
+
});
|
|
30
|
+
assert.equal(out, 'http://10.0.0.12:3005/api?x=1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('resolveMobileReachableServerUrl does not rewrite non-local URLs', () => {
|
|
34
|
+
const out = resolveMobileReachableServerUrl({
|
|
35
|
+
env: { HAPPY_STACKS_LAN_IP: '192.168.0.50' },
|
|
36
|
+
serverUrl: 'https://my-machine.tailnet.ts.net',
|
|
37
|
+
serverPort: 3005,
|
|
38
|
+
});
|
|
39
|
+
assert.equal(out, 'https://my-machine.tailnet.ts.net');
|
|
40
|
+
});
|
|
41
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readEnvValueFromFile } from '../env/read.mjs';
|
|
2
|
+
|
|
3
|
+
export const STACK_RESERVED_PORT_KEYS = [
|
|
4
|
+
'HAPPY_STACKS_SERVER_PORT',
|
|
5
|
+
'HAPPY_LOCAL_SERVER_PORT',
|
|
6
|
+
'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
|
|
7
|
+
'HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT',
|
|
8
|
+
'HAPPY_STACKS_PG_PORT',
|
|
9
|
+
'HAPPY_STACKS_REDIS_PORT',
|
|
10
|
+
'HAPPY_STACKS_MINIO_PORT',
|
|
11
|
+
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const INFRA_RESERVED_PORT_KEYS = [
|
|
15
|
+
'HAPPY_STACKS_SERVER_PORT',
|
|
16
|
+
'HAPPY_LOCAL_SERVER_PORT',
|
|
17
|
+
'HAPPY_STACKS_PG_PORT',
|
|
18
|
+
'HAPPY_STACKS_REDIS_PORT',
|
|
19
|
+
'HAPPY_STACKS_MINIO_PORT',
|
|
20
|
+
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function coercePort(v) {
|
|
24
|
+
const s = String(v ?? '').trim();
|
|
25
|
+
if (!s) return null;
|
|
26
|
+
const n = Number(s);
|
|
27
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
|
|
31
|
+
const raw =
|
|
32
|
+
(env.HAPPY_STACKS_SERVER_PORT ?? '').toString().trim() ||
|
|
33
|
+
(env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim() ||
|
|
34
|
+
'';
|
|
35
|
+
const n = raw ? Number(raw) : Number(defaultPort);
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function listPortsFromEnvObject(env, keys) {
|
|
40
|
+
const obj = env && typeof env === 'object' ? env : {};
|
|
41
|
+
const list = Array.isArray(keys) ? keys : [];
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const k of list) {
|
|
44
|
+
const p = coercePort(obj[k]);
|
|
45
|
+
if (p) out.push(p);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readServerPortFromEnvFile(envPath, { defaultPort = 3005 } = {}) {
|
|
51
|
+
const v =
|
|
52
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_STACKS_SERVER_PORT')) ||
|
|
53
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_LOCAL_SERVER_PORT')) ||
|
|
54
|
+
'';
|
|
55
|
+
const n = v ? Number(String(v).trim()) : Number(defaultPort);
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// For stack env files, "missing" means "ephemeral stack" (no pinned port).
|
|
60
|
+
export async function readPinnedServerPortFromEnvFile(envPath) {
|
|
61
|
+
const v =
|
|
62
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_STACKS_SERVER_PORT')) ||
|
|
63
|
+
(await readEnvValueFromFile(envPath, 'HAPPY_LOCAL_SERVER_PORT')) ||
|
|
64
|
+
'';
|
|
65
|
+
const n = v ? Number(String(v).trim()) : NaN;
|
|
66
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -59,6 +59,18 @@ export async function isHappyServerRunning(baseUrl) {
|
|
|
59
59
|
return true;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export async function waitForHappyHealthOk(baseUrl, { timeoutMs = 60_000, intervalMs = 300 } = {}) {
|
|
63
|
+
const deadline = Date.now() + timeoutMs;
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
// eslint-disable-next-line no-await-in-loop
|
|
66
|
+
const health = await fetchHappyHealth(baseUrl);
|
|
67
|
+
if (health.ok) return true;
|
|
68
|
+
// eslint-disable-next-line no-await-in-loop
|
|
69
|
+
await delay(intervalMs);
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
export async function waitForServerReady(url) {
|
|
63
75
|
const deadline = Date.now() + 60_000;
|
|
64
76
|
while (Date.now() < deadline) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
4
|
+
import { preferStackLocalhostUrl } from '../paths/localhost_host.mjs';
|
|
5
|
+
import { resolvePublicServerUrl } from '../../tailscale.mjs';
|
|
6
|
+
import { resolveServerPortFromEnv } from './port.mjs';
|
|
7
|
+
import { normalizeUrlNoTrailingSlash } from '../net/url.mjs';
|
|
8
|
+
|
|
9
|
+
function stackEnvExplicitlySetsPublicUrl({ env, stackName }) {
|
|
10
|
+
try {
|
|
11
|
+
const envPath =
|
|
12
|
+
(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
|
|
13
|
+
resolveStackEnvPath(stackName).envPath;
|
|
14
|
+
if (!envPath || !existsSync(envPath)) return false;
|
|
15
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
16
|
+
return /^(HAPPY_STACKS_SERVER_URL|HAPPY_LOCAL_SERVER_URL)=/m.test(raw);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stackEnvExplicitlySetsWebappUrl({ env, stackName }) {
|
|
23
|
+
try {
|
|
24
|
+
const envPath =
|
|
25
|
+
(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
|
|
26
|
+
resolveStackEnvPath(stackName).envPath;
|
|
27
|
+
if (!envPath || !existsSync(envPath)) return false;
|
|
28
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
29
|
+
return /^HAPPY_WEBAPP_URL=/m.test(raw);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getPublicServerUrlEnvOverride({ env = process.env, serverPort, stackName = null } = {}) {
|
|
36
|
+
const name =
|
|
37
|
+
(stackName ?? '').toString().trim() ||
|
|
38
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
39
|
+
getStackName(env);
|
|
40
|
+
const defaultPublicUrl = `http://localhost:${serverPort}`;
|
|
41
|
+
|
|
42
|
+
let envPublicUrl =
|
|
43
|
+
(env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
|
|
44
|
+
envPublicUrl = normalizeUrlNoTrailingSlash(envPublicUrl);
|
|
45
|
+
|
|
46
|
+
// Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
|
|
47
|
+
if (name !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName: name })) {
|
|
48
|
+
envPublicUrl = '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { defaultPublicUrl, envPublicUrl, publicServerUrl: envPublicUrl || defaultPublicUrl };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getWebappUrlEnvOverride({ env = process.env, stackName = null } = {}) {
|
|
55
|
+
const name =
|
|
56
|
+
(stackName ?? '').toString().trim() ||
|
|
57
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
58
|
+
getStackName(env);
|
|
59
|
+
|
|
60
|
+
let envWebappUrl = (env.HAPPY_WEBAPP_URL ?? '').toString().trim() || '';
|
|
61
|
+
|
|
62
|
+
// Safety: for non-main stacks, ignore a global HAPPY_WEBAPP_URL unless it was explicitly set in the stack env file.
|
|
63
|
+
if (name !== 'main' && envWebappUrl && !stackEnvExplicitlySetsWebappUrl({ env, stackName: name })) {
|
|
64
|
+
envWebappUrl = '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { envWebappUrl };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
|
|
71
|
+
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
72
|
+
const stackName =
|
|
73
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
74
|
+
getStackName(env);
|
|
75
|
+
const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
|
|
76
|
+
const resolved = await resolvePublicServerUrl({
|
|
77
|
+
internalServerUrl,
|
|
78
|
+
defaultPublicUrl,
|
|
79
|
+
envPublicUrl,
|
|
80
|
+
allowEnable,
|
|
81
|
+
stackName,
|
|
82
|
+
});
|
|
83
|
+
const publicServerUrl = normalizeUrlNoTrailingSlash(
|
|
84
|
+
await preferStackLocalhostUrl(resolved.publicServerUrl, { stackName })
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
internalServerUrl,
|
|
88
|
+
defaultPublicUrl,
|
|
89
|
+
envPublicUrl,
|
|
90
|
+
publicServerUrl,
|
|
91
|
+
publicServerUrlSource: resolved.source,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getInternalServerUrl({ env = process.env, defaultPort = 3005 } = {}) {
|
|
96
|
+
const port = resolveServerPortFromEnv({ env, defaultPort });
|
|
97
|
+
return { port, internalServerUrl: `http://127.0.0.1:${port}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { resolveServerPortFromEnv };
|
|
101
|
+
|