happy-stacks 0.1.2 → 0.2.0

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