happy-stacks 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +164 -89
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -0
  64. package/scripts/utils/dev/daemon.mjs +104 -0
  65. package/scripts/utils/dev/expo_web.mjs +112 -0
  66. package/scripts/utils/dev/server.mjs +183 -0
  67. package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
@@ -0,0 +1,27 @@
1
+ import { join } from 'node:path';
2
+
3
+ import { expandHome } from '../paths/canonical_home.mjs';
4
+ import { getDefaultAutostartPaths } from '../paths/paths.mjs';
5
+
6
+ export function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
7
+ const fromEnv = (env?.HAPPY_STACKS_CLI_HOME_DIR ?? env?.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
8
+ return fromEnv || join(stackBaseDir, 'cli');
9
+ }
10
+
11
+ export function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
12
+ const fromEnv = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
13
+ return fromEnv || join(stackBaseDir, 'server-light');
14
+ }
15
+
16
+ export function resolveCliHomeDir(env = process.env) {
17
+ const fromExplicit = (env.HAPPY_HOME_DIR ?? '').trim();
18
+ if (fromExplicit) {
19
+ return expandHome(fromExplicit);
20
+ }
21
+ const fromStacks = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
22
+ if (fromStacks) {
23
+ return expandHome(fromStacks);
24
+ }
25
+ return join(getDefaultAutostartPaths().baseDir, 'cli');
26
+ }
27
+
@@ -0,0 +1,152 @@
1
+ import { join, resolve } from 'node:path';
2
+ import { writeFile } from 'node:fs/promises';
3
+
4
+ import { expandHome } from '../paths/canonical_home.mjs';
5
+ import { getComponentDir, getWorkspaceDir, resolveStackEnvPath } from '../paths/paths.mjs';
6
+ import { ensureDir } from '../fs/ops.mjs';
7
+ import { getEnvValueAny } from '../env/values.mjs';
8
+ import { readEnvObjectFromFile } from '../env/read.mjs';
9
+ import { resolveCommandPath } from '../proc/commands.mjs';
10
+ import { run, runCapture } from '../proc/proc.mjs';
11
+ import { getCliHomeDirFromEnvOrDefault } from './dirs.mjs';
12
+
13
+ function resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv }) {
14
+ const raw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_WORKSPACE_DIR', 'HAPPY_LOCAL_WORKSPACE_DIR']);
15
+ if (!raw) {
16
+ return getWorkspaceDir(rootDir);
17
+ }
18
+ const expanded = expandHome(raw);
19
+ return expanded.startsWith('/') ? expanded : resolve(rootDir, expanded);
20
+ }
21
+
22
+ function resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component }) {
23
+ const raw = getEnvValueAny(stackEnv, keys);
24
+ if (!raw) return getComponentDir(rootDir, component);
25
+ const expanded = expandHome(raw);
26
+ if (expanded.startsWith('/')) return expanded;
27
+ const workspaceDir = resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv });
28
+ return resolve(workspaceDir, expanded);
29
+ }
30
+
31
+ export async function isCursorInstalled({ cwd, env } = {}) {
32
+ if (await resolveCommandPath('cursor', { cwd, env })) return true;
33
+ if (process.platform !== 'darwin') return false;
34
+ try {
35
+ await runCapture('open', ['-Ra', 'Cursor'], { cwd, env });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ export async function openWorkspaceInEditor({ rootDir, editor, workspacePath }) {
43
+ if (editor === 'code') {
44
+ const codePath = await resolveCommandPath('code', { cwd: rootDir, env: process.env });
45
+ if (!codePath) {
46
+ throw new Error(
47
+ "[stack] VS Code CLI 'code' not found on PATH. In VS Code: Cmd+Shift+P → 'Shell Command: Install code command in PATH'."
48
+ );
49
+ }
50
+ await run(codePath, ['-n', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
51
+ return;
52
+ }
53
+
54
+ const cursorPath = await resolveCommandPath('cursor', { cwd: rootDir, env: process.env });
55
+ if (cursorPath) {
56
+ try {
57
+ await run(cursorPath, ['-n', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
58
+ } catch {
59
+ await run(cursorPath, [workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
60
+ }
61
+ return;
62
+ }
63
+
64
+ if (process.platform === 'darwin') {
65
+ // Cursor installed but CLI missing is common on macOS.
66
+ await run('open', ['-na', 'Cursor', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
67
+ return;
68
+ }
69
+
70
+ throw new Error("[stack] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
71
+ }
72
+
73
+ export async function writeStackCodeWorkspace({
74
+ rootDir,
75
+ stackName,
76
+ includeStackDir,
77
+ includeAllComponents,
78
+ includeCliHome,
79
+ }) {
80
+ const { baseDir, envPath } = resolveStackEnvPath(stackName);
81
+ const stackEnv = await readEnvObjectFromFile(envPath);
82
+
83
+ const serverComponent =
84
+ getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
85
+
86
+ const selectedComponents = includeAllComponents
87
+ ? ['happy', 'happy-cli', 'happy-server-light', 'happy-server']
88
+ : ['happy', 'happy-cli', serverComponent];
89
+
90
+ const componentSpecs = [
91
+ { component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
92
+ { component: 'happy-cli', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI'] },
93
+ {
94
+ component: 'happy-server-light',
95
+ keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT'],
96
+ },
97
+ { component: 'happy-server', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER'] },
98
+ ];
99
+ const byName = new Map(componentSpecs.map((c) => [c.component, c.keys]));
100
+
101
+ const folders = [];
102
+ if (includeStackDir) {
103
+ folders.push({ name: `stack:${stackName}`, path: baseDir });
104
+ }
105
+ if (includeCliHome) {
106
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir: baseDir, env: stackEnv });
107
+ folders.push({ name: `cli:${stackName}`, path: expandHome(cliHomeDir) });
108
+ }
109
+ for (const component of selectedComponents) {
110
+ const keys = byName.get(component) ?? [];
111
+ const dir = resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component });
112
+ folders.push({ name: component, path: dir });
113
+ }
114
+
115
+ // Deduplicate by path (can happen if multiple components are pointed at the same dir).
116
+ const uniqFolders = folders.filter((f, i, arr) => arr.findIndex((x) => x.path === f.path) === i);
117
+
118
+ await ensureDir(baseDir);
119
+ const workspacePath = join(baseDir, `stack.${stackName}.code-workspace`);
120
+ const payload = {
121
+ folders: uniqFolders,
122
+ settings: {
123
+ 'search.exclude': {
124
+ '**/node_modules/**': true,
125
+ '**/.git/**': true,
126
+ '**/logs/**': true,
127
+ '**/cli/logs/**': true,
128
+ },
129
+ 'files.watcherExclude': {
130
+ '**/node_modules/**': true,
131
+ '**/.git/**': true,
132
+ '**/logs/**': true,
133
+ '**/cli/logs/**': true,
134
+ },
135
+ },
136
+ };
137
+ await writeFile(workspacePath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
138
+
139
+ return {
140
+ workspacePath,
141
+ baseDir,
142
+ envPath,
143
+ serverComponent,
144
+ folders: uniqFolders,
145
+ flags: {
146
+ includeStackDir: Boolean(includeStackDir),
147
+ includeCliHome: Boolean(includeCliHome),
148
+ includeAllComponents: Boolean(includeAllComponents),
149
+ },
150
+ };
151
+ }
152
+
@@ -0,0 +1,12 @@
1
+ export function sanitizeStackName(raw, { fallback = 'stack', maxLen = 64 } = {}) {
2
+ const s = String(raw ?? '')
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9-]+/g, '-')
6
+ .replace(/-+/g, '-')
7
+ .replace(/^-+/, '')
8
+ .replace(/-+$/, '');
9
+ const out = s || String(fallback ?? 'stack');
10
+ return Number.isFinite(maxLen) && maxLen > 0 ? out.slice(0, maxLen) : out;
11
+ }
12
+
@@ -0,0 +1,87 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ import { resolveStackEnvPath } from '../paths/paths.mjs';
6
+ import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
7
+ import { isPidAlive } from '../proc/pids.mjs';
8
+
9
+ export { isPidAlive };
10
+
11
+ export function getStackRuntimeStatePath(stackName) {
12
+ const { baseDir } = resolveStackEnvPath(stackName);
13
+ return join(baseDir, 'stack.runtime.json');
14
+ }
15
+
16
+ export async function readStackRuntimeStateFile(statePath) {
17
+ const parsed = await readJsonIfExists(statePath, { defaultValue: null });
18
+ return parsed && typeof parsed === 'object' ? parsed : null;
19
+ }
20
+
21
+ export async function writeStackRuntimeStateFile(statePath, state) {
22
+ if (!statePath) {
23
+ throw new Error('[stack] missing runtime state path');
24
+ }
25
+ await writeJsonAtomic(statePath, state);
26
+ }
27
+
28
+ function isPlainObject(v) {
29
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
30
+ }
31
+
32
+ function deepMerge(a, b) {
33
+ if (!isPlainObject(a) || !isPlainObject(b)) {
34
+ return b;
35
+ }
36
+ const out = { ...a };
37
+ for (const [k, v] of Object.entries(b)) {
38
+ if (isPlainObject(out[k]) && isPlainObject(v)) {
39
+ out[k] = deepMerge(out[k], v);
40
+ } else {
41
+ out[k] = v;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export async function updateStackRuntimeStateFile(statePath, patch) {
48
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
49
+ const next = deepMerge(existing, patch ?? {});
50
+ await writeStackRuntimeStateFile(statePath, next);
51
+ return next;
52
+ }
53
+
54
+ export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports } = {}) {
55
+ const now = new Date().toISOString();
56
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
57
+ const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
58
+ const next = deepMerge(existing, {
59
+ version: 1,
60
+ stackName,
61
+ script,
62
+ ephemeral: Boolean(ephemeral),
63
+ ownerPid,
64
+ ports: ports ?? {},
65
+ startedAt,
66
+ updatedAt: now,
67
+ });
68
+ await writeStackRuntimeStateFile(statePath, next);
69
+ return next;
70
+ }
71
+
72
+ export async function recordStackRuntimeUpdate(statePath, patch = {}) {
73
+ return await updateStackRuntimeStateFile(statePath, {
74
+ ...(patch ?? {}),
75
+ updatedAt: new Date().toISOString(),
76
+ });
77
+ }
78
+
79
+ export async function deleteStackRuntimeStateFile(statePath) {
80
+ try {
81
+ if (!statePath || !existsSync(statePath)) return;
82
+ await unlink(statePath);
83
+ } catch {
84
+ // ignore
85
+ }
86
+ }
87
+
@@ -0,0 +1,45 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { getLegacyStorageRoot, getStacksStorageRoot } from '../paths/paths.mjs';
6
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
7
+ import { resolveStackEnvPath } from '../paths/paths.mjs';
8
+
9
+ export async function listAllStackNames() {
10
+ const names = new Set(['main']);
11
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
12
+ const roots = [
13
+ // New layout: ~/.happy/stacks/<name>/env
14
+ getStacksStorageRoot(),
15
+ // Legacy layout: ~/.happy/local/stacks/<name>/env
16
+ ...(allowLegacy ? [join(getLegacyStorageRoot(), 'stacks')] : []),
17
+ ];
18
+
19
+ for (const root of roots) {
20
+ let entries = [];
21
+ try {
22
+ // eslint-disable-next-line no-await-in-loop
23
+ entries = await readdir(root, { withFileTypes: true });
24
+ } catch {
25
+ entries = [];
26
+ }
27
+ for (const ent of entries) {
28
+ if (!ent.isDirectory()) continue;
29
+ const name = ent.name;
30
+ if (!name || name.startsWith('.')) continue;
31
+ const envPath = join(root, name, 'env');
32
+ if (existsSync(envPath)) {
33
+ names.add(name);
34
+ }
35
+ }
36
+ }
37
+
38
+ return Array.from(names).sort();
39
+ }
40
+
41
+ export function stackExistsSync(stackName) {
42
+ const name = String(stackName ?? '').trim() || 'main';
43
+ if (name === 'main') return true;
44
+ return existsSync(resolveStackEnvPath(name).envPath);
45
+ }
@@ -0,0 +1,208 @@
1
+ import { runCapture } from '../proc/proc.mjs';
2
+ import { ensureDepsInstalled, pmExecBin } from '../proc/pm.mjs';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ function looksLikeMissingTableError(msg) {
7
+ const s = String(msg ?? '').toLowerCase();
8
+ return s.includes('does not exist') || s.includes('no such table');
9
+ }
10
+
11
+ async function probeAccountCount({ serverDir, env }) {
12
+ const probe = `
13
+ let db;
14
+ try {
15
+ const { PrismaClient } = await import('@prisma/client');
16
+ db = new PrismaClient();
17
+ const accountCount = await db.account.count();
18
+ console.log(JSON.stringify({ accountCount }));
19
+ } catch (e) {
20
+ console.log(
21
+ JSON.stringify({
22
+ error: {
23
+ name: e?.name,
24
+ message: e?.message,
25
+ code: e?.code,
26
+ },
27
+ })
28
+ );
29
+ } finally {
30
+ try {
31
+ await db?.$disconnect();
32
+ } catch {
33
+ // ignore
34
+ }
35
+ }
36
+ `.trim();
37
+
38
+ const out = await runCapture(process.execPath, ['--input-type=module', '-e', probe], { cwd: serverDir, env, timeoutMs: 15_000 });
39
+ const parsed = out.trim() ? JSON.parse(out.trim()) : {};
40
+ if (parsed?.error) {
41
+ const e = new Error(parsed.error.message || 'unknown prisma probe error');
42
+ if (typeof parsed.error.name === 'string' && parsed.error.name) e.name = parsed.error.name;
43
+ if (typeof parsed.error.code === 'string' && parsed.error.code) e.code = parsed.error.code;
44
+ throw e;
45
+ }
46
+ return Number(parsed.accountCount ?? 0);
47
+ }
48
+
49
+ export function resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive }) {
50
+ const raw = (env.HAPPY_STACKS_AUTO_AUTH_SEED ?? env.HAPPY_LOCAL_AUTO_AUTH_SEED ?? '').toString().trim();
51
+ if (raw) return raw !== '0';
52
+
53
+ // Legacy toggle (kept for existing setups):
54
+ // - if set, it only controls enable/disable; source stack remains configurable via HAPPY_STACKS_AUTH_SEED_FROM.
55
+ const legacy = (env.HAPPY_STACKS_AUTO_COPY_FROM_MAIN ?? env.HAPPY_LOCAL_AUTO_COPY_FROM_MAIN ?? '').toString().trim();
56
+ if (legacy) return legacy !== '0';
57
+
58
+ if (stackName === 'main') return false;
59
+
60
+ // Default:
61
+ // - always auto-seed in non-interactive contexts (agents/services)
62
+ // - in interactive shells, auto-seed only when the user explicitly configured a non-main seed stack
63
+ // (this avoids silently spreading main identity for users who haven't opted in yet).
64
+ if (!isInteractive) return true;
65
+ const seed = (env.HAPPY_STACKS_AUTH_SEED_FROM ?? env.HAPPY_LOCAL_AUTH_SEED_FROM ?? '').toString().trim();
66
+ return Boolean(seed && seed !== 'main');
67
+ }
68
+
69
+ export function resolveAuthSeedFromEnv(env) {
70
+ // Back-compat for an earlier experimental var name:
71
+ // - if set to a non-bool-ish stack name, treat it as the seed source
72
+ // - if set to "1"/"true", ignore (source comes from HAPPY_STACKS_AUTH_SEED_FROM)
73
+ const legacyAutoFrom = (env.HAPPY_STACKS_AUTO_AUTH_SEED_FROM ?? env.HAPPY_LOCAL_AUTO_AUTH_SEED_FROM ?? '').toString().trim();
74
+ if (legacyAutoFrom && legacyAutoFrom !== '0' && legacyAutoFrom !== '1' && legacyAutoFrom.toLowerCase() !== 'true') {
75
+ return legacyAutoFrom;
76
+ }
77
+ // Legacy toggle: "on" implies main (historical behavior).
78
+ const legacy = (env.HAPPY_STACKS_AUTO_COPY_FROM_MAIN ?? env.HAPPY_LOCAL_AUTO_COPY_FROM_MAIN ?? '').toString().trim();
79
+ if (legacy && legacy !== '0') return 'main';
80
+ // Otherwise, use the general default seed stack.
81
+ const seed = (env.HAPPY_STACKS_AUTH_SEED_FROM ?? env.HAPPY_LOCAL_AUTH_SEED_FROM ?? '').toString().trim();
82
+ return seed || 'main';
83
+ }
84
+
85
+ export async function ensureServerLightSchemaReady({ serverDir, env }) {
86
+ await ensureDepsInstalled(serverDir, 'happy-server-light');
87
+
88
+ try {
89
+ const accountCount = await probeAccountCount({ serverDir, env });
90
+ return { ok: true, pushed: false, accountCount };
91
+ } catch (e) {
92
+ const msg = e instanceof Error ? e.message : String(e);
93
+ if (!looksLikeMissingTableError(msg)) {
94
+ throw e;
95
+ }
96
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['db', 'push'], env });
97
+ const accountCount = await probeAccountCount({ serverDir, env });
98
+ return { ok: true, pushed: true, accountCount };
99
+ }
100
+ }
101
+
102
+ export async function ensureHappyServerSchemaReady({ serverDir, env }) {
103
+ await ensureDepsInstalled(serverDir, 'happy-server');
104
+
105
+ try {
106
+ const accountCount = await probeAccountCount({ serverDir, env });
107
+ return { ok: true, migrated: false, accountCount };
108
+ } catch (e) {
109
+ const msg = e instanceof Error ? e.message : String(e);
110
+ if (!looksLikeMissingTableError(msg)) {
111
+ throw e;
112
+ }
113
+ // If tables are missing, try migrations (safe for postgres). Then re-probe.
114
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
115
+ const accountCount = await probeAccountCount({ serverDir, env });
116
+ return { ok: true, migrated: true, accountCount };
117
+ }
118
+ }
119
+
120
+ export async function getAccountCountForServerComponent({ serverComponentName, serverDir, env, bestEffort = false }) {
121
+ if (serverComponentName === 'happy-server-light') {
122
+ const ready = await ensureServerLightSchemaReady({ serverDir, env });
123
+ return { ok: true, accountCount: Number.isFinite(ready.accountCount) ? ready.accountCount : 0 };
124
+ }
125
+ if (serverComponentName === 'happy-server') {
126
+ try {
127
+ const ready = await ensureHappyServerSchemaReady({ serverDir, env });
128
+ return { ok: true, accountCount: Number.isFinite(ready.accountCount) ? ready.accountCount : 0 };
129
+ } catch (e) {
130
+ if (!bestEffort) throw e;
131
+ return { ok: false, accountCount: null, error: e instanceof Error ? e.message : String(e) };
132
+ }
133
+ }
134
+ return { ok: false, accountCount: null, error: `unknown server component: ${serverComponentName}` };
135
+ }
136
+
137
+ export async function maybeAutoCopyAuthFromMainIfNeeded({
138
+ rootDir,
139
+ env,
140
+ enabled,
141
+ stackName,
142
+ cliHomeDir,
143
+ accountCount,
144
+ quiet = false,
145
+ authEnv = null,
146
+ }) {
147
+ const accessKeyPath = join(cliHomeDir, 'access.key');
148
+ const hasAccessKey = existsSync(accessKeyPath);
149
+
150
+ // "Initialized" heuristic:
151
+ // - if we have credentials AND (when known) at least one Account row, we don't need to seed from main.
152
+ const hasAccounts = typeof accountCount === 'number' ? accountCount > 0 : null;
153
+ const needsSeed = !hasAccessKey || hasAccounts === false;
154
+
155
+ if (!enabled || !needsSeed) {
156
+ return { ok: true, skipped: true, reason: !enabled ? 'disabled' : 'already_initialized' };
157
+ }
158
+
159
+ const reason = !hasAccessKey ? 'missing_credentials' : 'no_accounts';
160
+ const fromStackName = resolveAuthSeedFromEnv(env);
161
+ const linkAuth =
162
+ (env.HAPPY_STACKS_AUTH_LINK ?? env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
163
+ (env.HAPPY_STACKS_AUTH_MODE ?? env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
164
+ if (!quiet) {
165
+ console.log(`[local] auth: auto seed from ${fromStackName} for ${stackName} (${reason})`);
166
+ }
167
+
168
+ // Best-effort: copy credentials/master secret + seed accounts from the configured seed stack.
169
+ // Keep this non-fatal; the daemon will emit actionable errors if it still can't authenticate.
170
+ try {
171
+ const out = await runCapture(
172
+ process.execPath,
173
+ [`${rootDir}/scripts/auth.mjs`, 'copy-from', fromStackName, '--json', ...(linkAuth ? ['--link'] : [])],
174
+ {
175
+ cwd: rootDir,
176
+ env: authEnv && typeof authEnv === 'object' ? authEnv : env,
177
+ }
178
+ );
179
+ return { ok: true, skipped: false, reason, out: out.trim() ? JSON.parse(out) : null };
180
+ } catch (e) {
181
+ return { ok: false, skipped: false, reason, error: e instanceof Error ? e.message : String(e) };
182
+ }
183
+ }
184
+
185
+ export async function prepareDaemonAuthSeedIfNeeded({
186
+ rootDir,
187
+ env,
188
+ stackName,
189
+ cliHomeDir,
190
+ startDaemon,
191
+ isInteractive,
192
+ accountCount,
193
+ quiet = false,
194
+ authEnv = null,
195
+ }) {
196
+ if (!startDaemon) return { ok: true, skipped: true, reason: 'no_daemon' };
197
+ const enabled = resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive });
198
+ return await maybeAutoCopyAuthFromMainIfNeeded({
199
+ rootDir,
200
+ env,
201
+ enabled,
202
+ stackName,
203
+ cliHomeDir,
204
+ accountCount,
205
+ quiet,
206
+ authEnv,
207
+ });
208
+ }