happy-stacks 0.0.0 → 0.1.2

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 (42) hide show
  1. package/README.md +22 -4
  2. package/bin/happys.mjs +76 -5
  3. package/docs/server-flavors.md +61 -2
  4. package/docs/stacks.md +16 -4
  5. package/extras/swiftbar/auth-login.sh +5 -5
  6. package/extras/swiftbar/happy-stacks.5s.sh +83 -41
  7. package/extras/swiftbar/happys-term.sh +151 -0
  8. package/extras/swiftbar/happys.sh +52 -0
  9. package/extras/swiftbar/lib/render.sh +74 -56
  10. package/extras/swiftbar/lib/system.sh +37 -6
  11. package/extras/swiftbar/lib/utils.sh +180 -4
  12. package/extras/swiftbar/pnpm-term.sh +2 -122
  13. package/extras/swiftbar/pnpm.sh +2 -13
  14. package/extras/swiftbar/set-server-flavor.sh +8 -8
  15. package/extras/swiftbar/wt-pr.sh +1 -1
  16. package/package.json +1 -1
  17. package/scripts/auth.mjs +374 -3
  18. package/scripts/daemon.mjs +78 -11
  19. package/scripts/dev.mjs +122 -17
  20. package/scripts/init.mjs +238 -32
  21. package/scripts/migrate.mjs +292 -0
  22. package/scripts/mobile.mjs +51 -19
  23. package/scripts/run.mjs +118 -26
  24. package/scripts/service.mjs +176 -37
  25. package/scripts/stack.mjs +665 -22
  26. package/scripts/stop.mjs +157 -0
  27. package/scripts/tailscale.mjs +147 -21
  28. package/scripts/typecheck.mjs +145 -0
  29. package/scripts/ui_gateway.mjs +248 -0
  30. package/scripts/uninstall.mjs +3 -3
  31. package/scripts/utils/cli_registry.mjs +23 -0
  32. package/scripts/utils/config.mjs +9 -1
  33. package/scripts/utils/env.mjs +37 -15
  34. package/scripts/utils/expo.mjs +94 -0
  35. package/scripts/utils/happy_server_infra.mjs +430 -0
  36. package/scripts/utils/pm.mjs +11 -2
  37. package/scripts/utils/ports.mjs +51 -13
  38. package/scripts/utils/proc.mjs +46 -5
  39. package/scripts/utils/server.mjs +37 -0
  40. package/scripts/utils/stack_stop.mjs +206 -0
  41. package/scripts/utils/validate.mjs +42 -1
  42. package/scripts/worktrees.mjs +53 -7
@@ -1,125 +1,5 @@
1
1
  #!/bin/bash
2
2
  set -euo pipefail
3
3
 
