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
package/scripts/dev.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import './utils/env/env.mjs';
|
|
2
2
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
3
|
import { killProcessTree } from './utils/proc/proc.mjs';
|
|
4
|
-
import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
|
|
4
|
+
import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
|
|
5
5
|
import { killPortListeners } from './utils/net/ports.mjs';
|
|
6
6
|
import { getServerComponentName, isHappyServerRunning } from './utils/server/server.mjs';
|
|
7
7
|
import { requireDir } from './utils/proc/pm.mjs';
|
|
@@ -17,11 +17,15 @@ import { resolveStackContext } from './utils/stack/context.mjs';
|
|
|
17
17
|
import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
|
|
18
18
|
import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev/daemon.mjs';
|
|
19
19
|
import { startDevServer, watchDevServerAndRestart } from './utils/dev/server.mjs';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
|
|
21
|
+
import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
|
|
22
22
|
import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
23
23
|
import { waitForHttpOk } from './utils/server/server.mjs';
|
|
24
24
|
import { sanitizeDnsLabel } from './utils/net/dns.mjs';
|
|
25
|
+
import { getAccountCountForServerComponent, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
|
|
26
|
+
import { maybeRunInteractiveStackAuthSetup } from './utils/auth/interactive_stack_auth.mjs';
|
|
27
|
+
import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
|
|
28
|
+
import { daemonStartGate, formatDaemonAuthRequiredError } from './utils/auth/daemon_gate.mjs';
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
31
|
* Dev mode stack:
|
|
@@ -37,20 +41,38 @@ 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', '--watch', '--no-watch', '--no-browser'], json: true },
|
|
44
|
+
data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser', '--mobile'], json: true },
|
|
41
45
|
text: [
|
|
42
46
|
'[dev] usage:',
|
|
43
47
|
' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
|
|
44
48
|
' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
|
|
45
49
|
' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
|
|
46
50
|
' happys dev --no-browser # do not open the UI in your browser automatically',
|
|
51
|
+
' happys dev --mobile # also start Expo dev-client Metro for mobile',
|
|
47
52
|
' note: --json prints the resolved config (dry-run) and exits.',
|
|
53
|
+
'',
|
|
54
|
+
'note:',
|
|
55
|
+
' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
|
|
48
56
|
].join('\n'),
|
|
49
57
|
});
|
|
50
58
|
return;
|
|
51
59
|
}
|
|
52
60
|
const rootDir = getRootDir(import.meta.url);
|
|
53
61
|
|
|
62
|
+
const inferred = inferComponentFromCwd({
|
|
63
|
+
rootDir,
|
|
64
|
+
invokedCwd: getInvokedCwd(process.env),
|
|
65
|
+
components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
|
|
66
|
+
});
|
|
67
|
+
if (inferred) {
|
|
68
|
+
const stacksKey = componentDirEnvKey(inferred.component);
|
|
69
|
+
const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
|
|
70
|
+
// Stack env should win. Only infer from CWD when the component dir isn't already configured.
|
|
71
|
+
if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
|
|
72
|
+
process.env[stacksKey] = inferred.repoDir;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
const serverComponentName = getServerComponentName({ kv });
|
|
55
77
|
if (serverComponentName === 'both') {
|
|
56
78
|
throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
|
|
@@ -58,6 +80,7 @@ async function main() {
|
|
|
58
80
|
|
|
59
81
|
const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
|
|
60
82
|
const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
|
|
83
|
+
const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
61
84
|
const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
|
|
62
85
|
|
|
63
86
|
const serverDir = getComponentDir(rootDir, serverComponentName);
|
|
@@ -82,7 +105,10 @@ async function main() {
|
|
|
82
105
|
// - Only the main stack should ever auto-enable (or prefer) Tailscale Serve by default.
|
|
83
106
|
// - Non-main stacks should default to localhost URLs unless the user explicitly configured a public URL
|
|
84
107
|
// OR Tailscale Serve is already configured for this stack's internal URL (status matches).
|
|
85
|
-
const allowEnableTailscale =
|
|
108
|
+
const allowEnableTailscale =
|
|
109
|
+
!stackMode ||
|
|
110
|
+
stackName === 'main' ||
|
|
111
|
+
(baseEnv.HAPPY_STACKS_TAILSCALE_SERVE ?? baseEnv.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
|
|
86
112
|
const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
|
|
87
113
|
const internalServerUrl = resolvedUrls.internalServerUrl;
|
|
88
114
|
let publicServerUrl = resolvedUrls.publicServerUrl;
|
|
@@ -93,6 +119,8 @@ async function main() {
|
|
|
93
119
|
publicServerUrl = resolvedUrls.defaultPublicUrl;
|
|
94
120
|
}
|
|
95
121
|
}
|
|
122
|
+
// Expo app config: this is what both web + native app use to reach the Happy server.
|
|
123
|
+
// LAN rewrite (for dev-client) is centralized in ensureDevExpoServer.
|
|
96
124
|
const uiApiUrl = resolvedUrls.defaultPublicUrl;
|
|
97
125
|
const restart = flags.has('--restart');
|
|
98
126
|
const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
|
|
@@ -112,6 +140,7 @@ async function main() {
|
|
|
112
140
|
internalServerUrl,
|
|
113
141
|
publicServerUrl,
|
|
114
142
|
startUi,
|
|
143
|
+
startMobile,
|
|
115
144
|
startDaemon,
|
|
116
145
|
cliHomeDir,
|
|
117
146
|
},
|
|
@@ -135,13 +164,25 @@ async function main() {
|
|
|
135
164
|
const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
|
|
136
165
|
const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
|
|
137
166
|
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
167
|
+
// Expo dev server state (worktree-scoped): single Expo process per stack/worktree.
|
|
168
|
+
const startExpo = startUi || startMobile;
|
|
169
|
+
const expoPaths = getExpoStatePaths({
|
|
170
|
+
baseDir: autostart.baseDir,
|
|
171
|
+
kind: 'expo-dev',
|
|
172
|
+
projectDir: uiDir,
|
|
173
|
+
stateFileName: 'expo.state.json',
|
|
174
|
+
});
|
|
175
|
+
const expoRunning = startExpo ? await isStateProcessRunning(expoPaths.statePath) : { running: false, state: null };
|
|
176
|
+
let expoAlreadyRunning = Boolean(expoRunning.running);
|
|
177
|
+
|
|
178
|
+
if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startExpo || expoAlreadyRunning)) {
|
|
179
|
+
console.log(
|
|
180
|
+
`[local] dev: stack already running (server=${internalServerUrl}` +
|
|
181
|
+
`${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
|
|
182
|
+
`${startUi ? ` ui=${expoAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
|
|
183
|
+
`${startMobile ? ` mobile=${expoAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
|
|
184
|
+
`)`
|
|
185
|
+
);
|
|
145
186
|
return;
|
|
146
187
|
}
|
|
147
188
|
|
|
@@ -193,6 +234,75 @@ async function main() {
|
|
|
193
234
|
// - Ensure schema exists (server-light: db push; happy-server: migrate deploy if tables missing)
|
|
194
235
|
// - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
|
|
195
236
|
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
237
|
+
const accountProbe = await getAccountCountForServerComponent({
|
|
238
|
+
serverComponentName,
|
|
239
|
+
serverDir,
|
|
240
|
+
env: serverEnv,
|
|
241
|
+
bestEffort: true,
|
|
242
|
+
});
|
|
243
|
+
const accountCount = typeof accountProbe.accountCount === 'number' ? accountProbe.accountCount : null;
|
|
244
|
+
const autoSeedEnabled = resolveAutoCopyFromMainEnabled({ env: baseEnv, stackName, isInteractive });
|
|
245
|
+
|
|
246
|
+
let expoResEarly = null;
|
|
247
|
+
const wantsAuthFlow =
|
|
248
|
+
(baseEnv.HAPPY_STACKS_AUTH_FLOW ?? baseEnv.HAPPY_LOCAL_AUTH_FLOW ?? '').toString().trim() === '1' ||
|
|
249
|
+
(baseEnv.HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH ?? baseEnv.HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH ?? '').toString().trim() === '1';
|
|
250
|
+
|
|
251
|
+
// CRITICAL (review-pr / setup-pr guided login):
|
|
252
|
+
// In background/non-interactive runs, the daemon may block on auth. If we wait to start Expo web
|
|
253
|
+
// until after the daemon is authenticated, guided login will have no UI origin and will fall back
|
|
254
|
+
// to the server port (wrong). Start Expo web UI early when running an auth flow.
|
|
255
|
+
if (wantsAuthFlow && startUi && !expoResEarly) {
|
|
256
|
+
expoResEarly = await ensureDevExpoServer({
|
|
257
|
+
startUi,
|
|
258
|
+
startMobile,
|
|
259
|
+
uiDir,
|
|
260
|
+
autostart,
|
|
261
|
+
baseEnv,
|
|
262
|
+
apiServerUrl: uiApiUrl,
|
|
263
|
+
restart,
|
|
264
|
+
stackMode,
|
|
265
|
+
runtimeStatePath,
|
|
266
|
+
stackName,
|
|
267
|
+
envPath,
|
|
268
|
+
children,
|
|
269
|
+
spawnOptions: { stdio: ['ignore', 'ignore', 'ignore'] },
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
await maybeRunInteractiveStackAuthSetup({
|
|
273
|
+
rootDir,
|
|
274
|
+
// In dev mode, guided login must target the Expo web UI origin (not the server port).
|
|
275
|
+
// Mark this as an auth-flow so URL resolution fails closed if Expo isn't ready.
|
|
276
|
+
env: startUi ? { ...baseEnv, HAPPY_STACKS_AUTH_FLOW: '1', HAPPY_LOCAL_AUTH_FLOW: '1' } : baseEnv,
|
|
277
|
+
stackName,
|
|
278
|
+
cliHomeDir,
|
|
279
|
+
accountCount,
|
|
280
|
+
isInteractive,
|
|
281
|
+
autoSeedEnabled,
|
|
282
|
+
beforeLogin: async () => {
|
|
283
|
+
if (!startUi) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`[local] auth: interactive login requires the web UI.\n` +
|
|
286
|
+
`Re-run without --no-ui, or set HAPPY_WEBAPP_URL to a reachable Happy UI for this stack.`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (expoResEarly) return;
|
|
290
|
+
expoResEarly = await ensureDevExpoServer({
|
|
291
|
+
startUi,
|
|
292
|
+
startMobile,
|
|
293
|
+
uiDir,
|
|
294
|
+
autostart,
|
|
295
|
+
baseEnv,
|
|
296
|
+
apiServerUrl: uiApiUrl,
|
|
297
|
+
restart,
|
|
298
|
+
stackMode,
|
|
299
|
+
runtimeStatePath,
|
|
300
|
+
stackName,
|
|
301
|
+
envPath,
|
|
302
|
+
children,
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
});
|
|
196
306
|
await prepareDaemonAuthSeed({
|
|
197
307
|
rootDir,
|
|
198
308
|
env: baseEnv,
|
|
@@ -206,19 +316,35 @@ async function main() {
|
|
|
206
316
|
quiet: false,
|
|
207
317
|
});
|
|
208
318
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
319
|
+
if (startDaemon) {
|
|
320
|
+
const gate = daemonStartGate({ env: baseEnv, cliHomeDir });
|
|
321
|
+
if (!gate.ok) {
|
|
322
|
+
// In orchestrated auth flows (setup-pr/review-pr), we intentionally keep server/UI up
|
|
323
|
+
// for guided login and start daemon post-auth from the orchestrator.
|
|
324
|
+
if (gate.reason === 'auth_flow_missing_credentials') {
|
|
325
|
+
console.log('[local] auth flow: skipping daemon start until credentials exist');
|
|
326
|
+
} else {
|
|
327
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
328
|
+
if (!isInteractive) {
|
|
329
|
+
throw new Error(formatDaemonAuthRequiredError({ stackName, cliHomeDir }));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
await startDevDaemon({
|
|
334
|
+
startDaemon,
|
|
335
|
+
cliBin,
|
|
336
|
+
cliHomeDir,
|
|
337
|
+
internalServerUrl,
|
|
338
|
+
publicServerUrl,
|
|
339
|
+
restart,
|
|
340
|
+
isShuttingDown: () => shuttingDown,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
218
344
|
|
|
219
345
|
const cliWatcher = watchHappyCliAndRestartDaemon({
|
|
220
346
|
enabled: watchEnabled,
|
|
221
|
-
startDaemon,
|
|
347
|
+
startDaemon: startDaemon && daemonStartGate({ env: baseEnv, cliHomeDir }).ok,
|
|
222
348
|
buildCli,
|
|
223
349
|
cliDir,
|
|
224
350
|
cliBin,
|
|
@@ -263,43 +389,52 @@ async function main() {
|
|
|
263
389
|
);
|
|
264
390
|
}
|
|
265
391
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
392
|
+
const expoRes =
|
|
393
|
+
expoResEarly ??
|
|
394
|
+
(await ensureDevExpoServer({
|
|
395
|
+
startUi,
|
|
396
|
+
startMobile,
|
|
397
|
+
uiDir,
|
|
398
|
+
autostart,
|
|
399
|
+
baseEnv,
|
|
400
|
+
apiServerUrl: uiApiUrl,
|
|
401
|
+
restart,
|
|
402
|
+
stackMode,
|
|
403
|
+
runtimeStatePath,
|
|
404
|
+
stackName,
|
|
405
|
+
envPath,
|
|
406
|
+
children,
|
|
407
|
+
}));
|
|
279
408
|
if (startUi) {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
console.log(`[local] ui: open
|
|
286
|
-
} else if (
|
|
409
|
+
const uiPort = expoRes?.port;
|
|
410
|
+
const uiUrlRaw = uiPort ? `http://localhost:${uiPort}` : '';
|
|
411
|
+
const uiUrl = uiUrlRaw ? await preferStackLocalhostUrl(uiUrlRaw, { stackName }) : '';
|
|
412
|
+
if (expoRes?.reason === 'already_running' && expoRes.port) {
|
|
413
|
+
console.log(`[local] ui already running (pid=${expoRes.pid}, port=${expoRes.port})`);
|
|
414
|
+
if (uiUrl) console.log(`[local] ui: open ${uiUrl}`);
|
|
415
|
+
} else if (expoRes?.skipped === false && expoRes.port) {
|
|
416
|
+
if (uiUrl) console.log(`[local] ui: open ${uiUrl}`);
|
|
417
|
+
} else if (expoRes?.skipped && expoRes?.reason === 'already_running') {
|
|
287
418
|
console.log('[local] ui already running (skipping Expo start)');
|
|
288
419
|
}
|
|
289
420
|
|
|
290
421
|
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
291
|
-
const shouldOpen = isInteractive && !noBrowser && Boolean(
|
|
422
|
+
const shouldOpen = isInteractive && !noBrowser && Boolean(expoRes?.port);
|
|
292
423
|
if (shouldOpen) {
|
|
293
|
-
const url = `http://${host}:${uiRes.port}`;
|
|
294
424
|
// Prefer localhost for readiness checks (faster/more reliable), but open the stack-scoped hostname.
|
|
295
|
-
await waitForHttpOk(`http://localhost:${
|
|
296
|
-
const res = await openUrlInBrowser(
|
|
425
|
+
await waitForHttpOk(`http://localhost:${expoRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
|
|
426
|
+
const res = await openUrlInBrowser(uiUrl);
|
|
297
427
|
if (!res.ok) {
|
|
298
428
|
console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
|
|
299
429
|
}
|
|
300
430
|
}
|
|
301
431
|
}
|
|
302
432
|
|
|
433
|
+
if (startMobile && expoRes?.port) {
|
|
434
|
+
const metroUrl = await preferStackLocalhostUrl(`http://localhost:${expoRes.port}`, { stackName });
|
|
435
|
+
console.log(`[local] mobile: metro ${metroUrl}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
303
438
|
const shutdown = async () => {
|
|
304
439
|
if (shuttingDown) {
|
|
305
440
|
return;
|
package/scripts/edison.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { readTextOrEmpty } from './utils/fs/ops.mjs';
|
|
|
8
8
|
import { readJsonIfExists } from './utils/fs/json.mjs';
|
|
9
9
|
import { isPidAlive } from './utils/proc/pids.mjs';
|
|
10
10
|
import { run, runCapture } from './utils/proc/proc.mjs';
|
|
11
|
-
import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
|
|
11
|
+
import { preferStackLocalhostHost, resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
|
|
12
12
|
import { sanitizeStackName } from './utils/stack/names.mjs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
@@ -1781,7 +1781,9 @@ async function main() {
|
|
|
1781
1781
|
env.HAPPY_STACKS_EDISON_WRAPPER = '1';
|
|
1782
1782
|
// Provide a stack-scoped localhost hostname for validators and browser flows.
|
|
1783
1783
|
// This ensures origin isolation even if ports are reused later (common with ephemeral ports).
|
|
1784
|
-
const localhostHost =
|
|
1784
|
+
const localhostHost = Boolean(stackName)
|
|
1785
|
+
? await preferStackLocalhostHost({ stackName })
|
|
1786
|
+
: resolveLocalhostHost({ stackMode: false, stackName: 'main' });
|
|
1785
1787
|
env.HAPPY_STACKS_LOCALHOST_HOST = localhostHost;
|
|
1786
1788
|
env.HAPPY_LOCAL_LOCALHOST_HOST = localhostHost;
|
|
1787
1789
|
|
package/scripts/init.mjs
CHANGED
|
@@ -199,7 +199,9 @@ async function main() {
|
|
|
199
199
|
const storageDirRaw = parseArgValue(argv, 'storage-dir');
|
|
200
200
|
const storageDirOverride = expandHome((storageDirRaw ?? '').trim());
|
|
201
201
|
if (storageDirOverride) {
|
|
202
|
-
|
|
202
|
+
// In sandbox mode, storage dir MUST be isolated and must override any pre-existing env.
|
|
203
|
+
process.env.HAPPY_STACKS_STORAGE_DIR = isSandboxed() ? storageDirOverride : (process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDirOverride);
|
|
204
|
+
process.env.HAPPY_LOCAL_STORAGE_DIR = process.env.HAPPY_LOCAL_STORAGE_DIR ?? process.env.HAPPY_STACKS_STORAGE_DIR;
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
const cliRootDirRaw = parseArgValue(argv, 'cli-root-dir');
|
package/scripts/install.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import './utils/env/env.mjs';
|
|
2
2
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
3
|
import { pathExists } from './utils/fs/fs.mjs';
|
|
4
|
-
import { run } from './utils/proc/proc.mjs';
|
|
4
|
+
import { run, runCapture } from './utils/proc/proc.mjs';
|
|
5
5
|
import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
|
|
6
6
|
import { getServerComponentName } from './utils/server/server.mjs';
|
|
7
7
|
import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } from './utils/proc/pm.mjs';
|
|
@@ -24,8 +24,10 @@ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox
|
|
|
24
24
|
|
|
25
25
|
const DEFAULT_FORK_REPOS = {
|
|
26
26
|
serverLight: 'https://github.com/leeroybrun/happy-server-light.git',
|
|
27
|
-
//
|
|
28
|
-
|
|
27
|
+
// Both server flavors live as branches in the same fork repo:
|
|
28
|
+
// - happy-server-light (sqlite)
|
|
29
|
+
// - happy-server (full)
|
|
30
|
+
serverFull: 'https://github.com/leeroybrun/happy-server-light.git',
|
|
29
31
|
cli: 'https://github.com/leeroybrun/happy-cli.git',
|
|
30
32
|
ui: 'https://github.com/leeroybrun/happy.git',
|
|
31
33
|
};
|
|
@@ -44,7 +46,8 @@ function repoUrlsFromOwners({ forkOwner, upstreamOwner }) {
|
|
|
44
46
|
return {
|
|
45
47
|
forks: {
|
|
46
48
|
serverLight: fork('happy-server-light'),
|
|
47
|
-
|
|
49
|
+
// Fork convention: server full is a branch in happy-server-light repo (not a separate repo).
|
|
50
|
+
serverFull: fork('happy-server-light'),
|
|
48
51
|
cli: fork('happy-cli'),
|
|
49
52
|
ui: fork('happy'),
|
|
50
53
|
},
|
|
@@ -86,6 +89,51 @@ function getRepoUrls({ repoSource }) {
|
|
|
86
89
|
};
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
async function ensureGitBranchCheckedOut({ repoDir, branch, label }) {
|
|
93
|
+
if (!(await pathExists(join(repoDir, '.git')))) return;
|
|
94
|
+
const b = String(branch ?? '').trim();
|
|
95
|
+
if (!b) return;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const head = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir })).trim();
|
|
99
|
+
if (head && head === b) return;
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ensure branch exists locally, otherwise fetch it from origin.
|
|
105
|
+
let hasLocal = true;
|
|
106
|
+
try {
|
|
107
|
+
await run('git', ['show-ref', '--verify', '--quiet', `refs/heads/${b}`], { cwd: repoDir });
|
|
108
|
+
} catch {
|
|
109
|
+
hasLocal = false;
|
|
110
|
+
}
|
|
111
|
+
if (!hasLocal) {
|
|
112
|
+
try {
|
|
113
|
+
await run('git', ['fetch', '--quiet', 'origin', b], { cwd: repoDir });
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`[local] ${label}: expected branch "${b}" to exist in ${repoDir}.\n` +
|
|
117
|
+
`[local] Fix: use --forks for happy-server-light (sqlite), or use --server=happy-server with --upstream.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await run('git', ['checkout', '-q', b], { cwd: repoDir });
|
|
124
|
+
} catch {
|
|
125
|
+
// If remote-tracking branch exists but local doesn't, create it.
|
|
126
|
+
try {
|
|
127
|
+
await run('git', ['checkout', '-q', '-B', b, `origin/${b}`], { cwd: repoDir });
|
|
128
|
+
} catch {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`[local] ${label}: failed to checkout branch "${b}" in ${repoDir}.\n` +
|
|
131
|
+
`[local] Fix: re-run with --force in worktree flows, or delete the checkout and re-run install/bootstrap.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
89
137
|
async function ensureComponentPresent({ dir, label, repoUrl, allowClone }) {
|
|
90
138
|
if (await pathExists(dir)) {
|
|
91
139
|
return;
|
|
@@ -201,7 +249,22 @@ async function main() {
|
|
|
201
249
|
if (wantsHelp(argv, { flags })) {
|
|
202
250
|
printResult({
|
|
203
251
|
json,
|
|
204
|
-
data: {
|
|
252
|
+
data: {
|
|
253
|
+
flags: [
|
|
254
|
+
'--forks',
|
|
255
|
+
'--upstream',
|
|
256
|
+
'--clone',
|
|
257
|
+
'--no-clone',
|
|
258
|
+
'--autostart',
|
|
259
|
+
'--no-autostart',
|
|
260
|
+
'--server=...',
|
|
261
|
+
'--no-ui-build',
|
|
262
|
+
'--no-ui-deps',
|
|
263
|
+
'--no-cli-deps',
|
|
264
|
+
'--no-cli-build',
|
|
265
|
+
],
|
|
266
|
+
json: true,
|
|
267
|
+
},
|
|
205
268
|
text: [
|
|
206
269
|
'[bootstrap] usage:',
|
|
207
270
|
' happys bootstrap [--forks|--upstream] [--server=happy-server|happy-server-light|both] [--json]',
|
|
@@ -270,6 +333,17 @@ async function main() {
|
|
|
270
333
|
const disableAutostart = flags.has('--no-autostart');
|
|
271
334
|
|
|
272
335
|
const serverComponentName = (wizard?.serverComponentName ?? getServerComponentName({ kv })).trim();
|
|
336
|
+
// Safety: upstream server-light is not a separate upstream repo/branch today.
|
|
337
|
+
// Upstream slopus/happy-server is Postgres-only, while happy-server-light requires sqlite.
|
|
338
|
+
if (repoSource === 'upstream' && (serverComponentName === 'happy-server-light' || serverComponentName === 'both')) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`[bootstrap] --upstream is not supported for happy-server-light (sqlite).\n` +
|
|
341
|
+
`Reason: upstream ${DEFAULT_UPSTREAM_REPOS.serverLight} does not provide a happy-server-light branch.\n` +
|
|
342
|
+
`Fix:\n` +
|
|
343
|
+
`- use --forks (recommended), OR\n` +
|
|
344
|
+
`- use --server=happy-server with --upstream`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
273
347
|
const serverLightDir = getComponentDir(rootDir, 'happy-server-light');
|
|
274
348
|
const serverFullDir = getComponentDir(rootDir, 'happy-server');
|
|
275
349
|
const cliDir = getComponentDir(rootDir, 'happy-cli');
|
|
@@ -305,35 +379,57 @@ async function main() {
|
|
|
305
379
|
allowClone,
|
|
306
380
|
});
|
|
307
381
|
|
|
382
|
+
// Ensure expected branches are checked out for server flavors (avoids "server-light directory contains full server" mistakes).
|
|
383
|
+
if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
|
|
384
|
+
await ensureGitBranchCheckedOut({ repoDir: serverLightDir, branch: 'happy-server-light', label: 'SERVER' });
|
|
385
|
+
}
|
|
386
|
+
if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
|
|
387
|
+
// In fork mode, full server is a branch in the fork server repo. In upstream mode, use upstream main.
|
|
388
|
+
const serverFullBranch = repoSource === 'upstream' ? 'main' : 'happy-server';
|
|
389
|
+
await ensureGitBranchCheckedOut({ repoDir: serverFullDir, branch: serverFullBranch, label: 'SERVER_FULL' });
|
|
390
|
+
}
|
|
391
|
+
|
|
308
392
|
const cliDirFinal = cliDir;
|
|
309
393
|
const uiDirFinal = uiDir;
|
|
310
394
|
|
|
311
395
|
// Install deps
|
|
396
|
+
const skipUiDeps = flags.has('--no-ui-deps') || (process.env.HAPPY_STACKS_INSTALL_NO_UI_DEPS ?? '').trim() === '1';
|
|
397
|
+
const skipCliDeps = flags.has('--no-cli-deps') || (process.env.HAPPY_STACKS_INSTALL_NO_CLI_DEPS ?? '').trim() === '1';
|
|
312
398
|
if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
|
|
313
399
|
await ensureDepsInstalled(serverLightDir, 'happy-server-light');
|
|
314
400
|
}
|
|
315
401
|
if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
|
|
316
402
|
await ensureDepsInstalled(serverFullDir, 'happy-server');
|
|
317
403
|
}
|
|
318
|
-
|
|
319
|
-
|
|
404
|
+
if (!skipUiDeps) {
|
|
405
|
+
await ensureDepsInstalled(uiDirFinal, 'happy');
|
|
406
|
+
}
|
|
407
|
+
if (!skipCliDeps) {
|
|
408
|
+
await ensureDepsInstalled(cliDirFinal, 'happy-cli');
|
|
409
|
+
}
|
|
320
410
|
|
|
321
411
|
// CLI build + link
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
412
|
+
const skipCliBuild = flags.has('--no-cli-build') || (process.env.HAPPY_STACKS_INSTALL_NO_CLI_BUILD ?? '').trim() === '1';
|
|
413
|
+
if (!skipCliBuild) {
|
|
414
|
+
const buildCli = (process.env.HAPPY_LOCAL_CLI_BUILD ?? '1') !== '0';
|
|
415
|
+
const npmLinkCli = (process.env.HAPPY_LOCAL_NPM_LINK ?? '1') !== '0';
|
|
416
|
+
await ensureCliBuilt(cliDirFinal, { buildCli });
|
|
417
|
+
await ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli });
|
|
418
|
+
}
|
|
326
419
|
|
|
327
420
|
// Build UI (so run works without expo dev server)
|
|
421
|
+
const skipUiBuild = flags.has('--no-ui-build') || (process.env.HAPPY_STACKS_INSTALL_NO_UI_BUILD ?? '').trim() === '1';
|
|
328
422
|
const buildArgs = [join(rootDir, 'scripts', 'build.mjs')];
|
|
329
423
|
// Tauri builds are opt-in (slow + requires additional toolchain).
|
|
330
424
|
const buildTauri = wizard?.buildTauri ?? (flags.has('--tauri') && !flags.has('--no-tauri'));
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
425
|
+
if (!skipUiBuild) {
|
|
426
|
+
if (buildTauri) {
|
|
427
|
+
buildArgs.push('--tauri');
|
|
428
|
+
} else if (flags.has('--no-tauri')) {
|
|
429
|
+
buildArgs.push('--no-tauri');
|
|
430
|
+
}
|
|
431
|
+
await run(process.execPath, buildArgs, { cwd: rootDir });
|
|
335
432
|
}
|
|
336
|
-
await run(process.execPath, buildArgs, { cwd: rootDir });
|
|
337
433
|
|
|
338
434
|
// Optional autostart (macOS)
|
|
339
435
|
if (disableAutostart) {
|
package/scripts/lint.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
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/paths.mjs';
|
|
4
|
+
import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
|
|
5
5
|
import { ensureDepsInstalled } from './utils/proc/pm.mjs';
|
|
6
6
|
import { pathExists } from './utils/fs/fs.mjs';
|
|
7
7
|
import { run } from './utils/proc/proc.mjs';
|
|
8
8
|
import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
|
|
9
|
+
import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
|
|
9
10
|
|
|
10
11
|
const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
11
12
|
|
|
@@ -40,18 +41,37 @@ async function main() {
|
|
|
40
41
|
'examples:',
|
|
41
42
|
' happys lint',
|
|
42
43
|
' happys lint happy happy-cli',
|
|
44
|
+
'',
|
|
45
|
+
'note:',
|
|
46
|
+
' If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
|
|
43
47
|
].join('\n'),
|
|
44
48
|
});
|
|
45
49
|
return;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
const rootDir = getRootDir(import.meta.url);
|
|
53
|
+
|
|
48
54
|
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
49
|
-
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'];
|
|
50
72
|
const wantAll = requested.includes('all');
|
|
51
73
|
const components = wantAll ? DEFAULT_COMPONENTS : requested;
|
|
52
74
|
|
|
53
|
-
const rootDir = getRootDir(import.meta.url);
|
|
54
|
-
|
|
55
75
|
const results = [];
|
|
56
76
|
for (const component of components) {
|
|
57
77
|
if (!DEFAULT_COMPONENTS.includes(component)) {
|