happy-stacks 0.1.2 → 0.3.0

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