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.
Files changed (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  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/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -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/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /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 portRaw = (process.env.HAPPY_LOCAL_SERVER_PORT ?? process.env.HAPPY_STACKS_SERVER_PORT ?? '').trim();
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 = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
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(` ↪ auth is OK; this looks like a daemon/runtime issue. Try: happys doctor`);
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 = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
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: publicServerUrl,
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="${publicServerUrl}" node "${cliBin}" auth login${nodeArgs.includes('--force') ? ' --force' : ''}`;
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
- console.log(`[auth] stack: ${stackName}`);
217
- console.log(`[auth] launching login...`);
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
  }