happy-stacks 0.1.2 → 0.3.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 +164 -89
- package/bin/happys.mjs +70 -10
- package/docs/edison.md +381 -0
- package/docs/happy-development.md +733 -0
- package/docs/menubar.md +54 -0
- package/docs/paths-and-env.md +141 -0
- package/docs/stacks.md +39 -0
- package/extras/swiftbar/auth-login.sh +5 -2
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +131 -81
- package/extras/swiftbar/happys-term.sh +15 -38
- package/extras/swiftbar/happys.sh +15 -32
- package/extras/swiftbar/install.sh +99 -13
- package/extras/swiftbar/lib/git.sh +309 -1
- package/extras/swiftbar/lib/icons.sh +2 -2
- package/extras/swiftbar/lib/render.sh +209 -80
- package/extras/swiftbar/lib/system.sh +27 -4
- package/extras/swiftbar/lib/utils.sh +311 -28
- package/extras/swiftbar/pnpm.sh +2 -1
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +11 -2
- package/extras/swiftbar/wt-pr.sh +9 -2
- package/package.json +2 -1
- package/scripts/auth.mjs +521 -226
- package/scripts/build.mjs +29 -10
- package/scripts/cli-link.mjs +6 -6
- package/scripts/completion.mjs +18 -11
- package/scripts/daemon.mjs +133 -31
- package/scripts/dev.mjs +196 -137
- package/scripts/doctor.mjs +44 -55
- package/scripts/edison.mjs +1853 -0
- package/scripts/happy.mjs +10 -25
- package/scripts/init.mjs +46 -31
- package/scripts/install.mjs +21 -15
- package/scripts/lint.mjs +124 -0
- package/scripts/menubar.mjs +76 -10
- package/scripts/migrate.mjs +35 -35
- package/scripts/mobile.mjs +24 -17
- package/scripts/run.mjs +122 -35
- package/scripts/self.mjs +13 -35
- package/scripts/server_flavor.mjs +7 -7
- package/scripts/service.mjs +31 -28
- package/scripts/setup.mjs +694 -0
- package/scripts/setup_pr.mjs +165 -0
- package/scripts/stack.mjs +1851 -363
- package/scripts/stop.mjs +9 -6
- package/scripts/tailscale.mjs +23 -11
- package/scripts/test.mjs +123 -0
- package/scripts/tui.mjs +526 -0
- package/scripts/typecheck.mjs +10 -31
- package/scripts/ui_gateway.mjs +3 -3
- package/scripts/uninstall.mjs +21 -13
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/auth/files.mjs +56 -0
- package/scripts/utils/auth/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/login_ux.mjs +76 -0
- package/scripts/utils/auth/sources.mjs +12 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/dev/daemon.mjs +104 -0
- package/scripts/utils/dev/expo_web.mjs +112 -0
- package/scripts/utils/dev/server.mjs +183 -0
- package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -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/sandbox.mjs +14 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
- 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/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
- package/scripts/utils/paths/canonical_home.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +9 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/proc/ownership.mjs +135 -0
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/proc/pm.mjs +317 -0
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
- package/scripts/utils/proc/watch.mjs +63 -0
- package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
- package/scripts/utils/server/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
- package/scripts/utils/server/urls.mjs +91 -0
- package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
- package/scripts/utils/service/autostart_darwin.mjs +142 -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/runtime_state.mjs +87 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/stack/startup.mjs +208 -0
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
- package/scripts/utils/ui/browser.mjs +22 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/where.mjs +17 -10
- package/scripts/worktrees.mjs +110 -64
- package/scripts/utils/pm.mjs +0 -303
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
package/scripts/auth.mjs
CHANGED
|
@@ -1,133 +1,68 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
2
|
-
import { parseArgs } from './utils/args.mjs';
|
|
3
|
-
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
4
|
-
import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName, resolveStackEnvPath } from './utils/paths.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
4
|
+
import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName, resolveStackEnvPath } from './utils/paths/paths.mjs';
|
|
5
|
+
import { listAllStackNames } from './utils/stack/stacks.mjs';
|
|
5
6
|
import { resolvePublicServerUrl } from './tailscale.mjs';
|
|
7
|
+
import { getInternalServerUrl, getPublicServerUrlEnvOverride, getWebappUrlEnvOverride } from './utils/server/urls.mjs';
|
|
8
|
+
import { fetchHappyHealth } from './utils/server/server.mjs';
|
|
6
9
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
10
|
import { join } from 'node:path';
|
|
8
11
|
import { homedir } from 'node:os';
|
|
9
12
|
import { spawn } from 'node:child_process';
|
|
10
|
-
import {
|
|
13
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
11
14
|
import { dirname } from 'node:path';
|
|
12
15
|
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
16
|
+
import { parseEnvToObject } from './utils/env/dotenv.mjs';
|
|
17
|
+
import { ensureDepsInstalled, pmExecBin } from './utils/proc/pm.mjs';
|
|
18
|
+
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/server/infra/happy_server_infra.mjs';
|
|
19
|
+
import { clearDevAuthKey, readDevAuthKey, writeDevAuthKey } from './utils/auth/dev_key.mjs';
|
|
20
|
+
import { getExpoStatePaths, isStateProcessRunning } from './utils/expo/expo.mjs';
|
|
21
|
+
import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
|
|
22
|
+
import { printAuthLoginInstructions } from './utils/auth/login_ux.mjs';
|
|
23
|
+
import { copyFileIfMissing, linkFileIfMissing, removeFileOrSymlinkIfExists, writeSecretFileIfMissing } from './utils/auth/files.mjs';
|
|
24
|
+
import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
|
|
25
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
|
|
26
|
+
import { resolveHandyMasterSecretFromStack } from './utils/auth/handy_master_secret.mjs';
|
|
27
|
+
import { ensureDir, readTextIfExists } from './utils/fs/ops.mjs';
|
|
28
|
+
import { stackExistsSync } from './utils/stack/stacks.mjs';
|
|
29
|
+
import { checkDaemonState } from './daemon.mjs';
|
|
30
|
+
import {
|
|
31
|
+
getCliHomeDirFromEnvOrDefault,
|
|
32
|
+
getServerLightDataDirFromEnvOrDefault,
|
|
33
|
+
resolveCliHomeDir,
|
|
34
|
+
} from './utils/stack/dirs.mjs';
|
|
35
|
+
import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
|
|
36
|
+
|
|
37
|
+
function getInternalServerUrlCompat() {
|
|
38
|
+
const { port, internalServerUrl } = getInternalServerUrl({ env: process.env, defaultPort: 3005 });
|
|
39
|
+
return { port, url: internalServerUrl };
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
async function
|
|
42
|
+
async function resolveWebappUrlFromRunningExpo({ rootDir, stackName }) {
|
|
31
43
|
try {
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
44
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
45
|
+
const uiDir = getComponentDir(rootDir, 'happy');
|
|
46
|
+
const uiPaths = getExpoStatePaths({
|
|
47
|
+
baseDir,
|
|
48
|
+
kind: 'ui-dev',
|
|
49
|
+
projectDir: uiDir,
|
|
50
|
+
stateFileName: 'ui.state.json',
|
|
51
|
+
});
|
|
52
|
+
const uiRunning = await isStateProcessRunning(uiPaths.statePath);
|
|
53
|
+
if (!uiRunning.running) return null;
|
|
54
|
+
const port = Number(uiRunning.state?.port);
|
|
55
|
+
if (!Number.isFinite(port) || port <= 0) return null;
|
|
56
|
+
const host = resolveLocalhostHost({ stackMode: stackName !== 'main', stackName });
|
|
57
|
+
return `http://${host}:${port}`;
|
|
36
58
|
} catch {
|
|
37
59
|
return null;
|
|
38
60
|
}
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
|
|
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
|
-
}
|
|
63
|
+
// NOTE: common fs helpers live in scripts/utils/fs/ops.mjs
|
|
120
64
|
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function resolveCliHomeDir() {
|
|
125
|
-
const fromEnv = (process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? process.env.HAPPY_STACKS_CLI_HOME_DIR ?? '').trim();
|
|
126
|
-
if (fromEnv) {
|
|
127
|
-
return expandTilde(fromEnv);
|
|
128
|
-
}
|
|
129
|
-
return join(getDefaultAutostartPaths().baseDir, 'cli');
|
|
130
|
-
}
|
|
65
|
+
// (auth file copy/link helpers live in scripts/utils/auth/files.mjs)
|
|
131
66
|
|
|
132
67
|
function fileHasContent(path) {
|
|
133
68
|
try {
|
|
@@ -138,67 +73,14 @@ function fileHasContent(path) {
|
|
|
138
73
|
}
|
|
139
74
|
}
|
|
140
75
|
|
|
141
|
-
function checkDaemonState(cliHomeDir) {
|
|
142
|
-
const statePath = join(cliHomeDir, 'daemon.state.json');
|
|
143
|
-
const lockPath = join(cliHomeDir, 'daemon.state.json.lock');
|
|
144
|
-
|
|
145
|
-
const alive = (pid) => {
|
|
146
|
-
try {
|
|
147
|
-
process.kill(pid, 0);
|
|
148
|
-
return true;
|
|
149
|
-
} catch {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
if (existsSync(statePath)) {
|
|
155
|
-
try {
|
|
156
|
-
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
157
|
-
const pid = Number(state?.pid);
|
|
158
|
-
if (Number.isFinite(pid) && pid > 0) {
|
|
159
|
-
return alive(pid) ? { status: 'running', pid } : { status: 'stale_state', pid };
|
|
160
|
-
}
|
|
161
|
-
return { status: 'bad_state' };
|
|
162
|
-
} catch {
|
|
163
|
-
return { status: 'bad_state' };
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (existsSync(lockPath)) {
|
|
168
|
-
try {
|
|
169
|
-
const pid = Number(readFileSync(lockPath, 'utf-8').trim());
|
|
170
|
-
if (Number.isFinite(pid) && pid > 0) {
|
|
171
|
-
return alive(pid) ? { status: 'starting', pid } : { status: 'stale_lock', pid };
|
|
172
|
-
}
|
|
173
|
-
} catch {
|
|
174
|
-
// ignore
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return { status: 'stopped' };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function fetchHealth(internalServerUrl) {
|
|
182
|
-
const ctl = new AbortController();
|
|
183
|
-
const t = setTimeout(() => ctl.abort(), 1500);
|
|
184
|
-
try {
|
|
185
|
-
const res = await fetch(`${internalServerUrl}/health`, { method: 'GET', signal: ctl.signal });
|
|
186
|
-
const body = (await res.text()).trim();
|
|
187
|
-
return { ok: res.ok, status: res.status, body };
|
|
188
|
-
} catch {
|
|
189
|
-
return { ok: false, status: null, body: null };
|
|
190
|
-
} finally {
|
|
191
|
-
clearTimeout(t);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
76
|
function authLoginSuggestion(stackName) {
|
|
196
77
|
return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
|
|
197
78
|
}
|
|
198
79
|
|
|
199
|
-
function
|
|
80
|
+
function authCopyFromSeedSuggestion(stackName) {
|
|
200
81
|
if (stackName === 'main') return null;
|
|
201
|
-
|
|
82
|
+
const from = resolveAuthSeedFromEnv(process.env);
|
|
83
|
+
return `happys stack auth ${stackName} copy-from ${from}`;
|
|
202
84
|
}
|
|
203
85
|
|
|
204
86
|
function resolveServerComponentForCurrentStack() {
|
|
@@ -208,6 +90,70 @@ function resolveServerComponentForCurrentStack() {
|
|
|
208
90
|
);
|
|
209
91
|
}
|
|
210
92
|
|
|
93
|
+
async function cmdDevKey({ argv, json }) {
|
|
94
|
+
const { flags, kv } = parseArgs(argv);
|
|
95
|
+
const wantPrint = flags.has('--print');
|
|
96
|
+
const fmtRaw = (kv.get('--format') ?? '').trim();
|
|
97
|
+
// UX: the Happy UI restore screen expects the "backup" (XXXXX-...) format.
|
|
98
|
+
//
|
|
99
|
+
// IMPORTANT: the Happy restore screen treats any key containing '-' as "backup format",
|
|
100
|
+
// so printing a base64url key (which may contain '-') is *not reliably pasteable*.
|
|
101
|
+
// Default to backup always unless explicitly overridden.
|
|
102
|
+
const fmt = fmtRaw || 'backup'; // base64url | backup
|
|
103
|
+
const set = (kv.get('--set') ?? '').trim();
|
|
104
|
+
const clear = flags.has('--clear');
|
|
105
|
+
|
|
106
|
+
if (set) {
|
|
107
|
+
const res = await writeDevAuthKey({ env: process.env, input: set });
|
|
108
|
+
if (json) {
|
|
109
|
+
printResult({ json, data: { ok: true, action: 'set', path: res.path } });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(`[auth] dev-key saved to ${res.path}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (clear) {
|
|
116
|
+
const res = await clearDevAuthKey({ env: process.env });
|
|
117
|
+
if (json) {
|
|
118
|
+
printResult({ json, data: { ok: res.ok, action: 'clear', ...res } });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
console.log(res.deleted ? `[auth] dev-key removed (${res.path})` : `[auth] dev-key not set (${res.path})`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const out = await readDevAuthKey({ env: process.env });
|
|
126
|
+
if (!out.ok) {
|
|
127
|
+
throw new Error(`[auth] dev-key: ${out.error ?? 'failed'}`);
|
|
128
|
+
}
|
|
129
|
+
if (!out.secretKeyBase64Url) {
|
|
130
|
+
const msg =
|
|
131
|
+
`[auth] dev-key is not configured.\n` +
|
|
132
|
+
`Set it once (local-only, not committed):\n` +
|
|
133
|
+
` happys auth dev-key --set "<base64url-secret-or-backup-format>"\n` +
|
|
134
|
+
`Or export it for this shell:\n` +
|
|
135
|
+
` export HAPPY_STACKS_DEV_AUTH_SECRET_KEY="<base64url-secret>"\n`;
|
|
136
|
+
if (json) {
|
|
137
|
+
printResult({ json, data: { ok: false, error: 'missing_dev_key', file: out.path ?? null } });
|
|
138
|
+
} else {
|
|
139
|
+
console.log(msg);
|
|
140
|
+
}
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const value = fmt === 'backup' ? out.backup : out.secretKeyBase64Url;
|
|
145
|
+
if (wantPrint) {
|
|
146
|
+
process.stdout.write(value + '\n');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (json) {
|
|
150
|
+
printResult({ json, data: { ok: true, key: value, format: fmt, source: out.source ?? null } });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(`[auth] dev-key (${fmt}) [source=${out.source ?? 'unknown'}]`);
|
|
154
|
+
console.log(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
211
157
|
async function runNodeCapture({ cwd, env, args, stdin }) {
|
|
212
158
|
return await new Promise((resolvePromise, rejectPromise) => {
|
|
213
159
|
const child = spawn(process.execPath, args, {
|
|
@@ -243,10 +189,25 @@ function resolveServerComponentFromEnv(env) {
|
|
|
243
189
|
return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
244
190
|
}
|
|
245
191
|
|
|
246
|
-
function
|
|
247
|
-
const v = (env.DATABASE_URL ?? '').trim();
|
|
248
|
-
if (
|
|
249
|
-
|
|
192
|
+
function resolveDatabaseUrlForStackOrThrow({ env, stackName, baseDir, serverComponent, label }) {
|
|
193
|
+
const v = (env.DATABASE_URL ?? '').toString().trim();
|
|
194
|
+
if (v) {
|
|
195
|
+
if (serverComponent === 'happy-server') {
|
|
196
|
+
const lower = v.toLowerCase();
|
|
197
|
+
const ok = lower.startsWith('postgresql://') || lower.startsWith('postgres://');
|
|
198
|
+
if (!ok) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`[auth] invalid DATABASE_URL for ${label || `stack "${stackName}"`}: expected postgresql://... (got ${JSON.stringify(v)})`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return v;
|
|
205
|
+
}
|
|
206
|
+
if (serverComponent === 'happy-server-light') {
|
|
207
|
+
const dataDir = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').toString().trim() || join(baseDir, 'server-light');
|
|
208
|
+
return `file:${join(dataDir, 'happy-server-light.sqlite')}`;
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`[auth] missing DATABASE_URL for ${label || `stack "${stackName}"`}`);
|
|
250
211
|
}
|
|
251
212
|
|
|
252
213
|
function resolveServerComponentDir({ rootDir, serverComponent }) {
|
|
@@ -261,6 +222,7 @@ async function seedAccountsFromSourceDbToTargetDb({
|
|
|
261
222
|
targetStackName,
|
|
262
223
|
targetServerComponent,
|
|
263
224
|
targetDatabaseUrl,
|
|
225
|
+
force = false,
|
|
264
226
|
}) {
|
|
265
227
|
const sourceCwd = resolveServerComponentDir({ rootDir, serverComponent: fromServerComponent });
|
|
266
228
|
const targetCwd = resolveServerComponentDir({ rootDir, serverComponent: targetServerComponent });
|
|
@@ -295,6 +257,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
295
257
|
});
|
|
296
258
|
import { PrismaClient } from '@prisma/client';
|
|
297
259
|
import fs from 'node:fs';
|
|
260
|
+
const FORCE = ${force ? 'true' : 'false'};
|
|
298
261
|
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
299
262
|
const accounts = raw ? JSON.parse(raw) : [];
|
|
300
263
|
const db = new PrismaClient();
|
|
@@ -308,6 +271,29 @@ try {
|
|
|
308
271
|
} catch (e) {
|
|
309
272
|
// Prisma unique constraint violation
|
|
310
273
|
if (e && typeof e === 'object' && 'code' in e && e.code === 'P2002') {
|
|
274
|
+
// Two common cases:
|
|
275
|
+
// - id already exists (fine)
|
|
276
|
+
// - publicKey already exists on a different id (auth mismatch -> machine FK failures later)
|
|
277
|
+
//
|
|
278
|
+
// For --force, we try to delete the conflicting row by publicKey and then retry insert.
|
|
279
|
+
// Without --force, fail-closed with a helpful error so users don't end up with "seeded" but broken stacks.
|
|
280
|
+
try {
|
|
281
|
+
const existing = await db.account.findUnique({ where: { publicKey: a.publicKey }, select: { id: true } });
|
|
282
|
+
if (existing?.id && existing.id !== a.id) {
|
|
283
|
+
if (!FORCE) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
\`account publicKey conflict: target already has publicKey for id=\${existing.id}, but seed wants id=\${a.id}. Re-run with --force to replace the conflicting account row.\`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
// Best-effort delete; will fail if other rows reference this account (then we fail closed).
|
|
289
|
+
await db.account.delete({ where: { publicKey: a.publicKey } });
|
|
290
|
+
await db.account.create({ data: { id: a.id, publicKey: a.publicKey } });
|
|
291
|
+
insertedCount += 1;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
} catch (inner) {
|
|
295
|
+
throw inner;
|
|
296
|
+
}
|
|
311
297
|
continue;
|
|
312
298
|
}
|
|
313
299
|
throw e;
|
|
@@ -346,17 +332,144 @@ try {
|
|
|
346
332
|
async function cmdCopyFrom({ argv, json }) {
|
|
347
333
|
const rootDir = getRootDir(import.meta.url);
|
|
348
334
|
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
335
|
|
|
353
336
|
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
354
337
|
const fromStackName = (positionals[1] ?? '').trim();
|
|
355
338
|
if (!fromStackName) {
|
|
356
|
-
throw new Error(
|
|
339
|
+
throw new Error(
|
|
340
|
+
'[auth] usage: happys stack auth <name> copy-from <sourceStack|legacy> [--force] [--with-infra] [--json] OR happys auth copy-from <sourceStack|legacy> --all [--except=main,dev-auth] [--force] [--with-infra] [--json]\n' +
|
|
341
|
+
'notes:\n' +
|
|
342
|
+
' - sourceStack can be a stack name (e.g. main, dev-auth)\n' +
|
|
343
|
+
' - legacy uses ~/.happy/{cli,server-light} as a source (best-effort)'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const { flags, kv } = parseArgs(argv);
|
|
348
|
+
const all = flags.has('--all');
|
|
349
|
+
if (isLegacyAuthSourceName(fromStackName) && isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
'[auth] legacy auth source is disabled in sandbox mode.\n' +
|
|
352
|
+
'Reason: it reads from ~/.happy (global user state).\n' +
|
|
353
|
+
'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const force =
|
|
357
|
+
flags.has('--force') ||
|
|
358
|
+
flags.has('--overwrite') ||
|
|
359
|
+
(kv.get('--force') ?? '').trim() === '1' ||
|
|
360
|
+
(kv.get('--overwrite') ?? '').trim() === '1';
|
|
361
|
+
const withInfra =
|
|
362
|
+
flags.has('--with-infra') ||
|
|
363
|
+
flags.has('--ensure-infra') ||
|
|
364
|
+
flags.has('--infra') ||
|
|
365
|
+
(kv.get('--with-infra') ?? '').trim() === '1' ||
|
|
366
|
+
(kv.get('--ensure-infra') ?? '').trim() === '1';
|
|
367
|
+
const linkMode =
|
|
368
|
+
flags.has('--link') ||
|
|
369
|
+
flags.has('--symlink') ||
|
|
370
|
+
flags.has('--link-auth') ||
|
|
371
|
+
(kv.get('--link') ?? '').trim() === '1' ||
|
|
372
|
+
(kv.get('--symlink') ?? '').trim() === '1' ||
|
|
373
|
+
(kv.get('--auth-mode') ?? '').trim() === 'link' ||
|
|
374
|
+
(process.env.HAPPY_STACKS_AUTH_LINK ?? process.env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
|
|
375
|
+
(process.env.HAPPY_STACKS_AUTH_MODE ?? process.env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
|
|
376
|
+
const allowMain = flags.has('--allow-main') || flags.has('--main-ok') || (kv.get('--allow-main') ?? '').trim() === '1';
|
|
377
|
+
const exceptRaw = (kv.get('--except') ?? '').trim();
|
|
378
|
+
const except = new Set(exceptRaw.split(',').map((s) => s.trim()).filter(Boolean));
|
|
379
|
+
|
|
380
|
+
if (all) {
|
|
381
|
+
// Global bulk operation (no stack context required).
|
|
382
|
+
const stacks = await listAllStackNames();
|
|
383
|
+
const results = [];
|
|
384
|
+
const totalTargets = stacks.filter((s) => !except.has(s) && s !== fromStackName).length;
|
|
385
|
+
let idx = 0;
|
|
386
|
+
const progress = (line) => {
|
|
387
|
+
// In JSON mode, never pollute stdout (reserved for final JSON).
|
|
388
|
+
// eslint-disable-next-line no-console
|
|
389
|
+
(json ? console.error : console.log)(line);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
progress(
|
|
393
|
+
`[auth] copy-from --all: from=${fromStackName}${except.size ? ` (except=${[...except].join(',')})` : ''}${force ? ' (force)' : ''}${withInfra ? ' (with-infra)' : ''}`
|
|
394
|
+
);
|
|
395
|
+
for (const target of stacks) {
|
|
396
|
+
if (except.has(target)) {
|
|
397
|
+
progress(`- ↪ ${target}: skipped (excluded)`);
|
|
398
|
+
results.push({ stackName: target, ok: true, skipped: true, reason: 'excluded' });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (target === fromStackName) {
|
|
402
|
+
progress(`- ↪ ${target}: skipped (source_stack)`);
|
|
403
|
+
results.push({ stackName: target, ok: true, skipped: true, reason: 'source_stack' });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
idx += 1;
|
|
408
|
+
progress(`[auth] [${idx}/${totalTargets}] seeding stack "${target}"...`);
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const out = await runNodeCapture({
|
|
412
|
+
cwd: rootDir,
|
|
413
|
+
env: process.env,
|
|
414
|
+
args: [
|
|
415
|
+
join(rootDir, 'scripts', 'stack.mjs'),
|
|
416
|
+
'auth',
|
|
417
|
+
target,
|
|
418
|
+
'--',
|
|
419
|
+
'copy-from',
|
|
420
|
+
fromStackName,
|
|
421
|
+
'--json',
|
|
422
|
+
...(force ? ['--force'] : []),
|
|
423
|
+
...(withInfra ? ['--with-infra'] : []),
|
|
424
|
+
...(linkMode ? ['--link'] : []),
|
|
425
|
+
],
|
|
426
|
+
});
|
|
427
|
+
const parsed = out.stdout.trim() ? JSON.parse(out.stdout.trim()) : null;
|
|
428
|
+
|
|
429
|
+
const copied = parsed?.copied && typeof parsed.copied === 'object' ? parsed.copied : null;
|
|
430
|
+
const db = copied?.dbAccounts ? `db=${copied.dbAccounts.insertedCount}/${copied.dbAccounts.sourceCount}` : copied?.dbError ? `db=skipped` : `db=unknown`;
|
|
431
|
+
const secret = copied?.secret ? 'secret' : null;
|
|
432
|
+
const cli = copied?.accessKey || copied?.settings ? 'cli' : null;
|
|
433
|
+
const any = copied?.secret || copied?.accessKey || copied?.settings || copied?.db;
|
|
434
|
+
const summary = any ? `seeded (${[db, secret, cli].filter(Boolean).join(', ')})` : `noop (already has auth)`;
|
|
435
|
+
progress(`- ✅ ${target}: ${summary}`);
|
|
436
|
+
if (copied?.dbError) {
|
|
437
|
+
progress(` - db seed skipped: ${copied.dbError}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
results.push({ stackName: target, ok: true, skipped: false, fromStackName, out: parsed });
|
|
441
|
+
} catch (e) {
|
|
442
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
443
|
+
progress(`- ❌ ${target}: failed`);
|
|
444
|
+
progress(` - ${msg}`);
|
|
445
|
+
results.push({ stackName: target, ok: false, skipped: false, fromStackName, error: msg });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const ok = results.every((r) => r.ok);
|
|
450
|
+
if (json) {
|
|
451
|
+
printResult({ json, data: { ok, fromStackName, results } });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// (we already streamed progress above)
|
|
455
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
456
|
+
const skipped = results.filter((r) => r.ok && r.skipped).length;
|
|
457
|
+
const seeded = results.filter((r) => r.ok && !r.skipped).length;
|
|
458
|
+
// eslint-disable-next-line no-console
|
|
459
|
+
console.log(`[auth] done: ok=${ok ? 'true' : 'false'} seeded=${seeded} skipped=${skipped} failed=${failed}`);
|
|
460
|
+
if (!ok) process.exit(1);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (stackName === 'main' && !allowMain) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
'[auth] copy-from is intended for stack-scoped usage (e.g. happys stack auth <name> copy-from main), or pass --all.\n' +
|
|
467
|
+
'If you really intend to seed the main Happy Stacks install, re-run with: --allow-main'
|
|
468
|
+
);
|
|
357
469
|
}
|
|
358
470
|
|
|
359
471
|
const serverComponent = resolveServerComponentForCurrentStack();
|
|
472
|
+
const serverDirForPrisma = resolveServerComponentDir({ rootDir, serverComponent });
|
|
360
473
|
const targetBaseDir = getDefaultAutostartPaths().baseDir;
|
|
361
474
|
const targetCli = resolveCliHomeDir();
|
|
362
475
|
const targetServerLightDataDir =
|
|
@@ -364,7 +477,20 @@ async function cmdCopyFrom({ argv, json }) {
|
|
|
364
477
|
const targetSecretFile =
|
|
365
478
|
(process.env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(targetBaseDir, 'happy-server', 'handy-master-secret.txt');
|
|
366
479
|
|
|
367
|
-
const
|
|
480
|
+
const isLegacySource = isLegacyAuthSourceName(fromStackName);
|
|
481
|
+
if (isLegacySource && isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
'[auth] legacy auth source is disabled in sandbox mode.\n' +
|
|
484
|
+
'Reason: it reads from ~/.happy (global user state).\n' +
|
|
485
|
+
'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const { secret, source } = await resolveHandyMasterSecretFromStack({
|
|
489
|
+
stackName: fromStackName,
|
|
490
|
+
requireStackExists: !isLegacySource,
|
|
491
|
+
allowLegacyAuthSource: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
|
|
492
|
+
allowLegacyMainFallback: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
|
|
493
|
+
});
|
|
368
494
|
|
|
369
495
|
const copied = {
|
|
370
496
|
secret: false,
|
|
@@ -379,49 +505,150 @@ async function cmdCopyFrom({ argv, json }) {
|
|
|
379
505
|
|
|
380
506
|
if (secret) {
|
|
381
507
|
if (serverComponent === 'happy-server-light') {
|
|
382
|
-
|
|
508
|
+
const target = join(targetServerLightDataDir, 'handy-master-secret.txt');
|
|
509
|
+
const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
|
|
510
|
+
if (linkMode && sourcePath && existsSync(sourcePath)) {
|
|
511
|
+
copied.secret = await linkFileIfMissing({ from: sourcePath, to: target, force });
|
|
512
|
+
} else {
|
|
513
|
+
copied.secret = await writeSecretFileIfMissing({ path: target, secret, force });
|
|
514
|
+
}
|
|
383
515
|
} else if (serverComponent === 'happy-server') {
|
|
384
|
-
|
|
516
|
+
const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
|
|
517
|
+
if (linkMode && sourcePath && existsSync(sourcePath)) {
|
|
518
|
+
copied.secret = await linkFileIfMissing({ from: sourcePath, to: targetSecretFile, force });
|
|
519
|
+
} else {
|
|
520
|
+
copied.secret = await writeSecretFileIfMissing({ path: targetSecretFile, secret, force });
|
|
521
|
+
}
|
|
385
522
|
}
|
|
386
523
|
}
|
|
387
524
|
|
|
388
|
-
const sourceBaseDir =
|
|
389
|
-
const sourceEnvRaw = await readTextIfExists(
|
|
525
|
+
const sourceBaseDir = isLegacySource ? getLegacyHappyBaseDir() : resolveStackEnvPath(fromStackName).baseDir;
|
|
526
|
+
const sourceEnvRaw = isLegacySource ? '' : await readTextIfExists(resolveStackEnvPath(fromStackName).envPath);
|
|
390
527
|
const sourceEnv = sourceEnvRaw ? parseEnvToObject(sourceEnvRaw) : {};
|
|
391
|
-
const sourceCli =
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
528
|
+
const sourceCli = isLegacySource
|
|
529
|
+
? join(sourceBaseDir, 'cli')
|
|
530
|
+
: getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
|
|
531
|
+
|
|
532
|
+
if (linkMode) {
|
|
533
|
+
copied.accessKey = await linkFileIfMissing({ from: join(sourceCli, 'access.key'), to: join(targetCli, 'access.key'), force });
|
|
534
|
+
copied.settings = await linkFileIfMissing({ from: join(sourceCli, 'settings.json'), to: join(targetCli, 'settings.json'), force });
|
|
535
|
+
} else {
|
|
536
|
+
copied.accessKey = await copyFileIfMissing({
|
|
537
|
+
from: join(sourceCli, 'access.key'),
|
|
538
|
+
to: join(targetCli, 'access.key'),
|
|
539
|
+
mode: 0o600,
|
|
540
|
+
force,
|
|
541
|
+
});
|
|
542
|
+
copied.settings = await copyFileIfMissing({
|
|
543
|
+
from: join(sourceCli, 'settings.json'),
|
|
544
|
+
to: join(targetCli, 'settings.json'),
|
|
545
|
+
mode: 0o600,
|
|
546
|
+
force,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
403
549
|
|
|
404
550
|
// Best-effort DB seeding: copy Account rows from source stack DB to target stack DB.
|
|
405
551
|
// This avoids FK failures (e.g., Prisma P2003) when the target DB is fresh but the copied token
|
|
406
552
|
// refers to an account ID that does not exist there yet.
|
|
407
553
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
554
|
+
// Ensure prisma is runnable (best-effort). If deps aren't installed, we'll fall back to skipping DB seeding.
|
|
555
|
+
// IMPORTANT: when running with --json, keep stdout clean (no yarn/prisma chatter).
|
|
556
|
+
await ensureDepsInstalled(serverDirForPrisma, serverComponent, { quiet: json }).catch(() => {});
|
|
557
|
+
|
|
558
|
+
const fromServerComponent = isLegacySource ? 'happy-server-light' : resolveServerComponentFromEnv(sourceEnv);
|
|
559
|
+
const fromDatabaseUrl = resolveDatabaseUrlForStackOrThrow({
|
|
560
|
+
env: sourceEnv,
|
|
561
|
+
stackName: fromStackName,
|
|
562
|
+
baseDir: sourceBaseDir,
|
|
563
|
+
serverComponent: fromServerComponent,
|
|
564
|
+
label: `source stack "${fromStackName}"`,
|
|
565
|
+
});
|
|
410
566
|
const targetEnv = process.env;
|
|
411
567
|
const targetServerComponent = resolveServerComponentFromEnv(targetEnv);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
568
|
+
let targetDatabaseUrl;
|
|
569
|
+
try {
|
|
570
|
+
targetDatabaseUrl = resolveDatabaseUrlForStackOrThrow({
|
|
571
|
+
env: targetEnv,
|
|
572
|
+
stackName,
|
|
573
|
+
baseDir: targetBaseDir,
|
|
574
|
+
serverComponent: targetServerComponent,
|
|
575
|
+
label: `target stack "${stackName}"`,
|
|
576
|
+
});
|
|
577
|
+
} catch (e) {
|
|
578
|
+
// For full server stacks, allow `copy-from --with-infra` to bring up Docker infra just-in-time
|
|
579
|
+
// so we can seed DB accounts reliably.
|
|
580
|
+
const managed = (targetEnv.HAPPY_STACKS_MANAGED_INFRA ?? targetEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0';
|
|
581
|
+
if (targetServerComponent === 'happy-server' && withInfra && managed) {
|
|
582
|
+
const { port } = getInternalServerUrlCompat();
|
|
583
|
+
const publicServerUrl = `http://localhost:${port}`;
|
|
584
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
585
|
+
const infra = await ensureHappyServerManagedInfra({
|
|
586
|
+
stackName,
|
|
587
|
+
baseDir: targetBaseDir,
|
|
588
|
+
serverPort: port,
|
|
589
|
+
publicServerUrl,
|
|
590
|
+
envPath,
|
|
591
|
+
env: targetEnv,
|
|
592
|
+
quiet: json,
|
|
593
|
+
// Auth seeding only needs Postgres; don't block on Minio bucket init.
|
|
594
|
+
skipMinioInit: true,
|
|
595
|
+
});
|
|
596
|
+
targetDatabaseUrl = infra?.env?.DATABASE_URL ?? '';
|
|
597
|
+
} else {
|
|
598
|
+
throw e;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (!targetDatabaseUrl) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
`[auth] missing DATABASE_URL for target stack "${stackName}". ` +
|
|
604
|
+
(targetServerComponent === 'happy-server' ? `If this is a managed infra stack, re-run with --with-infra.` : '')
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const runSeed = async () => {
|
|
609
|
+
const seeded = await seedAccountsFromSourceDbToTargetDb({
|
|
610
|
+
rootDir,
|
|
611
|
+
fromStackName,
|
|
612
|
+
fromServerComponent,
|
|
613
|
+
fromDatabaseUrl,
|
|
614
|
+
targetStackName: stackName,
|
|
615
|
+
targetServerComponent,
|
|
616
|
+
targetDatabaseUrl,
|
|
617
|
+
force,
|
|
618
|
+
});
|
|
619
|
+
copied.dbAccounts = { sourceCount: seeded.sourceCount, insertedCount: seeded.insertedCount };
|
|
620
|
+
copied.db = true;
|
|
621
|
+
copied.dbError = null;
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
await runSeed();
|
|
626
|
+
} catch (e) {
|
|
627
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
628
|
+
// If the target DB exists but hasn't had schema applied yet, Prisma will report missing tables.
|
|
629
|
+
// Fix it best-effort by applying schema, then retry seeding once.
|
|
630
|
+
const looksLikeMissingTable = msg.toLowerCase().includes('does not exist') || msg.toLowerCase().includes('no such table');
|
|
631
|
+
if (looksLikeMissingTable) {
|
|
632
|
+
if (serverComponent === 'happy-server-light') {
|
|
633
|
+
await pmExecBin({
|
|
634
|
+
dir: serverDirForPrisma,
|
|
635
|
+
bin: 'prisma',
|
|
636
|
+
args: ['db', 'push'],
|
|
637
|
+
env: { ...process.env, DATABASE_URL: targetDatabaseUrl },
|
|
638
|
+
quiet: json,
|
|
639
|
+
}).catch(() => {});
|
|
640
|
+
} else if (serverComponent === 'happy-server') {
|
|
641
|
+
await applyHappyServerMigrations({
|
|
642
|
+
serverDir: serverDirForPrisma,
|
|
643
|
+
env: { ...process.env, DATABASE_URL: targetDatabaseUrl },
|
|
644
|
+
quiet: json,
|
|
645
|
+
}).catch(() => {});
|
|
646
|
+
}
|
|
647
|
+
await runSeed();
|
|
648
|
+
} else {
|
|
649
|
+
throw e;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
425
652
|
} catch (err) {
|
|
426
653
|
copied.db = false;
|
|
427
654
|
copied.dbAccounts = null;
|
|
@@ -455,9 +682,8 @@ async function cmdStatus({ json }) {
|
|
|
455
682
|
const rootDir = getRootDir(import.meta.url);
|
|
456
683
|
const stackName = getStackName();
|
|
457
684
|
|
|
458
|
-
const { port, url: internalServerUrl } =
|
|
459
|
-
const defaultPublicUrl =
|
|
460
|
-
const envPublicUrl = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
|
|
685
|
+
const { port, url: internalServerUrl } = getInternalServerUrlCompat();
|
|
686
|
+
const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port, stackName });
|
|
461
687
|
const { publicServerUrl } = await resolvePublicServerUrl({
|
|
462
688
|
internalServerUrl,
|
|
463
689
|
defaultPublicUrl,
|
|
@@ -478,7 +704,12 @@ async function cmdStatus({ json }) {
|
|
|
478
704
|
};
|
|
479
705
|
|
|
480
706
|
const daemon = checkDaemonState(cliHomeDir);
|
|
481
|
-
const
|
|
707
|
+
const healthRaw = await fetchHappyHealth(internalServerUrl);
|
|
708
|
+
const health = {
|
|
709
|
+
ok: Boolean(healthRaw.ok),
|
|
710
|
+
status: healthRaw.status,
|
|
711
|
+
body: healthRaw.text ? healthRaw.text.trim() : null,
|
|
712
|
+
};
|
|
482
713
|
|
|
483
714
|
const out = {
|
|
484
715
|
stackName,
|
|
@@ -519,9 +750,9 @@ async function cmdStatus({ json }) {
|
|
|
519
750
|
console.log(authLine);
|
|
520
751
|
if (!auth.ok) {
|
|
521
752
|
console.log(` ↪ run: ${authLoginSuggestion(stackName)}`);
|
|
522
|
-
const
|
|
523
|
-
if (
|
|
524
|
-
console.log(` ↪ or (recommended if
|
|
753
|
+
const copyFromSeed = authCopyFromSeedSuggestion(stackName);
|
|
754
|
+
if (copyFromSeed) {
|
|
755
|
+
console.log(` ↪ or (recommended if your seed stack is already logged in): ${copyFromSeed}`);
|
|
525
756
|
}
|
|
526
757
|
}
|
|
527
758
|
console.log(daemonLine);
|
|
@@ -539,22 +770,31 @@ async function cmdStatus({ json }) {
|
|
|
539
770
|
async function cmdLogin({ argv, json }) {
|
|
540
771
|
const rootDir = getRootDir(import.meta.url);
|
|
541
772
|
const stackName = getStackName();
|
|
773
|
+
const { kv } = parseArgs(argv);
|
|
542
774
|
|
|
543
|
-
const { port, url: internalServerUrl } =
|
|
544
|
-
const defaultPublicUrl =
|
|
545
|
-
const envPublicUrl = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
|
|
775
|
+
const { port, url: internalServerUrl } = getInternalServerUrlCompat();
|
|
776
|
+
const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port, stackName });
|
|
546
777
|
const { publicServerUrl } = await resolvePublicServerUrl({
|
|
547
778
|
internalServerUrl,
|
|
548
779
|
defaultPublicUrl,
|
|
549
780
|
envPublicUrl,
|
|
550
781
|
allowEnable: false,
|
|
551
782
|
});
|
|
783
|
+
const { envWebappUrl } = getWebappUrlEnvOverride({ env: process.env, stackName });
|
|
784
|
+
const expoWebappUrl = await resolveWebappUrlFromRunningExpo({ rootDir, stackName });
|
|
785
|
+
const webappUrl = envWebappUrl || expoWebappUrl || publicServerUrl;
|
|
786
|
+
const webappUrlSource = expoWebappUrl ? 'expo' : envWebappUrl ? 'stack env override' : 'server';
|
|
552
787
|
|
|
553
788
|
const cliHomeDir = resolveCliHomeDir();
|
|
554
789
|
const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
|
|
555
790
|
|
|
556
791
|
const force = !argv.includes('--no-force');
|
|
557
792
|
const wantPrint = argv.includes('--print');
|
|
793
|
+
const contextRaw =
|
|
794
|
+
(kv.get('--context') ?? process.env.HAPPY_STACKS_AUTH_LOGIN_CONTEXT ?? process.env.HAPPY_LOCAL_AUTH_LOGIN_CONTEXT ?? '')
|
|
795
|
+
.toString()
|
|
796
|
+
.trim();
|
|
797
|
+
const context = contextRaw || (stackName === 'main' ? 'generic' : 'stack');
|
|
558
798
|
|
|
559
799
|
const nodeArgs = [cliBin, 'auth', 'login'];
|
|
560
800
|
if (force || argv.includes('--force')) {
|
|
@@ -565,11 +805,11 @@ async function cmdLogin({ argv, json }) {
|
|
|
565
805
|
...process.env,
|
|
566
806
|
HAPPY_HOME_DIR: cliHomeDir,
|
|
567
807
|
HAPPY_SERVER_URL: internalServerUrl,
|
|
568
|
-
HAPPY_WEBAPP_URL:
|
|
808
|
+
HAPPY_WEBAPP_URL: webappUrl,
|
|
569
809
|
};
|
|
570
810
|
|
|
571
811
|
if (wantPrint) {
|
|
572
|
-
const cmd = `HAPPY_HOME_DIR="${cliHomeDir}" HAPPY_SERVER_URL="${internalServerUrl}" HAPPY_WEBAPP_URL="${
|
|
812
|
+
const cmd = `HAPPY_HOME_DIR="${cliHomeDir}" HAPPY_SERVER_URL="${internalServerUrl}" HAPPY_WEBAPP_URL="${webappUrl}" node "${cliBin}" auth login${nodeArgs.includes('--force') ? ' --force' : ''}`;
|
|
573
813
|
if (json) {
|
|
574
814
|
printResult({ json, data: { ok: true, stackName, cmd } });
|
|
575
815
|
} else {
|
|
@@ -579,8 +819,15 @@ async function cmdLogin({ argv, json }) {
|
|
|
579
819
|
}
|
|
580
820
|
|
|
581
821
|
if (!json) {
|
|
582
|
-
|
|
583
|
-
|
|
822
|
+
printAuthLoginInstructions({
|
|
823
|
+
stackName,
|
|
824
|
+
context,
|
|
825
|
+
webappUrl,
|
|
826
|
+
webappUrlSource,
|
|
827
|
+
internalServerUrl,
|
|
828
|
+
publicServerUrl,
|
|
829
|
+
rerunCmd: authLoginSuggestion(stackName),
|
|
830
|
+
});
|
|
584
831
|
}
|
|
585
832
|
|
|
586
833
|
const child = spawn(process.execPath, nodeArgs, {
|
|
@@ -589,7 +836,45 @@ async function cmdLogin({ argv, json }) {
|
|
|
589
836
|
stdio: 'inherit',
|
|
590
837
|
});
|
|
591
838
|
|
|
839
|
+
const timeoutMsRaw =
|
|
840
|
+
(process.env.HAPPY_STACKS_AUTH_LOGIN_TIMEOUT_MS ?? process.env.HAPPY_LOCAL_AUTH_LOGIN_TIMEOUT_MS ?? '600000').toString().trim();
|
|
841
|
+
const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 600000;
|
|
842
|
+
const hasTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
843
|
+
|
|
844
|
+
let exiting = false;
|
|
845
|
+
const killChild = (signal) => {
|
|
846
|
+
if (exiting) return;
|
|
847
|
+
exiting = true;
|
|
848
|
+
try {
|
|
849
|
+
child.kill(signal);
|
|
850
|
+
} catch {
|
|
851
|
+
// ignore
|
|
852
|
+
}
|
|
853
|
+
setTimeout(() => {
|
|
854
|
+
try {
|
|
855
|
+
if (child.pid) process.kill(child.pid, 'SIGKILL');
|
|
856
|
+
} catch {
|
|
857
|
+
// ignore
|
|
858
|
+
}
|
|
859
|
+
}, 1500).unref?.();
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const onSigint = () => killChild('SIGINT');
|
|
863
|
+
const onSigterm = () => killChild('SIGTERM');
|
|
864
|
+
process.on('SIGINT', onSigint);
|
|
865
|
+
process.on('SIGTERM', onSigterm);
|
|
866
|
+
|
|
867
|
+
const t = hasTimeout
|
|
868
|
+
? setTimeout(() => {
|
|
869
|
+
console.warn(`[auth] login timed out after ${timeoutMs}ms (set HAPPY_STACKS_AUTH_LOGIN_TIMEOUT_MS=0 to disable)`);
|
|
870
|
+
killChild('SIGTERM');
|
|
871
|
+
}, timeoutMs)
|
|
872
|
+
: null;
|
|
873
|
+
|
|
592
874
|
await new Promise((resolve) => child.on('exit', resolve));
|
|
875
|
+
process.off('SIGINT', onSigint);
|
|
876
|
+
process.off('SIGTERM', onSigterm);
|
|
877
|
+
if (t) clearTimeout(t);
|
|
593
878
|
if (json) {
|
|
594
879
|
printResult({ json, data: { ok: child.exitCode === 0, exitCode: child.exitCode } });
|
|
595
880
|
} else if (child.exitCode && child.exitCode !== 0) {
|
|
@@ -606,16 +891,22 @@ async function main() {
|
|
|
606
891
|
if (wantsHelp(argv, { flags }) || cmd === 'help') {
|
|
607
892
|
printResult({
|
|
608
893
|
json,
|
|
609
|
-
data: { commands: ['status', 'login', 'copy-from'], stackScoped: 'happys stack auth <name> status|login|copy-from' },
|
|
894
|
+
data: { commands: ['status', 'login', 'copy-from', 'dev-key'], stackScoped: 'happys stack auth <name> status|login|copy-from' },
|
|
610
895
|
text: [
|
|
611
896
|
'[auth] usage:',
|
|
612
897
|
' happys auth status [--json]',
|
|
613
898
|
' happys auth login [--force] [--print] [--json]',
|
|
899
|
+
' happys auth copy-from <sourceStack|legacy> --all [--except=main,dev-auth] [--force] [--with-infra] [--link] [--json]',
|
|
900
|
+
' happys auth dev-key [--print] [--format=base64url|backup] [--set=<base64url>] [--clear] [--json]',
|
|
901
|
+
'',
|
|
902
|
+
'advanced:',
|
|
903
|
+
' happys auth login --context=selfhost|dev|stack # UX labels only',
|
|
904
|
+
' happys auth copy-from legacy --allow-main [--link] [--force] # reuse (symlink) or copy ~/.happy creds into main happy-stacks install',
|
|
614
905
|
'',
|
|
615
906
|
'stack-scoped:',
|
|
616
907
|
' happys stack auth <name> status [--json]',
|
|
617
908
|
' happys stack auth <name> login [--force] [--print] [--json]',
|
|
618
|
-
' happys stack auth <name> copy-from <sourceStack> [--json]',
|
|
909
|
+
' happys stack auth <name> copy-from <sourceStack|legacy> [--force] [--with-infra] [--link] [--json]',
|
|
619
910
|
].join('\n'),
|
|
620
911
|
});
|
|
621
912
|
return;
|
|
@@ -633,6 +924,10 @@ async function main() {
|
|
|
633
924
|
await cmdCopyFrom({ argv, json });
|
|
634
925
|
return;
|
|
635
926
|
}
|
|
927
|
+
if (cmd === 'dev-key') {
|
|
928
|
+
await cmdDevKey({ argv, json });
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
636
931
|
|
|
637
932
|
throw new Error(`[auth] unknown command: ${cmd}`);
|
|
638
933
|
}
|