4
- # Open preferred terminal and run a happys command.
5
- #
6
- # Preference order follows wt shell semantics:
7
- # - HAPPY_LOCAL_WT_TERMINAL=ghostty|iterm|terminal|current
8
- # (also accepts "auto" which tries ghostty->iterm->terminal->current)
9
- #
10
- # Notes:
11
- # - iTerm / Terminal: we run the command automatically via AppleScript.
12
- # - Ghostty: best-effort; if we can't run the command, we open Ghostty in the dir and copy the command to clipboard.
13
-
14
- HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
15
- HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
16
-
17
- WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-$HAPPY_STACKS_HOME_DIR/workspace}"
18
- if [[ ! -d "$WORKDIR" ]]; then
19
- WORKDIR="$HOME"
20
- fi
21
-
22
- PNPM_SH="$HAPPY_LOCAL_DIR/extras/swiftbar/pnpm.sh"
23
- if [[ ! -x "$PNPM_SH" ]]; then
24
- echo "missing happys wrapper: $PNPM_SH" >&2
25
- exit 1
26
- fi
27
-
28
- pref_raw="$(echo "${HAPPY_STACKS_WT_TERMINAL:-${HAPPY_LOCAL_WT_TERMINAL:-auto}}" | tr '[:upper:]' '[:lower:]')"
29
- pref="$pref_raw"
30
- if [[ "$pref" == "" ]]; then pref="auto"; fi
31
-
32
- cmd=( "$PNPM_SH" "$@" )
33
-
34
- escape_for_osascript_string() {
35
- # Escape for inclusion inside an AppleScript string literal.
36
- # (We generate: write text "<cmd>")
37
- local s="$1"
38
- s="${s//\\/\\\\}"
39
- s="${s//\"/\\\"}"
40
- echo "$s"
41
- }
42
-
43
- shell_cmd() {
44
- # Build a zsh command that cds and runs happys (via wrapper), leaving the shell open.
45
- local joined=""
46
- local q
47
- joined="cd \"${WORKDIR//\"/\\\"}\"; "
48
- for q in "${cmd[@]}"; do
49
- # Basic shell quoting
50
- if [[ "$q" =~ [[:space:]\\"\'\$\`\!\&\|\;\<\>\(\)\[\]\{\}] ]]; then
51
- joined+="'${q//\'/\'\\\'\'}' "
52
- else
53
- joined+="$q "
54
- fi
55
- done
56
- joined+="; echo; echo \"[happy-stacks] done\"; exec /bin/zsh -i"
57
- echo "$joined"
58
- }
59
-
60
- run_iterm() {
61
- if ! command -v osascript >/dev/null 2>&1; then
62
- return 1
63
- fi
64
- local s
65
- s="$(shell_cmd)"
66
- s="$(escape_for_osascript_string "$s")"
67
- osascript \
68
- -e 'tell application "iTerm" to activate' \
69
- -e 'tell application "iTerm" to create window with default profile' \
70
- -e "tell application \"iTerm\" to tell current session of current window to write text \"${s}\"" >/dev/null
71
- }
72
-
73
- run_terminal_app() {
74
- if ! command -v osascript >/dev/null 2>&1; then
75
- return 1
76
- fi
77
- local s
78
- s="$(shell_cmd)"
79
- # Terminal.app uses do script.
80
- s="$(escape_for_osascript_string "$s")"
81
- osascript \
82
- -e 'tell application "Terminal" to activate' \
83
- -e "tell application \"Terminal\" to do script \"${s}\"" >/dev/null
84
- }
85
-
86
- run_ghostty() {
87
- if ! command -v ghostty >/dev/null 2>&1; then
88
- return 1
89
- fi
90
-
91
- # Best-effort: try to run the command. If ghostty doesn't support -e on this system,
92
- # fall back to opening the dir and copying the command.
93
- local s
94
- s="$(shell_cmd)"
95
- if ghostty --working-directory "$WORKDIR" -e /bin/zsh -lc "$s" >/dev/null 2>&1; then
96
- return 0
97
- fi
98
-
99
- # Fallback: open in dir and copy command for manual paste.
100
- echo -n "$s" | pbcopy 2>/dev/null || true
101
- ghostty --working-directory "$WORKDIR" >/dev/null 2>&1 || true
102
- return 0
103
- }
104
-
105
- try_one() {
106
- local t="$1"
107
- case "$t" in
108
- ghostty) run_ghostty ;;
109
- iterm) run_iterm ;;
110
- terminal) run_terminal_app ;;
111
- current) ( cd "$WORKDIR"; exec "${cmd[@]}" ) ;;
112
- *) return 1 ;;
113
- esac
114
- }
115
-
116
- if [[ "$pref" == "auto" ]]; then
117
- for t in ghostty iterm terminal current; do
118
- if try_one "$t"; then
119
- exit 0
120
- fi
121
- done
122
- exit 1
123
- fi
124
-
125
- try_one "$pref"
4
+ # Back-compat wrapper. Use `happys-term.sh` for new installs.
5
+ exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/happys-term.sh" "$@"
@@ -2,20 +2,9 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # Back-compat wrapper for SwiftBar menu actions.
5
- # Historically this executed `pnpm` in the cloned repo; it now executes `happys`.
5
+ # Historically this executed `pnpm`; now it delegates to `happys.sh`.
6
6
 
7
7
  HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
8
8
  HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
9
9
 
10
- HAPPYS_BIN="$HAPPY_LOCAL_DIR/bin/happys"
11
- if [[ ! -x "$HAPPYS_BIN" ]]; then
12
- HAPPYS_BIN="$(command -v happys 2>/dev/null || true)"
13
- fi
14
-
15
- if [[ -z "${HAPPYS_BIN:-}" ]]; then
16
- echo "happys not found (run: npx happy-stacks init, or npm i -g happy-stacks)" >&2
17
- exit 1
18
- fi
19
-
20
- exec "$HAPPYS_BIN" "$@"
21
-
10
+ exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/happys.sh" "$@"
@@ -27,31 +27,31 @@ fi
27
27
  HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
