happy-stacks 0.3.0 → 0.5.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 (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,23 @@
1
+ import { getStackName, resolveStackEnvPath } from './paths/paths.mjs';
2
+ import { getStackRuntimeStatePath } from './stack_runtime_state.mjs';
3
+
4
+ export function resolveStackContext({ env = process.env, autostart = null } = {}) {
5
+ const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
6
+ const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName(env);
7
+ const stackMode = Boolean(explicitStack);
8
+
9
+ const envPath =
10
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
+ resolveStackEnvPath(stackName, env).envPath;
12
+
13
+ const runtimeStatePath =
14
+ (env.HAPPY_STACKS_RUNTIME_STATE_PATH ?? env.HAPPY_LOCAL_RUNTIME_STATE_PATH ?? '').toString().trim() ||
15
+ getStackRuntimeStatePath(stackName);
16
+
17
+ const explicitEphemeral =
18
+ (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
19
+ const ephemeral = explicitEphemeral || (stackMode && stackName !== 'main');
20
+
21
+ return { stackMode, stackName, envPath, runtimeStatePath, ephemeral };
22
+ }
23
+
@@ -0,0 +1,104 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import { resolveStackEnvPath } from './paths/paths.mjs';
6
+
7
+ export function getStackRuntimeStatePath(stackName) {
8
+ const { baseDir } = resolveStackEnvPath(stackName);
9
+ return join(baseDir, 'stack.runtime.json');
10
+ }
11
+
12
+ export function isPidAlive(pid) {
13
+ const n = Number(pid);
14
+ if (!Number.isFinite(n) || n <= 1) return false;
15
+ try {
16
+ process.kill(n, 0);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export async function readStackRuntimeStateFile(statePath) {
24
+ try {
25
+ if (!statePath || !existsSync(statePath)) return null;
26
+ const raw = await readFile(statePath, 'utf-8');
27
+ const parsed = JSON.parse(raw);
28
+ return parsed && typeof parsed === 'object' ? parsed : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export async function writeStackRuntimeStateFile(statePath, state) {
35
+ if (!statePath) {
36
+ throw new Error('[stack] missing runtime state path');
37
+ }
38
+ const dir = dirname(statePath);
39
+ await mkdir(dir, { recursive: true }).catch(() => {});
40
+ const tmp = join(dir, `.stack.runtime.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
41
+ await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
42
+ await rename(tmp, statePath);
43
+ }
44
+
45
+ function isPlainObject(v) {
46
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
47
+ }
48
+
49
+ function deepMerge(a, b) {
50
+ if (!isPlainObject(a) || !isPlainObject(b)) {
51
+ return b;
52
+ }
53
+ const out = { ...a };
54
+ for (const [k, v] of Object.entries(b)) {
55
+ if (isPlainObject(out[k]) && isPlainObject(v)) {
56
+ out[k] = deepMerge(out[k], v);
57
+ } else {
58
+ out[k] = v;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export async function updateStackRuntimeStateFile(statePath, patch) {
65
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
66
+ const next = deepMerge(existing, patch ?? {});
67
+ await writeStackRuntimeStateFile(statePath, next);
68
+ return next;
69
+ }
70
+
71
+ export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports } = {}) {
72
+ const now = new Date().toISOString();
73
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
74
+ const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
75
+ const next = deepMerge(existing, {
76
+ version: 1,
77
+ stackName,
78
+ script,
79
+ ephemeral: Boolean(ephemeral),
80
+ ownerPid,
81
+ ports: ports ?? {},
82
+ startedAt,
83
+ updatedAt: now,
84
+ });
85
+ await writeStackRuntimeStateFile(statePath, next);
86
+ return next;
87
+ }
88
+
89
+ export async function recordStackRuntimeUpdate(statePath, patch = {}) {
90
+ return await updateStackRuntimeStateFile(statePath, {
91
+ ...(patch ?? {}),
92
+ updatedAt: new Date().toISOString(),
93
+ });
94
+ }
95
+
96
+ export async function deleteStackRuntimeStateFile(statePath) {
97
+ try {
98
+ if (!statePath || !existsSync(statePath)) return;
99
+ await unlink(statePath);
100
+ } catch {
101
+ // ignore
102
+ }
103
+ }
104
+
@@ -0,0 +1,38 @@
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
+
8
+ export async function listAllStackNames() {
9
+ const names = new Set(['main']);
10
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
11
+ const roots = [
12
+ // New layout: ~/.happy/stacks/<name>/env
13
+ getStacksStorageRoot(),
14
+ // Legacy layout: ~/.happy/local/stacks/<name>/env
15
+ ...(allowLegacy ? [join(getLegacyStorageRoot(), 'stacks')] : []),
16
+ ];
17
+
18
+ for (const root of roots) {
19
+ let entries = [];
20
+ try {
21
+ // eslint-disable-next-line no-await-in-loop
22
+ entries = await readdir(root, { withFileTypes: true });
23
+ } catch {
24
+ entries = [];
25
+ }
26
+ for (const ent of entries) {
27
+ if (!ent.isDirectory()) continue;
28
+ const name = ent.name;
29
+ if (!name || name.startsWith('.')) continue;
30
+ const envPath = join(root, name, 'env');
31
+ if (existsSync(envPath)) {
32
+ names.add(name);
33
+ }
34
+ }
35
+ }
36
+
37
+ return Array.from(names).sort();
38
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tailscale IP detection utilities.
3
+ *
4
+ * Provides functions to detect the local Tailscale IPv4 address for port forwarding.
5
+ */
6
+
7
+ import { runCaptureResult } from '../proc/proc.mjs';
8
+ import { resolveCommandPath } from '../proc/commands.mjs';
9
+ import { access, constants } from 'node:fs/promises';
10
+
11
+ const TAILSCALE_TIMEOUT_MS = 3000;
12
+
13
+ /**
14
+ * Check if a path is executable.
15
+ */
16
+ async function isExecutable(path) {
17
+ try {
18
+ await access(path, constants.X_OK);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Tailscale env: strip XPC_SERVICE_NAME which can cause hangs in LaunchAgent contexts.
27
+ */
28
+ function tailscaleEnv() {
29
+ const env = { ...process.env };
30
+ delete env.XPC_SERVICE_NAME;
31
+ return env;
32
+ }
33
+
34
+ /**
35
+ * Resolve the tailscale CLI path.
36
+ *
37
+ * Priority:
38
+ * 1. HAPPY_LOCAL_TAILSCALE_BIN env override
39
+ * 2. PATH lookup
40
+ * 3. macOS app bundle paths
41
+ */
42
+ export async function resolveTailscaleCmd() {
43
+ // Explicit override
44
+ if (process.env.HAPPY_LOCAL_TAILSCALE_BIN?.trim()) {
45
+ return process.env.HAPPY_LOCAL_TAILSCALE_BIN.trim();
46
+ }
47
+
48
+ // Try PATH first
49
+ try {
50
+ const found = await resolveCommandPath('tailscale', { env: tailscaleEnv(), timeoutMs: TAILSCALE_TIMEOUT_MS });
51
+ if (found) return found;
52
+ } catch {
53
+ // ignore
54
+ }
55
+
56
+ // macOS app bundle paths
57
+ const appCliPath = '/Applications/Tailscale.app/Contents/MacOS/tailscale';
58
+ if (await isExecutable(appCliPath)) return appCliPath;
59
+
60
+ const appPath = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
61
+ if (await isExecutable(appPath)) return appPath;
62
+
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Get the local Tailscale IPv4 address.
68
+ *
69
+ * @returns {Promise<string | null>} The Tailscale IPv4 address, or null if unavailable.
70
+ */
71
+ export async function getTailscaleIpv4() {
72
+ const cmd = await resolveTailscaleCmd();
73
+ if (!cmd) return null;
74
+
75
+ const result = await runCaptureResult(cmd, ['ip', '-4'], {
76
+ env: tailscaleEnv(),
77
+ timeoutMs: TAILSCALE_TIMEOUT_MS,
78
+ });
79
+
80
+ if (!result.ok) return null;
81
+
82
+ const ip = result.out.trim().split('\n')[0]?.trim();
83
+ // Validate IPv4 format (basic check)
84
+ if (!ip || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) return null;
85
+
86
+ return ip;
87
+ }
88
+
89
+ /**
90
+ * Check if Tailscale is available and connected.
91
+ *
92
+ * @returns {Promise<boolean>}
93
+ */
94
+ export async function isTailscaleAvailable() {
95
+ const ip = await getTailscaleIpv4();
96
+ return Boolean(ip);
97
+ }
98
+
99
+ /**
100
+ * Get Tailscale status information.
101
+ *
102
+ * @returns {Promise<{ available: boolean, ip: string | null, error: string | null }>}
103
+ */
104
+ export async function getTailscaleStatus() {
105
+ const cmd = await resolveTailscaleCmd();
106
+ if (!cmd) {
107
+ return { available: false, ip: null, error: 'tailscale CLI not found' };
108
+ }
109
+
110
+ const ip = await getTailscaleIpv4();
111
+ if (!ip) {
112
+ return { available: false, ip: null, error: 'tailscale not connected or no IPv4 address' };
113
+ }
114
+
115
+ return { available: true, ip, error: null };
116
+ }
@@ -0,0 +1,39 @@
1
+ function supportsAnsi() {
2
+ if (!process.stdout.isTTY) return false;
3
+ if (process.env.NO_COLOR) return false;
4
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
5
+ return true;
6
+ }
7
+
8
+ function wrap(code, s) {
9
+ return supportsAnsi() ? `\x1b[${code}m${s}\x1b[0m` : String(s);
10
+ }
11
+
12
+ export function ansiEnabled() {
13
+ return supportsAnsi();
14
+ }
15
+
16
+ export function bold(s) {
17
+ return wrap('1', s);
18
+ }
19
+
20
+ export function dim(s) {
21
+ return wrap('2', s);
22
+ }
23
+
24
+ export function red(s) {
25
+ return wrap('31', s);
26
+ }
27
+
28
+ export function green(s) {
29
+ return wrap('32', s);
30
+ }
31
+
32
+ export function yellow(s) {
33
+ return wrap('33', s);
34
+ }
35
+
36
+ export function cyan(s) {
37
+ return wrap('36', s);
38
+ }
39
+
@@ -0,0 +1,17 @@
1
+ import qrcodeTerminal from 'qrcode-terminal';
2
+
3
+ export async function renderQrAscii(text, { small = true } = {}) {
4
+ const qrText = String(text ?? '');
5
+ if (!qrText) return { ok: false, lines: [], error: 'empty QR payload' };
6
+ try {
7
+ const out = await new Promise((resolvePromise) => {
8
+ qrcodeTerminal.generate(qrText, { small: Boolean(small) }, (qr) => resolvePromise(String(qr ?? '')));
9
+ });
10
+ // Important: keep whitespace; scanners rely on quiet-zone padding.
11
+ const lines = String(out ?? '').replace(/\r/g, '').split('\n');
12
+ return { ok: true, lines, error: null };
13
+ } catch (e) {
14
+ return { ok: false, lines: [], error: e instanceof Error ? e.message : String(e) };
15
+ }
16
+ }
17
+
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import { getComponentsDir } from './paths/paths.mjs';
4
+
5
+ function isInside(path, dir) {
6
+ const p = resolve(path);
7
+ const d = resolve(dir);
8
+ return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
9
+ }
10
+
11
+ export function detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir }) {
12
+ const componentsDir = getComponentsDir(rootDir);
13
+
14
+ const other = serverComponentName === 'happy-server-light' ? 'happy-server' : serverComponentName === 'happy-server' ? 'happy-server-light' : null;
15
+ if (!other) {
16
+ return null;
17
+ }
18
+
19
+ const otherRepo = resolve(componentsDir, other);
20
+ const otherWts = resolve(componentsDir, '.worktrees', other);
21
+
22
+ if (isInside(serverDir, otherRepo) || isInside(serverDir, otherWts)) {
23
+ return { expected: serverComponentName, actual: other, serverDir };
24
+ }
25
+
26
+ return null;
27
+ }
28
+
29
+ export function assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir }) {
30
+ const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir });
31
+ if (!mismatch) {
32
+ return;
33
+ }
34
+
35
+ const hint =
36
+ mismatch.expected === 'happy-server-light'
37
+ ? 'Fix: either switch flavor (`happys srv use happy-server`) or switch the active checkout for happy-server-light (`happys wt use happy-server-light default` or a worktree under .worktrees/happy-server-light/).'
38
+ : 'Fix: either switch flavor (`happys srv use happy-server-light`) or switch the active checkout for happy-server (`happys wt use happy-server default` or a worktree under .worktrees/happy-server/).';
39
+
40
+ throw new Error(
41
+ `[server] server component dir mismatch:\n` +
42
+ `- selected flavor: ${mismatch.expected}\n` +
43
+ `- but HAPPY_STACKS_COMPONENT_DIR_* points inside: ${mismatch.actual}\n` +
44
+ `- path: ${mismatch.serverDir}\n` +
45
+ `${hint}`
46
+ );
47
+ }
48
+
49
+ function detectPrismaProvider(schemaText) {
50
+ // Best-effort parse of:
51
+ // datasource db { provider = "sqlite" ... }
52
+ const m = schemaText.match(/datasource\s+db\s*\{[\s\S]*?\bprovider\s*=\s*\"([a-zA-Z0-9_-]+)\"/m);
53
+ return m?.[1] ?? '';
54
+ }
55
+
56
+ export function assertServerPrismaProviderMatches({ serverComponentName, serverDir }) {
57
+ const schemaPath = join(serverDir, 'prisma', 'schema.prisma');
58
+ let schemaText = '';
59
+ try {
60
+ schemaText = readFileSync(schemaPath, 'utf-8');
61
+ } catch {
62
+ // If it doesn't exist, skip validation; not every server component necessarily uses Prisma.
63
+ return;
64
+ }
65
+
66
+ const provider = detectPrismaProvider(schemaText);
67
+ if (!provider) {
68
+ return;
69
+ }
70
+
71
+ if (serverComponentName === 'happy-server-light' && provider !== 'sqlite') {
72
+ throw new Error(
73
+ `[server] happy-server-light expects Prisma datasource provider \"sqlite\", but found \"${provider}\" in:\n` +
74
+ `- ${schemaPath}\n` +
75
+ `This usually means you're pointing happy-server-light at an upstream happy-server checkout/PR (Postgres).\n` +
76
+ `Fix: either switch server flavor to happy-server, or point happy-server-light at a fork checkout that keeps sqlite support.`
77
+ );
78
+ }
79
+
80
+ if (serverComponentName === 'happy-server' && provider === 'sqlite') {
81
+ throw new Error(
82
+ `[server] happy-server expects Prisma datasource provider \"postgresql\", but found \"sqlite\" in:\n` +
83
+ `- ${schemaPath}\n` +
84
+ `Fix: either switch server flavor to happy-server-light, or point happy-server at the full-server checkout.`
85
+ );
86
+ }
87
+ }
88
+
package/scripts/where.mjs CHANGED
@@ -26,8 +26,8 @@ async function main() {
26
26
  if (wantsHelp(argv, { flags }) || argv.includes('help')) {
27
27
  printResult({
28
28
  json,
29
- data: { flags: ['--json'], commands: ['where', 'env'] },
30
- text: ['[where] usage:', ' happys where [--json]', ' happys env [--json]'].join('\n'),
29
+ data: { flags: ['--json'], commands: ['where'] },
30
+ text: ['[where] usage:', ' happys where [--json]'].join('\n'),
31
31
  });
32
32
  return;
33
33
  }