happy-stacks 0.0.0 → 0.1.2
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 +22 -4
- package/bin/happys.mjs +76 -5
- package/docs/server-flavors.md +61 -2
- package/docs/stacks.md +16 -4
- package/extras/swiftbar/auth-login.sh +5 -5
- package/extras/swiftbar/happy-stacks.5s.sh +83 -41
- package/extras/swiftbar/happys-term.sh +151 -0
- package/extras/swiftbar/happys.sh +52 -0
- package/extras/swiftbar/lib/render.sh +74 -56
- package/extras/swiftbar/lib/system.sh +37 -6
- package/extras/swiftbar/lib/utils.sh +180 -4
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +2 -13
- package/extras/swiftbar/set-server-flavor.sh +8 -8
- package/extras/swiftbar/wt-pr.sh +1 -1
- package/package.json +1 -1
- package/scripts/auth.mjs +374 -3
- package/scripts/daemon.mjs +78 -11
- package/scripts/dev.mjs +122 -17
- package/scripts/init.mjs +238 -32
- package/scripts/migrate.mjs +292 -0
- package/scripts/mobile.mjs +51 -19
- package/scripts/run.mjs +118 -26
- package/scripts/service.mjs +176 -37
- package/scripts/stack.mjs +665 -22
- package/scripts/stop.mjs +157 -0
- package/scripts/tailscale.mjs +147 -21
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/utils/cli_registry.mjs +23 -0
- package/scripts/utils/config.mjs +9 -1
- package/scripts/utils/env.mjs +37 -15
- package/scripts/utils/expo.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +430 -0
- package/scripts/utils/pm.mjs +11 -2
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +46 -5
- package/scripts/utils/server.mjs +37 -0
- package/scripts/utils/stack_stop.mjs +206 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/worktrees.mjs +53 -7
|
@@ -1,125 +1,5 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
# Preference order follows wt shell semantics:
|
|
7
|
-
# - HAPPY_LOCAL_WT_TERMINAL=ghostty|iterm|terminal|current
|
|
8
|
-
# (also accepts "auto" which tries ghostty->iterm->terminal->current)
|
|
9
|
-
#
|
|
10
|
-
# Notes:
|
|
11
|
-
# - iTerm / Terminal: we run the command automatically via AppleScript.
|
|
12
|
-
# - Ghostty: best-effort; if we can't run the command, we open Ghostty in the dir and copy the command to clipboard.
|
|
13
|
-
|
|
14
|
-
HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
|
|
15
|
-
HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
|
|
16
|
-
|
|
17
|
-
WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-$HAPPY_STACKS_HOME_DIR/workspace}"
|
|
18
|
-
if [[ ! -d "$WORKDIR" ]]; then
|
|
19
|
-
WORKDIR="$HOME"
|
|
20
|
-
fi
|
|
21
|
-
|
|
22
|
-
PNPM_SH="$HAPPY_LOCAL_DIR/extras/swiftbar/pnpm.sh"
|
|
23
|
-
if [[ ! -x "$PNPM_SH" ]]; then
|
|
24
|
-
echo "missing happys wrapper: $PNPM_SH" >&2
|
|
25
|
-
exit 1
|
|
26
|
-
fi
|
|
27
|
-
|
|
28
|
-
pref_raw="$(echo "${HAPPY_STACKS_WT_TERMINAL:-${HAPPY_LOCAL_WT_TERMINAL:-auto}}" | tr '[:upper:]' '[:lower:]')"
|
|
29
|
-
pref="$pref_raw"
|
|
30
|
-
if [[ "$pref" == "" ]]; then pref="auto"; fi
|
|
31
|
-
|
|
32
|
-
cmd=( "$PNPM_SH" "$@" )
|
|
33
|
-
|
|
34
|
-
escape_for_osascript_string() {
|
|
35
|
-
# Escape for inclusion inside an AppleScript string literal.
|
|
36
|
-
# (We generate: write text "<cmd>")
|
|
37
|
-
local s="$1"
|
|
38
|
-
s="${s//\\/\\\\}"
|
|
39
|
-
s="${s//\"/\\\"}"
|
|
40
|
-
echo "$s"
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
shell_cmd() {
|
|
44
|
-
# Build a zsh command that cds and runs happys (via wrapper), leaving the shell open.
|
|
45
|
-
local joined=""
|
|
46
|
-
local q
|
|
47
|
-
joined="cd \"${WORKDIR//\"/\\\"}\"; "
|
|
48
|
-
for q in "${cmd[@]}"; do
|
|
49
|
-
# Basic shell quoting
|
|
50
|
-
if [[ "$q" =~ [[:space:]\\"\'\$\`\!\&\|\;\<\>\(\)\[\]\{\}] ]]; then
|
|
51
|
-
joined+="'${q//\'/\'\\\'\'}' "
|
|
52
|
-
else
|
|
53
|
-
joined+="$q "
|
|
54
|
-
fi
|
|
55
|
-
done
|
|
56
|
-
joined+="; echo; echo \"[happy-stacks] done\"; exec /bin/zsh -i"
|
|
57
|
-
echo "$joined"
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
run_iterm() {
|
|
61
|
-
if ! command -v osascript >/dev/null 2>&1; then
|
|
62
|
-
return 1
|
|
63
|
-
fi
|
|
64
|
-
local s
|
|
65
|
-
s="$(shell_cmd)"
|
|
66
|
-
s="$(escape_for_osascript_string "$s")"
|
|
67
|
-
osascript \
|
|
68
|
-
-e 'tell application "iTerm" to activate' \
|
|
69
|
-
-e 'tell application "iTerm" to create window with default profile' \
|
|
70
|
-
-e "tell application \"iTerm\" to tell current session of current window to write text \"${s}\"" >/dev/null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
run_terminal_app() {
|
|
74
|
-
if ! command -v osascript >/dev/null 2>&1; then
|
|
75
|
-
return 1
|
|
76
|
-
fi
|
|
77
|
-
local s
|
|
78
|
-
s="$(shell_cmd)"
|
|
79
|
-
# Terminal.app uses do script.
|
|
80
|
-
s="$(escape_for_osascript_string "$s")"
|
|
81
|
-
osascript \
|
|
82
|
-
-e 'tell application "Terminal" to activate' \
|
|
83
|
-
-e "tell application \"Terminal\" to do script \"${s}\"" >/dev/null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
run_ghostty() {
|
|
87
|
-
if ! command -v ghostty >/dev/null 2>&1; then
|
|
88
|
-
return 1
|
|
89
|
-
fi
|
|
90
|
-
|
|
91
|
-
# Best-effort: try to run the command. If ghostty doesn't support -e on this system,
|
|
92
|
-
# fall back to opening the dir and copying the command.
|
|
93
|
-
local s
|
|
94
|
-
s="$(shell_cmd)"
|
|
95
|
-
if ghostty --working-directory "$WORKDIR" -e /bin/zsh -lc "$s" >/dev/null 2>&1; then
|
|
96
|
-
return 0
|
|
97
|
-
fi
|
|
98
|
-
|
|
99
|
-
# Fallback: open in dir and copy command for manual paste.
|
|
100
|
-
echo -n "$s" | pbcopy 2>/dev/null || true
|
|
101
|
-
ghostty --working-directory "$WORKDIR" >/dev/null 2>&1 || true
|
|
102
|
-
return 0
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try_one() {
|
|
106
|
-
local t="$1"
|
|
107
|
-
case "$t" in
|
|
108
|
-
ghostty) run_ghostty ;;
|
|
109
|
-
iterm) run_iterm ;;
|
|
110
|
-
terminal) run_terminal_app ;;
|
|
111
|
-
current) ( cd "$WORKDIR"; exec "${cmd[@]}" ) ;;
|
|
112
|
-
*) return 1 ;;
|
|
113
|
-
esac
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if [[ "$pref" == "auto" ]]; then
|
|
117
|
-
for t in ghostty iterm terminal current; do
|
|
118
|
-
if try_one "$t"; then
|
|
119
|
-
exit 0
|
|
120
|
-
fi
|
|
121
|
-
done
|
|
122
|
-
exit 1
|
|
123
|
-
fi
|
|
124
|
-
|
|
125
|
-
try_one "$pref"
|
|
4
|
+
# Back-compat wrapper. Use `happys-term.sh` for new installs.
|
|
5
|
+
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/happys-term.sh" "$@"
|
package/extras/swiftbar/pnpm.sh
CHANGED
|
@@ -2,20 +2,9 @@
|
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
4
|
# Back-compat wrapper for SwiftBar menu actions.
|
|
5
|
-
# Historically this executed `pnpm
|
|
5
|
+
# Historically this executed `pnpm`; now it delegates to `happys.sh`.
|
|
6
6
|
|
|
7
7
|
HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
|
|
8
8
|
HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
if [[ ! -x "$HAPPYS_BIN" ]]; then
|
|
12
|
-
HAPPYS_BIN="$(command -v happys 2>/dev/null || true)"
|
|
13
|
-
fi
|
|
14
|
-
|
|
15
|
-
if [[ -z "${HAPPYS_BIN:-}" ]]; then
|
|
16
|
-
echo "happys not found (run: npx happy-stacks init, or npm i -g happy-stacks)" >&2
|
|
17
|
-
exit 1
|
|
18
|
-
fi
|
|
19
|
-
|
|
20
|
-
exec "$HAPPYS_BIN" "$@"
|
|
21
|
-
|
|
10
|
+
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/happys.sh" "$@"
|
|
@@ -27,31 +27,31 @@ fi
|
|
|
27
27
|
HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
|
|
28
28
|
HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
if [[ ! -x "$
|
|
30
|
+
HAPPYS_BIN="$HAPPY_LOCAL_DIR/extras/swiftbar/happys.sh"
|
|
31
|
+
if [[ ! -x "$HAPPYS_BIN" ]]; then
|
|
32
32
|
echo "happys wrapper not found (run: happys menubar install)" >&2
|
|
33
33
|
exit 1
|
|
34
34
|
fi
|
|
35
35
|
|
|
36
36
|
restart_main_service_best_effort() {
|
|
37
|
-
"$
|
|
37
|
+
"$HAPPYS_BIN" service:restart >/dev/null 2>&1 || true
|
|
38
38
|
# If the installed LaunchAgent is still legacy/baked, reinstall so it persists only env-file pointer.
|
|
39
|
-
"$
|
|
39
|
+
"$HAPPYS_BIN" service:install >/dev/null 2>&1 || true
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
restart_stack_service_best_effort() {
|
|
43
43
|
local name="$1"
|
|
44
|
-
"$
|
|
45
|
-
"$
|
|
44
|
+
"$HAPPYS_BIN" stack service:restart "$name" >/dev/null 2>&1 || true
|
|
45
|
+
"$HAPPYS_BIN" stack service:install "$name" >/dev/null 2>&1 || true
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
if [[ "$STACK" == "main" ]]; then
|
|
49
|
-
"$
|
|
49
|
+
"$HAPPYS_BIN" srv -- use "$FLAVOR"
|
|
50
50
|
restart_main_service_best_effort
|
|
51
51
|
echo "ok: main -> $FLAVOR"
|
|
52
52
|
exit 0
|
|
53
53
|
fi
|
|
54
54
|
|
|
55
|
-
"$
|
|
55
|
+
"$HAPPYS_BIN" stack srv "$STACK" -- use "$FLAVOR"
|
|
56
56
|
restart_stack_service_best_effort "$STACK"
|
|
57
57
|
echo "ok: $STACK -> $FLAVOR"
|
package/extras/swiftbar/wt-pr.sh
CHANGED
|
@@ -20,7 +20,7 @@ STACK_NAME="${2:-}"
|
|
|
20
20
|
HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
|
|
21
21
|
HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
|
|
22
22
|
|
|
23
|
-
HAPPYS="$HAPPY_LOCAL_DIR/extras/swiftbar/
|
|
23
|
+
HAPPYS="$HAPPY_LOCAL_DIR/extras/swiftbar/happys.sh"
|
|
24
24
|
if [[ ! -x "$HAPPYS" ]]; then
|
|
25
25
|
HAPPYS="$(command -v happys 2>/dev/null || true)"
|
|
26
26
|
fi
|
package/package.json
CHANGED
package/scripts/auth.mjs
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import './utils/env.mjs';
|
|
2
2
|
import { parseArgs } from './utils/args.mjs';
|
|
3
3
|
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
4
|
-
import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName } from './utils/paths.mjs';
|
|
4
|
+
import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName, resolveStackEnvPath } from './utils/paths.mjs';
|
|
5
5
|
import { resolvePublicServerUrl } from './tailscale.mjs';
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
+
import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { parseDotenv } from './utils/dotenv.mjs';
|
|
10
14
|
|
|
11
15
|
function getInternalServerUrl() {
|
|
12
16
|
const portRaw = (process.env.HAPPY_LOCAL_SERVER_PORT ?? process.env.HAPPY_STACKS_SERVER_PORT ?? '').trim();
|
|
@@ -19,6 +23,104 @@ function expandTilde(p) {
|
|
|
19
23
|
return p.replace(/^~(?=\/)/, homedir());
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
async function ensureDir(p) {
|
|
27
|
+
await mkdir(p, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readTextIfExists(path) {
|
|
31
|
+
try {
|
|
32
|
+
if (!existsSync(path)) return null;
|
|
33
|
+
const raw = await readFile(path, 'utf-8');
|
|
34
|
+
const t = raw.trim();
|
|
35
|
+
return t ? t : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function writeSecretFileIfMissing({ path, secret }) {
|
|
42
|
+
if (existsSync(path)) return false;
|
|
43
|
+
await ensureDir(dirname(path));
|
|
44
|
+
await writeFile(path, secret, { encoding: 'utf-8', mode: 0o600 });
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function copyFileIfMissing({ from, to, mode }) {
|
|
49
|
+
if (existsSync(to)) return false;
|
|
50
|
+
if (!existsSync(from)) return false;
|
|
51
|
+
await ensureDir(dirname(to));
|
|
52
|
+
await copyFile(from, to);
|
|
53
|
+
if (mode) {
|
|
54
|
+
await chmod(to, mode).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseEnvToObject(raw) {
|
|
60
|
+
const parsed = parseDotenv(raw);
|
|
61
|
+
return Object.fromEntries(parsed.entries());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getStackDir(stackName) {
|
|
65
|
+
return resolveStackEnvPath(stackName).baseDir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getStackEnvPath(stackName) {
|
|
69
|
+
return resolveStackEnvPath(stackName).envPath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stackExistsSync(stackName) {
|
|
73
|
+
if (stackName === 'main') return true;
|
|
74
|
+
const envPath = getStackEnvPath(stackName);
|
|
75
|
+
return existsSync(envPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
79
|
+
const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
|
|
80
|
+
return fromEnv || join(stackBaseDir, 'cli');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
84
|
+
const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
|
|
85
|
+
return fromEnv || join(stackBaseDir, 'server-light');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function resolveHandyMasterSecretFromStack({ stackName, requireStackExists }) {
|
|
89
|
+
if (requireStackExists && !stackExistsSync(stackName)) {
|
|
90
|
+
throw new Error(`[auth] cannot copy auth: source stack "${stackName}" does not exist`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sourceBaseDir = getStackDir(stackName);
|
|
94
|
+
const sourceEnvPath = getStackEnvPath(stackName);
|
|
95
|
+
const raw = await readTextIfExists(sourceEnvPath);
|
|
96
|
+
const env = raw ? parseEnvToObject(raw) : {};
|
|
97
|
+
|
|
98
|
+
const inline = (env.HANDY_MASTER_SECRET ?? '').trim();
|
|
99
|
+
if (inline) {
|
|
100
|
+
return { secret: inline, source: `${sourceEnvPath} (HANDY_MASTER_SECRET)` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const secretFile = (env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim();
|
|
104
|
+
if (secretFile) {
|
|
105
|
+
const secret = await readTextIfExists(secretFile);
|
|
106
|
+
if (secret) return { secret, source: secretFile };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dataDir = getServerLightDataDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env });
|
|
110
|
+
const secretPath = join(dataDir, 'handy-master-secret.txt');
|
|
111
|
+
const secret = await readTextIfExists(secretPath);
|
|
112
|
+
if (secret) return { secret, source: secretPath };
|
|
113
|
+
|
|
114
|
+
// Last-resort legacy: if main has never been migrated to stack dirs.
|
|
115
|
+
if (stackName === 'main') {
|
|
116
|
+
const legacy = join(homedir(), '.happy', 'server-light', 'handy-master-secret.txt');
|
|
117
|
+
const legacySecret = await readTextIfExists(legacy);
|
|
118
|
+
if (legacySecret) return { secret: legacySecret, source: legacy };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { secret: null, source: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
22
124
|
function resolveCliHomeDir() {
|
|
23
125
|
const fromEnv = (process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? process.env.HAPPY_STACKS_CLI_HOME_DIR ?? '').trim();
|
|
24
126
|
if (fromEnv) {
|
|
@@ -94,6 +196,261 @@ function authLoginSuggestion(stackName) {
|
|
|
94
196
|
return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
|
|
95
197
|
}
|
|
96
198
|
|
|
199
|
+
function authCopyFromMainSuggestion(stackName) {
|
|
200
|
+
if (stackName === 'main') return null;
|
|
201
|
+
return `happys stack auth ${stackName} copy-from main`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveServerComponentForCurrentStack() {
|
|
205
|
+
return (
|
|
206
|
+
(process.env.HAPPY_STACKS_SERVER_COMPONENT ?? process.env.HAPPY_LOCAL_SERVER_COMPONENT ?? 'happy-server-light').trim() ||
|
|
207
|
+
'happy-server-light'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runNodeCapture({ cwd, env, args, stdin }) {
|
|
212
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
213
|
+
const child = spawn(process.execPath, args, {
|
|
214
|
+
cwd,
|
|
215
|
+
env,
|
|
216
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
217
|
+
});
|
|
218
|
+
let stdout = '';
|
|
219
|
+
let stderr = '';
|
|
220
|
+
child.stdout.on('data', (d) => {
|
|
221
|
+
stdout += String(d);
|
|
222
|
+
});
|
|
223
|
+
child.stderr.on('data', (d) => {
|
|
224
|
+
stderr += String(d);
|
|
225
|
+
});
|
|
226
|
+
child.on('error', (err) => rejectPromise(err));
|
|
227
|
+
child.on('close', (code) => {
|
|
228
|
+
if (code === 0) {
|
|
229
|
+
resolvePromise({ stdout, stderr });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
rejectPromise(new Error(`node exited with ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
|
|
233
|
+
});
|
|
234
|
+
if (stdin != null) {
|
|
235
|
+
child.stdin.write(String(stdin));
|
|
236
|
+
}
|
|
237
|
+
child.stdin.end();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function resolveServerComponentFromEnv(env) {
|
|
242
|
+
const v = (env.HAPPY_STACKS_SERVER_COMPONENT ?? env.HAPPY_LOCAL_SERVER_COMPONENT ?? 'happy-server-light').trim() || 'happy-server-light';
|
|
243
|
+
return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveDatabaseUrlFromEnvOrThrow(env, { label }) {
|
|
247
|
+
const v = (env.DATABASE_URL ?? '').trim();
|
|
248
|
+
if (!v) throw new Error(`[auth] missing DATABASE_URL for ${label}`);
|
|
249
|
+
return v;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function resolveServerComponentDir({ rootDir, serverComponent }) {
|
|
253
|
+
return getComponentDir(rootDir, serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function seedAccountsFromSourceDbToTargetDb({
|
|
257
|
+
rootDir,
|
|
258
|
+
fromStackName,
|
|
259
|
+
fromServerComponent,
|
|
260
|
+
fromDatabaseUrl,
|
|
261
|
+
targetStackName,
|
|
262
|
+
targetServerComponent,
|
|
263
|
+
targetDatabaseUrl,
|
|
264
|
+
}) {
|
|
265
|
+
const sourceCwd = resolveServerComponentDir({ rootDir, serverComponent: fromServerComponent });
|
|
266
|
+
const targetCwd = resolveServerComponentDir({ rootDir, serverComponent: targetServerComponent });
|
|
267
|
+
|
|
268
|
+
const listScript = `
|
|
269
|
+
process.on('uncaughtException', (e) => {
|
|
270
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
271
|
+
process.exit(1);
|
|
272
|
+
});
|
|
273
|
+
process.on('unhandledRejection', (e) => {
|
|
274
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
275
|
+
process.exit(1);
|
|
276
|
+
});
|
|
277
|
+
import { PrismaClient } from '@prisma/client';
|
|
278
|
+
const db = new PrismaClient();
|
|
279
|
+
try {
|
|
280
|
+
const accounts = await db.account.findMany({ select: { id: true, publicKey: true } });
|
|
281
|
+
console.log(JSON.stringify(accounts));
|
|
282
|
+
} finally {
|
|
283
|
+
await db.$disconnect();
|
|
284
|
+
}
|
|
285
|
+
`.trim();
|
|
286
|
+
|
|
287
|
+
const insertScript = `
|
|
288
|
+
process.on('uncaughtException', (e) => {
|
|
289
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
290
|
+
process.exit(1);
|
|
291
|
+
});
|
|
292
|
+
process.on('unhandledRejection', (e) => {
|
|
293
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
294
|
+
process.exit(1);
|
|
295
|
+
});
|
|
296
|
+
import { PrismaClient } from '@prisma/client';
|
|
297
|
+
import fs from 'node:fs';
|
|
298
|
+
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
299
|
+
const accounts = raw ? JSON.parse(raw) : [];
|
|
300
|
+
const db = new PrismaClient();
|
|
301
|
+
try {
|
|
302
|
+
let insertedCount = 0;
|
|
303
|
+
for (const a of accounts) {
|
|
304
|
+
// eslint-disable-next-line no-await-in-loop
|
|
305
|
+
try {
|
|
306
|
+
await db.account.create({ data: { id: a.id, publicKey: a.publicKey } });
|
|
307
|
+
insertedCount += 1;
|
|
308
|
+
} catch (e) {
|
|
309
|
+
// Prisma unique constraint violation
|
|
310
|
+
if (e && typeof e === 'object' && 'code' in e && e.code === 'P2002') {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
throw e;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
console.log(JSON.stringify({ sourceCount: accounts.length, insertedCount }));
|
|
317
|
+
} finally {
|
|
318
|
+
await db.$disconnect();
|
|
319
|
+
}
|
|
320
|
+
`.trim();
|
|
321
|
+
|
|
322
|
+
const { stdout: srcOut } = await runNodeCapture({
|
|
323
|
+
cwd: sourceCwd,
|
|
324
|
+
env: { ...process.env, DATABASE_URL: fromDatabaseUrl },
|
|
325
|
+
args: ['--input-type=module', '-e', listScript],
|
|
326
|
+
});
|
|
327
|
+
const accounts = srcOut.trim() ? JSON.parse(srcOut.trim()) : [];
|
|
328
|
+
|
|
329
|
+
const { stdout: insOut } = await runNodeCapture({
|
|
330
|
+
cwd: targetCwd,
|
|
331
|
+
env: { ...process.env, DATABASE_URL: targetDatabaseUrl },
|
|
332
|
+
args: ['--input-type=module', '-e', insertScript],
|
|
333
|
+
stdin: JSON.stringify(accounts),
|
|
334
|
+
});
|
|
335
|
+
const res = insOut.trim() ? JSON.parse(insOut.trim()) : { sourceCount: accounts.length, insertedCount: 0 };
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
ok: true,
|
|
339
|
+
fromStackName,
|
|
340
|
+
targetStackName,
|
|
341
|
+
sourceCount: Number(res.sourceCount ?? accounts.length) || 0,
|
|
342
|
+
insertedCount: Number(res.insertedCount ?? 0) || 0,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function cmdCopyFrom({ argv, json }) {
|
|
347
|
+
const rootDir = getRootDir(import.meta.url);
|
|
348
|
+
const stackName = getStackName();
|
|
349
|
+
if (stackName === 'main') {
|
|
350
|
+
throw new Error('[auth] copy-from is intended for stack-scoped usage (e.g. happys stack auth <name> copy-from main)');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
354
|
+
const fromStackName = (positionals[1] ?? '').trim();
|
|
355
|
+
if (!fromStackName) {
|
|
356
|
+
throw new Error('[auth] usage: happys stack auth <name> copy-from <sourceStack> [--json]');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const serverComponent = resolveServerComponentForCurrentStack();
|
|
360
|
+
const targetBaseDir = getDefaultAutostartPaths().baseDir;
|
|
361
|
+
const targetCli = resolveCliHomeDir();
|
|
362
|
+
const targetServerLightDataDir =
|
|
363
|
+
(process.env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim() || join(targetBaseDir, 'server-light');
|
|
364
|
+
const targetSecretFile =
|
|
365
|
+
(process.env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(targetBaseDir, 'happy-server', 'handy-master-secret.txt');
|
|
366
|
+
|
|
367
|
+
const { secret, source } = await resolveHandyMasterSecretFromStack({ stackName: fromStackName, requireStackExists: true });
|
|
368
|
+
|
|
369
|
+
const copied = {
|
|
370
|
+
secret: false,
|
|
371
|
+
accessKey: false,
|
|
372
|
+
settings: false,
|
|
373
|
+
db: false,
|
|
374
|
+
dbAccounts: null,
|
|
375
|
+
dbError: null,
|
|
376
|
+
sourceStack: fromStackName,
|
|
377
|
+
stackName,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (secret) {
|
|
381
|
+
if (serverComponent === 'happy-server-light') {
|
|
382
|
+
copied.secret = await writeSecretFileIfMissing({ path: join(targetServerLightDataDir, 'handy-master-secret.txt'), secret });
|
|
383
|
+
} else if (serverComponent === 'happy-server') {
|
|
384
|
+
copied.secret = await writeSecretFileIfMissing({ path: targetSecretFile, secret });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const sourceBaseDir = getStackDir(fromStackName);
|
|
389
|
+
const sourceEnvRaw = await readTextIfExists(getStackEnvPath(fromStackName));
|
|
390
|
+
const sourceEnv = sourceEnvRaw ? parseEnvToObject(sourceEnvRaw) : {};
|
|
391
|
+
const sourceCli = getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
|
|
392
|
+
|
|
393
|
+
copied.accessKey = await copyFileIfMissing({
|
|
394
|
+
from: join(sourceCli, 'access.key'),
|
|
395
|
+
to: join(targetCli, 'access.key'),
|
|
396
|
+
mode: 0o600,
|
|
397
|
+
});
|
|
398
|
+
copied.settings = await copyFileIfMissing({
|
|
399
|
+
from: join(sourceCli, 'settings.json'),
|
|
400
|
+
to: join(targetCli, 'settings.json'),
|
|
401
|
+
mode: 0o600,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Best-effort DB seeding: copy Account rows from source stack DB to target stack DB.
|
|
405
|
+
// This avoids FK failures (e.g., Prisma P2003) when the target DB is fresh but the copied token
|
|
406
|
+
// refers to an account ID that does not exist there yet.
|
|
407
|
+
try {
|
|
408
|
+
const fromServerComponent = resolveServerComponentFromEnv(sourceEnv);
|
|
409
|
+
const fromDatabaseUrl = resolveDatabaseUrlFromEnvOrThrow(sourceEnv, { label: `source stack "${fromStackName}"` });
|
|
410
|
+
const targetEnv = process.env;
|
|
411
|
+
const targetServerComponent = resolveServerComponentFromEnv(targetEnv);
|
|
412
|
+
const targetDatabaseUrl = resolveDatabaseUrlFromEnvOrThrow(targetEnv, { label: `target stack "${stackName}"` });
|
|
413
|
+
|
|
414
|
+
const seeded = await seedAccountsFromSourceDbToTargetDb({
|
|
415
|
+
rootDir,
|
|
416
|
+
fromStackName,
|
|
417
|
+
fromServerComponent,
|
|
418
|
+
fromDatabaseUrl,
|
|
419
|
+
targetStackName: stackName,
|
|
420
|
+
targetServerComponent,
|
|
421
|
+
targetDatabaseUrl,
|
|
422
|
+
});
|
|
423
|
+
copied.dbAccounts = { sourceCount: seeded.sourceCount, insertedCount: seeded.insertedCount };
|
|
424
|
+
copied.db = true;
|
|
425
|
+
} catch (err) {
|
|
426
|
+
copied.db = false;
|
|
427
|
+
copied.dbAccounts = null;
|
|
428
|
+
copied.dbError = err instanceof Error ? err.message : String(err);
|
|
429
|
+
if (!json) {
|
|
430
|
+
console.warn(`[auth] db seed skipped: ${copied.dbError}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (json) {
|
|
435
|
+
printResult({ json, data: { ok: true, copied } });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const any = copied.secret || copied.accessKey || copied.settings || copied.db;
|
|
440
|
+
if (!any) {
|
|
441
|
+
console.log(`[auth] nothing to copy (target already has auth files)`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
console.log(`[auth] copied auth from "${fromStackName}" into "${stackName}" (no re-login needed)`);
|
|
446
|
+
if (copied.secret) console.log(` - master secret: copied (${source || 'unknown source'})`);
|
|
447
|
+
if (copied.dbAccounts) {
|
|
448
|
+
console.log(` - db: seeded Account rows (inserted=${copied.dbAccounts.insertedCount}/${copied.dbAccounts.sourceCount})`);
|
|
449
|
+
}
|
|
450
|
+
if (copied.accessKey) console.log(` - cli: copied access.key`);
|
|
451
|
+
if (copied.settings) console.log(` - cli: copied settings.json`);
|
|
452
|
+
}
|
|
453
|
+
|
|
97
454
|
async function cmdStatus({ json }) {
|
|
98
455
|
const rootDir = getRootDir(import.meta.url);
|
|
99
456
|
const stackName = getStackName();
|
|
@@ -162,11 +519,20 @@ async function cmdStatus({ json }) {
|
|
|
162
519
|
console.log(authLine);
|
|
163
520
|
if (!auth.ok) {
|
|
164
521
|
console.log(` ↪ run: ${authLoginSuggestion(stackName)}`);
|
|
522
|
+
const copyFromMain = authCopyFromMainSuggestion(stackName);
|
|
523
|
+
if (copyFromMain) {
|
|
524
|
+
console.log(` ↪ or (recommended if main is already logged in): ${copyFromMain}`);
|
|
525
|
+
}
|
|
165
526
|
}
|
|
166
527
|
console.log(daemonLine);
|
|
167
528
|
console.log(serverLine);
|
|
529
|
+
if (!health.ok) {
|
|
530
|
+
const startHint = stackName === 'main' ? 'happys dev' : `happys stack dev ${stackName}`;
|
|
531
|
+
console.log(` ↪ this stack does not appear to be running. Start it with: ${startHint}`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
168
534
|
if (auth.ok && daemon.status !== 'running') {
|
|
169
|
-
console.log(` ↪
|
|
535
|
+
console.log(` ↪ daemon is not running for this stack. If you expected it to be running, try: happys doctor`);
|
|
170
536
|
}
|
|
171
537
|
}
|
|
172
538
|
|
|
@@ -240,7 +606,7 @@ async function main() {
|
|
|
240
606
|
if (wantsHelp(argv, { flags }) || cmd === 'help') {
|
|
241
607
|
printResult({
|
|
242
608
|
json,
|
|
243
|
-
data: { commands: ['status', 'login'], stackScoped: 'happys stack auth <name> status|login' },
|
|
609
|
+
data: { commands: ['status', 'login', 'copy-from'], stackScoped: 'happys stack auth <name> status|login|copy-from' },
|
|
244
610
|
text: [
|
|
245
611
|
'[auth] usage:',
|
|
246
612
|
' happys auth status [--json]',
|
|
@@ -249,6 +615,7 @@ async function main() {
|
|
|
249
615
|
'stack-scoped:',
|
|
250
616
|
' happys stack auth <name> status [--json]',
|
|
251
617
|
' happys stack auth <name> login [--force] [--print] [--json]',
|
|
618
|
+
' happys stack auth <name> copy-from <sourceStack> [--json]',
|
|
252
619
|
].join('\n'),
|
|
253
620
|
});
|
|
254
621
|
return;
|
|
@@ -262,6 +629,10 @@ async function main() {
|
|
|
262
629
|
await cmdLogin({ argv, json });
|
|
263
630
|
return;
|
|
264
631
|
}
|
|
632
|
+
if (cmd === 'copy-from') {
|
|
633
|
+
await cmdCopyFrom({ argv, json });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
265
636
|
|
|
266
637
|
throw new Error(`[auth] unknown command: ${cmd}`);
|
|
267
638
|
}
|