28
28
  HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
29
29
 
30
- PNPM_BIN="$HAPPY_LOCAL_DIR/extras/swiftbar/pnpm.sh"
31
- if [[ ! -x "$PNPM_BIN" ]]; then
30
+ HAPPYS_BIN="$HAPPY_LOCAL_DIR/extras/swiftbar/happys.sh"
31
+ if [[ ! -x "$HAPPYS_BIN" ]]; then
32
32
  echo "happys wrapper not found (run: happys menubar install)" >&2
33
33
  exit 1
34
34
  fi
35
35
 
36
36
  restart_main_service_best_effort() {
37
- "$PNPM_BIN" service:restart >/dev/null 2>&1 || true
37
+ "$HAPPYS_BIN" service:restart >/dev/null 2>&1 || true
38
38
  # If the installed LaunchAgent is still legacy/baked, reinstall so it persists only env-file pointer.
39
- "$PNPM_BIN" service:install >/dev/null 2>&1 || true
39
+ "$HAPPYS_BIN" service:install >/dev/null 2>&1 || true
40
40
  }
41
41
 
42
42
  restart_stack_service_best_effort() {
43
43
  local name="$1"
44
- "$PNPM_BIN" stack service:restart "$name" >/dev/null 2>&1 || true
45
- "$PNPM_BIN" stack service:install "$name" >/dev/null 2>&1 || true
44
+ "$HAPPYS_BIN" stack service:restart "$name" >/dev/null 2>&1 || true
45
+ "$HAPPYS_BIN" stack service:install "$name" >/dev/null 2>&1 || true
46
46
  }
47
47
 
48
48
  if [[ "$STACK" == "main" ]]; then
49
- "$PNPM_BIN" srv -- use "$FLAVOR"
49
+ "$HAPPYS_BIN" srv -- use "$FLAVOR"
50
50
  restart_main_service_best_effort
51
51
  echo "ok: main -> $FLAVOR"
52
52
  exit 0
53
53
  fi
54
54
 
55
- "$PNPM_BIN" stack srv "$STACK" -- use "$FLAVOR"
55
+ "$HAPPYS_BIN" stack srv "$STACK" -- use "$FLAVOR"
56
56
  restart_stack_service_best_effort "$STACK"
57
57
  echo "ok: $STACK -> $FLAVOR"
@@ -20,7 +20,7 @@ STACK_NAME="${2:-}"
20
20
  HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
21
21
  HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
22
22
 
23
- HAPPYS="$HAPPY_LOCAL_DIR/extras/swiftbar/pnpm.sh"
23
+ HAPPYS="$HAPPY_LOCAL_DIR/extras/swiftbar/happys.sh"
24
24
  if [[ ! -x "$HAPPYS" ]]; then
25
25
  HAPPYS="$(command -v happys 2>/dev/null || true)"
26
26
  fi
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "happy-stacks",
3
3
  "type": "module",
4
- "version": "0.0.0",
4
+ "version": "0.1.2",
5
5
  "packageManager": "pnpm@10.18.3",
6
6
  "bin": {
7
7
  "happys": "./bin/happys.mjs",
package/scripts/auth.mjs CHANGED
@@ -1,12 +1,16 @@
1
1
  import './utils/env.mjs';
2
2
  import { parseArgs } from './utils/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
4
- import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName } from './utils/paths.mjs';
4
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName, resolveStackEnvPath } from './utils/paths.mjs';
5
5
  import { resolvePublicServerUrl } from './tailscale.mjs';
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { spawn } from 'node:child_process';
10
+ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { dirname } from 'node:path';
12
+
13
+ import { parseDotenv } from './utils/dotenv.mjs';
10
14
 
