happy-stacks 0.1.0 → 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 +130 -74
- package/bin/happys.mjs +140 -9
- 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/server-flavors.md +61 -2
- package/docs/stacks.md +55 -4
- package/extras/swiftbar/auth-login.sh +10 -7
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +175 -83
- package/extras/swiftbar/happys-term.sh +128 -0
- package/extras/swiftbar/happys.sh +35 -0
- 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 +279 -132
- package/extras/swiftbar/lib/system.sh +64 -10
- package/extras/swiftbar/lib/utils.sh +469 -10
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +4 -14
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +19 -10
- package/extras/swiftbar/wt-pr.sh +10 -3
- package/package.json +2 -1
- package/scripts/auth.mjs +833 -14
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +200 -23
- package/scripts/dev.mjs +230 -57
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +275 -46
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +302 -0
- package/scripts/mobile.mjs +59 -21
- package/scripts/run.mjs +222 -43
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +190 -38
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +2273 -92
- package/scripts/stop.mjs +160 -0
- package/scripts/tailscale.mjs +164 -23
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +21 -13
- 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} +71 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +13 -1
- 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 +94 -23
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +96 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +484 -0
- 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 +132 -22
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +75 -7
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +61 -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 +255 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +135 -15
- /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,24 +1,163 @@
|
|
|
1
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 } from './utils/paths.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.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';
|
|
12
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
|
|
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';
|
|
10
26
|
|
|
11
27
|
function getInternalServerUrl() {
|
|
12
|
-
const
|
|
13
|
-
const port = portRaw ? Number(portRaw) : 3005;
|
|
14
|
-
const n = Number.isFinite(port) ? port : 3005;
|
|
28
|
+
const n = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
|
|
15
29
|
return { port: n, url: `http://127.0.0.1:${n}` };
|
|
16
30
|
}
|
|
17
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
|
+
|
|
18
57
|
function expandTilde(p) {
|
|
19
58
|
return p.replace(/^~(?=\/)/, homedir());
|
|
20
59
|
}
|
|
21
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
|
+
|
|
115
|
+
async function ensureDir(p) {
|
|
116
|
+
await mkdir(p, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readTextIfExists(path) {
|
|
120
|
+
try {
|
|
121
|
+
if (!existsSync(path)) return null;
|
|
122
|
+
const raw = await readFile(path, 'utf-8');
|
|
123
|
+
const t = raw.trim();
|
|
124
|
+
return t ? t : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// (auth file copy/link helpers live in scripts/utils/auth_files.mjs)
|
|
131
|
+
|
|
132
|
+
function parseEnvToObject(raw) {
|
|
133
|
+
const parsed = parseDotenv(raw);
|
|
134
|
+
return Object.fromEntries(parsed.entries());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getStackDir(stackName) {
|
|
138
|
+
return resolveStackEnvPath(stackName).baseDir;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getStackEnvPath(stackName) {
|
|
142
|
+
return resolveStackEnvPath(stackName).envPath;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stackExistsSync(stackName) {
|
|
146
|
+
if (stackName === 'main') return true;
|
|
147
|
+
const envPath = getStackEnvPath(stackName);
|
|
148
|
+
return existsSync(envPath);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
152
|
+
const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
|
|
153
|
+
return fromEnv || join(stackBaseDir, 'cli');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
157
|
+
const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
|
|
158
|
+
return fromEnv || join(stackBaseDir, 'server-light');
|
|
159
|
+
}
|
|
160
|
+
|
|
22
161
|
function resolveCliHomeDir() {
|
|
23
162
|
const fromEnv = (process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? process.env.HAPPY_STACKS_CLI_HOME_DIR ?? '').trim();
|
|
24
163
|
if (fromEnv) {
|
|
@@ -94,13 +233,614 @@ function authLoginSuggestion(stackName) {
|
|
|
94
233
|
return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
|
|
95
234
|
}
|
|
96
235
|
|
|
236
|
+
function authCopyFromSeedSuggestion(stackName) {
|
|
237
|
+
if (stackName === 'main') return null;
|
|
238
|
+
const from = resolveAuthSeedFromEnv(process.env);
|
|
239
|
+
return `happys stack auth ${stackName} copy-from ${from}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function resolveServerComponentForCurrentStack() {
|
|
243
|
+
return (
|
|
244
|
+
(process.env.HAPPY_STACKS_SERVER_COMPONENT ?? process.env.HAPPY_LOCAL_SERVER_COMPONENT ?? 'happy-server-light').trim() ||
|
|
245
|
+
'happy-server-light'
|
|
246
|
+
);
|
|
247
|
+
}
|
|
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
|
+
|
|
313
|
+
async function runNodeCapture({ cwd, env, args, stdin }) {
|
|
314
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
315
|
+
const child = spawn(process.execPath, args, {
|
|
316
|
+
cwd,
|
|
317
|
+
env,
|
|
318
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
319
|
+
});
|
|
320
|
+
let stdout = '';
|
|
321
|
+
let stderr = '';
|
|
322
|
+
child.stdout.on('data', (d) => {
|
|
323
|
+
stdout += String(d);
|
|
324
|
+
});
|
|
325
|
+
child.stderr.on('data', (d) => {
|
|
326
|
+
stderr += String(d);
|
|
327
|
+
});
|
|
328
|
+
child.on('error', (err) => rejectPromise(err));
|
|
329
|
+
child.on('close', (code) => {
|
|
330
|
+
if (code === 0) {
|
|
331
|
+
resolvePromise({ stdout, stderr });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
rejectPromise(new Error(`node exited with ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
|
|
335
|
+
});
|
|
336
|
+
if (stdin != null) {
|
|
337
|
+
child.stdin.write(String(stdin));
|
|
338
|
+
}
|
|
339
|
+
child.stdin.end();
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function resolveServerComponentFromEnv(env) {
|
|
344
|
+
const v = (env.HAPPY_STACKS_SERVER_COMPONENT ?? env.HAPPY_LOCAL_SERVER_COMPONENT ?? 'happy-server-light').trim() || 'happy-server-light';
|
|
345
|
+
return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
|
|
346
|
+
}
|
|
347
|
+
|
|
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}"`}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function resolveServerComponentDir({ rootDir, serverComponent }) {
|
|
370
|
+
return getComponentDir(rootDir, serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function seedAccountsFromSourceDbToTargetDb({
|
|
374
|
+
rootDir,
|
|
375
|
+
fromStackName,
|
|
376
|
+
fromServerComponent,
|
|
377
|
+
fromDatabaseUrl,
|
|
378
|
+
targetStackName,
|
|
379
|
+
targetServerComponent,
|
|
380
|
+
targetDatabaseUrl,
|
|
381
|
+
force = false,
|
|
382
|
+
}) {
|
|
383
|
+
const sourceCwd = resolveServerComponentDir({ rootDir, serverComponent: fromServerComponent });
|
|
384
|
+
const targetCwd = resolveServerComponentDir({ rootDir, serverComponent: targetServerComponent });
|
|
385
|
+
|
|
386
|
+
const listScript = `
|
|
387
|
+
process.on('uncaughtException', (e) => {
|
|
388
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
389
|
+
process.exit(1);
|
|
390
|
+
});
|
|
391
|
+
process.on('unhandledRejection', (e) => {
|
|
392
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
393
|
+
process.exit(1);
|
|
394
|
+
});
|
|
395
|
+
import { PrismaClient } from '@prisma/client';
|
|
396
|
+
const db = new PrismaClient();
|
|
397
|
+
try {
|
|
398
|
+
const accounts = await db.account.findMany({ select: { id: true, publicKey: true } });
|
|
399
|
+
console.log(JSON.stringify(accounts));
|
|
400
|
+
} finally {
|
|
401
|
+
await db.$disconnect();
|
|
402
|
+
}
|
|
403
|
+
`.trim();
|
|
404
|
+
|
|
405
|
+
const insertScript = `
|
|
406
|
+
process.on('uncaughtException', (e) => {
|
|
407
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
408
|
+
process.exit(1);
|
|
409
|
+
});
|
|
410
|
+
process.on('unhandledRejection', (e) => {
|
|
411
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
412
|
+
process.exit(1);
|
|
413
|
+
});
|
|
414
|
+
import { PrismaClient } from '@prisma/client';
|
|
415
|
+
import fs from 'node:fs';
|
|
416
|
+
const FORCE = ${force ? 'true' : 'false'};
|
|
417
|
+
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
418
|
+
const accounts = raw ? JSON.parse(raw) : [];
|
|
419
|
+
const db = new PrismaClient();
|
|
420
|
+
try {
|
|
421
|
+
let insertedCount = 0;
|
|
422
|
+
for (const a of accounts) {
|
|
423
|
+
// eslint-disable-next-line no-await-in-loop
|
|
424
|
+
try {
|
|
425
|
+
await db.account.create({ data: { id: a.id, publicKey: a.publicKey } });
|
|
426
|
+
insertedCount += 1;
|
|
427
|
+
} catch (e) {
|
|
428
|
+
// Prisma unique constraint violation
|
|
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
|
+
}
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
throw e;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
console.log(JSON.stringify({ sourceCount: accounts.length, insertedCount }));
|
|
459
|
+
} finally {
|
|
460
|
+
await db.$disconnect();
|
|
461
|
+
}
|
|
462
|
+
`.trim();
|
|
463
|
+
|
|
464
|
+
const { stdout: srcOut } = await runNodeCapture({
|
|
465
|
+
cwd: sourceCwd,
|
|
466
|
+
env: { ...process.env, DATABASE_URL: fromDatabaseUrl },
|
|
467
|
+
args: ['--input-type=module', '-e', listScript],
|
|
468
|
+
});
|
|
469
|
+
const accounts = srcOut.trim() ? JSON.parse(srcOut.trim()) : [];
|
|
470
|
+
|
|
471
|
+
const { stdout: insOut } = await runNodeCapture({
|
|
472
|
+
cwd: targetCwd,
|
|
473
|
+
env: { ...process.env, DATABASE_URL: targetDatabaseUrl },
|
|
474
|
+
args: ['--input-type=module', '-e', insertScript],
|
|
475
|
+
stdin: JSON.stringify(accounts),
|
|
476
|
+
});
|
|
477
|
+
const res = insOut.trim() ? JSON.parse(insOut.trim()) : { sourceCount: accounts.length, insertedCount: 0 };
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
fromStackName,
|
|
482
|
+
targetStackName,
|
|
483
|
+
sourceCount: Number(res.sourceCount ?? accounts.length) || 0,
|
|
484
|
+
insertedCount: Number(res.insertedCount ?? 0) || 0,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function cmdCopyFrom({ argv, json }) {
|
|
489
|
+
const rootDir = getRootDir(import.meta.url);
|
|
490
|
+
const stackName = getStackName();
|
|
491
|
+
|
|
492
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
493
|
+
const fromStackName = (positionals[1] ?? '').trim();
|
|
494
|
+
if (!fromStackName) {
|
|
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
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const serverComponent = resolveServerComponentForCurrentStack();
|
|
628
|
+
const serverDirForPrisma = resolveServerComponentDir({ rootDir, serverComponent });
|
|
629
|
+
const targetBaseDir = getDefaultAutostartPaths().baseDir;
|
|
630
|
+
const targetCli = resolveCliHomeDir();
|
|
631
|
+
const targetServerLightDataDir =
|
|
632
|
+
(process.env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim() || join(targetBaseDir, 'server-light');
|
|
633
|
+
const targetSecretFile =
|
|
634
|
+
(process.env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(targetBaseDir, 'happy-server', 'handy-master-secret.txt');
|
|
635
|
+
|
|
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
|
+
});
|
|
650
|
+
|
|
651
|
+
const copied = {
|
|
652
|
+
secret: false,
|
|
653
|
+
accessKey: false,
|
|
654
|
+
settings: false,
|
|
655
|
+
db: false,
|
|
656
|
+
dbAccounts: null,
|
|
657
|
+
dbError: null,
|
|
658
|
+
sourceStack: fromStackName,
|
|
659
|
+
stackName,
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
if (secret) {
|
|
663
|
+
if (serverComponent === 'happy-server-light') {
|
|
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
|
+
}
|
|
671
|
+
} else if (serverComponent === 'happy-server') {
|
|
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
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const sourceBaseDir = isLegacySource ? getLegacyHappyBaseDir() : getStackDir(fromStackName);
|
|
682
|
+
const sourceEnvRaw = isLegacySource ? '' : await readTextIfExists(getStackEnvPath(fromStackName));
|
|
683
|
+
const sourceEnv = sourceEnvRaw ? parseEnvToObject(sourceEnvRaw) : {};
|
|
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
|
+
}
|
|
705
|
+
|
|
706
|
+
// Best-effort DB seeding: copy Account rows from source stack DB to target stack DB.
|
|
707
|
+
// This avoids FK failures (e.g., Prisma P2003) when the target DB is fresh but the copied token
|
|
708
|
+
// refers to an account ID that does not exist there yet.
|
|
709
|
+
try {
|
|
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
|
+
});
|
|
722
|
+
const targetEnv = process.env;
|
|
723
|
+
const targetServerComponent = resolveServerComponentFromEnv(targetEnv);
|
|
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
|
+
}
|
|
808
|
+
} catch (err) {
|
|
809
|
+
copied.db = false;
|
|
810
|
+
copied.dbAccounts = null;
|
|
811
|
+
copied.dbError = err instanceof Error ? err.message : String(err);
|
|
812
|
+
if (!json) {
|
|
813
|
+
console.warn(`[auth] db seed skipped: ${copied.dbError}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (json) {
|
|
818
|
+
printResult({ json, data: { ok: true, copied } });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const any = copied.secret || copied.accessKey || copied.settings || copied.db;
|
|
823
|
+
if (!any) {
|
|
824
|
+
console.log(`[auth] nothing to copy (target already has auth files)`);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
console.log(`[auth] copied auth from "${fromStackName}" into "${stackName}" (no re-login needed)`);
|
|
829
|
+
if (copied.secret) console.log(` - master secret: copied (${source || 'unknown source'})`);
|
|
830
|
+
if (copied.dbAccounts) {
|
|
831
|
+
console.log(` - db: seeded Account rows (inserted=${copied.dbAccounts.insertedCount}/${copied.dbAccounts.sourceCount})`);
|
|
832
|
+
}
|
|
833
|
+
if (copied.accessKey) console.log(` - cli: copied access.key`);
|
|
834
|
+
if (copied.settings) console.log(` - cli: copied settings.json`);
|
|
835
|
+
}
|
|
836
|
+
|
|
97
837
|
async function cmdStatus({ json }) {
|
|
98
838
|
const rootDir = getRootDir(import.meta.url);
|
|
99
839
|
const stackName = getStackName();
|
|
100
840
|
|
|
101
841
|
const { port, url: internalServerUrl } = getInternalServerUrl();
|
|
102
842
|
const defaultPublicUrl = `http://localhost:${port}`;
|
|
103
|
-
const envPublicUrl = (
|
|
843
|
+
const envPublicUrl = resolveEnvPublicUrlForStack({ stackName });
|
|
104
844
|
const { publicServerUrl } = await resolvePublicServerUrl({
|
|
105
845
|
internalServerUrl,
|
|
106
846
|
defaultPublicUrl,
|
|
@@ -162,33 +902,52 @@ async function cmdStatus({ json }) {
|
|
|
162
902
|
console.log(authLine);
|
|
163
903
|
if (!auth.ok) {
|
|
164
904
|
console.log(` ↪ run: ${authLoginSuggestion(stackName)}`);
|
|
905
|
+
const copyFromSeed = authCopyFromSeedSuggestion(stackName);
|
|
906
|
+
if (copyFromSeed) {
|
|
907
|
+
console.log(` ↪ or (recommended if your seed stack is already logged in): ${copyFromSeed}`);
|
|
908
|
+
}
|
|
165
909
|
}
|
|
166
910
|
console.log(daemonLine);
|
|
167
911
|
console.log(serverLine);
|
|
912
|
+
if (!health.ok) {
|
|
913
|
+
const startHint = stackName === 'main' ? 'happys dev' : `happys stack dev ${stackName}`;
|
|
914
|
+
console.log(` ↪ this stack does not appear to be running. Start it with: ${startHint}`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
168
917
|
if (auth.ok && daemon.status !== 'running') {
|
|
169
|
-
console.log(` ↪
|
|
918
|
+
console.log(` ↪ daemon is not running for this stack. If you expected it to be running, try: happys doctor`);
|
|
170
919
|
}
|
|
171
920
|
}
|
|
172
921
|
|
|
173
922
|
async function cmdLogin({ argv, json }) {
|
|
174
923
|
const rootDir = getRootDir(import.meta.url);
|
|
175
924
|
const stackName = getStackName();
|
|
925
|
+
const { kv } = parseArgs(argv);
|
|
176
926
|
|
|
177
927
|
const { port, url: internalServerUrl } = getInternalServerUrl();
|
|
178
928
|
const defaultPublicUrl = `http://localhost:${port}`;
|
|
179
|
-
const envPublicUrl = (
|
|
929
|
+
const envPublicUrl = resolveEnvPublicUrlForStack({ stackName });
|
|
180
930
|
const { publicServerUrl } = await resolvePublicServerUrl({
|
|
181
931
|
internalServerUrl,
|
|
182
932
|
defaultPublicUrl,
|
|
183
933
|
envPublicUrl,
|
|
184
934
|
allowEnable: false,
|
|
185
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';
|
|
186
940
|
|
|
187
941
|
const cliHomeDir = resolveCliHomeDir();
|
|
188
942
|
const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
|
|
189
943
|
|
|
190
944
|
const force = !argv.includes('--no-force');
|
|
191
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');
|
|
192
951
|
|
|
193
952
|
const nodeArgs = [cliBin, 'auth', 'login'];
|
|
194
953
|
if (force || argv.includes('--force')) {
|
|
@@ -199,11 +958,11 @@ async function cmdLogin({ argv, json }) {
|
|
|
199
958
|
...process.env,
|
|
200
959
|
HAPPY_HOME_DIR: cliHomeDir,
|
|
201
960
|
HAPPY_SERVER_URL: internalServerUrl,
|
|
202
|
-
HAPPY_WEBAPP_URL:
|
|
961
|
+
HAPPY_WEBAPP_URL: webappUrl,
|
|
203
962
|
};
|
|
204
963
|
|
|
205
964
|
if (wantPrint) {
|
|
206
|
-
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' : ''}`;
|
|
207
966
|
if (json) {
|
|
208
967
|
printResult({ json, data: { ok: true, stackName, cmd } });
|
|
209
968
|
} else {
|
|
@@ -213,8 +972,15 @@ async function cmdLogin({ argv, json }) {
|
|
|
213
972
|
}
|
|
214
973
|
|
|
215
974
|
if (!json) {
|
|
216
|
-
|
|
217
|
-
|
|
975
|
+
printAuthLoginInstructions({
|
|
976
|
+
stackName,
|
|
977
|
+
context,
|
|
978
|
+
webappUrl,
|
|
979
|
+
webappUrlSource,
|
|
980
|
+
internalServerUrl,
|
|
981
|
+
publicServerUrl,
|
|
982
|
+
rerunCmd: authLoginSuggestion(stackName),
|
|
983
|
+
});
|
|
218
984
|
}
|
|
219
985
|
|
|
220
986
|
const child = spawn(process.execPath, nodeArgs, {
|
|
@@ -223,7 +989,45 @@ async function cmdLogin({ argv, json }) {
|
|
|
223
989
|
stdio: 'inherit',
|
|
224
990
|
});
|
|
225
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
|
+
|
|
226
1027
|
await new Promise((resolve) => child.on('exit', resolve));
|
|
1028
|
+
process.off('SIGINT', onSigint);
|
|
1029
|
+
process.off('SIGTERM', onSigterm);
|
|
1030
|
+
if (t) clearTimeout(t);
|
|
227
1031
|
if (json) {
|
|
228
1032
|
printResult({ json, data: { ok: child.exitCode === 0, exitCode: child.exitCode } });
|
|
229
1033
|
} else if (child.exitCode && child.exitCode !== 0) {
|
|
@@ -240,15 +1044,22 @@ async function main() {
|
|
|
240
1044
|
if (wantsHelp(argv, { flags }) || cmd === 'help') {
|
|
241
1045
|
printResult({
|
|
242
1046
|
json,
|
|
243
|
-
data: { commands: ['status', 'login'], stackScoped: 'happys stack auth <name> status|login' },
|
|
1047
|
+
data: { commands: ['status', 'login', 'copy-from', 'dev-key'], stackScoped: 'happys stack auth <name> status|login|copy-from' },
|
|
244
1048
|
text: [
|
|
245
1049
|
'[auth] usage:',
|
|
246
1050
|
' happys auth status [--json]',
|
|
247
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',
|
|
248
1058
|
'',
|
|
249
1059
|
'stack-scoped:',
|
|
250
1060
|
' happys stack auth <name> status [--json]',
|
|
251
1061
|
' happys stack auth <name> login [--force] [--print] [--json]',
|
|
1062
|
+
' happys stack auth <name> copy-from <sourceStack|legacy> [--force] [--with-infra] [--link] [--json]',
|
|
252
1063
|
].join('\n'),
|
|
253
1064
|
});
|
|
254
1065
|
return;
|
|
@@ -262,6 +1073,14 @@ async function main() {
|
|
|
262
1073
|
await cmdLogin({ argv, json });
|
|
263
1074
|
return;
|
|
264
1075
|
}
|
|
1076
|
+
if (cmd === 'copy-from') {
|
|
1077
|
+
await cmdCopyFrom({ argv, json });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (cmd === 'dev-key') {
|
|
1081
|
+
await cmdDevKey({ argv, json });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
265
1084
|
|
|
266
1085
|
throw new Error(`[auth] unknown command: ${cmd}`);
|
|
267
1086
|
}
|