happy-stacks 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -25
- package/bin/happys.mjs +116 -17
- package/docs/happy-development.md +2 -2
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/package.json +5 -1
- package/scripts/auth.mjs +59 -208
- package/scripts/build.mjs +58 -12
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +5 -5
- package/scripts/daemon.mjs +168 -20
- package/scripts/dev.mjs +196 -70
- package/scripts/doctor.mjs +20 -36
- package/scripts/edison.mjs +105 -78
- package/scripts/happy.mjs +8 -19
- package/scripts/init.mjs +8 -14
- package/scripts/install.mjs +119 -23
- package/scripts/lint.mjs +31 -32
- package/scripts/menubar.mjs +6 -13
- package/scripts/migrate.mjs +11 -21
- package/scripts/mobile.mjs +93 -108
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +217 -0
- package/scripts/review_pr.mjs +368 -0
- package/scripts/run.mjs +95 -21
- package/scripts/self.mjs +11 -29
- package/scripts/server_flavor.mjs +4 -4
- package/scripts/service.mjs +19 -29
- package/scripts/setup.mjs +63 -160
- package/scripts/setup_pr.mjs +592 -52
- package/scripts/stack.mjs +608 -200
- package/scripts/stop.mjs +3 -3
- package/scripts/tailscale.mjs +44 -11
- package/scripts/test.mjs +52 -36
- package/scripts/tui.mjs +314 -74
- package/scripts/typecheck.mjs +31 -32
- package/scripts/ui_gateway.mjs +1 -1
- package/scripts/uninstall.mjs +6 -6
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
- package/scripts/utils/auth/sources.mjs +38 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +24 -0
- package/scripts/utils/cli/cwd_scope.mjs +82 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/smoke_help.mjs +2 -2
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
- package/scripts/utils/dev/expo_dev.mjs +246 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
- package/scripts/utils/dev_auth_key.mjs +1 -1
- package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
- package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
- package/scripts/utils/handy_master_secret.mjs +2 -2
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +56 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/runners/coderabbit.mjs +19 -0
- package/scripts/utils/review/runners/codex.mjs +51 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
- package/scripts/utils/server/urls.mjs +101 -0
- package/scripts/utils/server/validate.mjs +88 -0
- package/scripts/utils/service/autostart_darwin.mjs +182 -0
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +23 -0
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +88 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
- package/scripts/utils/stack_context.mjs +3 -3
- package/scripts/utils/stack_runtime_state.mjs +1 -1
- package/scripts/utils/stacks.mjs +2 -2
- package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/utils/validate.mjs +1 -1
- package/scripts/where.mjs +6 -6
- package/scripts/worktrees.mjs +171 -113
- package/scripts/utils/auth_sources.mjs +0 -12
- package/scripts/utils/dev_expo_web.mjs +0 -112
- package/scripts/utils/localhost_host.mjs +0 -17
- package/scripts/utils/server_port.mjs +0 -9
- package/scripts/utils/server_urls.mjs +0 -54
- /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
- /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
- /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
package/scripts/build.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
2
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
|
-
import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
|
|
4
|
-
import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/pm.mjs';
|
|
3
|
+
import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
|
|
4
|
+
import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/proc/pm.mjs';
|
|
5
|
+
import { resolveServerPortFromEnv } from './utils/server/urls.mjs';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
7
|
import { readFile, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
7
8
|
import { tailscaleServeHttpsUrl } from './tailscale.mjs';
|
|
8
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';
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* Build a lightweight static web UI bundle (no Expo dev server).
|
|
@@ -28,12 +32,29 @@ async function main() {
|
|
|
28
32
|
' happys build [--tauri] [--json]',
|
|
29
33
|
' (legacy in a cloned repo): pnpm build [-- --tauri] [--json]',
|
|
30
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.',
|
|
31
38
|
].join('\n'),
|
|
32
39
|
});
|
|
33
40
|
return;
|
|
34
41
|
}
|
|
35
42
|
const rootDir = getRootDir(import.meta.url);
|
|
36
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
|
+
|
|
37
58
|
// Optional: skip building the web UI bundle.
|
|
38
59
|
//
|
|
39
60
|
// This is useful for evidence capture flows that validate non-UI components (e.g. `happy-cli`)
|
|
@@ -43,9 +64,7 @@ async function main() {
|
|
|
43
64
|
const uiDir = getComponentDir(rootDir, 'happy');
|
|
44
65
|
await requireDir('happy', uiDir);
|
|
45
66
|
|
|
46
|
-
const serverPort = process.env
|
|
47
|
-
? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
|
|
48
|
-
: 3005;
|
|
67
|
+
const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
|
|
49
68
|
|
|
50
69
|
// For Tauri builds we embed an explicit API base URL (tauri:// origins cannot use window.location.origin).
|
|
51
70
|
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
@@ -88,7 +107,17 @@ async function main() {
|
|
|
88
107
|
};
|
|
89
108
|
|
|
90
109
|
// Expo CLI is available via node_modules/.bin once dependencies are installed.
|
|
91
|
-
|
|
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
|
+
}
|
|
92
121
|
|
|
93
122
|
if (json) {
|
|
94
123
|
printResult({ json, data: { ok: true, outDir, tauriBuilt: false } });
|
|
@@ -147,13 +176,30 @@ async function main() {
|
|
|
147
176
|
};
|
|
148
177
|
delete tauriEnv.EXPO_PUBLIC_WEB_BASE_URL;
|
|
149
178
|
|
|
150
|
-
|
|
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({
|
|
151
190
|
dir: uiDir,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
],
|
|
156
201
|
env: tauriEnv,
|
|
202
|
+
ensureDepsLabel: 'happy',
|
|
157
203
|
});
|
|
158
204
|
|
|
159
205
|
// Build the Tauri app using a generated config that skips upstream beforeBuildCommand (which uses yarn).
|
package/scripts/cli-link.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
2
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
|
-
import { getComponentDir, getRootDir } from './utils/paths.mjs';
|
|
4
|
-
import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
|
|
3
|
+
import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
|
|
4
|
+
import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/proc/pm.mjs';
|
|
5
5
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
6
6
|
|
|
7
7
|
/**
|
package/scripts/completion.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
2
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
@@ -7,11 +7,11 @@ import { join } from 'node:path';
|
|
|
7
7
|
|
|
8
8
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
9
9
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
10
|
-
import { runCapture } from './utils/proc.mjs';
|
|
10
|
+
import { runCapture } from './utils/proc/proc.mjs';
|
|
11
11
|
import { getHappysRegistry } from './utils/cli/cli_registry.mjs';
|
|
12
|
-
import { expandHome } from './utils/canonical_home.mjs';
|
|
13
|
-
import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
|
|
14
|
-
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
|
|
12
|
+
import { expandHome } from './utils/paths/canonical_home.mjs';
|
|
13
|
+
import { getHappyStacksHomeDir, getRootDir } from './utils/paths/paths.mjs';
|
|
14
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
|
|
15
15
|
|
|
16
16
|
function detectShell() {
|
|
17
17
|
const raw = (process.env.SHELL ?? '').toLowerCase();
|
package/scripts/daemon.mjs
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { spawnProc, run, runCapture } from './utils/proc.mjs';
|
|
2
|
-
import { resolveAuthSeedFromEnv } from './utils/
|
|
3
|
-
import { getStacksStorageRoot } from './utils/paths.mjs';
|
|
4
|
-
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
|
|
1
|
+
import { spawnProc, run, runCapture } from './utils/proc/proc.mjs';
|
|
2
|
+
import { resolveAuthSeedFromEnv, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
|
|
3
|
+
import { getStacksStorageRoot } from './utils/paths/paths.mjs';
|
|
4
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
|
|
5
|
+
import { runCaptureIfCommandExists } from './utils/proc/commands.mjs';
|
|
6
|
+
import { readLastLines } from './utils/fs/tail.mjs';
|
|
7
|
+
import { ensureCliBuilt } from './utils/proc/pm.mjs';
|
|
5
8
|
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
6
9
|
import { chmod, copyFile, mkdir } from 'node:fs/promises';
|
|
7
|
-
import { join } from 'node:path';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
8
11
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
12
|
import { homedir } from 'node:os';
|
|
13
|
+
import { getRootDir } from './utils/paths/paths.mjs';
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Daemon lifecycle helpers for happy-stacks.
|
|
@@ -28,7 +32,7 @@ export async function cleanupStaleDaemonState(homeDir) {
|
|
|
28
32
|
|
|
29
33
|
const lsofHasPath = async (pid, pathNeedle) => {
|
|
30
34
|
try {
|
|
31
|
-
const out = await
|
|
35
|
+
const out = await runCaptureIfCommandExists('lsof', ['-nP', '-p', String(pid)]);
|
|
32
36
|
return out.includes(pathNeedle);
|
|
33
37
|
} catch {
|
|
34
38
|
return false;
|
|
@@ -173,16 +177,46 @@ function getLatestDaemonLogPath(homeDir) {
|
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
function
|
|
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.
|
|
177
185
|
try {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
return parts.slice(Math.max(0, parts.length - lines)).join('\n');
|
|
186
|
+
const binDir = dirname(bin);
|
|
187
|
+
return join(binDir, '..', 'dist', 'index.mjs');
|
|
181
188
|
} catch {
|
|
182
189
|
return null;
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
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
|
+
|
|
186
220
|
function excerptIndicatesMissingAuth(excerpt) {
|
|
187
221
|
if (!excerpt) return false;
|
|
188
222
|
return (
|
|
@@ -191,6 +225,24 @@ function excerptIndicatesMissingAuth(excerpt) {
|
|
|
191
225
|
);
|
|
192
226
|
}
|
|
193
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
|
+
|
|
194
246
|
function authLoginHint() {
|
|
195
247
|
const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
196
248
|
return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
|
|
@@ -203,6 +255,27 @@ function authCopyFromSeedHint() {
|
|
|
203
255
|
return `happys stack auth ${stackName} copy-from ${seed}`;
|
|
204
256
|
}
|
|
205
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
|
+
|
|
206
279
|
async function seedCredentialsIfMissing({ cliHomeDir }) {
|
|
207
280
|
const stacksRoot = getStacksStorageRoot();
|
|
208
281
|
const allowGlobal = sandboxAllowsGlobalSideEffects();
|
|
@@ -297,7 +370,7 @@ async function killDaemonFromLockFile({ cliHomeDir }) {
|
|
|
297
370
|
// We do this by checking that `lsof -p <pid>` includes the lock path (or state file path).
|
|
298
371
|
let ownsLock = false;
|
|
299
372
|
try {
|
|
300
|
-
const out = await
|
|
373
|
+
const out = await runCaptureIfCommandExists('lsof', ['-nP', '-p', String(pid)]);
|
|
301
374
|
ownsLock = out.includes(lockPath) || out.includes(join(cliHomeDir, 'daemon.state.json')) || out.includes(join(cliHomeDir, 'logs'));
|
|
302
375
|
} catch {
|
|
303
376
|
ownsLock = false;
|
|
@@ -381,11 +454,27 @@ export async function startLocalDaemonWithAuth({
|
|
|
381
454
|
publicServerUrl,
|
|
382
455
|
isShuttingDown,
|
|
383
456
|
forceRestart = false,
|
|
457
|
+
env = process.env,
|
|
458
|
+
stackName = null,
|
|
384
459
|
}) {
|
|
385
|
-
const
|
|
386
|
-
|
|
460
|
+
const resolvedStackName =
|
|
461
|
+
(stackName ?? '').toString().trim() ||
|
|
462
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
463
|
+
'main';
|
|
464
|
+
const baseEnv = { ...env };
|
|
387
465
|
const daemonEnv = getDaemonEnv({ baseEnv, cliHomeDir, internalServerUrl, publicServerUrl });
|
|
388
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
|
+
|
|
389
478
|
// If this is a migrated/new stack home dir, seed credentials from the user's existing login (best-effort)
|
|
390
479
|
// to avoid requiring an interactive auth flow under launchd.
|
|
391
480
|
const migrateCreds = (baseEnv.HAPPY_STACKS_MIGRATE_CREDENTIALS ?? baseEnv.HAPPY_LOCAL_MIGRATE_CREDENTIALS ?? '1').trim() !== '0';
|
|
@@ -394,6 +483,20 @@ export async function startLocalDaemonWithAuth({
|
|
|
394
483
|
}
|
|
395
484
|
|
|
396
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
|
+
|
|
397
500
|
if (!forceRestart && (existing.status === 'running' || existing.status === 'starting')) {
|
|
398
501
|
const pid = existing.pid;
|
|
399
502
|
const matches = await daemonEnvMatches({ pid, cliHomeDir, internalServerUrl, publicServerUrl });
|
|
@@ -427,7 +530,7 @@ export async function startLocalDaemonWithAuth({
|
|
|
427
530
|
}
|
|
428
531
|
|
|
429
532
|
// Best-effort: for the main stack, also stop the legacy global daemon home (~/.happy) to prevent legacy overlap.
|
|
430
|
-
if (
|
|
533
|
+
if (resolvedStackName === 'main' && (!isSandboxed() || sandboxAllowsGlobalSideEffects())) {
|
|
431
534
|
const legacyEnv = { ...daemonEnv, HAPPY_HOME_DIR: join(homedir(), '.happy') };
|
|
432
535
|
try {
|
|
433
536
|
await new Promise((resolve) => {
|
|
@@ -460,10 +563,20 @@ export async function startLocalDaemonWithAuth({
|
|
|
460
563
|
return { ok: true, exitCode, excerpt: null, logPath: null };
|
|
461
564
|
}
|
|
462
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
|
+
|
|
463
576
|
const logPath =
|
|
464
577
|
getLatestDaemonLogPath(cliHomeDir) ||
|
|
465
578
|
((!isSandboxed() || sandboxAllowsGlobalSideEffects()) ? getLatestDaemonLogPath(join(homedir(), '.happy')) : null);
|
|
466
|
-
const excerpt = logPath ? readLastLines(logPath, 120) : null;
|
|
579
|
+
const excerpt = logPath ? await readLastLines(logPath, 120) : null;
|
|
467
580
|
return { ok: false, exitCode, excerpt, logPath };
|
|
468
581
|
};
|
|
469
582
|
|
|
@@ -476,21 +589,32 @@ export async function startLocalDaemonWithAuth({
|
|
|
476
589
|
}
|
|
477
590
|
|
|
478
591
|
if (excerptIndicatesMissingAuth(first.excerpt)) {
|
|
592
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || allowDaemonWaitForAuthWithoutTty();
|
|
479
593
|
const copyHint = authCopyFromSeedHint();
|
|
480
|
-
|
|
594
|
+
const hint =
|
|
481
595
|
`[local] daemon is not authenticated yet (expected on first run).\n` +
|
|
482
|
-
`[local] Keeping the server running so you can login.\n` +
|
|
483
596
|
`[local] In another terminal, run:\n` +
|
|
484
597
|
`${authLoginHint()}\n` +
|
|
485
|
-
(copyHint ? `[local] Or (recommended if main is already logged in):\n${copyHint}\n` : '')
|
|
486
|
-
|
|
487
|
-
|
|
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}...`);
|
|
488
604
|
|
|
489
605
|
const ok = await waitForCredentialsFile({ path: credentialsPath, timeoutMs: 10 * 60_000, isShuttingDown });
|
|
490
606
|
if (!ok) {
|
|
491
607
|
throw new Error('Timed out waiting for daemon credentials (auth login not completed)');
|
|
492
608
|
}
|
|
493
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
|
+
|
|
494
618
|
console.log('[local] credentials detected, retrying daemon start...');
|
|
495
619
|
const second = await startOnce();
|
|
496
620
|
if (!second.ok) {
|
|
@@ -499,6 +623,30 @@ export async function startLocalDaemonWithAuth({
|
|
|
499
623
|
}
|
|
500
624
|
throw new Error('Failed to start daemon (after credentials were created)');
|
|
501
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
|
+
}
|
|
502
650
|
} else {
|
|
503
651
|
const copyHint = authCopyFromSeedHint();
|
|
504
652
|
console.error(
|