11
15
  function getInternalServerUrl() {
12
16
  const portRaw = (process.env.HAPPY_LOCAL_SERVER_PORT ?? process.env.HAPPY_STACKS_SERVER_PORT ?? '').trim();
@@ -19,6 +23,104 @@ function expandTilde(p) {
19
23
  return p.replace(/^~(?=\/)/, homedir());
20
24
  }
21
25
 
26
+ async function ensureDir(p) {
27
+ await mkdir(p, { recursive: true });
28
+ }
29
+
30
+ async function readTextIfExists(path) {
31
+ 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;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
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
+ }
120
+
121
+ return { secret: null, source: null };
122
+ }
123
+
22
124
  function resolveCliHomeDir() {
23
125
  const fromEnv = (process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? process.env.HAPPY_STACKS_CLI_HOME_DIR ?? '').trim();
24
126
  if (fromEnv) {
@@ -94,6 +196,261 @@ function authLoginSuggestion(stackName) {
94
196
  return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
95
197
  }
96
198
 
199
+ function authCopyFromMainSuggestion(stackName) {
200
+ if (stackName === 'main') return null;
201
+ return `happys stack auth ${stackName} copy-from main`;
202
+ }
203
+
204
+ function resolveServerComponentForCurrentStack() {
205
+ return (
206
+ (process.env.HAPPY_STACKS_SERVER_COMPONENT ?? process.env.HAPPY_LOCAL_SERVER_COMPONENT ?? 'happy-server-light').trim() ||
207
+ 'happy-server-light'
208
+ );
209
+ }
210
+
211
+ async function runNodeCapture({ cwd, env, args, stdin }) {
212
+ return await new Promise((resolvePromise, rejectPromise) => {
213
+ const child = spawn(process.execPath, args, {
214
+ cwd,
215
+ env,
216
+ stdio: ['pipe', 'pipe', 'pipe'],
217
+ });
218
+ let stdout = '';
219
+ let stderr = '';
220
+ child.stdout.on('data', (d) => {
221
+ stdout += String(d);
222
+ });
223
+ child.stderr.on('data', (d) => {
224
+ stderr += String(d);
225
+ });
226
+ child.on('error', (err) => rejectPromise(err));
227
+ child.on('close', (code) => {
228
+ if (code === 0) {
229
+ resolvePromise({ stdout, stderr });
230
+ return;
231
+ }
232
+ rejectPromise(new Error(`node exited with ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
233
+ });
234
+ if (stdin != null) {
235
+ child.stdin.write(String(stdin));
236
+ }
237
+ child.stdin.end();
238
+ });
239
+ }
240
+
241
+ function resolveServerComponentFromEnv(env) {
242
+ const v = (env.HAPPY_STACKS_SERVER_COMPONENT ?? env.HAPPY_LOCAL_SERVER_COMPONENT ?? 'happy-server-light').trim() || 'happy-server-light';
243
+ return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
244
+ }
245
+
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;
250
+ }
251
+
252
+ function resolveServerComponentDir({ rootDir, serverComponent }) {
253
+ return getComponentDir(rootDir, serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light');
254
+ }
255
+
256
+ async function seedAccountsFromSourceDbToTargetDb({
257
+ rootDir,
258
+ fromStackName,
259
+ fromServerComponent,
260
+ fromDatabaseUrl,
261
+ targetStackName,
262
+ targetServerComponent,
263
+ targetDatabaseUrl,
264
+ }) {
265
+ const sourceCwd = resolveServerComponentDir({ rootDir, serverComponent: fromServerComponent });
266
+ const targetCwd = resolveServerComponentDir({ rootDir, serverComponent: targetServerComponent });
267
+
268
+ const listScript = `
269
+ process.on('uncaughtException', (e) => {
270
+ console.error(e instanceof Error ? e.message : String(e));
271
+ process.exit(1);
272
+ });
273
+ process.on('unhandledRejection', (e) => {
274
+ console.error(e instanceof Error ? e.message : String(e));
275
+ process.exit(1);
276
+ });
277
+ import { PrismaClient } from '@prisma/client';
278
+ const db = new PrismaClient();
279
+ try {
280
+ const accounts = await db.account.findMany({ select: { id: true, publicKey: true } });
281
+ console.log(JSON.stringify(accounts));
282
+ } finally {
283
+ await db.$disconnect();
284
+ }
285
+ `.trim();
286
+
287
+ const insertScript = `
288
+ process.on('uncaughtException', (e) => {
289
+ console.error(e instanceof Error ? e.message : String(e));
290
+ process.exit(1);
291
+ });
292
+ process.on('unhandledRejection', (e) => {
293
+ console.error(e instanceof Error ? e.message : String(e));
294
+ process.exit(1);
295
+ });
296
+ import { PrismaClient } from '@prisma/client';
297
+ import fs from 'node:fs';
298
+ const raw = fs.readFileSync(0, 'utf8').trim();
299
+ const accounts = raw ? JSON.parse(raw) : [];
300
+ const db = new PrismaClient();
301
+ try {
302
+ let insertedCount = 0;
303
+ for (const a of accounts) {
304
+ // eslint-disable-next-line no-await-in-loop
305
+ try {
306
+ await db.account.create({ data: { id: a.id, publicKey: a.publicKey } });
307
+ insertedCount += 1;
308
+ } catch (e) {
309
+ // Prisma unique constraint violation
310
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'P2002') {
311
+ continue;
312
+ }
313
+ throw e;
314
+ }
315
+ }
316
+ console.log(JSON.stringify({ sourceCount: accounts.length, insertedCount }));
317
+ } finally {
318
+ await db.$disconnect();
319
+ }
320
+ `.trim();
321
+
322
+ const { stdout: srcOut } = await runNodeCapture({
323
+ cwd: sourceCwd,
324
+ env: { ...process.env, DATABASE_URL: fromDatabaseUrl },
325
+ args: ['--input-type=module', '-e', listScript],
326
+ });
327
+ const accounts = srcOut.trim() ? JSON.parse(srcOut.trim()) : [];
328
+
329
+ const { stdout: insOut } = await runNodeCapture({
330
+ cwd: targetCwd,
331
+ env: { ...process.env, DATABASE_URL: targetDatabaseUrl },
332
+ args: ['--input-type=module', '-e', insertScript],
333
+ stdin: JSON.stringify(accounts),
334
+ });
335
+ const res = insOut.trim() ? JSON.parse(insOut.trim()) : { sourceCount: accounts.length, insertedCount: 0 };
336
+
337
+ return {
338
+ ok: true,
339
+ fromStackName,
340
+ targetStackName,
341
+ sourceCount: Number(res.sourceCount ?? accounts.length) || 0,
342
+ insertedCount: Number(res.insertedCount ?? 0) || 0,
343
+ };
344
+ }
345
+
346
+ async function cmdCopyFrom({ argv, json }) {
347
+ const rootDir = getRootDir(import.meta.url);
348
+ 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
+
353
+ const positionals = argv.filter((a) => !a.startsWith('--'));
354
+ const fromStackName = (positionals[1] ?? '').trim();
355
+ if (!fromStackName) {
356
+ throw new Error('[auth] usage: happys stack auth <name> copy-from <sourceStack> [--json]');
357
+ }
358
+
359
+ const serverComponent = resolveServerComponentForCurrentStack();
360
+ const targetBaseDir = getDefaultAutostartPaths().baseDir;
361
+ const targetCli = resolveCliHomeDir();
362
+ const targetServerLightDataDir =
363
+ (process.env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim() || join(targetBaseDir, 'server-light');
364
+ const targetSecretFile =
365
+ (process.env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(targetBaseDir, 'happy-server', 'handy-master-secret.txt');
366
+
367
+ const { secret, source } = await resolveHandyMasterSecretFromStack({ stackName: fromStackName, requireStackExists: true });
368
+
369
+ const copied = {
370
+ secret: false,
371
+ accessKey: false,
372
+ settings: false,
373
+ db: false,
374
+ dbAccounts: null,
375
+ dbError: null,
376
+ sourceStack: fromStackName,
377
+ stackName,
378
+ };
379
+
380
+ if (secret) {
381
+ if (serverComponent === 'happy-server-light') {
382
+ copied.secret = await writeSecretFileIfMissing({ path: join(targetServerLightDataDir, 'handy-master-secret.txt'), secret });
383
+ } else if (serverComponent === 'happy-server') {
384
+ copied.secret = await writeSecretFileIfMissing({ path: targetSecretFile, secret });
385
+ }
386
+ }
387
+
388
+ const sourceBaseDir = getStackDir(fromStackName);
389
+ const sourceEnvRaw = await readTextIfExists(getStackEnvPath(fromStackName));
390
+ 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
+ });
403
+
404
+ // Best-effort DB seeding: copy Account rows from source stack DB to target stack DB.
405
+ // This avoids FK failures (e.g., Prisma P2003) when the target DB is fresh but the copied token
406
+ // refers to an account ID that does not exist there yet.
407
+ try {
408
+ const fromServerComponent = resolveServerComponentFromEnv(sourceEnv);
409
+ const fromDatabaseUrl = resolveDatabaseUrlFromEnvOrThrow(sourceEnv, { label: `source stack "${fromStackName}"` });
410
+ const targetEnv = process.env;
411
+ 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;
425
+ } catch (err) {
426
+ copied.db = false;
427
+ copied.dbAccounts = null;
428
+ copied.dbError = err instanceof Error ? err.message : String(err);
429
+ if (!json) {
430
+ console.warn(`[auth] db seed skipped: ${copied.dbError}`);
431
+ }
432
+ }
433
+
434
+ if (json) {
435
+ printResult({ json, data: { ok: true, copied } });
436
+ return;
437
+ }
438
+
439
+ const any = copied.secret || copied.accessKey || copied.settings || copied.db;
440
+ if (!any) {
441
+ console.log(`[auth] nothing to copy (target already has auth files)`);
442
+ return;
443
+ }
444
+
445
+ console.log(`[auth] copied auth from "${fromStackName}" into "${stackName}" (no re-login needed)`);
446
+ if (copied.secret) console.log(` - master secret: copied (${source || 'unknown source'})`);
447
+ if (copied.dbAccounts) {
448
+ console.log(` - db: seeded Account rows (inserted=${copied.dbAccounts.insertedCount}/${copied.dbAccounts.sourceCount})`);
449
+ }
450
+ if (copied.accessKey) console.log(` - cli: copied access.key`);
451
+ if (copied.settings) console.log(` - cli: copied settings.json`);
452
+ }
453
+
97
454
  async function cmdStatus({ json }) {
98
455
  const rootDir = getRootDir(import.meta.url);
99
456
  const stackName = getStackName();
@@ -162,11 +519,20 @@ async function cmdStatus({ json }) {
162
519
  console.log(authLine);
163
520
  if (!auth.ok) {
164
521
  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}`);
525
+ }
165
526
  }
166
527
  console.log(daemonLine);
167
528
  console.log(serverLine);
529
+ if (!health.ok) {
530
+ const startHint = stackName === 'main' ? 'happys dev' : `happys stack dev ${stackName}`;
531
+ console.log(` ↪ this stack does not appear to be running. Start it with: ${startHint}`);
532
+ return;
533
+ }
168
534
  if (auth.ok && daemon.status !== 'running') {
169
- console.log(` ↪ auth is OK; this looks like a daemon/runtime issue. Try: happys doctor`);
535
+ console.log(` ↪ daemon is not running for this stack. If you expected it to be running, try: happys doctor`);
170
536
  }
171
537
  }
172
538
 
@@ -240,7 +606,7 @@ async function main() {
240
606
  if (wantsHelp(argv, { flags }) || cmd === 'help') {
241
607
  printResult({
242
608
  json,
243
- data: { commands: ['status', 'login'], stackScoped: 'happys stack auth <name> status|login' },
609
+ data: { commands: ['status', 'login', 'copy-from'], stackScoped: 'happys stack auth <name> status|login|copy-from' },
244
610
  text: [
245
611
  '[auth] usage:',
246
612
  ' happys auth status [--json]',
@@ -249,6 +615,7 @@ async function main() {
249
615
  'stack-scoped:',
250
616
  ' happys stack auth <name> status [--json]',
251
617
  ' happys stack auth <name> login [--force] [--print] [--json]',
618
+ ' happys stack auth <name> copy-from <sourceStack> [--json]',
252
619
  ].join('\n'),
253
620
  });
254
621
  return;
@@ -262,6 +629,10 @@ async function main() {
262
629
  await cmdLogin({ argv, json });
263
630
  return;
264
631
  }
632
+ if (cmd === 'copy-from') {
633
+ await cmdCopyFrom({ argv, json });
634
+ return;
635
+ }
265
636
 
266
637
  throw new Error(`[auth] unknown command: ${cmd}`);
267
638
  }