happy-stacks 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -7
- package/bin/happys.mjs +114 -15
- 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 +11 -7
- package/scripts/build.mjs +54 -7
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +181 -46
- package/scripts/edison.mjs +4 -2
- package/scripts/init.mjs +3 -1
- package/scripts/install.mjs +112 -16
- package/scripts/lint.mjs +24 -4
- package/scripts/mobile.mjs +88 -104
- 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 +83 -9
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +42 -43
- package/scripts/setup_pr.mjs +591 -34
- package/scripts/stack.mjs +503 -45
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +309 -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 +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/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/dev/daemon.mjs +47 -3
- 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 +15 -25
- package/scripts/utils/dev_auth_key.mjs +169 -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 +24 -20
- 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/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 +42 -38
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +69 -12
- package/scripts/utils/proc/proc.mjs +76 -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/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/urls.mjs +14 -4
- 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 +2 -2
- 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 +7 -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/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/worktrees.mjs +141 -55
- package/scripts/utils/dev/expo_web.mjs +0 -112
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { getRootDir } from './utils/paths/paths.mjs';
|
|
8
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
9
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
10
|
+
import { getVerbosityLevel } from './utils/cli/verbosity.mjs';
|
|
11
|
+
import { createStepPrinter } from './utils/cli/progress.mjs';
|
|
12
|
+
import { prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
13
|
+
import { assertCliPrereqs } from './utils/cli/prereqs.mjs';
|
|
14
|
+
import { randomToken } from './utils/crypto/tokens.mjs';
|
|
15
|
+
import { inferPrStackBaseName } from './utils/stack/pr_stack_name.mjs';
|
|
16
|
+
import { sanitizeStackName } from './utils/stack/names.mjs';
|
|
17
|
+
import { listReviewPrSandboxes, reviewPrSandboxPrefixPath, writeReviewPrSandboxMeta } from './utils/sandbox/review_pr_sandbox.mjs';
|
|
18
|
+
|
|
19
|
+
function supportsAnsi() {
|
|
20
|
+
if (!process.stdout.isTTY) return false;
|
|
21
|
+
if (process.env.NO_COLOR) return false;
|
|
22
|
+
if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bold(s) {
|
|
27
|
+
return supportsAnsi() ? `\x1b[1m${s}\x1b[0m` : String(s);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dim(s) {
|
|
31
|
+
return supportsAnsi() ? `\x1b[2m${s}\x1b[0m` : String(s);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cyan(s) {
|
|
35
|
+
return supportsAnsi() ? `\x1b[36m${s}\x1b[0m` : String(s);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function usage() {
|
|
39
|
+
return [
|
|
40
|
+
'[review-pr] usage:',
|
|
41
|
+
' happys review-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--name=<stack>] [--dev|--start] [--mobile|--no-mobile] [--forks|--upstream] [--seed-auth|--no-seed-auth] [--copy-auth-from=<stack>] [--link-auth|--copy-auth] [--update] [--force] [--keep-sandbox] [--json] [-- <stack dev/start args...>]',
|
|
42
|
+
'',
|
|
43
|
+
'What it does:',
|
|
44
|
+
'- creates a temporary sandbox dir',
|
|
45
|
+
'- runs `happys setup-pr ...` inside that sandbox (fully isolated state)',
|
|
46
|
+
'- on exit (including Ctrl+C): stops sandbox processes and deletes the sandbox dir',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function waitForExit(child) {
|
|
51
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
52
|
+
child.on('error', rejectPromise);
|
|
53
|
+
child.on('close', (code, signal) => resolvePromise({ code: code ?? 1, signal: signal ?? null }));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function tryStopSandbox({ rootDir, sandboxDir }) {
|
|
58
|
+
const bin = join(rootDir, 'bin', 'happys.mjs');
|
|
59
|
+
const child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'stop', '--yes', '--aggressive', '--sweep-owned', '--no-service'], {
|
|
60
|
+
cwd: rootDir,
|
|
61
|
+
env: process.env,
|
|
62
|
+
stdio: 'ignore',
|
|
63
|
+
});
|
|
64
|
+
await waitForExit(child);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function argvHasFlag(argv, names) {
|
|
68
|
+
for (const n of names) {
|
|
69
|
+
if (argv.includes(n)) return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function kvValue(argv, names) {
|
|
75
|
+
for (const a of argv) {
|
|
76
|
+
for (const n of names) {
|
|
77
|
+
if (a === n) {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
if (a.startsWith(`${n}=`)) {
|
|
81
|
+
return a.slice(`${n}=`.length);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
const rootDir = getRootDir(import.meta.url);
|
|
90
|
+
const argv = process.argv.slice(2);
|
|
91
|
+
const { flags } = parseArgs(argv);
|
|
92
|
+
const json = wantsJson(argv, { flags });
|
|
93
|
+
const verbosity = getVerbosityLevel(process.env);
|
|
94
|
+
const steps = createStepPrinter({ enabled: Boolean(process.stdout.isTTY && !json && verbosity === 0) });
|
|
95
|
+
|
|
96
|
+
if (wantsHelp(argv, { flags })) {
|
|
97
|
+
printResult({ json, data: { usage: usage() }, text: usage() });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await assertCliPrereqs({ git: true, pnpm: true });
|
|
102
|
+
|
|
103
|
+
// Determine a stable base stack name from PR inputs (used for sandbox discovery),
|
|
104
|
+
// and a per-run unique stack name by default (prevents browser storage collisions across deleted sandboxes).
|
|
105
|
+
const prHappy = (kvValue(argv, ['--happy']) ?? '').trim();
|
|
106
|
+
const prCli = (kvValue(argv, ['--happy-cli']) ?? '').trim();
|
|
107
|
+
const prServer = (kvValue(argv, ['--happy-server']) ?? '').trim();
|
|
108
|
+
const prServerLight = (kvValue(argv, ['--happy-server-light']) ?? '').trim();
|
|
109
|
+
const explicitName = (kvValue(argv, ['--name']) ?? '').trim();
|
|
110
|
+
|
|
111
|
+
const baseStackName = explicitName
|
|
112
|
+
? sanitizeStackName(explicitName, { fallback: 'pr', maxLen: 64 })
|
|
113
|
+
: inferPrStackBaseName({ happy: prHappy, happyCli: prCli, server: prServer, serverLight: prServerLight, fallback: 'pr' });
|
|
114
|
+
|
|
115
|
+
const shouldAutoSuffix = !explicitName;
|
|
116
|
+
const uniqueSuffix = randomToken(4); // short, URL-safe-ish
|
|
117
|
+
const newStackName = shouldAutoSuffix
|
|
118
|
+
? sanitizeStackName(`${baseStackName}-${uniqueSuffix}`, { fallback: baseStackName, maxLen: 64 })
|
|
119
|
+
: baseStackName;
|
|
120
|
+
|
|
121
|
+
// Look for leftover sandboxes for the same PR base name (typically due to --keep-sandbox / failures).
|
|
122
|
+
const canPrompt = Boolean(process.stdout.isTTY && process.stdin.isTTY && !json);
|
|
123
|
+
const existingSandboxes = canPrompt ? await listReviewPrSandboxes({ baseStackName }) : [];
|
|
124
|
+
|
|
125
|
+
if (process.stdout.isTTY && !json) {
|
|
126
|
+
const intro = [
|
|
127
|
+
'',
|
|
128
|
+
'',
|
|
129
|
+
bold(`✨ ${cyan('Happy Stacks')} review-pr ✨`),
|
|
130
|
+
'',
|
|
131
|
+
'It will help you review a PR for Happy in a completely isolated environment.',
|
|
132
|
+
dim('Uses `happy-server-light` (no Redis, no Postgres, no Docker).'),
|
|
133
|
+
dim('Desktop browser + optional mobile review (Expo dev-client).'),
|
|
134
|
+
'',
|
|
135
|
+
bold('What will happen:'),
|
|
136
|
+
`- ${cyan('sandbox')}: temporary isolated Happy install`,
|
|
137
|
+
`- ${cyan('components')}: clone/install (inside the sandbox only)`,
|
|
138
|
+
`- ${cyan('start')}: start the Happy stack in sandbox (server, daemon, web, mobile)`,
|
|
139
|
+
`- ${cyan('login')}: guide you through Happy login for this sandbox`,
|
|
140
|
+
`- ${cyan('browser')}: open the Happy web app`,
|
|
141
|
+
`- ${cyan('mobile')}: start Expo dev-client (optional)`,
|
|
142
|
+
`- ${cyan('cleanup')}: stop processes + delete sandbox on exit`,
|
|
143
|
+
'',
|
|
144
|
+
dim('Everything is deleted automatically when you exit.'),
|
|
145
|
+
dim('Your main Happy installation remains untouched.'),
|
|
146
|
+
'',
|
|
147
|
+
dim('Tips:'),
|
|
148
|
+
dim('- Add `-v` / `-vv` / `-vvv` to show the full logs'),
|
|
149
|
+
dim('- Add `--keep-sandbox` to keep the sandbox directory between runs'),
|
|
150
|
+
'',
|
|
151
|
+
existingSandboxes.length
|
|
152
|
+
? bold('Choose how to proceed') + dim(' (or Ctrl+C to cancel).')
|
|
153
|
+
: bold('Press Enter to proceed') + dim(' (or Ctrl+C to cancel).'),
|
|
154
|
+
].join('\n');
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.log(intro);
|
|
157
|
+
if (!existingSandboxes.length) {
|
|
158
|
+
await withRl(async (rl) => {
|
|
159
|
+
await prompt(rl, '', { defaultValue: '' });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let sandboxDir = '';
|
|
165
|
+
let createdNewSandbox = false;
|
|
166
|
+
let reusedSandboxMeta = null;
|
|
167
|
+
|
|
168
|
+
if (existingSandboxes.length) {
|
|
169
|
+
const picked = await withRl(async (rl) => {
|
|
170
|
+
const options = [
|
|
171
|
+
{ label: 'Create a new sandbox (recommended)', value: 'new' },
|
|
172
|
+
...existingSandboxes.map((s) => {
|
|
173
|
+
const stackLabel = s.stackName ? `stack=${s.stackName}` : 'stack=?';
|
|
174
|
+
return { label: `Reuse existing sandbox (${stackLabel}) — ${s.dir}`, value: s.dir };
|
|
175
|
+
}),
|
|
176
|
+
];
|
|
177
|
+
return await promptSelect(rl, {
|
|
178
|
+
title: 'Review-pr sandbox:',
|
|
179
|
+
options,
|
|
180
|
+
defaultIndex: 0,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
if (picked === 'new') {
|
|
184
|
+
steps.start('create temporary sandbox');
|
|
185
|
+
const prefix = reviewPrSandboxPrefixPath(baseStackName);
|
|
186
|
+
sandboxDir = resolve(await mkdtemp(prefix));
|
|
187
|
+
createdNewSandbox = true;
|
|
188
|
+
steps.stop('✓', 'create temporary sandbox');
|
|
189
|
+
} else {
|
|
190
|
+
sandboxDir = resolve(String(picked));
|
|
191
|
+
reusedSandboxMeta = existingSandboxes.find((s) => resolve(s.dir) === sandboxDir) ?? null;
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
steps.start('create temporary sandbox');
|
|
195
|
+
const prefix = reviewPrSandboxPrefixPath(baseStackName);
|
|
196
|
+
sandboxDir = resolve(await mkdtemp(prefix));
|
|
197
|
+
createdNewSandbox = true;
|
|
198
|
+
steps.stop('✓', 'create temporary sandbox');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If we're reusing a sandbox, prefer the stack name recorded in its meta file (keeps hostname stable),
|
|
202
|
+
// but only when the user did not explicitly pass --name.
|
|
203
|
+
const effectiveStackName =
|
|
204
|
+
!explicitName && reusedSandboxMeta?.stackName
|
|
205
|
+
? sanitizeStackName(reusedSandboxMeta.stackName, { fallback: baseStackName, maxLen: 64 })
|
|
206
|
+
: newStackName;
|
|
207
|
+
|
|
208
|
+
// Safety marker to ensure we only delete what we created.
|
|
209
|
+
const markerPath = join(sandboxDir, '.happy-stacks-sandbox-marker');
|
|
210
|
+
// Always ensure the marker exists for safety; write meta only for new sandboxes.
|
|
211
|
+
try {
|
|
212
|
+
if (!existsSync(markerPath)) {
|
|
213
|
+
await writeFile(markerPath, 'review-pr\n', 'utf-8');
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// ignore; deletion guard will fail closed later if marker is missing
|
|
217
|
+
}
|
|
218
|
+
if (createdNewSandbox && existsSync(markerPath)) {
|
|
219
|
+
try {
|
|
220
|
+
await writeReviewPrSandboxMeta({ sandboxDir, baseStackName, stackName: effectiveStackName, argv });
|
|
221
|
+
} catch {
|
|
222
|
+
// ignore
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const bin = join(rootDir, 'bin', 'happys.mjs');
|
|
227
|
+
|
|
228
|
+
let child = null;
|
|
229
|
+
let gotSignal = null;
|
|
230
|
+
let childExitCode = null;
|
|
231
|
+
|
|
232
|
+
const forwardSignal = (sig) => {
|
|
233
|
+
const first = gotSignal == null;
|
|
234
|
+
gotSignal = gotSignal ?? sig;
|
|
235
|
+
if (first && process.stdout.isTTY && !json) {
|
|
236
|
+
// eslint-disable-next-line no-console
|
|
237
|
+
console.log('\n[review-pr] received Ctrl+C — cleaning up sandbox, please wait...');
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
child?.kill(sig);
|
|
241
|
+
} catch {
|
|
242
|
+
// ignore
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const onSigInt = () => forwardSignal('SIGINT');
|
|
247
|
+
const onSigTerm = () => forwardSignal('SIGTERM');
|
|
248
|
+
process.on('SIGINT', onSigInt);
|
|
249
|
+
process.on('SIGTERM', onSigTerm);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const wantsStart = flags.has('--start') || flags.has('--prod');
|
|
253
|
+
const hasMobileFlag = argv.includes('--mobile') || argv.includes('--with-mobile') || argv.includes('--no-mobile');
|
|
254
|
+
const argvWithDefaults =
|
|
255
|
+
process.stdout.isTTY && !json && !wantsStart && !hasMobileFlag ? [...argv, '--mobile'] : argv;
|
|
256
|
+
|
|
257
|
+
// If the caller did not explicitly name the stack, make it unique per run.
|
|
258
|
+
// This prevents browser storage collisions when sandboxes are deleted between runs.
|
|
259
|
+
const hasNameFlag = argvWithDefaults.some((a) => a === '--name' || a.startsWith('--name='));
|
|
260
|
+
const argvFinal = hasNameFlag ? argvWithDefaults : [...argvWithDefaults, `--name=${effectiveStackName}`];
|
|
261
|
+
|
|
262
|
+
child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'setup-pr', ...argvFinal], {
|
|
263
|
+
cwd: rootDir,
|
|
264
|
+
env: process.env,
|
|
265
|
+
stdio: 'inherit',
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const { code } = await waitForExit(child);
|
|
269
|
+
childExitCode = code;
|
|
270
|
+
process.exitCode = code;
|
|
271
|
+
} finally {
|
|
272
|
+
process.off('SIGINT', onSigInt);
|
|
273
|
+
process.off('SIGTERM', onSigTerm);
|
|
274
|
+
|
|
275
|
+
steps.start('stop sandbox processes (best-effort)');
|
|
276
|
+
try {
|
|
277
|
+
// Best-effort stop before deleting the sandbox.
|
|
278
|
+
await tryStopSandbox({ rootDir, sandboxDir });
|
|
279
|
+
steps.stop('✓', 'stop sandbox processes (best-effort)');
|
|
280
|
+
} catch {
|
|
281
|
+
steps.stop('x', 'stop sandbox processes (best-effort)');
|
|
282
|
+
// eslint-disable-next-line no-console
|
|
283
|
+
console.warn(`[review-pr] warning: failed to stop all sandbox processes. Attempting cleanup anyway.`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// On failure, offer to keep the sandbox for inspection (TTY only).
|
|
287
|
+
// - `--keep-sandbox` always wins (no prompt)
|
|
288
|
+
// - on signals, don't prompt (just follow the normal cleanup rules)
|
|
289
|
+
const keepSandbox = flags.has('--keep-sandbox');
|
|
290
|
+
const failed = !json && (childExitCode ?? 0) !== 0;
|
|
291
|
+
const canPromptKeep =
|
|
292
|
+
failed &&
|
|
293
|
+
!keepSandbox &&
|
|
294
|
+
!gotSignal &&
|
|
295
|
+
Boolean(process.stdout.isTTY && process.stdin.isTTY) &&
|
|
296
|
+
!json;
|
|
297
|
+
|
|
298
|
+
let keepOnFail = false;
|
|
299
|
+
if (failed && !keepSandbox && !gotSignal) {
|
|
300
|
+
if (canPromptKeep) {
|
|
301
|
+
// Default: keep in verbose mode, delete otherwise.
|
|
302
|
+
const defaultKeep = getVerbosityLevel(process.env) > 0;
|
|
303
|
+
keepOnFail = await withRl(async (rl) => {
|
|
304
|
+
return await promptSelect(rl, {
|
|
305
|
+
title: 'Review-pr failed. Keep the sandbox for inspection?',
|
|
306
|
+
options: [
|
|
307
|
+
{ label: 'yes (keep sandbox directory)', value: true },
|
|
308
|
+
{ label: 'no (delete sandbox directory)', value: false },
|
|
309
|
+
],
|
|
310
|
+
defaultIndex: defaultKeep ? 0 : 1,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
} else {
|
|
314
|
+
// Non-interactive: keep old behavior (verbose keeps, otherwise delete).
|
|
315
|
+
keepOnFail = getVerbosityLevel(process.env) > 0;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const shouldDeleteSandbox = !keepSandbox && !(failed && keepOnFail);
|
|
320
|
+
|
|
321
|
+
steps.start('delete sandbox directory');
|
|
322
|
+
// Only delete if marker exists (paranoia guard).
|
|
323
|
+
// Note: if marker is missing, we intentionally leave the sandbox dir on disk.
|
|
324
|
+
try {
|
|
325
|
+
if (!existsSync(markerPath)) {
|
|
326
|
+
throw new Error('missing marker');
|
|
327
|
+
}
|
|
328
|
+
if (!shouldDeleteSandbox) {
|
|
329
|
+
steps.stop('!', 'delete sandbox directory');
|
|
330
|
+
// eslint-disable-next-line no-console
|
|
331
|
+
console.warn(`[review-pr] sandbox preserved at: ${sandboxDir}`);
|
|
332
|
+
if (!json && (childExitCode ?? 0) !== 0) {
|
|
333
|
+
// eslint-disable-next-line no-console
|
|
334
|
+
console.warn(`[review-pr] tip: inspect stack wiring with:`);
|
|
335
|
+
// eslint-disable-next-line no-console
|
|
336
|
+
console.warn(` npx happy-stacks --sandbox-dir "${sandboxDir}" stack info ${effectiveStackName}`);
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
await rm(markerPath, { force: false });
|
|
340
|
+
await rm(sandboxDir, { recursive: true, force: true });
|
|
341
|
+
steps.stop('✓', 'delete sandbox directory');
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
steps.stop('x', 'delete sandbox directory');
|
|
345
|
+
// eslint-disable-next-line no-console
|
|
346
|
+
console.warn(`[review-pr] warning: failed to delete sandbox directory: ${sandboxDir}`);
|
|
347
|
+
// eslint-disable-next-line no-console
|
|
348
|
+
console.warn(`[review-pr] you can remove it manually after stopping any remaining processes.`);
|
|
349
|
+
// Preserve conventional exit codes on signals.
|
|
350
|
+
if (gotSignal) {
|
|
351
|
+
const code = gotSignal === 'SIGINT' ? 130 : gotSignal === 'SIGTERM' ? 143 : 1;
|
|
352
|
+
process.exitCode = process.exitCode ?? code;
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Preserve conventional exit codes on signals.
|
|
357
|
+
if (gotSignal) {
|
|
358
|
+
const code = gotSignal === 'SIGINT' ? 130 : gotSignal === 'SIGTERM' ? 143 : 1;
|
|
359
|
+
process.exitCode = process.exitCode ?? code;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
main().catch((err) => {
|
|
365
|
+
console.error('[review-pr] failed:', err);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
});
|
|
368
|
+
|
package/scripts/run.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import './utils/env/env.mjs';
|
|
|
2
2
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
3
|
import { pathExists } from './utils/fs/fs.mjs';
|
|
4
4
|
import { killProcessTree, runCapture, spawnProc } from './utils/proc/proc.mjs';
|
|
5
|
-
import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
|
|
5
|
+
import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
|
|
6
6
|
import { killPortListeners } from './utils/net/ports.mjs';
|
|
7
7
|
import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server/server.mjs';
|
|
8
8
|
import { ensureCliBuilt, ensureDepsInstalled, pmExecBin, pmSpawnScript, requireDir } from './utils/proc/pm.mjs';
|
|
@@ -14,12 +14,16 @@ import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './da
|
|
|
14
14
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
15
15
|
import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/server/validate.mjs';
|
|
16
16
|
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/server/infra/happy_server_infra.mjs';
|
|
17
|
-
import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './utils/stack/startup.mjs';
|
|
17
|
+
import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
|
|
18
18
|
import { recordStackRuntimeStart, recordStackRuntimeUpdate } from './utils/stack/runtime_state.mjs';
|
|
19
19
|
import { resolveStackContext } from './utils/stack/context.mjs';
|
|
20
20
|
import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
|
|
22
22
|
import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
23
|
+
import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
|
|
24
|
+
import { maybeRunInteractiveStackAuthSetup } from './utils/auth/interactive_stack_auth.mjs';
|
|
25
|
+
import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
|
|
26
|
+
import { daemonStartGate, formatDaemonAuthRequiredError } from './utils/auth/daemon_gate.mjs';
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* Run the local stack in "production-like" mode:
|
|
@@ -27,7 +31,7 @@ import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
|
27
31
|
* - happy-cli daemon
|
|
28
32
|
* - serve prebuilt UI via happy-server-light (/)
|
|
29
33
|
*
|
|
30
|
-
*
|
|
34
|
+
* Optional: Expo dev-client Metro for mobile reviewers (`--mobile`).
|
|
31
35
|
*/
|
|
32
36
|
|
|
33
37
|
async function main() {
|
|
@@ -37,12 +41,15 @@ async function main() {
|
|
|
37
41
|
if (wantsHelp(argv, { flags })) {
|
|
38
42
|
printResult({
|
|
39
43
|
json,
|
|
40
|
-
data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser'], json: true },
|
|
44
|
+
data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser', '--mobile'], json: true },
|
|
41
45
|
text: [
|
|
42
46
|
'[start] usage:',
|
|
43
47
|
' happys start [--server=happy-server|happy-server-light] [--restart] [--json]',
|
|
44
48
|
' (legacy in a cloned repo): pnpm start [-- --server=happy-server|happy-server-light] [--json]',
|
|
45
49
|
' note: --json prints the resolved config (dry-run) and exits.',
|
|
50
|
+
'',
|
|
51
|
+
'note:',
|
|
52
|
+
' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
|
|
46
53
|
].join('\n'),
|
|
47
54
|
});
|
|
48
55
|
return;
|
|
@@ -50,6 +57,20 @@ async function main() {
|
|
|
50
57
|
|
|
51
58
|
const rootDir = getRootDir(import.meta.url);
|
|
52
59
|
|
|
60
|
+
const inferred = inferComponentFromCwd({
|
|
61
|
+
rootDir,
|
|
62
|
+
invokedCwd: getInvokedCwd(process.env),
|
|
63
|
+
components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
|
|
64
|
+
});
|
|
65
|
+
if (inferred) {
|
|
66
|
+
const stacksKey = componentDirEnvKey(inferred.component);
|
|
67
|
+
const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
|
|
68
|
+
// Stack env should win. Only infer from CWD when the component dir isn't already configured.
|
|
69
|
+
if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
|
|
70
|
+
process.env[stacksKey] = inferred.repoDir;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
|
|
54
75
|
|
|
55
76
|
// Internal URL used by local processes on this machine.
|
|
@@ -67,6 +88,7 @@ async function main() {
|
|
|
67
88
|
const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
|
|
68
89
|
const serveUiWanted = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
|
|
69
90
|
const serveUi = serveUiWanted;
|
|
91
|
+
const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
70
92
|
const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
|
|
71
93
|
const uiPrefix = process.env.HAPPY_LOCAL_UI_PREFIX?.trim() ? process.env.HAPPY_LOCAL_UI_PREFIX.trim() : '/';
|
|
72
94
|
const autostart = getDefaultAutostartPaths();
|
|
@@ -78,12 +100,16 @@ async function main() {
|
|
|
78
100
|
|
|
79
101
|
const serverDir = getComponentDir(rootDir, serverComponentName);
|
|
80
102
|
const cliDir = getComponentDir(rootDir, 'happy-cli');
|
|
103
|
+
const uiDir = getComponentDir(rootDir, 'happy');
|
|
81
104
|
|
|
82
105
|
assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
|
|
83
106
|
assertServerPrismaProviderMatches({ serverComponentName, serverDir });
|
|
84
107
|
|
|
85
108
|
await requireDir(serverComponentName, serverDir);
|
|
86
109
|
await requireDir('happy-cli', cliDir);
|
|
110
|
+
if (startMobile) {
|
|
111
|
+
await requireDir('happy', uiDir);
|
|
112
|
+
}
|
|
87
113
|
|
|
88
114
|
const cliBin = join(cliDir, 'bin', 'happy.mjs');
|
|
89
115
|
|
|
@@ -99,12 +125,14 @@ async function main() {
|
|
|
99
125
|
mode: 'start',
|
|
100
126
|
serverComponentName,
|
|
101
127
|
serverDir,
|
|
128
|
+
uiDir,
|
|
102
129
|
cliDir,
|
|
103
130
|
serverPort,
|
|
104
131
|
internalServerUrl,
|
|
105
132
|
publicServerUrl,
|
|
106
133
|
startDaemon,
|
|
107
134
|
serveUi,
|
|
135
|
+
startMobile,
|
|
108
136
|
uiPrefix,
|
|
109
137
|
uiBuildDir,
|
|
110
138
|
cliHomeDir,
|
|
@@ -125,7 +153,7 @@ async function main() {
|
|
|
125
153
|
let shuttingDown = false;
|
|
126
154
|
const baseEnv = { ...process.env };
|
|
127
155
|
const stackCtx = resolveStackContext({ env: baseEnv, autostart });
|
|
128
|
-
const { stackMode, runtimeStatePath, stackName, ephemeral } = stackCtx;
|
|
156
|
+
const { stackMode, runtimeStatePath, stackName, envPath, ephemeral } = stackCtx;
|
|
129
157
|
|
|
130
158
|
// Ensure happy-cli is install+build ready before starting the daemon.
|
|
131
159
|
const buildCli = (baseEnv.HAPPY_STACKS_CLI_BUILD ?? baseEnv.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
|
|
@@ -133,12 +161,18 @@ async function main() {
|
|
|
133
161
|
|
|
134
162
|
// Ensure server deps exist before any Prisma/docker work.
|
|
135
163
|
await ensureDepsInstalled(serverDir, serverComponentName);
|
|
164
|
+
if (startMobile) {
|
|
165
|
+
await ensureDepsInstalled(uiDir, 'happy');
|
|
166
|
+
}
|
|
136
167
|
|
|
137
168
|
// Public URL automation:
|
|
138
169
|
// - Only the main stack should ever auto-enable Tailscale Serve by default.
|
|
139
170
|
// - Non-main stacks default to localhost unless the user explicitly configured a public URL
|
|
140
171
|
// OR Tailscale Serve is already configured for this stack's internal URL (status matches).
|
|
141
|
-
const allowEnableTailscale =
|
|
172
|
+
const allowEnableTailscale =
|
|
173
|
+
!stackMode ||
|
|
174
|
+
stackName === 'main' ||
|
|
175
|
+
(baseEnv.HAPPY_STACKS_TAILSCALE_SERVE ?? baseEnv.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
|
|
142
176
|
const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
|
|
143
177
|
if (stackMode && stackName !== 'main' && !resolvedUrls.envPublicUrl) {
|
|
144
178
|
const src = String(resolvedUrls.publicServerUrlSource ?? '');
|
|
@@ -331,9 +365,8 @@ async function main() {
|
|
|
331
365
|
// Auto-open UI (interactive only) using the stack-scoped hostname when applicable.
|
|
332
366
|
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
333
367
|
if (isInteractive && !noBrowser) {
|
|
334
|
-
const host = resolveLocalhostHost({ stackMode, stackName: autostart.stackName });
|
|
335
368
|
const prefix = uiPrefix.startsWith('/') ? uiPrefix : `/${uiPrefix}`;
|
|
336
|
-
const openUrl = `http
|
|
369
|
+
const openUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}${prefix}`, { stackName: autostart.stackName });
|
|
337
370
|
const res = await openUrlInBrowser(openUrl);
|
|
338
371
|
if (!res.ok) {
|
|
339
372
|
console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
|
|
@@ -343,6 +376,16 @@ async function main() {
|
|
|
343
376
|
|
|
344
377
|
// Daemon
|
|
345
378
|
if (startDaemon) {
|
|
379
|
+
const gate = daemonStartGate({ env: baseEnv, cliHomeDir });
|
|
380
|
+
if (!gate.ok) {
|
|
381
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
382
|
+
// In orchestrated auth flows, keep server/UI up and let the orchestrator start daemon post-auth.
|
|
383
|
+
if (gate.reason === 'auth_flow_missing_credentials') {
|
|
384
|
+
console.log('[local] auth flow: skipping daemon start until credentials exist');
|
|
385
|
+
} else if (!isInteractive) {
|
|
386
|
+
throw new Error(formatDaemonAuthRequiredError({ stackName: autostart.stackName, cliHomeDir }));
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
346
389
|
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
347
390
|
if (serverComponentName === 'happy-server' && happyServerAccountCount == null) {
|
|
348
391
|
const acct = await getAccountCountForServerComponent({
|
|
@@ -355,6 +398,16 @@ async function main() {
|
|
|
355
398
|
}
|
|
356
399
|
const accountCount =
|
|
357
400
|
serverComponentName === 'happy-server-light' ? serverLightAccountCount : happyServerAccountCount;
|
|
401
|
+
const autoSeedEnabled = resolveAutoCopyFromMainEnabled({ env: baseEnv, stackName: autostart.stackName, isInteractive });
|
|
402
|
+
await maybeRunInteractiveStackAuthSetup({
|
|
403
|
+
rootDir,
|
|
404
|
+
env: baseEnv,
|
|
405
|
+
stackName: autostart.stackName,
|
|
406
|
+
cliHomeDir,
|
|
407
|
+
accountCount,
|
|
408
|
+
isInteractive,
|
|
409
|
+
autoSeedEnabled,
|
|
410
|
+
});
|
|
358
411
|
await prepareDaemonAuthSeedIfNeeded({
|
|
359
412
|
rootDir,
|
|
360
413
|
env: baseEnv,
|
|
@@ -372,6 +425,27 @@ async function main() {
|
|
|
372
425
|
publicServerUrl,
|
|
373
426
|
isShuttingDown: () => shuttingDown,
|
|
374
427
|
forceRestart: restart,
|
|
428
|
+
env: baseEnv,
|
|
429
|
+
stackName: autostart.stackName,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Optional: start Expo dev-client Metro for mobile reviewers.
|
|
435
|
+
if (startMobile) {
|
|
436
|
+
await ensureDevExpoServer({
|
|
437
|
+
startUi: false,
|
|
438
|
+
startMobile: true,
|
|
439
|
+
uiDir,
|
|
440
|
+
autostart,
|
|
441
|
+
baseEnv,
|
|
442
|
+
apiServerUrl: publicServerUrl,
|
|
443
|
+
restart,
|
|
444
|
+
stackMode,
|
|
445
|
+
runtimeStatePath,
|
|
446
|
+
stackName,
|
|
447
|
+
envPath,
|
|
448
|
+
children,
|
|
375
449
|
});
|
|
376
450
|
}
|
|
377
451
|
|
package/scripts/service.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './utils/env/env.mjs';
|
|
2
2
|
import { run, runCapture } from './utils/proc/proc.mjs';
|
|
3
|
-
import { getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
|
|
3
|
+
import { getComponentDir, getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
|
|
4
4
|
import { getInternalServerUrl, getPublicServerUrlEnvOverride } from './utils/server/urls.mjs';
|
|
5
5
|
import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/service/autostart_darwin.mjs';
|
|
6
6
|
import { getCanonicalHomeDir } from './utils/env/config.mjs';
|
|
@@ -278,7 +278,7 @@ async function postStartDiagnostics() {
|
|
|
278
278
|
}
|
|
279
279
|
const { publicServerUrl: publicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port });
|
|
280
280
|
|
|
281
|
-
const cliDir =
|
|
281
|
+
const cliDir = getComponentDir(rootDir, 'happy-cli');
|
|
282
282
|
const cliBin = join(cliDir, 'bin', 'happy.mjs');
|
|
283
283
|
|
|
284
284
|
const accessKey = join(cliHomeDir, 'access.key');
|