happy-stacks 0.1.2 → 0.2.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 +121 -83
- 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 +560 -112
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +130 -20
- package/scripts/dev.mjs +201 -133
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +43 -20
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +25 -15
- package/scripts/mobile.mjs +13 -7
- package/scripts/run.mjs +114 -27
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +15 -2
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +1792 -254
- package/scripts/stop.mjs +6 -3
- package/scripts/tailscale.mjs +17 -2
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +2 -2
- package/scripts/ui_gateway.mjs +2 -2
- package/scripts/uninstall.mjs +18 -10
- package/scripts/utils/auth_files.mjs +58 -0
- package/scripts/utils/auth_login_ux.mjs +76 -0
- package/scripts/utils/auth_sources.mjs +12 -0
- package/scripts/utils/browser.mjs +22 -0
- package/scripts/utils/canonical_home.mjs +20 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +6 -2
- package/scripts/utils/dev_auth_key.mjs +169 -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/env.mjs +60 -11
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +4 -2
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +100 -46
- package/scripts/utils/localhost_host.mjs +17 -0
- package/scripts/utils/ownership.mjs +135 -0
- package/scripts/utils/paths.mjs +5 -2
- package/scripts/utils/pm.mjs +121 -20
- package/scripts/utils/proc.mjs +29 -2
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +24 -0
- package/scripts/utils/server_port.mjs +9 -0
- package/scripts/utils/server_urls.mjs +54 -0
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stack_startup.mjs +208 -0
- package/scripts/utils/stack_stop.mjs +79 -30
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +82 -8
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/auth.mjs
CHANGED
|
@@ -1,28 +1,117 @@
|
|
|
1
1
|
import './utils/env.mjs';
|
|
2
|
-
import { parseArgs } from './utils/args.mjs';
|
|
3
|
-
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
2
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
3
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
4
4
|
import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName, resolveStackEnvPath } from './utils/paths.mjs';
|
|
5
|
+
import { listAllStackNames } from './utils/stacks.mjs';
|
|
5
6
|
import { resolvePublicServerUrl } from './tailscale.mjs';
|
|
7
|
+
import { resolveServerPortFromEnv } from './utils/server_urls.mjs';
|
|
6
8
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
9
|
import { join } from 'node:path';
|
|
8
10
|
import { homedir } from 'node:os';
|
|
9
11
|
import { spawn } from 'node:child_process';
|
|
10
|
-
import {
|
|
12
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
13
|
import { dirname } from 'node:path';
|
|
12
14
|
|
|
13
15
|
import { parseDotenv } from './utils/dotenv.mjs';
|
|
16
|
+
import { ensureDepsInstalled, pmExecBin } from './utils/pm.mjs';
|
|
17
|
+
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/happy_server_infra.mjs';
|
|
18
|
+
import { clearDevAuthKey, readDevAuthKey, writeDevAuthKey } from './utils/dev_auth_key.mjs';
|
|
19
|
+
import { getExpoStatePaths, isStateProcessRunning } from './utils/expo.mjs';
|
|
20
|
+
import { resolveAuthSeedFromEnv } from './utils/stack_startup.mjs';
|
|
21
|
+
import { printAuthLoginInstructions } from './utils/auth_login_ux.mjs';
|
|
22
|
+
import { copyFileIfMissing, linkFileIfMissing, removeFileOrSymlinkIfExists, writeSecretFileIfMissing } from './utils/auth_files.mjs';
|
|
23
|
+
import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth_sources.mjs';
|
|
24
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
|
|
25
|
+
import { resolveHandyMasterSecretFromStack } from './utils/handy_master_secret.mjs';
|
|
14
26
|
|
|
15
27
|
function getInternalServerUrl() {
|
|
16
|
-
const
|
|
17
|
-
const port = portRaw ? Number(portRaw) : 3005;
|
|
18
|
-
const n = Number.isFinite(port) ? port : 3005;
|
|
28
|
+
const n = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
|
|
19
29
|
return { port: n, url: `http://127.0.0.1:${n}` };
|
|
20
30
|
}
|
|
21
31
|
|
|
32
|
+
function resolveEnvPublicUrlForStack({ stackName }) {
|
|
33
|
+
const candidate = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
|
|
34
|
+
|
|
35
|
+
// For main, allow the user's global/public URL override (commonly a Tailscale Serve URL).
|
|
36
|
+
if (stackName === 'main') {
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// For non-main stacks, do NOT inherit a global/server URL override from ~/.happy-stacks/env.local
|
|
41
|
+
// (which often points at main). Only use a public URL override if it is explicitly present in the
|
|
42
|
+
// stack env file itself.
|
|
43
|
+
const envPath =
|
|
44
|
+
(process.env.HAPPY_STACKS_ENV_FILE ?? '').trim() ||
|
|
45
|
+
(process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim() ||
|
|
46
|
+
getStackEnvPath(stackName);
|
|
47
|
+
try {
|
|
48
|
+
if (!envPath || !existsSync(envPath)) return '';
|
|
49
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
50
|
+
const env = raw ? parseEnvToObject(raw) : {};
|
|
51
|
+
return (env.HAPPY_LOCAL_SERVER_URL ?? env.HAPPY_STACKS_SERVER_URL ?? '').toString().trim();
|
|
52
|
+
} catch {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
22
57
|
function expandTilde(p) {
|
|
23
58
|
return p.replace(/^~(?=\/)/, homedir());
|
|
24
59
|
}
|
|
25
60
|
|
|
61
|
+
function resolveEnvWebappUrlForStack({ stackName }) {
|
|
62
|
+
const candidate = (process.env.HAPPY_WEBAPP_URL ?? '').trim();
|
|
63
|
+
|
|
64
|
+
// For main, allow the user's global override.
|
|
65
|
+
if (stackName === 'main') {
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// For non-main stacks, only respect HAPPY_WEBAPP_URL if it is explicitly present in the stack env file.
|
|
70
|
+
const envPath =
|
|
71
|
+
(process.env.HAPPY_STACKS_ENV_FILE ?? '').trim() ||
|
|
72
|
+
(process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim() ||
|
|
73
|
+
getStackEnvPath(stackName);
|
|
74
|
+
try {
|
|
75
|
+
if (!envPath || !existsSync(envPath)) return '';
|
|
76
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
77
|
+
const env = raw ? parseEnvToObject(raw) : {};
|
|
78
|
+
return (env.HAPPY_WEBAPP_URL ?? '').toString().trim();
|
|
79
|
+
} catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
|
|
85
|
+
const s = String(raw ?? '')
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
88
|
+
.replace(/-+/g, '-')
|
|
89
|
+
.replace(/^-+/, '')
|
|
90
|
+
.replace(/-+$/, '');
|
|
91
|
+
return s || fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveWebappUrlFromRunningExpo({ rootDir, stackName }) {
|
|
95
|
+
try {
|
|
96
|
+
const baseDir = getStackDir(stackName);
|
|
97
|
+
const uiDir = getComponentDir(rootDir, 'happy');
|
|
98
|
+
const uiPaths = getExpoStatePaths({
|
|
99
|
+
baseDir,
|
|
100
|
+
kind: 'ui-dev',
|
|
101
|
+
projectDir: uiDir,
|
|
102
|
+
stateFileName: 'ui.state.json',
|
|
103
|
+
});
|
|
104
|
+
const uiRunning = await isStateProcessRunning(uiPaths.statePath);
|
|
105
|
+
if (!uiRunning.running) return null;
|
|
106
|
+
const port = Number(uiRunning.state?.port);
|
|
107
|
+
if (!Number.isFinite(port) || port <= 0) return null;
|
|
108
|
+
const host = stackName && stackName !== 'main' ? `happy-${sanitizeDnsLabel(stackName)}.localhost` : 'localhost';
|
|
109
|
+
return `http://${host}:${port}`;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
26
115
|
async function ensureDir(p) {
|
|
27
116
|
await mkdir(p, { recursive: true });
|
|
28
117
|
}
|
|
@@ -38,23 +127,7 @@ async function readTextIfExists(path) {
|
|
|
38
127
|
}
|
|
39
128
|
}
|
|
40
129
|
|
|
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
|
-
}
|
|
130
|
+
// (auth file copy/link helpers live in scripts/utils/auth_files.mjs)
|
|
58
131
|
|
|
59
132
|
function parseEnvToObject(raw) {
|
|
60
133
|
const parsed = parseDotenv(raw);
|
|
@@ -85,42 +158,6 @@ function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
|
85
158
|
return fromEnv || join(stackBaseDir, 'server-light');
|
|
86
159
|
}
|
|
87
160
|
|
|
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
|
-
|
|
124
161
|
function resolveCliHomeDir() {
|
|
125
162
|
const fromEnv = (process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? process.env.HAPPY_STACKS_CLI_HOME_DIR ?? '').trim();
|
|
126
163
|
if (fromEnv) {
|
|
@@ -196,9 +233,10 @@ function authLoginSuggestion(stackName) {
|
|
|
196
233
|
return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
|
|
197
234
|
}
|
|
198
235
|
|
|
199
|
-
function
|
|
236
|
+
function authCopyFromSeedSuggestion(stackName) {
|
|
200
237
|
if (stackName === 'main') return null;
|
|
201
|
-
|
|
238
|
+
const from = resolveAuthSeedFromEnv(process.env);
|
|
239
|
+
return `happys stack auth ${stackName} copy-from ${from}`;
|
|
202
240
|
}
|
|
203
241
|
|
|
204
242
|
function resolveServerComponentForCurrentStack() {
|
|
@@ -208,6 +246,70 @@ function resolveServerComponentForCurrentStack() {
|
|
|
208
246
|
);
|
|
209
247
|
}
|
|
210
248
|
|
|
249
|
+
async function cmdDevKey({ argv, json }) {
|
|
250
|
+
const { flags, kv } = parseArgs(argv);
|
|
251
|
+
const wantPrint = flags.has('--print');
|
|
252
|
+
const fmtRaw = (kv.get('--format') ?? '').trim();
|
|
253
|
+
// UX: the Happy UI restore screen expects the "backup" (XXXXX-...) format.
|
|
254
|
+
//
|
|
255
|
+
// IMPORTANT: the Happy restore screen treats any key containing '-' as "backup format",
|
|
256
|
+
// so printing a base64url key (which may contain '-') is *not reliably pasteable*.
|
|
257
|
+
// Default to backup always unless explicitly overridden.
|
|
258
|
+
const fmt = fmtRaw || 'backup'; // base64url | backup
|
|
259
|
+
const set = (kv.get('--set') ?? '').trim();
|
|
260
|
+
const clear = flags.has('--clear');
|
|
261
|
+
|
|
262
|
+
if (set) {
|
|
263
|
+
const res = await writeDevAuthKey({ env: process.env, input: set });
|
|
264
|
+
if (json) {
|
|
265
|
+
printResult({ json, data: { ok: true, action: 'set', path: res.path } });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
console.log(`[auth] dev-key saved to ${res.path}`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (clear) {
|
|
272
|
+
const res = await clearDevAuthKey({ env: process.env });
|
|
273
|
+
if (json) {
|
|
274
|
+
printResult({ json, data: { ok: res.ok, action: 'clear', ...res } });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
console.log(res.deleted ? `[auth] dev-key removed (${res.path})` : `[auth] dev-key not set (${res.path})`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const out = await readDevAuthKey({ env: process.env });
|
|
282
|
+
if (!out.ok) {
|
|
283
|
+
throw new Error(`[auth] dev-key: ${out.error ?? 'failed'}`);
|
|
284
|
+
}
|
|
285
|
+
if (!out.secretKeyBase64Url) {
|
|
286
|
+
const msg =
|
|
287
|
+
`[auth] dev-key is not configured.\n` +
|
|
288
|
+
`Set it once (local-only, not committed):\n` +
|
|
289
|
+
` happys auth dev-key --set "<base64url-secret-or-backup-format>"\n` +
|
|
290
|
+
`Or export it for this shell:\n` +
|
|
291
|
+
` export HAPPY_STACKS_DEV_AUTH_SECRET_KEY="<base64url-secret>"\n`;
|
|
292
|
+
if (json) {
|
|
293
|
+
printResult({ json, data: { ok: false, error: 'missing_dev_key', file: out.path ?? null } });
|
|
294
|
+
} else {
|
|
295
|
+
console.log(msg);
|
|
296
|
+
}
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const value = fmt === 'backup' ? out.backup : out.secretKeyBase64Url;
|
|
301
|
+
if (wantPrint) {
|
|
302
|
+
process.stdout.write(value + '\n');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (json) {
|
|
306
|
+
printResult({ json, data: { ok: true, key: value, format: fmt, source: out.source ?? null } });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
console.log(`[auth] dev-key (${fmt}) [source=${out.source ?? 'unknown'}]`);
|
|
310
|
+
console.log(value);
|
|
311
|
+
}
|
|
312
|
+
|
|
211
313
|
async function runNodeCapture({ cwd, env, args, stdin }) {
|
|
212
314
|
return await new Promise((resolvePromise, rejectPromise) => {
|
|
213
315
|
const child = spawn(process.execPath, args, {
|
|
@@ -243,10 +345,25 @@ function resolveServerComponentFromEnv(env) {
|
|
|
243
345
|
return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
244
346
|
}
|
|
245
347
|
|
|
246
|
-
function
|
|
247
|
-
const v = (env.DATABASE_URL ?? '').trim();
|
|
248
|
-
if (
|
|
249
|
-
|
|
348
|
+
function resolveDatabaseUrlForStackOrThrow({ env, stackName, baseDir, serverComponent, label }) {
|
|
349
|
+
const v = (env.DATABASE_URL ?? '').toString().trim();
|
|
350
|
+
if (v) {
|
|
351
|
+
if (serverComponent === 'happy-server') {
|
|
352
|
+
const lower = v.toLowerCase();
|
|
353
|
+
const ok = lower.startsWith('postgresql://') || lower.startsWith('postgres://');
|
|
354
|
+
if (!ok) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`[auth] invalid DATABASE_URL for ${label || `stack "${stackName}"`}: expected postgresql://... (got ${JSON.stringify(v)})`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return v;
|
|
361
|
+
}
|
|
362
|
+
if (serverComponent === 'happy-server-light') {
|
|
363
|
+
const dataDir = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').toString().trim() || join(baseDir, 'server-light');
|
|
364
|
+
return `file:${join(dataDir, 'happy-server-light.sqlite')}`;
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`[auth] missing DATABASE_URL for ${label || `stack "${stackName}"`}`);
|
|
250
367
|
}
|
|
251
368
|
|
|
252
369
|
function resolveServerComponentDir({ rootDir, serverComponent }) {
|
|
@@ -261,6 +378,7 @@ async function seedAccountsFromSourceDbToTargetDb({
|
|
|
261
378
|
targetStackName,
|
|
262
379
|
targetServerComponent,
|
|
263
380
|
targetDatabaseUrl,
|
|
381
|
+
force = false,
|
|
264
382
|
}) {
|
|
265
383
|
const sourceCwd = resolveServerComponentDir({ rootDir, serverComponent: fromServerComponent });
|
|
266
384
|
const targetCwd = resolveServerComponentDir({ rootDir, serverComponent: targetServerComponent });
|
|
@@ -295,6 +413,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
295
413
|
});
|
|
296
414
|
import { PrismaClient } from '@prisma/client';
|
|
297
415
|
import fs from 'node:fs';
|
|
416
|
+
const FORCE = ${force ? 'true' : 'false'};
|
|
298
417
|
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
299
418
|
const accounts = raw ? JSON.parse(raw) : [];
|
|
300
419
|
const db = new PrismaClient();
|
|
@@ -308,6 +427,29 @@ try {
|
|
|
308
427
|
} catch (e) {
|
|
309
428
|
// Prisma unique constraint violation
|
|
310
429
|
if (e && typeof e === 'object' && 'code' in e && e.code === 'P2002') {
|
|
430
|
+
// Two common cases:
|
|
431
|
+
// - id already exists (fine)
|
|
432
|
+
// - publicKey already exists on a different id (auth mismatch -> machine FK failures later)
|
|
433
|
+
//
|
|
434
|
+
// For --force, we try to delete the conflicting row by publicKey and then retry insert.
|
|
435
|
+
// Without --force, fail-closed with a helpful error so users don't end up with "seeded" but broken stacks.
|
|
436
|
+
try {
|
|
437
|
+
const existing = await db.account.findUnique({ where: { publicKey: a.publicKey }, select: { id: true } });
|
|
438
|
+
if (existing?.id && existing.id !== a.id) {
|
|
439
|
+
if (!FORCE) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
\`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.\`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
// Best-effort delete; will fail if other rows reference this account (then we fail closed).
|
|
445
|
+
await db.account.delete({ where: { publicKey: a.publicKey } });
|
|
446
|
+
await db.account.create({ data: { id: a.id, publicKey: a.publicKey } });
|
|
447
|
+
insertedCount += 1;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
} catch (inner) {
|
|
451
|
+
throw inner;
|
|
452
|
+
}
|
|
311
453
|
continue;
|
|
312
454
|
}
|
|
313
455
|
throw e;
|
|
@@ -346,17 +488,144 @@ try {
|
|
|
346
488
|
async function cmdCopyFrom({ argv, json }) {
|
|
347
489
|
const rootDir = getRootDir(import.meta.url);
|
|
348
490
|
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
491
|
|
|
353
492
|
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
354
493
|
const fromStackName = (positionals[1] ?? '').trim();
|
|
355
494
|
if (!fromStackName) {
|
|
356
|
-
throw new Error(
|
|
495
|
+
throw new Error(
|
|
496
|
+
'[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' +
|
|
497
|
+
'notes:\n' +
|
|
498
|
+
' - sourceStack can be a stack name (e.g. main, dev-auth)\n' +
|
|
499
|
+
' - legacy uses ~/.happy/{cli,server-light} as a source (best-effort)'
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const { flags, kv } = parseArgs(argv);
|
|
504
|
+
const all = flags.has('--all');
|
|
505
|
+
if (isLegacyAuthSourceName(fromStackName) && isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
'[auth] legacy auth source is disabled in sandbox mode.\n' +
|
|
508
|
+
'Reason: it reads from ~/.happy (global user state).\n' +
|
|
509
|
+
'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const force =
|
|
513
|
+
flags.has('--force') ||
|
|
514
|
+
flags.has('--overwrite') ||
|
|
515
|
+
(kv.get('--force') ?? '').trim() === '1' ||
|
|
516
|
+
(kv.get('--overwrite') ?? '').trim() === '1';
|
|
517
|
+
const withInfra =
|
|
518
|
+
flags.has('--with-infra') ||
|
|
519
|
+
flags.has('--ensure-infra') ||
|
|
520
|
+
flags.has('--infra') ||
|
|
521
|
+
(kv.get('--with-infra') ?? '').trim() === '1' ||
|
|
522
|
+
(kv.get('--ensure-infra') ?? '').trim() === '1';
|
|
523
|
+
const linkMode =
|
|
524
|
+
flags.has('--link') ||
|
|
525
|
+
flags.has('--symlink') ||
|
|
526
|
+
flags.has('--link-auth') ||
|
|
527
|
+
(kv.get('--link') ?? '').trim() === '1' ||
|
|
528
|
+
(kv.get('--symlink') ?? '').trim() === '1' ||
|
|
529
|
+
(kv.get('--auth-mode') ?? '').trim() === 'link' ||
|
|
530
|
+
(process.env.HAPPY_STACKS_AUTH_LINK ?? process.env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
|
|
531
|
+
(process.env.HAPPY_STACKS_AUTH_MODE ?? process.env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
|
|
532
|
+
const allowMain = flags.has('--allow-main') || flags.has('--main-ok') || (kv.get('--allow-main') ?? '').trim() === '1';
|
|
533
|
+
const exceptRaw = (kv.get('--except') ?? '').trim();
|
|
534
|
+
const except = new Set(exceptRaw.split(',').map((s) => s.trim()).filter(Boolean));
|
|
535
|
+
|
|
536
|
+
if (all) {
|
|
537
|
+
// Global bulk operation (no stack context required).
|
|
538
|
+
const stacks = await listAllStackNames();
|
|
539
|
+
const results = [];
|
|
540
|
+
const totalTargets = stacks.filter((s) => !except.has(s) && s !== fromStackName).length;
|
|
541
|
+
let idx = 0;
|
|
542
|
+
const progress = (line) => {
|
|
543
|
+
// In JSON mode, never pollute stdout (reserved for final JSON).
|
|
544
|
+
// eslint-disable-next-line no-console
|
|
545
|
+
(json ? console.error : console.log)(line);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
progress(
|
|
549
|
+
`[auth] copy-from --all: from=${fromStackName}${except.size ? ` (except=${[...except].join(',')})` : ''}${force ? ' (force)' : ''}${withInfra ? ' (with-infra)' : ''}`
|
|
550
|
+
);
|
|
551
|
+
for (const target of stacks) {
|
|
552
|
+
if (except.has(target)) {
|
|
553
|
+
progress(`- ↪ ${target}: skipped (excluded)`);
|
|
554
|
+
results.push({ stackName: target, ok: true, skipped: true, reason: 'excluded' });
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (target === fromStackName) {
|
|
558
|
+
progress(`- ↪ ${target}: skipped (source_stack)`);
|
|
559
|
+
results.push({ stackName: target, ok: true, skipped: true, reason: 'source_stack' });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
idx += 1;
|
|
564
|
+
progress(`[auth] [${idx}/${totalTargets}] seeding stack "${target}"...`);
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
const out = await runNodeCapture({
|
|
568
|
+
cwd: rootDir,
|
|
569
|
+
env: process.env,
|
|
570
|
+
args: [
|
|
571
|
+
join(rootDir, 'scripts', 'stack.mjs'),
|
|
572
|
+
'auth',
|
|
573
|
+
target,
|
|
574
|
+
'--',
|
|
575
|
+
'copy-from',
|
|
576
|
+
fromStackName,
|
|
577
|
+
'--json',
|
|
578
|
+
...(force ? ['--force'] : []),
|
|
579
|
+
...(withInfra ? ['--with-infra'] : []),
|
|
580
|
+
...(linkMode ? ['--link'] : []),
|
|
581
|
+
],
|
|
582
|
+
});
|
|
583
|
+
const parsed = out.stdout.trim() ? JSON.parse(out.stdout.trim()) : null;
|
|
584
|
+
|
|
585
|
+
const copied = parsed?.copied && typeof parsed.copied === 'object' ? parsed.copied : null;
|
|
586
|
+
const db = copied?.dbAccounts ? `db=${copied.dbAccounts.insertedCount}/${copied.dbAccounts.sourceCount}` : copied?.dbError ? `db=skipped` : `db=unknown`;
|
|
587
|
+
const secret = copied?.secret ? 'secret' : null;
|
|
588
|
+
const cli = copied?.accessKey || copied?.settings ? 'cli' : null;
|
|
589
|
+
const any = copied?.secret || copied?.accessKey || copied?.settings || copied?.db;
|
|
590
|
+
const summary = any ? `seeded (${[db, secret, cli].filter(Boolean).join(', ')})` : `noop (already has auth)`;
|
|
591
|
+
progress(`- ✅ ${target}: ${summary}`);
|
|
592
|
+
if (copied?.dbError) {
|
|
593
|
+
progress(` - db seed skipped: ${copied.dbError}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
results.push({ stackName: target, ok: true, skipped: false, fromStackName, out: parsed });
|
|
597
|
+
} catch (e) {
|
|
598
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
599
|
+
progress(`- ❌ ${target}: failed`);
|
|
600
|
+
progress(` - ${msg}`);
|
|
601
|
+
results.push({ stackName: target, ok: false, skipped: false, fromStackName, error: msg });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const ok = results.every((r) => r.ok);
|
|
606
|
+
if (json) {
|
|
607
|
+
printResult({ json, data: { ok, fromStackName, results } });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// (we already streamed progress above)
|
|
611
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
612
|
+
const skipped = results.filter((r) => r.ok && r.skipped).length;
|
|
613
|
+
const seeded = results.filter((r) => r.ok && !r.skipped).length;
|
|
614
|
+
// eslint-disable-next-line no-console
|
|
615
|
+
console.log(`[auth] done: ok=${ok ? 'true' : 'false'} seeded=${seeded} skipped=${skipped} failed=${failed}`);
|
|
616
|
+
if (!ok) process.exit(1);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (stackName === 'main' && !allowMain) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
'[auth] copy-from is intended for stack-scoped usage (e.g. happys stack auth <name> copy-from main), or pass --all.\n' +
|
|
623
|
+
'If you really intend to seed the main Happy Stacks install, re-run with: --allow-main'
|
|
624
|
+
);
|
|
357
625
|
}
|
|
358
626
|
|
|
359
627
|
const serverComponent = resolveServerComponentForCurrentStack();
|
|
628
|
+
const serverDirForPrisma = resolveServerComponentDir({ rootDir, serverComponent });
|
|
360
629
|
const targetBaseDir = getDefaultAutostartPaths().baseDir;
|
|
361
630
|
const targetCli = resolveCliHomeDir();
|
|
362
631
|
const targetServerLightDataDir =
|
|
@@ -364,7 +633,20 @@ async function cmdCopyFrom({ argv, json }) {
|
|
|
364
633
|
const targetSecretFile =
|
|
365
634
|
(process.env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(targetBaseDir, 'happy-server', 'handy-master-secret.txt');
|
|
366
635
|
|
|
367
|
-
const
|
|
636
|
+
const isLegacySource = isLegacyAuthSourceName(fromStackName);
|
|
637
|
+
if (isLegacySource && isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
'[auth] legacy auth source is disabled in sandbox mode.\n' +
|
|
640
|
+
'Reason: it reads from ~/.happy (global user state).\n' +
|
|
641
|
+
'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const { secret, source } = await resolveHandyMasterSecretFromStack({
|
|
645
|
+
stackName: fromStackName,
|
|
646
|
+
requireStackExists: !isLegacySource,
|
|
647
|
+
allowLegacyAuthSource: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
|
|
648
|
+
allowLegacyMainFallback: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
|
|
649
|
+
});
|
|
368
650
|
|
|
369
651
|
const copied = {
|
|
370
652
|
secret: false,
|
|
@@ -379,49 +661,150 @@ async function cmdCopyFrom({ argv, json }) {
|
|
|
379
661
|
|
|
380
662
|
if (secret) {
|
|
381
663
|
if (serverComponent === 'happy-server-light') {
|
|
382
|
-
|
|
664
|
+
const target = join(targetServerLightDataDir, 'handy-master-secret.txt');
|
|
665
|
+
const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
|
|
666
|
+
if (linkMode && sourcePath && existsSync(sourcePath)) {
|
|
667
|
+
copied.secret = await linkFileIfMissing({ from: sourcePath, to: target, force });
|
|
668
|
+
} else {
|
|
669
|
+
copied.secret = await writeSecretFileIfMissing({ path: target, secret, force });
|
|
670
|
+
}
|
|
383
671
|
} else if (serverComponent === 'happy-server') {
|
|
384
|
-
|
|
672
|
+
const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
|
|
673
|
+
if (linkMode && sourcePath && existsSync(sourcePath)) {
|
|
674
|
+
copied.secret = await linkFileIfMissing({ from: sourcePath, to: targetSecretFile, force });
|
|
675
|
+
} else {
|
|
676
|
+
copied.secret = await writeSecretFileIfMissing({ path: targetSecretFile, secret, force });
|
|
677
|
+
}
|
|
385
678
|
}
|
|
386
679
|
}
|
|
387
680
|
|
|
388
|
-
const sourceBaseDir = getStackDir(fromStackName);
|
|
389
|
-
const sourceEnvRaw = await readTextIfExists(getStackEnvPath(fromStackName));
|
|
681
|
+
const sourceBaseDir = isLegacySource ? getLegacyHappyBaseDir() : getStackDir(fromStackName);
|
|
682
|
+
const sourceEnvRaw = isLegacySource ? '' : await readTextIfExists(getStackEnvPath(fromStackName));
|
|
390
683
|
const sourceEnv = sourceEnvRaw ? parseEnvToObject(sourceEnvRaw) : {};
|
|
391
|
-
const sourceCli =
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
684
|
+
const sourceCli = isLegacySource
|
|
685
|
+
? join(sourceBaseDir, 'cli')
|
|
686
|
+
: getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
|
|
687
|
+
|
|
688
|
+
if (linkMode) {
|
|
689
|
+
copied.accessKey = await linkFileIfMissing({ from: join(sourceCli, 'access.key'), to: join(targetCli, 'access.key'), force });
|
|
690
|
+
copied.settings = await linkFileIfMissing({ from: join(sourceCli, 'settings.json'), to: join(targetCli, 'settings.json'), force });
|
|
691
|
+
} else {
|
|
692
|
+
copied.accessKey = await copyFileIfMissing({
|
|
693
|
+
from: join(sourceCli, 'access.key'),
|
|
694
|
+
to: join(targetCli, 'access.key'),
|
|
695
|
+
mode: 0o600,
|
|
696
|
+
force,
|
|
697
|
+
});
|
|
698
|
+
copied.settings = await copyFileIfMissing({
|
|
699
|
+
from: join(sourceCli, 'settings.json'),
|
|
700
|
+
to: join(targetCli, 'settings.json'),
|
|
701
|
+
mode: 0o600,
|
|
702
|
+
force,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
403
705
|
|
|
404
706
|
// Best-effort DB seeding: copy Account rows from source stack DB to target stack DB.
|
|
405
707
|
// This avoids FK failures (e.g., Prisma P2003) when the target DB is fresh but the copied token
|
|
406
708
|
// refers to an account ID that does not exist there yet.
|
|
407
709
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
710
|
+
// Ensure prisma is runnable (best-effort). If deps aren't installed, we'll fall back to skipping DB seeding.
|
|
711
|
+
// IMPORTANT: when running with --json, keep stdout clean (no yarn/prisma chatter).
|
|
712
|
+
await ensureDepsInstalled(serverDirForPrisma, serverComponent, { quiet: json }).catch(() => {});
|
|
713
|
+
|
|
714
|
+
const fromServerComponent = isLegacySource ? 'happy-server-light' : resolveServerComponentFromEnv(sourceEnv);
|
|
715
|
+
const fromDatabaseUrl = resolveDatabaseUrlForStackOrThrow({
|
|
716
|
+
env: sourceEnv,
|
|
717
|
+
stackName: fromStackName,
|
|
718
|
+
baseDir: sourceBaseDir,
|
|
719
|
+
serverComponent: fromServerComponent,
|
|
720
|
+
label: `source stack "${fromStackName}"`,
|
|
721
|
+
});
|
|
410
722
|
const targetEnv = process.env;
|
|
411
723
|
const targetServerComponent = resolveServerComponentFromEnv(targetEnv);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
724
|
+
let targetDatabaseUrl;
|
|
725
|
+
try {
|
|
726
|
+
targetDatabaseUrl = resolveDatabaseUrlForStackOrThrow({
|
|
727
|
+
env: targetEnv,
|
|
728
|
+
stackName,
|
|
729
|
+
baseDir: targetBaseDir,
|
|
730
|
+
serverComponent: targetServerComponent,
|
|
731
|
+
label: `target stack "${stackName}"`,
|
|
732
|
+
});
|
|
733
|
+
} catch (e) {
|
|
734
|
+
// For full server stacks, allow `copy-from --with-infra` to bring up Docker infra just-in-time
|
|
735
|
+
// so we can seed DB accounts reliably.
|
|
736
|
+
const managed = (targetEnv.HAPPY_STACKS_MANAGED_INFRA ?? targetEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0';
|
|
737
|
+
if (targetServerComponent === 'happy-server' && withInfra && managed) {
|
|
738
|
+
const { port } = getInternalServerUrl();
|
|
739
|
+
const publicServerUrl = `http://localhost:${port}`;
|
|
740
|
+
const envPath = getStackEnvPath(stackName);
|
|
741
|
+
const infra = await ensureHappyServerManagedInfra({
|
|
742
|
+
stackName,
|
|
743
|
+
baseDir: targetBaseDir,
|
|
744
|
+
serverPort: port,
|
|
745
|
+
publicServerUrl,
|
|
746
|
+
envPath,
|
|
747
|
+
env: targetEnv,
|
|
748
|
+
quiet: json,
|
|
749
|
+
// Auth seeding only needs Postgres; don't block on Minio bucket init.
|
|
750
|
+
skipMinioInit: true,
|
|
751
|
+
});
|
|
752
|
+
targetDatabaseUrl = infra?.env?.DATABASE_URL ?? '';
|
|
753
|
+
} else {
|
|
754
|
+
throw e;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (!targetDatabaseUrl) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`[auth] missing DATABASE_URL for target stack "${stackName}". ` +
|
|
760
|
+
(targetServerComponent === 'happy-server' ? `If this is a managed infra stack, re-run with --with-infra.` : '')
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const runSeed = async () => {
|
|
765
|
+
const seeded = await seedAccountsFromSourceDbToTargetDb({
|
|
766
|
+
rootDir,
|
|
767
|
+
fromStackName,
|
|
768
|
+
fromServerComponent,
|
|
769
|
+
fromDatabaseUrl,
|
|
770
|
+
targetStackName: stackName,
|
|
771
|
+
targetServerComponent,
|
|
772
|
+
targetDatabaseUrl,
|
|
773
|
+
force,
|
|
774
|
+
});
|
|
775
|
+
copied.dbAccounts = { sourceCount: seeded.sourceCount, insertedCount: seeded.insertedCount };
|
|
776
|
+
copied.db = true;
|
|
777
|
+
copied.dbError = null;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
await runSeed();
|
|
782
|
+
} catch (e) {
|
|
783
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
784
|
+
// If the target DB exists but hasn't had schema applied yet, Prisma will report missing tables.
|
|
785
|
+
// Fix it best-effort by applying schema, then retry seeding once.
|
|
786
|
+
const looksLikeMissingTable = msg.toLowerCase().includes('does not exist') || msg.toLowerCase().includes('no such table');
|
|
787
|
+
if (looksLikeMissingTable) {
|
|
788
|
+
if (serverComponent === 'happy-server-light') {
|
|
789
|
+
await pmExecBin({
|
|
790
|
+
dir: serverDirForPrisma,
|
|
791
|
+
bin: 'prisma',
|
|
792
|
+
args: ['db', 'push'],
|
|
793
|
+
env: { ...process.env, DATABASE_URL: targetDatabaseUrl },
|
|
794
|
+
quiet: json,
|
|
795
|
+
}).catch(() => {});
|
|
796
|
+
} else if (serverComponent === 'happy-server') {
|
|
797
|
+
await applyHappyServerMigrations({
|
|
798
|
+
serverDir: serverDirForPrisma,
|
|
799
|
+
env: { ...process.env, DATABASE_URL: targetDatabaseUrl },
|
|
800
|
+
quiet: json,
|
|
801
|
+
}).catch(() => {});
|
|
802
|
+
}
|
|
803
|
+
await runSeed();
|
|
804
|
+
} else {
|
|
805
|
+
throw e;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
425
808
|
} catch (err) {
|
|
426
809
|
copied.db = false;
|
|
427
810
|
copied.dbAccounts = null;
|
|
@@ -457,7 +840,7 @@ async function cmdStatus({ json }) {
|
|
|
457
840
|
|
|
458
841
|
const { port, url: internalServerUrl } = getInternalServerUrl();
|
|
459
842
|
const defaultPublicUrl = `http://localhost:${port}`;
|
|
460
|
-
const envPublicUrl = (
|
|
843
|
+
const envPublicUrl = resolveEnvPublicUrlForStack({ stackName });
|
|
461
844
|
const { publicServerUrl } = await resolvePublicServerUrl({
|
|
462
845
|
internalServerUrl,
|
|
463
846
|
defaultPublicUrl,
|
|
@@ -519,9 +902,9 @@ async function cmdStatus({ json }) {
|
|
|
519
902
|
console.log(authLine);
|
|
520
903
|
if (!auth.ok) {
|
|
521
904
|
console.log(` ↪ run: ${authLoginSuggestion(stackName)}`);
|
|
522
|
-
const
|
|
523
|
-
if (
|
|
524
|
-
console.log(` ↪ or (recommended if
|
|
905
|
+
const copyFromSeed = authCopyFromSeedSuggestion(stackName);
|
|
906
|
+
if (copyFromSeed) {
|
|
907
|
+
console.log(` ↪ or (recommended if your seed stack is already logged in): ${copyFromSeed}`);
|
|
525
908
|
}
|
|
526
909
|
}
|
|
527
910
|
console.log(daemonLine);
|
|
@@ -539,22 +922,32 @@ async function cmdStatus({ json }) {
|
|
|
539
922
|
async function cmdLogin({ argv, json }) {
|
|
540
923
|
const rootDir = getRootDir(import.meta.url);
|
|
541
924
|
const stackName = getStackName();
|
|
925
|
+
const { kv } = parseArgs(argv);
|
|
542
926
|
|
|
543
927
|
const { port, url: internalServerUrl } = getInternalServerUrl();
|
|
544
928
|
const defaultPublicUrl = `http://localhost:${port}`;
|
|
545
|
-
const envPublicUrl = (
|
|
929
|
+
const envPublicUrl = resolveEnvPublicUrlForStack({ stackName });
|
|
546
930
|
const { publicServerUrl } = await resolvePublicServerUrl({
|
|
547
931
|
internalServerUrl,
|
|
548
932
|
defaultPublicUrl,
|
|
549
933
|
envPublicUrl,
|
|
550
934
|
allowEnable: false,
|
|
551
935
|
});
|
|
936
|
+
const envWebappUrl = resolveEnvWebappUrlForStack({ stackName });
|
|
937
|
+
const expoWebappUrl = await resolveWebappUrlFromRunningExpo({ rootDir, stackName });
|
|
938
|
+
const webappUrl = envWebappUrl || expoWebappUrl || publicServerUrl;
|
|
939
|
+
const webappUrlSource = expoWebappUrl ? 'expo' : envWebappUrl ? 'stack env override' : 'server';
|
|
552
940
|
|
|
553
941
|
const cliHomeDir = resolveCliHomeDir();
|
|
554
942
|
const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
|
|
555
943
|
|
|
556
944
|
const force = !argv.includes('--no-force');
|
|
557
945
|
const wantPrint = argv.includes('--print');
|
|
946
|
+
const contextRaw =
|
|
947
|
+
(kv.get('--context') ?? process.env.HAPPY_STACKS_AUTH_LOGIN_CONTEXT ?? process.env.HAPPY_LOCAL_AUTH_LOGIN_CONTEXT ?? '')
|
|
948
|
+
.toString()
|
|
949
|
+
.trim();
|
|
950
|
+
const context = contextRaw || (stackName === 'main' ? 'generic' : 'stack');
|
|
558
951
|
|
|
559
952
|
const nodeArgs = [cliBin, 'auth', 'login'];
|
|
560
953
|
if (force || argv.includes('--force')) {
|
|
@@ -565,11 +958,11 @@ async function cmdLogin({ argv, json }) {
|
|
|
565
958
|
...process.env,
|
|
566
959
|
HAPPY_HOME_DIR: cliHomeDir,
|
|
567
960
|
HAPPY_SERVER_URL: internalServerUrl,
|
|
568
|
-
HAPPY_WEBAPP_URL:
|
|
961
|
+
HAPPY_WEBAPP_URL: webappUrl,
|
|
569
962
|
};
|
|
570
963
|
|
|
571
964
|
if (wantPrint) {
|
|
572
|
-
const cmd = `HAPPY_HOME_DIR="${cliHomeDir}" HAPPY_SERVER_URL="${internalServerUrl}" HAPPY_WEBAPP_URL="${
|
|
965
|
+
const cmd = `HAPPY_HOME_DIR="${cliHomeDir}" HAPPY_SERVER_URL="${internalServerUrl}" HAPPY_WEBAPP_URL="${webappUrl}" node "${cliBin}" auth login${nodeArgs.includes('--force') ? ' --force' : ''}`;
|
|
573
966
|
if (json) {
|
|
574
967
|
printResult({ json, data: { ok: true, stackName, cmd } });
|
|
575
968
|
} else {
|
|
@@ -579,8 +972,15 @@ async function cmdLogin({ argv, json }) {
|
|
|
579
972
|
}
|
|
580
973
|
|
|
581
974
|
if (!json) {
|
|
582
|
-
|
|
583
|
-
|
|
975
|
+
printAuthLoginInstructions({
|
|
976
|
+
stackName,
|
|
977
|
+
context,
|
|
978
|
+
webappUrl,
|
|
979
|
+
webappUrlSource,
|
|
980
|
+
internalServerUrl,
|
|
981
|
+
publicServerUrl,
|
|
982
|
+
rerunCmd: authLoginSuggestion(stackName),
|
|
983
|
+
});
|
|
584
984
|
}
|
|
585
985
|
|
|
586
986
|
const child = spawn(process.execPath, nodeArgs, {
|
|
@@ -589,7 +989,45 @@ async function cmdLogin({ argv, json }) {
|
|
|
589
989
|
stdio: 'inherit',
|
|
590
990
|
});
|
|
591
991
|
|
|
992
|
+
const timeoutMsRaw =
|
|
993
|
+
(process.env.HAPPY_STACKS_AUTH_LOGIN_TIMEOUT_MS ?? process.env.HAPPY_LOCAL_AUTH_LOGIN_TIMEOUT_MS ?? '600000').toString().trim();
|
|
994
|
+
const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 600000;
|
|
995
|
+
const hasTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
996
|
+
|
|
997
|
+
let exiting = false;
|
|
998
|
+
const killChild = (signal) => {
|
|
999
|
+
if (exiting) return;
|
|
1000
|
+
exiting = true;
|
|
1001
|
+
try {
|
|
1002
|
+
child.kill(signal);
|
|
1003
|
+
} catch {
|
|
1004
|
+
// ignore
|
|
1005
|
+
}
|
|
1006
|
+
setTimeout(() => {
|
|
1007
|
+
try {
|
|
1008
|
+
if (child.pid) process.kill(child.pid, 'SIGKILL');
|
|
1009
|
+
} catch {
|
|
1010
|
+
// ignore
|
|
1011
|
+
}
|
|
1012
|
+
}, 1500).unref?.();
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const onSigint = () => killChild('SIGINT');
|
|
1016
|
+
const onSigterm = () => killChild('SIGTERM');
|
|
1017
|
+
process.on('SIGINT', onSigint);
|
|
1018
|
+
process.on('SIGTERM', onSigterm);
|
|
1019
|
+
|
|
1020
|
+
const t = hasTimeout
|
|
1021
|
+
? setTimeout(() => {
|
|
1022
|
+
console.warn(`[auth] login timed out after ${timeoutMs}ms (set HAPPY_STACKS_AUTH_LOGIN_TIMEOUT_MS=0 to disable)`);
|
|
1023
|
+
killChild('SIGTERM');
|
|
1024
|
+
}, timeoutMs)
|
|
1025
|
+
: null;
|
|
1026
|
+
|
|
592
1027
|
await new Promise((resolve) => child.on('exit', resolve));
|
|
1028
|
+
process.off('SIGINT', onSigint);
|
|
1029
|
+
process.off('SIGTERM', onSigterm);
|
|
1030
|
+
if (t) clearTimeout(t);
|
|
593
1031
|
if (json) {
|
|
594
1032
|
printResult({ json, data: { ok: child.exitCode === 0, exitCode: child.exitCode } });
|
|
595
1033
|
} else if (child.exitCode && child.exitCode !== 0) {
|
|
@@ -606,16 +1044,22 @@ async function main() {
|
|
|
606
1044
|
if (wantsHelp(argv, { flags }) || cmd === 'help') {
|
|
607
1045
|
printResult({
|
|
608
1046
|
json,
|
|
609
|
-
data: { commands: ['status', 'login', 'copy-from'], stackScoped: 'happys stack auth <name> status|login|copy-from' },
|
|
1047
|
+
data: { commands: ['status', 'login', 'copy-from', 'dev-key'], stackScoped: 'happys stack auth <name> status|login|copy-from' },
|
|
610
1048
|
text: [
|
|
611
1049
|
'[auth] usage:',
|
|
612
1050
|
' happys auth status [--json]',
|
|
613
1051
|
' happys auth login [--force] [--print] [--json]',
|
|
1052
|
+
' happys auth copy-from <sourceStack|legacy> --all [--except=main,dev-auth] [--force] [--with-infra] [--link] [--json]',
|
|
1053
|
+
' happys auth dev-key [--print] [--format=base64url|backup] [--set=<base64url>] [--clear] [--json]',
|
|
1054
|
+
'',
|
|
1055
|
+
'advanced:',
|
|
1056
|
+
' happys auth login --context=selfhost|dev|stack # UX labels only',
|
|
1057
|
+
' happys auth copy-from legacy --allow-main [--link] [--force] # reuse (symlink) or copy ~/.happy creds into main happy-stacks install',
|
|
614
1058
|
'',
|
|
615
1059
|
'stack-scoped:',
|
|
616
1060
|
' happys stack auth <name> status [--json]',
|
|
617
1061
|
' happys stack auth <name> login [--force] [--print] [--json]',
|
|
618
|
-
' happys stack auth <name> copy-from <sourceStack> [--json]',
|
|
1062
|
+
' happys stack auth <name> copy-from <sourceStack|legacy> [--force] [--with-infra] [--link] [--json]',
|
|
619
1063
|
].join('\n'),
|
|
620
1064
|
});
|
|
621
1065
|
return;
|
|
@@ -633,6 +1077,10 @@ async function main() {
|
|
|
633
1077
|
await cmdCopyFrom({ argv, json });
|
|
634
1078
|
return;
|
|
635
1079
|
}
|
|
1080
|
+
if (cmd === 'dev-key') {
|
|
1081
|
+
await cmdDevKey({ argv, json });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
636
1084
|
|
|
637
1085
|
throw new Error(`[auth] unknown command: ${cmd}`);
|
|
638
1086
|
}
|