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/build.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import './utils/env/env.mjs';
|
|
2
2
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
|
-
import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
|
|
3
|
+
import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
|
|
4
4
|
import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/proc/pm.mjs';
|
|
5
5
|
import { resolveServerPortFromEnv } from './utils/server/urls.mjs';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { readFile, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { tailscaleServeHttpsUrl } from './tailscale.mjs';
|
|
9
9
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
10
|
+
import { ensureExpoIsolationEnv, getExpoStatePaths, wantsExpoClearCache } from './utils/expo/expo.mjs';
|
|
11
|
+
import { expoExec } from './utils/expo/command.mjs';
|
|
12
|
+
import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Build a lightweight static web UI bundle (no Expo dev server).
|
|
@@ -29,12 +32,29 @@ async function main() {
|
|
|
29
32
|
' happys build [--tauri] [--json]',
|
|
30
33
|
' (legacy in a cloned repo): pnpm build [-- --tauri] [--json]',
|
|
31
34
|
' node scripts/build.mjs [--tauri|--no-tauri] [--no-ui] [--json]',
|
|
35
|
+
'',
|
|
36
|
+
'note:',
|
|
37
|
+
' If run from inside the Happy UI checkout/worktree, the build uses that checkout.',
|
|
32
38
|
].join('\n'),
|
|
33
39
|
});
|
|
34
40
|
return;
|
|
35
41
|
}
|
|
36
42
|
const rootDir = getRootDir(import.meta.url);
|
|
37
43
|
|
|
44
|
+
// If invoked from inside the Happy UI checkout/worktree, prefer that directory without requiring `happys wt use ...`.
|
|
45
|
+
const inferred = inferComponentFromCwd({
|
|
46
|
+
rootDir,
|
|
47
|
+
invokedCwd: getInvokedCwd(process.env),
|
|
48
|
+
components: ['happy'],
|
|
49
|
+
});
|
|
50
|
+
if (inferred?.component === 'happy') {
|
|
51
|
+
const stacksKey = componentDirEnvKey('happy');
|
|
52
|
+
const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
|
|
53
|
+
if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
|
|
54
|
+
process.env[stacksKey] = inferred.repoDir;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
// Optional: skip building the web UI bundle.
|
|
39
59
|
//
|
|
40
60
|
// This is useful for evidence capture flows that validate non-UI components (e.g. `happy-cli`)
|
|
@@ -87,7 +107,17 @@ async function main() {
|
|
|
87
107
|
};
|
|
88
108
|
|
|
89
109
|
// Expo CLI is available via node_modules/.bin once dependencies are installed.
|
|
90
|
-
|
|
110
|
+
{
|
|
111
|
+
const paths = getExpoStatePaths({
|
|
112
|
+
baseDir: getDefaultAutostartPaths().baseDir,
|
|
113
|
+
kind: 'ui-export',
|
|
114
|
+
projectDir: uiDir,
|
|
115
|
+
stateFileName: 'ui.export.state.json',
|
|
116
|
+
});
|
|
117
|
+
await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
|
|
118
|
+
const args = ['export', '--platform', 'web', '--output-dir', outDir, ...(wantsExpoClearCache({ env }) ? ['-c'] : [])];
|
|
119
|
+
await expoExec({ dir: uiDir, args, env, ensureDepsLabel: 'happy' });
|
|
120
|
+
}
|
|
91
121
|
|
|
92
122
|
if (json) {
|
|
93
123
|
printResult({ json, data: { ok: true, outDir, tauriBuilt: false } });
|
|
@@ -146,13 +176,30 @@ async function main() {
|
|
|
146
176
|
};
|
|
147
177
|
delete tauriEnv.EXPO_PUBLIC_WEB_BASE_URL;
|
|
148
178
|
|
|
149
|
-
|
|
179
|
+
{
|
|
180
|
+
const paths = getExpoStatePaths({
|
|
181
|
+
baseDir: getDefaultAutostartPaths().baseDir,
|
|
182
|
+
kind: 'ui-export-tauri',
|
|
183
|
+
projectDir: uiDir,
|
|
184
|
+
stateFileName: 'ui.export.tauri.state.json',
|
|
185
|
+
});
|
|
186
|
+
await ensureExpoIsolationEnv({ env: tauriEnv, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await expoExec({
|
|
150
190
|
dir: uiDir,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
191
|
+
args: [
|
|
192
|
+
'export',
|
|
193
|
+
'--platform',
|
|
194
|
+
'web',
|
|
195
|
+
'--output-dir',
|
|
196
|
+
tauriDistDir,
|
|
197
|
+
// Important: clear bundler cache so EXPO_PUBLIC_* inlining doesn't reuse
|
|
198
|
+
// the previous (web) export's transform results.
|
|
199
|
+
'-c',
|
|
200
|
+
],
|
|
155
201
|
env: tauriEnv,
|
|
202
|
+
ensureDepsLabel: 'happy',
|
|
156
203
|
});
|
|
157
204
|
|
|
158
205
|
// Build the Tauri app using a generated config that skips upstream beforeBuildCommand (which uses yarn).
|
package/scripts/daemon.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { spawnProc, run, runCapture } from './utils/proc/proc.mjs';
|
|
2
|
-
import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
|
|
2
|
+
import { resolveAuthSeedFromEnv, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
|
|
3
3
|
import { getStacksStorageRoot } from './utils/paths/paths.mjs';
|
|
4
4
|
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
|
|
5
5
|
import { runCaptureIfCommandExists } from './utils/proc/commands.mjs';
|
|
6
6
|
import { readLastLines } from './utils/fs/tail.mjs';
|
|
7
|
+
import { ensureCliBuilt } from './utils/proc/pm.mjs';
|
|
7
8
|
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
8
9
|
import { chmod, copyFile, mkdir } from 'node:fs/promises';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
10
11
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
11
12
|
import { homedir } from 'node:os';
|
|
13
|
+
import { getRootDir } from './utils/paths/paths.mjs';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Daemon lifecycle helpers for happy-stacks.
|
|
@@ -175,6 +177,46 @@ function getLatestDaemonLogPath(homeDir) {
|
|
|
175
177
|
}
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
function resolveHappyCliDistEntrypoint(cliBin) {
|
|
181
|
+
const bin = String(cliBin ?? '').trim();
|
|
182
|
+
if (!bin) return null;
|
|
183
|
+
// In component checkouts/worktrees we launch via <cliDir>/bin/happy.mjs, which expects dist output.
|
|
184
|
+
// Use this to protect restarts from bricking the running daemon if dist disappears mid-build.
|
|
185
|
+
try {
|
|
186
|
+
const binDir = dirname(bin);
|
|
187
|
+
return join(binDir, '..', 'dist', 'index.mjs');
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function ensureHappyCliDistExists({ cliBin }) {
|
|
194
|
+
const distEntrypoint = resolveHappyCliDistEntrypoint(cliBin);
|
|
195
|
+
if (!distEntrypoint) return { ok: false, distEntrypoint: null, built: false, reason: 'unknown_cli_bin' };
|
|
196
|
+
if (existsSync(distEntrypoint)) return { ok: true, distEntrypoint, built: false, reason: 'exists' };
|
|
197
|
+
|
|
198
|
+
// Try to recover automatically: missing dist is a common first-run worktree issue.
|
|
199
|
+
// We build in-place using the cliDir that owns this cliBin (../ from bin/).
|
|
200
|
+
const cliDir = join(dirname(cliBin), '..');
|
|
201
|
+
const buildCli =
|
|
202
|
+
(process.env.HAPPY_STACKS_CLI_BUILD ?? process.env.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
|
|
203
|
+
if (!buildCli) {
|
|
204
|
+
return { ok: false, distEntrypoint, built: false, reason: 'build_disabled' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
// eslint-disable-next-line no-console
|
|
209
|
+
console.warn(`[local] happy-cli build output missing; rebuilding (${cliDir})...`);
|
|
210
|
+
await ensureCliBuilt(cliDir, { buildCli: true });
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return { ok: false, distEntrypoint, built: false, reason: String(e?.message ?? e) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return existsSync(distEntrypoint)
|
|
216
|
+
? { ok: true, distEntrypoint, built: true, reason: 'rebuilt' }
|
|
217
|
+
: { ok: false, distEntrypoint, built: true, reason: 'rebuilt_but_missing' };
|
|
218
|
+
}
|
|
219
|
+
|
|
178
220
|
function excerptIndicatesMissingAuth(excerpt) {
|
|
179
221
|
if (!excerpt) return false;
|
|
180
222
|
return (
|
|
@@ -183,6 +225,24 @@ function excerptIndicatesMissingAuth(excerpt) {
|
|
|
183
225
|
);
|
|
184
226
|
}
|
|
185
227
|
|
|
228
|
+
function excerptIndicatesInvalidAuth(excerpt) {
|
|
229
|
+
if (!excerpt) return false;
|
|
230
|
+
return (
|
|
231
|
+
excerpt.includes('Auth failed - invalid token') ||
|
|
232
|
+
excerpt.includes('Request failed with status code 401') ||
|
|
233
|
+
excerpt.includes('"status":401') ||
|
|
234
|
+
excerpt.includes('[DAEMON RUN][FATAL]') && excerpt.includes('status code 401')
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function allowDaemonWaitForAuthWithoutTty() {
|
|
239
|
+
const raw = (process.env.HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH ?? process.env.HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH ?? '')
|
|
240
|
+
.toString()
|
|
241
|
+
.trim()
|
|
242
|
+
.toLowerCase();
|
|
243
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y';
|
|
244
|
+
}
|
|
245
|
+
|
|
186
246
|
function authLoginHint() {
|
|
187
247
|
const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
188
248
|
return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
|
|
@@ -195,6 +255,27 @@ function authCopyFromSeedHint() {
|
|
|
195
255
|
return `happys stack auth ${stackName} copy-from ${seed}`;
|
|
196
256
|
}
|
|
197
257
|
|
|
258
|
+
async function maybeAutoReseedInvalidAuth({ stackName, quiet = false }) {
|
|
259
|
+
if (stackName === 'main') return { ok: false, skipped: true, reason: 'main' };
|
|
260
|
+
const env = process.env;
|
|
261
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
262
|
+
const enabled = resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive });
|
|
263
|
+
if (!enabled) return { ok: false, skipped: true, reason: 'disabled' };
|
|
264
|
+
|
|
265
|
+
const seed = resolveAuthSeedFromEnv(env);
|
|
266
|
+
if (!quiet) {
|
|
267
|
+
console.log(`[local] auth: invalid token detected; re-seeding ${stackName} from ${seed}...`);
|
|
268
|
+
}
|
|
269
|
+
const rootDir = getRootDir(import.meta.url);
|
|
270
|
+
|
|
271
|
+
// Use stack-scoped auth copy so env/database resolution is correct for the target stack.
|
|
272
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'auth', stackName, '--', 'copy-from', seed], {
|
|
273
|
+
cwd: rootDir,
|
|
274
|
+
env,
|
|
275
|
+
});
|
|
276
|
+
return { ok: true, skipped: false, seed };
|
|
277
|
+
}
|
|
278
|
+
|
|
198
279
|
async function seedCredentialsIfMissing({ cliHomeDir }) {
|
|
199
280
|
const stacksRoot = getStacksStorageRoot();
|
|
200
281
|
const allowGlobal = sandboxAllowsGlobalSideEffects();
|
|
@@ -373,11 +454,27 @@ export async function startLocalDaemonWithAuth({
|
|
|
373
454
|
publicServerUrl,
|
|
374
455
|
isShuttingDown,
|
|
375
456
|
forceRestart = false,
|
|
457
|
+
env = process.env,
|
|
458
|
+
stackName = null,
|
|
376
459
|
}) {
|
|
377
|
-
const
|
|
378
|
-
|
|
460
|
+
const resolvedStackName =
|
|
461
|
+
(stackName ?? '').toString().trim() ||
|
|
462
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
463
|
+
'main';
|
|
464
|
+
const baseEnv = { ...env };
|
|
379
465
|
const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
|
|
380
466
|
|
|
467
|
+
const distEntrypoint = resolveHappyCliDistEntrypoint(cliBin);
|
|
468
|
+
const distCheck = await ensureHappyCliDistExists({ cliBin });
|
|
469
|
+
if (!distCheck.ok) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`[local] happy-cli dist entrypoint is missing (${distEntrypoint}).\n` +
|
|
472
|
+
`[local] Refusing to start/restart daemon because it would crash with MODULE_NOT_FOUND.\n` +
|
|
473
|
+
`[local] Fix: rebuild happy-cli in the active checkout/worktree.\n` +
|
|
474
|
+
(distCheck.reason ? `[local] Detail: ${distCheck.reason}\n` : '')
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
381
478
|
// If this is a migrated/new stack home dir, seed credentials from the user's existing login (best-effort)
|
|
382
479
|
// to avoid requiring an interactive auth flow under launchd.
|
|
383
480
|
const migrateCreds = (baseEnv.HAPPY_STACKS_MIGRATE_CREDENTIALS ?? baseEnv.HAPPY_LOCAL_MIGRATE_CREDENTIALS ?? '1').trim() !== '0';
|
|
@@ -386,6 +483,20 @@ export async function startLocalDaemonWithAuth({
|
|
|
386
483
|
}
|
|
387
484
|
|
|
388
485
|
const existing = checkDaemonState(cliHomeDir);
|
|
486
|
+
// If the daemon is already running and we're restarting it, refuse to stop it unless the
|
|
487
|
+
// happy-cli dist entrypoint exists. Otherwise a rebuild (rm -rf dist) can brick the stack.
|
|
488
|
+
if (
|
|
489
|
+
distEntrypoint &&
|
|
490
|
+
!existsSync(distEntrypoint) &&
|
|
491
|
+
(existing.status === 'running' || existing.status === 'starting')
|
|
492
|
+
) {
|
|
493
|
+
console.warn(
|
|
494
|
+
`[local] happy-cli dist entrypoint is missing (${distEntrypoint}).\n` +
|
|
495
|
+
`[local] Refusing to restart daemon to avoid downtime. Rebuild happy-cli first.`
|
|
496
|
+
);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
389
500
|
if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
|
|
390
501
|
const pid = existing.pid;
|
|
391
502
|
const matches = await daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl });
|
|
@@ -419,7 +530,7 @@ export async function startLocalDaemonWithAuth({
|
|
|
419
530
|
}
|
|
420
531
|
|
|
421
532
|
// Best-effort: for the main stack, also stop the legacy global daemon home (~/.happy) to prevent legacy overlap.
|
|
422
|
-
if (
|
|
533
|
+
if (resolvedStackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
|
|
423
534
|
const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
|
|
424
535
|
try {
|
|
425
536
|
await new Promise((resolve) => {
|
|
@@ -452,6 +563,16 @@ export async function startLocalDaemonWithAuth({
|
|
|
452
563
|
return { ok: true, exitCode, excerpt: null, logPath: null };
|
|
453
564
|
}
|
|
454
565
|
|
|
566
|
+
// Some daemon versions (or transient races) can return non-zero even if the daemon
|
|
567
|
+
// is already running / starting for this stack home dir (e.g. "lock already held").
|
|
568
|
+
// In those cases, fail-open and keep the stack running; callers can still surface
|
|
569
|
+
// daemon status separately.
|
|
570
|
+
await delay(500);
|
|
571
|
+
const stateAfter = checkDaemonState(cliHomeDir);
|
|
572
|
+
if (stateAfter.status === 'running' || stateAfter.status === 'starting') {
|
|
573
|
+
return { ok: true, exitCode, excerpt: null, logPath: null };
|
|
574
|
+
}
|
|
575
|
+
|
|
455
576
|
const logPath =
|
|
456
577
|
getLatestDaemonLogPath(cliHomeDir) ||
|
|
457
578
|
((!isSandboxed() || sandboxAllowsGlobalSideEffects()) ? getLatestDaemonLogPath(join(homedir(), '.happy')) : null);
|
|
@@ -468,21 +589,32 @@ export async function startLocalDaemonWithAuth({
|
|
|
468
589
|
}
|
|
469
590
|
|
|
470
591
|
if (excerptIndicatesMissingAuth(first.excerpt)) {
|
|
592
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || allowDaemonWaitForAuthWithoutTty();
|
|
471
593
|
const copyHint = authCopyFromSeedHint();
|
|
472
|
-
|
|
594
|
+
const hint =
|
|
473
595
|
`[local] daemon is not authenticated yet (expected on first run).\n` +
|
|
474
|
-
`[local] Keeping the server running so you can login.\n` +
|
|
475
596
|
`[local] In another terminal, run:\n` +
|
|
476
597
|
`${authLoginHint()}\n` +
|
|
477
|
-
(copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '')
|
|
478
|
-
|
|
479
|
-
|
|
598
|
+
(copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '');
|
|
599
|
+
if (!isInteractive) {
|
|
600
|
+
throw new Error(`${hint}[local] Non-interactive mode: refusing to wait for credentials.`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
console.error(`${hint}[local] Keeping the server running so you can login.\n[local] Waiting for credentials at ${credentialsPath}...`);
|
|
480
604
|
|
|
481
605
|
const ok = await waitForCredentialsFile({ path: credentialsPath, timeoutMs: 10 * 60_000, isShuttingDown });
|
|
482
606
|
if (!ok) {
|
|
483
607
|
throw new Error('Timed out waiting for daemon credentials (auth login not completed)');
|
|
484
608
|
}
|
|
485
609
|
|
|
610
|
+
// If a daemon start attempt was already in-flight (or a previous daemon is already running),
|
|
611
|
+
// avoid a second concurrent start and treat it as success.
|
|
612
|
+
await delay(500);
|
|
613
|
+
const stateAfterCreds = checkDaemonState(cliHomeDir);
|
|
614
|
+
if (stateAfterCreds.status === 'running' || stateAfterCreds.status === 'starting') {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
486
618
|
console.log('[local] credentials detected, retrying daemon start...');
|
|
487
619
|
const second = await startOnce();
|
|
488
620
|
if (!second.ok) {
|
|
@@ -491,6 +623,30 @@ export async function startLocalDaemonWithAuth({
|
|
|
491
623
|
}
|
|
492
624
|
throw new Error('Failed to start daemon (after credentials were created)');
|
|
493
625
|
}
|
|
626
|
+
} else if (excerptIndicatesInvalidAuth(first.excerpt)) {
|
|
627
|
+
// Credentials exist but are rejected by this server (common when a stack's env/DB was reset,
|
|
628
|
+
// or credentials were copied from a different stack identity).
|
|
629
|
+
try {
|
|
630
|
+
await maybeAutoReseedInvalidAuth({ stackName });
|
|
631
|
+
} catch (e) {
|
|
632
|
+
const copyHint = authCopyFromSeedHint();
|
|
633
|
+
console.error(
|
|
634
|
+
`[local] daemon credentials were rejected by the server (401).\n` +
|
|
635
|
+
`[local] Fix:\n` +
|
|
636
|
+
(copyHint ? `- ${copyHint}\n` : '') +
|
|
637
|
+
`- ${authLoginHint()}`
|
|
638
|
+
);
|
|
639
|
+
throw e;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
console.log('[local] auth re-seeded, retrying daemon start...');
|
|
643
|
+
const second = await startOnce();
|
|
644
|
+
if (!second.ok) {
|
|
645
|
+
if (second.excerpt) {
|
|
646
|
+
console.error(`[local] daemon still failed to start; last daemon log (${second.logPath}):\n${second.excerpt}`);
|
|
647
|
+
}
|
|
648
|
+
throw new Error('Failed to start daemon (after auth re-seed)');
|
|
649
|
+
}
|
|
494
650
|
} else {
|
|
495
651
|
const copyHint = authCopyFromSeedHint();
|
|
496
652
|
console.error(
|