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
package/scripts/tui.mjs
CHANGED
|
@@ -1,33 +1,31 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { readFile } from 'node:fs/promises';
|
|
5
3
|
import { join, resolve, sep } from 'node:path';
|
|
6
4
|
|
|
7
|
-
import { parseDotenv } from './utils/dotenv.mjs';
|
|
8
5
|
import { printResult } from './utils/cli/cli.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
6
|
+
import { readEnvObjectFromFile } from './utils/env/read.mjs';
|
|
7
|
+
import { getComponentsDir, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
|
|
8
|
+
import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack/runtime_state.mjs';
|
|
9
|
+
import { getEnvValueAny } from './utils/env/values.mjs';
|
|
10
|
+
import { padRight, parsePrefixedLabel, stripAnsi } from './utils/ui/text.mjs';
|
|
11
|
+
import { commandExists } from './utils/proc/commands.mjs';
|
|
12
|
+
import { renderQrAscii } from './utils/ui/qr.mjs';
|
|
13
|
+
import { resolveMobileQrPayload } from './utils/mobile/dev_client_links.mjs';
|
|
11
14
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
return
|
|
15
|
+
function nowTs() {
|
|
16
|
+
const d = new Date();
|
|
17
|
+
return d.toISOString().slice(11, 19);
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
20
|
+
function supportsAnsi() {
|
|
21
|
+
if (!process.stdout.isTTY) return false;
|
|
22
|
+
if (process.env.NO_COLOR) return false;
|
|
23
|
+
if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
|
|
24
|
+
return true;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
return m ? m[1] : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function nowTs() {
|
|
29
|
-
const d = new Date();
|
|
30
|
-
return d.toISOString().slice(11, 19);
|
|
27
|
+
function cyan(s) {
|
|
28
|
+
return supportsAnsi() ? `\x1b[36m${s}\x1b[0m` : String(s);
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
function clamp(n, lo, hi) {
|
|
@@ -45,7 +43,13 @@ function pushLine(pane, line, { maxLines = 4000 } = {}) {
|
|
|
45
43
|
}
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
function
|
|
46
|
+
function getPaneHeightForLines(lines, { min = 3, max = 16 } = {}) {
|
|
47
|
+
const n = Array.isArray(lines) ? lines.length : 0;
|
|
48
|
+
// +2 for box borders
|
|
49
|
+
return clamp(n + 2, min, max);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function drawBox({ x, y, w, h, title, lines, scroll, active = false }) {
|
|
49
53
|
const top = y;
|
|
50
54
|
const bottom = y + h - 1;
|
|
51
55
|
const left = x;
|
|
@@ -69,12 +73,14 @@ function drawBox({ x, y, w, h, title, lines, scroll }) {
|
|
|
69
73
|
const midLine = '│' + ' '.repeat(Math.max(0, w - 2)) + '│';
|
|
70
74
|
const botLine = '└' + horiz + '┘';
|
|
71
75
|
|
|
76
|
+
const style = (s) => (active ? cyan(s) : s);
|
|
77
|
+
|
|
72
78
|
const out = [];
|
|
73
|
-
out.push({ row: top, col: left, text: topLine });
|
|
79
|
+
out.push({ row: top, col: left, text: style(topLine) });
|
|
74
80
|
for (let r = top + 1; r < bottom; r++) {
|
|
75
|
-
out.push({ row: r, col: left, text: midLine });
|
|
81
|
+
out.push({ row: r, col: left, text: style(midLine) });
|
|
76
82
|
}
|
|
77
|
-
out.push({ row: bottom, col: left, text: botLine });
|
|
83
|
+
out.push({ row: bottom, col: left, text: style(botLine) });
|
|
78
84
|
|
|
79
85
|
const innerW = Math.max(0, w - 2);
|
|
80
86
|
const innerH = Math.max(0, h - 2);
|
|
@@ -107,14 +113,24 @@ function inferStackNameFromForwardedArgs(args) {
|
|
|
107
113
|
return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
const readEnvObject = readEnvObjectFromFile;
|
|
117
|
+
|
|
118
|
+
function getEnvVal(env, key, legacyKey) {
|
|
119
|
+
return getEnvValueAny(env, [key, legacyKey]) || '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function nextLineBreakIndex(s) {
|
|
123
|
+
const n = s.indexOf('\n');
|
|
124
|
+
const r = s.indexOf('\r');
|
|
125
|
+
if (n < 0) return r;
|
|
126
|
+
if (r < 0) return n;
|
|
127
|
+
return Math.min(n, r);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function consumeLineBreak(buf) {
|
|
131
|
+
if (buf.startsWith('\r\n')) return buf.slice(2);
|
|
132
|
+
if (buf.startsWith('\n') || buf.startsWith('\r')) return buf.slice(1);
|
|
133
|
+
return buf;
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
function formatComponentRef({ rootDir, component, dir }) {
|
|
@@ -122,8 +138,11 @@ function formatComponentRef({ rootDir, component, dir }) {
|
|
|
122
138
|
if (!raw) return '(unset)';
|
|
123
139
|
|
|
124
140
|
const abs = resolve(raw);
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
// Respect sandbox workspace layout:
|
|
142
|
+
// - default: <workspace>/components/<component>
|
|
143
|
+
// - worktrees: <workspace>/components/.worktrees/<component>/<owner>/<branch...>
|
|
144
|
+
const defaultDir = resolve(join(getComponentsDir(rootDir), component));
|
|
145
|
+
const worktreesPrefix = resolve(join(getComponentsDir(rootDir), '.worktrees', component)) + sep;
|
|
127
146
|
|
|
128
147
|
if (abs === defaultDir) return 'default';
|
|
129
148
|
if (abs.startsWith(worktreesPrefix)) {
|
|
@@ -132,12 +151,6 @@ function formatComponentRef({ rootDir, component, dir }) {
|
|
|
132
151
|
return abs;
|
|
133
152
|
}
|
|
134
153
|
|
|
135
|
-
function getEnvVal(env, k1, k2) {
|
|
136
|
-
const a = String(env?.[k1] ?? '').trim();
|
|
137
|
-
if (a) return a;
|
|
138
|
-
return String(env?.[k2] ?? '').trim();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
154
|
async function buildStackSummaryLines({ rootDir, stackName }) {
|
|
142
155
|
const { envPath, baseDir } = resolveStackEnvPath(stackName);
|
|
143
156
|
const env = await readEnvObject(envPath);
|
|
@@ -145,10 +158,12 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
|
|
|
145
158
|
const runtime = await readStackRuntimeStateFile(runtimePath);
|
|
146
159
|
|
|
147
160
|
const serverComponent =
|
|
148
|
-
|
|
161
|
+
getEnvValueAny(env, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
|
|
149
162
|
|
|
150
163
|
const ports = runtime?.ports && typeof runtime.ports === 'object' ? runtime.ports : {};
|
|
151
|
-
const
|
|
164
|
+
const expo = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo : {};
|
|
165
|
+
const expoPort = expo?.port ?? expo?.webPort ?? expo?.mobilePort ?? null;
|
|
166
|
+
const expoDevClientEnabled = Boolean(expo?.devClientEnabled);
|
|
152
167
|
const processes = runtime?.processes && typeof runtime.processes === 'object' ? runtime.processes : {};
|
|
153
168
|
|
|
154
169
|
const components = [
|
|
@@ -180,13 +195,21 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
|
|
|
180
195
|
lines.push('');
|
|
181
196
|
lines.push('ports:');
|
|
182
197
|
lines.push(` server: ${ports?.server ?? '(unknown)'}`);
|
|
183
|
-
if (
|
|
198
|
+
if (expoPort) lines.push(` expo: ${expoPort}`);
|
|
184
199
|
if (ports?.backend) lines.push(` backend: ${ports.backend}`);
|
|
185
200
|
|
|
201
|
+
if (expoPort && expoDevClientEnabled) {
|
|
202
|
+
const payload = resolveMobileQrPayload({ env: process.env, port: Number(expoPort) });
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push('expo dev-client links:');
|
|
205
|
+
if (payload.metroUrl) lines.push(` metro: ${payload.metroUrl}`);
|
|
206
|
+
if (payload.scheme && payload.deepLink) lines.push(` link: ${payload.deepLink}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
186
209
|
lines.push('');
|
|
187
210
|
lines.push('pids:');
|
|
188
211
|
if (processes?.serverPid) lines.push(` serverPid: ${processes.serverPid}`);
|
|
189
|
-
if (processes?.
|
|
212
|
+
if (processes?.expoPid) lines.push(` expoPid: ${processes.expoPid}`);
|
|
190
213
|
if (processes?.daemonPid) lines.push(` daemonPid: ${processes.daemonPid}`);
|
|
191
214
|
if (processes?.uiGatewayPid) lines.push(` uiGatewayPid: ${processes.uiGatewayPid}`);
|
|
192
215
|
|
|
@@ -200,6 +223,29 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
|
|
|
200
223
|
return lines;
|
|
201
224
|
}
|
|
202
225
|
|
|
226
|
+
async function buildExpoQrPaneLines({ stackName }) {
|
|
227
|
+
const runtimePath = getStackRuntimeStatePath(stackName);
|
|
228
|
+
const runtime = await readStackRuntimeStateFile(runtimePath);
|
|
229
|
+
const expo = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo : {};
|
|
230
|
+
const port = Number(expo?.port ?? expo?.mobilePort ?? expo?.webPort);
|
|
231
|
+
const enabled = Boolean(expo?.devClientEnabled);
|
|
232
|
+
if (!enabled || !Number.isFinite(port) || port <= 0) {
|
|
233
|
+
return { visible: false, lines: [] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const payload = resolveMobileQrPayload({ env: process.env, port });
|
|
237
|
+
// Try to keep the QR compact:
|
|
238
|
+
// - qrcode-terminal uses a terminal-friendly pattern with adequate quiet-zone.
|
|
239
|
+
const qr = await renderQrAscii(payload.payload, { small: true });
|
|
240
|
+
const lines = [];
|
|
241
|
+
if (qr.ok) {
|
|
242
|
+
lines.push(...qr.lines);
|
|
243
|
+
} else {
|
|
244
|
+
lines.push(`(QR unavailable) ${qr.error || ''}`.trim());
|
|
245
|
+
}
|
|
246
|
+
return { visible: true, lines };
|
|
247
|
+
}
|
|
248
|
+
|
|
203
249
|
async function main() {
|
|
204
250
|
const argv = process.argv.slice(2);
|
|
205
251
|
|
|
@@ -233,7 +279,7 @@ async function main() {
|
|
|
233
279
|
' q / Ctrl+C : quit (sends SIGINT to child)',
|
|
234
280
|
'',
|
|
235
281
|
'panes (default):',
|
|
236
|
-
' orchestration | summary | local | server |
|
|
282
|
+
' orchestration | summary | local | server | expo | daemon | stack logs',
|
|
237
283
|
].join('\n'),
|
|
238
284
|
});
|
|
239
285
|
return;
|
|
@@ -252,11 +298,13 @@ async function main() {
|
|
|
252
298
|
const panes = [
|
|
253
299
|
mkPane('orch', 'orchestration', { visible: true, kind: 'log' }),
|
|
254
300
|
mkPane('summary', `stack summary (${stackName})`, { visible: true, kind: 'summary' }),
|
|
301
|
+
// Data-only pane: we render QR inside the Expo pane (no separate box).
|
|
302
|
+
mkPane('qr', 'expo QR', { visible: false, kind: 'qr' }),
|
|
255
303
|
mkPane('local', 'local', { visible: true, kind: 'log' }),
|
|
256
|
-
mkPane('server', 'server', { visible:
|
|
257
|
-
mkPane('
|
|
258
|
-
mkPane('daemon', 'daemon', { visible:
|
|
259
|
-
mkPane('stacklog', 'stack logs', { visible:
|
|
304
|
+
mkPane('server', 'server', { visible: false, kind: 'log' }),
|
|
305
|
+
mkPane('expo', 'expo', { visible: false, kind: 'log' }),
|
|
306
|
+
mkPane('daemon', 'daemon', { visible: false, kind: 'log' }),
|
|
307
|
+
mkPane('stacklog', 'stack logs', { visible: false, kind: 'log' }),
|
|
260
308
|
];
|
|
261
309
|
|
|
262
310
|
const paneIndexById = new Map(panes.map((p, i) => [p.id, i]));
|
|
@@ -267,12 +315,18 @@ async function main() {
|
|
|
267
315
|
|
|
268
316
|
let paneId = 'local';
|
|
269
317
|
if (normalized.includes('server')) paneId = 'server';
|
|
270
|
-
else if (normalized === 'ui') paneId = '
|
|
318
|
+
else if (normalized === 'ui') paneId = 'expo';
|
|
319
|
+
else if (normalized === 'mobile') paneId = 'expo';
|
|
320
|
+
else if (normalized === 'expo') paneId = 'expo';
|
|
271
321
|
else if (normalized.includes('daemon')) paneId = 'daemon';
|
|
272
322
|
else if (normalized === 'stack') paneId = 'stacklog';
|
|
273
323
|
else if (normalized === 'local') paneId = 'local';
|
|
274
324
|
|
|
275
325
|
const idx = paneIndexById.get(paneId) ?? paneIndexById.get('local');
|
|
326
|
+
if (panes[idx] && !panes[idx].visible && panes[idx].kind === 'log') {
|
|
327
|
+
panes[idx].visible = true;
|
|
328
|
+
// If the focused pane was hidden before, keep focus stable but ensure render updates layout.
|
|
329
|
+
}
|
|
276
330
|
pushLine(panes[idx], line);
|
|
277
331
|
};
|
|
278
332
|
|
|
@@ -281,28 +335,40 @@ async function main() {
|
|
|
281
335
|
};
|
|
282
336
|
|
|
283
337
|
let layout = 'columns'; // single | split | columns
|
|
284
|
-
let focused =
|
|
338
|
+
let focused = paneIndexById.get('local'); // default focus
|
|
285
339
|
let paused = false;
|
|
286
340
|
let renderScheduled = false;
|
|
287
341
|
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
342
|
+
const wantsPty = process.platform !== 'win32' && (await commandExists('script', { cwd: rootDir }));
|
|
343
|
+
const child = wantsPty
|
|
344
|
+
? // Use a pseudo-terminal so tools like Expo print QR/status output that they hide in non-TTY mode.
|
|
345
|
+
// `script` is available by default on macOS (and common on Linux).
|
|
346
|
+
spawn('script', ['-q', '/dev/null', process.execPath, happysBin, ...forwarded], {
|
|
347
|
+
cwd: rootDir,
|
|
348
|
+
env: { ...process.env },
|
|
349
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
350
|
+
detached: process.platform !== 'win32',
|
|
351
|
+
})
|
|
352
|
+
: spawn(process.execPath, [happysBin, ...forwarded], {
|
|
353
|
+
cwd: rootDir,
|
|
354
|
+
env: { ...process.env },
|
|
355
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
356
|
+
detached: process.platform !== 'win32',
|
|
357
|
+
});
|
|
294
358
|
|
|
295
|
-
logOrch(
|
|
359
|
+
logOrch(
|
|
360
|
+
`spawned: ${wantsPty ? 'script -q /dev/null ' : ''}node ${happysBin} ${forwarded.join(' ')} (pid=${child.pid})`
|
|
361
|
+
);
|
|
296
362
|
|
|
297
363
|
const buf = { out: '', err: '' };
|
|
298
364
|
const flush = (kind) => {
|
|
299
365
|
const key = kind === 'stderr' ? 'err' : 'out';
|
|
300
366
|
let b = buf[key];
|
|
301
367
|
while (true) {
|
|
302
|
-
const idx = b
|
|
368
|
+
const idx = nextLineBreakIndex(b);
|
|
303
369
|
if (idx < 0) break;
|
|
304
370
|
const line = b.slice(0, idx);
|
|
305
|
-
b = b.slice(idx
|
|
371
|
+
b = consumeLineBreak(b.slice(idx));
|
|
306
372
|
routeLine(line);
|
|
307
373
|
}
|
|
308
374
|
buf[key] = b;
|
|
@@ -331,6 +397,19 @@ async function main() {
|
|
|
331
397
|
} catch (e) {
|
|
332
398
|
panes[idx].lines = [`summary error: ${e instanceof Error ? e.message : String(e)}`];
|
|
333
399
|
}
|
|
400
|
+
|
|
401
|
+
// QR pane: driven by runtime state (expo port) and rendered independently of logs.
|
|
402
|
+
try {
|
|
403
|
+
const qrIdx = paneIndexById.get('qr');
|
|
404
|
+
const qr = await buildExpoQrPaneLines({ stackName });
|
|
405
|
+
// Data-only pane (kept hidden): rendered inside the expo pane.
|
|
406
|
+
panes[qrIdx].visible = false;
|
|
407
|
+
panes[qrIdx].lines = qr.lines;
|
|
408
|
+
} catch {
|
|
409
|
+
const qrIdx = paneIndexById.get('qr');
|
|
410
|
+
panes[qrIdx].visible = false;
|
|
411
|
+
panes[qrIdx].lines = [];
|
|
412
|
+
}
|
|
334
413
|
scheduleRender();
|
|
335
414
|
}
|
|
336
415
|
|
|
@@ -404,7 +483,9 @@ async function main() {
|
|
|
404
483
|
process.stdout.write('\x1b[?25l');
|
|
405
484
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
406
485
|
|
|
407
|
-
const
|
|
486
|
+
const focusPane = panes[focused];
|
|
487
|
+
const focusLabel = focusPane ? `${focusPane.id} (${focusPane.title})` : String(focused);
|
|
488
|
+
const header = `happys tui | ${forwarded.join(' ')} | layout=${layout} | focus=${focusLabel}`;
|
|
408
489
|
process.stdout.write(padRight(header, cols) + '\n');
|
|
409
490
|
|
|
410
491
|
const bodyY = 1;
|
|
@@ -412,9 +493,22 @@ async function main() {
|
|
|
412
493
|
const footerY = rows - 1;
|
|
413
494
|
|
|
414
495
|
const drawWrites = [];
|
|
496
|
+
|
|
497
|
+
const contentY = bodyY;
|
|
498
|
+
let contentH = bodyH;
|
|
499
|
+
|
|
415
500
|
if (layout === 'single') {
|
|
416
501
|
const pane = panes[focused];
|
|
417
|
-
const box = drawBox({
|
|
502
|
+
const box = drawBox({
|
|
503
|
+
x: 0,
|
|
504
|
+
y: contentY,
|
|
505
|
+
w: cols,
|
|
506
|
+
h: contentH,
|
|
507
|
+
title: pane.title,
|
|
508
|
+
lines: pane.lines,
|
|
509
|
+
scroll: pane.scroll,
|
|
510
|
+
active: true,
|
|
511
|
+
});
|
|
418
512
|
pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
|
|
419
513
|
drawWrites.push(...box.out);
|
|
420
514
|
} else if (layout === 'split') {
|
|
@@ -424,38 +518,184 @@ async function main() {
|
|
|
424
518
|
const leftPane = panes[paneIndexById.get('orch')];
|
|
425
519
|
const rightPane = panes[focused === paneIndexById.get('orch') ? paneIndexById.get('local') : focused];
|
|
426
520
|
|
|
427
|
-
const leftBox = drawBox({
|
|
521
|
+
const leftBox = drawBox({
|
|
522
|
+
x: 0,
|
|
523
|
+
y: contentY,
|
|
524
|
+
w: leftW,
|
|
525
|
+
h: contentH,
|
|
526
|
+
title: leftPane.title,
|
|
527
|
+
lines: leftPane.lines,
|
|
528
|
+
scroll: leftPane.scroll,
|
|
529
|
+
active: focused === paneIndexById.get('orch'),
|
|
530
|
+
});
|
|
428
531
|
leftPane.scroll = clamp(leftPane.scroll, 0, leftBox.maxScroll);
|
|
429
532
|
drawWrites.push(...leftBox.out);
|
|
430
533
|
|
|
431
|
-
const rightBox = drawBox({
|
|
534
|
+
const rightBox = drawBox({
|
|
535
|
+
x: leftW,
|
|
536
|
+
y: contentY,
|
|
537
|
+
w: rightW,
|
|
538
|
+
h: contentH,
|
|
539
|
+
title: rightPane.title,
|
|
540
|
+
lines: rightPane.lines,
|
|
541
|
+
scroll: rightPane.scroll,
|
|
542
|
+
active: focused === (paneIndexById.get(rightPane.id) ?? focused),
|
|
543
|
+
});
|
|
432
544
|
rightPane.scroll = clamp(rightPane.scroll, 0, rightBox.maxScroll);
|
|
433
545
|
drawWrites.push(...rightBox.out);
|
|
434
546
|
} else {
|
|
435
|
-
// columns: render
|
|
436
|
-
const
|
|
547
|
+
// columns: render a compact top row (orch + summary), then render QR alongside Expo logs.
|
|
548
|
+
const orchIdx = paneIndexById.get('orch');
|
|
549
|
+
const summaryIdx = paneIndexById.get('summary');
|
|
550
|
+
const qrIdx = paneIndexById.get('qr');
|
|
551
|
+
const qrPane = panes[qrIdx];
|
|
552
|
+
const qrVisible = Boolean(qrPane?.visible && qrPane.lines?.length);
|
|
553
|
+
|
|
554
|
+
const topPanes = [panes[orchIdx], panes[summaryIdx]];
|
|
555
|
+
const topCount = topPanes.length;
|
|
556
|
+
const topH = getPaneHeightForLines(panes[summaryIdx].lines, { min: 6, max: 14 });
|
|
557
|
+
|
|
558
|
+
const topY = contentY;
|
|
559
|
+
const belowY = contentY + topH;
|
|
560
|
+
const belowH = Math.max(0, contentH - topH);
|
|
561
|
+
|
|
562
|
+
const colW = Math.floor(cols / topCount);
|
|
563
|
+
for (let i = 0; i < topCount; i++) {
|
|
564
|
+
const pane = topPanes[i];
|
|
565
|
+
const x = i === topCount - 1 ? colW * i : colW * i;
|
|
566
|
+
const w = i === topCount - 1 ? cols - colW * i : colW;
|
|
567
|
+
const box = drawBox({
|
|
568
|
+
x,
|
|
569
|
+
y: topY,
|
|
570
|
+
w,
|
|
571
|
+
h: topH,
|
|
572
|
+
title: pane.title,
|
|
573
|
+
lines: pane.lines,
|
|
574
|
+
scroll: pane.scroll,
|
|
575
|
+
active: paneIndexById.get(pane.id) === focused,
|
|
576
|
+
});
|
|
577
|
+
pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
|
|
578
|
+
drawWrites.push(...box.out);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Remaining panes: exclude the top-row panes. QR is rendered inside the expo pane.
|
|
582
|
+
const visibleAll = visiblePaneIndexes()
|
|
583
|
+
.filter((idx) => idx !== orchIdx && idx !== summaryIdx && idx !== qrIdx)
|
|
584
|
+
.map((idx) => panes[idx]);
|
|
437
585
|
const leftW = Math.floor(cols / 2);
|
|
438
586
|
const rightW = cols - leftW;
|
|
439
587
|
|
|
440
588
|
const leftPanes = [];
|
|
441
589
|
const rightPanes = [];
|
|
590
|
+
const expoPane = panes[paneIndexById.get('expo')];
|
|
591
|
+
const visible = visibleAll.filter((p) => p !== expoPane);
|
|
442
592
|
for (let i = 0; i < visible.length; i++) {
|
|
443
593
|
(i % 2 === 0 ? leftPanes : rightPanes).push(visible[i]);
|
|
444
594
|
}
|
|
595
|
+
if (expoPane?.visible) {
|
|
596
|
+
rightPanes.unshift(expoPane);
|
|
597
|
+
}
|
|
445
598
|
|
|
446
599
|
const layoutColumn = (colX, colW, colPanes) => {
|
|
447
600
|
if (!colPanes.length) return;
|
|
448
601
|
const n = colPanes.length;
|
|
449
|
-
const base = Math.max(3, Math.floor(
|
|
450
|
-
let y =
|
|
602
|
+
const base = Math.max(3, Math.floor(belowH / n));
|
|
603
|
+
let y = belowY;
|
|
451
604
|
for (let i = 0; i < n; i++) {
|
|
452
605
|
const pane = colPanes[i];
|
|
453
|
-
const remaining =
|
|
454
|
-
|
|
606
|
+
const remaining = belowY + belowH - y;
|
|
607
|
+
let h = i === n - 1 ? remaining : Math.min(base, remaining);
|
|
455
608
|
if (h < 3) break;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
609
|
+
if (pane.id === 'expo') {
|
|
610
|
+
const qrLines = Array.isArray(qrPane?.lines) ? qrPane.lines : [];
|
|
611
|
+
const qrHas = Boolean(qrLines.length);
|
|
612
|
+
const qrMinH = qrHas ? Math.max(6, qrLines.length + 2) : 0; // +2 borders
|
|
613
|
+
if (qrMinH && h < qrMinH) {
|
|
614
|
+
h = Math.min(remaining, qrMinH);
|
|
615
|
+
if (h < 3) break;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (qrHas) {
|
|
619
|
+
// Split the expo pane horizontally:
|
|
620
|
+
// left = expo logs, right = QR. This uses width instead of extra height.
|
|
621
|
+
const maxLineLen = qrLines.reduce((m, l) => Math.max(m, stripAnsi(l).length), 0);
|
|
622
|
+
const minLogW = 24;
|
|
623
|
+
const minQrW = 22;
|
|
624
|
+
const maxQrW = Math.max(0, Math.min(80, colW - minLogW));
|
|
625
|
+
const fixedQrWRaw = (process.env.HAPPY_STACKS_TUI_QR_WIDTH ?? process.env.HAPPY_LOCAL_TUI_QR_WIDTH ?? '').toString().trim();
|
|
626
|
+
const fixedQrW = fixedQrWRaw ? Number(fixedQrWRaw) : 44;
|
|
627
|
+
const qrW = clamp(Number.isFinite(fixedQrW) && fixedQrW > 0 ? fixedQrW : maxLineLen + 2, minQrW, maxQrW);
|
|
628
|
+
const canSplit = qrW >= minQrW && colW - qrW >= minLogW;
|
|
629
|
+
|
|
630
|
+
if (canSplit) {
|
|
631
|
+
const logW = colW - qrW;
|
|
632
|
+
const logBox = drawBox({
|
|
633
|
+
x: colX,
|
|
634
|
+
y,
|
|
635
|
+
w: logW,
|
|
636
|
+
h,
|
|
637
|
+
title: pane.title,
|
|
638
|
+
lines: pane.lines,
|
|
639
|
+
scroll: pane.scroll,
|
|
640
|
+
active: paneIndexById.get(pane.id) === focused,
|
|
641
|
+
});
|
|
642
|
+
pane.scroll = clamp(pane.scroll, 0, logBox.maxScroll);
|
|
643
|
+
drawWrites.push(...logBox.out);
|
|
644
|
+
|
|
645
|
+
const qrBox = drawBox({
|
|
646
|
+
x: colX + logW,
|
|
647
|
+
y,
|
|
648
|
+
w: qrW,
|
|
649
|
+
h,
|
|
650
|
+
title: qrPane.title,
|
|
651
|
+
lines: qrLines,
|
|
652
|
+
scroll: 0,
|
|
653
|
+
active: paneIndexById.get(pane.id) === focused,
|
|
654
|
+
});
|
|
655
|
+
drawWrites.push(...qrBox.out);
|
|
656
|
+
} else {
|
|
657
|
+
// Too narrow to split cleanly: fallback to single expo log box.
|
|
658
|
+
const box = drawBox({
|
|
659
|
+
x: colX,
|
|
660
|
+
y,
|
|
661
|
+
w: colW,
|
|
662
|
+
h,
|
|
663
|
+
title: pane.title,
|
|
664
|
+
lines: pane.lines,
|
|
665
|
+
scroll: pane.scroll,
|
|
666
|
+
active: paneIndexById.get(pane.id) === focused,
|
|
667
|
+
});
|
|
668
|
+
pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
|
|
669
|
+
drawWrites.push(...box.out);
|
|
670
|
+
}
|
|
671
|
+
} else {
|
|
672
|
+
const box = drawBox({
|
|
673
|
+
x: colX,
|
|
674
|
+
y,
|
|
675
|
+
w: colW,
|
|
676
|
+
h,
|
|
677
|
+
title: pane.title,
|
|
678
|
+
lines: pane.lines,
|
|
679
|
+
scroll: pane.scroll,
|
|
680
|
+
active: paneIndexById.get(pane.id) === focused,
|
|
681
|
+
});
|
|
682
|
+
pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
|
|
683
|
+
drawWrites.push(...box.out);
|
|
684
|
+
}
|
|
685
|
+
} else {
|
|
686
|
+
const box = drawBox({
|
|
687
|
+
x: colX,
|
|
688
|
+
y,
|
|
689
|
+
w: colW,
|
|
690
|
+
h,
|
|
691
|
+
title: pane.title,
|
|
692
|
+
lines: pane.lines,
|
|
693
|
+
scroll: pane.scroll,
|
|
694
|
+
active: paneIndexById.get(pane.id) === focused,
|
|
695
|
+
});
|
|
696
|
+
pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
|
|
697
|
+
drawWrites.push(...box.out);
|
|
698
|
+
}
|
|
459
699
|
y += h;
|
|
460
700
|
}
|
|
461
701
|
};
|
package/scripts/typecheck.mjs
CHANGED
|
@@ -1,36 +1,16 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
2
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
3
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
4
|
-
import { getComponentDir, getRootDir } from './utils/paths.mjs';
|
|
5
|
-
import { ensureDepsInstalled
|
|
6
|
-
import { pathExists } from './utils/fs.mjs';
|
|
7
|
-
import { run } from './utils/proc.mjs';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
|
|
5
|
+
import { ensureDepsInstalled } from './utils/proc/pm.mjs';
|
|
6
|
+
import { pathExists } from './utils/fs/fs.mjs';
|
|
7
|
+
import { run } from './utils/proc/proc.mjs';
|
|
8
|
+
import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
|
|
9
|
+
import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
|
|
10
10
|
|
|
11
11
|
const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
12
12
|
|
|
13
|
-
async function detectPackageManagerCmd(dir) {
|
|
14
|
-
if (await pathExists(join(dir, 'yarn.lock'))) {
|
|
15
|
-
return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
|
|
16
|
-
}
|
|
17
|
-
await requirePnpm();
|
|
18
|
-
return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function readScripts(dir) {
|
|
22
|
-
try {
|
|
23
|
-
const raw = await readFile(join(dir, 'package.json'), 'utf-8');
|
|
24
|
-
const pkg = JSON.parse(raw);
|
|
25
|
-
const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
26
|
-
return scripts;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
13
|
function pickTypecheckScript(scripts) {
|
|
33
|
-
if (!scripts) return null;
|
|
34
14
|
const candidates = [
|
|
35
15
|
'typecheck',
|
|
36
16
|
'type-check',
|
|
@@ -39,7 +19,7 @@ function pickTypecheckScript(scripts) {
|
|
|
39
19
|
'tsc',
|
|
40
20
|
'typescript',
|
|
41
21
|
];
|
|
42
|
-
return
|
|
22
|
+
return pickFirstScript(scripts, candidates);
|
|
43
23
|
}
|
|
44
24
|
|
|
45
25
|
async function main() {
|
|
@@ -61,18 +41,37 @@ async function main() {
|
|
|
61
41
|
'examples:',
|
|
62
42
|
' happys typecheck',
|
|
63
43
|
' happys typecheck happy happy-cli',
|
|
44
|
+
'',
|
|
45
|
+
'note:',
|
|
46
|
+
' If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
|
|
64
47
|
].join('\n'),
|
|
65
48
|
});
|
|
66
49
|
return;
|
|
67
50
|
}
|
|
68
51
|
|
|
52
|
+
const rootDir = getRootDir(import.meta.url);
|
|
53
|
+
|
|
69
54
|
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
70
|
-
const
|
|
55
|
+
const inferred =
|
|
56
|
+
positionals.length === 0
|
|
57
|
+
? inferComponentFromCwd({
|
|
58
|
+
rootDir,
|
|
59
|
+
invokedCwd: getInvokedCwd(process.env),
|
|
60
|
+
components: DEFAULT_COMPONENTS,
|
|
61
|
+
})
|
|
62
|
+
: null;
|
|
63
|
+
if (inferred) {
|
|
64
|
+
const stacksKey = componentDirEnvKey(inferred.component);
|
|
65
|
+
const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
|
|
66
|
+
if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
|
|
67
|
+
process.env[stacksKey] = inferred.repoDir;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const requested = positionals.length ? positionals : inferred ? [inferred.component] : ['all'];
|
|
71
72
|
const wantAll = requested.includes('all');
|
|
72
73
|
const components = wantAll ? DEFAULT_COMPONENTS : requested;
|
|
73
74
|
|
|
74
|
-
const rootDir = getRootDir(import.meta.url);
|
|
75
|
-
|
|
76
75
|
const results = [];
|
|
77
76
|
for (const component of components) {
|
|
78
77
|
if (!DEFAULT_COMPONENTS.includes(component)) {
|
|
@@ -86,7 +85,7 @@ async function main() {
|
|
|
86
85
|
continue;
|
|
87
86
|
}
|
|
88
87
|
|
|
89
|
-
const scripts = await
|
|
88
|
+
const scripts = await readPackageJsonScripts(dir);
|
|
90
89
|
if (!scripts) {
|
|
91
90
|
results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
|
|
92
91
|
continue;
|
package/scripts/ui_gateway.mjs
CHANGED