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,185 @@
1
+ import { join, resolve } from 'node:path';
2
+
3
+ import { prompt, promptWorktreeSource } from '../cli/wizard.mjs';
4
+ import { coerceHappyMonorepoRootFromPath, getComponentsDir } from '../paths/paths.mjs';
5
+ import { resolveComponentSpecToDir } from '../git/worktrees.mjs';
6
+
7
+ function wantsNo(raw) {
8
+ const v = String(raw ?? '').trim().toLowerCase();
9
+ return v === 'n' || v === 'no' || v === '0' || v === 'false';
10
+ }
11
+
12
+ function resolveHappySpecDir({ rootDir, spec }) {
13
+ if (!spec) return '';
14
+ if (spec === 'default' || spec === 'main') {
15
+ return join(getComponentsDir(rootDir), 'happy');
16
+ }
17
+ if (typeof spec === 'string') {
18
+ const dir = resolveComponentSpecToDir({ rootDir, component: 'happy', spec });
19
+ return dir ? resolve(rootDir, dir) : '';
20
+ }
21
+ return '';
22
+ }
23
+
24
+ export async function interactiveNew({ rootDir, rl, defaults, deps = {} }) {
25
+ const promptFn = deps.prompt ?? prompt;
26
+ const promptWorktreeSourceFn = deps.promptWorktreeSource ?? promptWorktreeSource;
27
+
28
+ const out = { ...defaults };
29
+
30
+ if (!out.stackName) {
31
+ out.stackName = (await rl.question('Stack name: ')).trim();
32
+ }
33
+ if (!out.stackName) {
34
+ throw new Error('[stack] stack name is required');
35
+ }
36
+ if (out.stackName === 'main') {
37
+ throw new Error('[stack] stack name "main" is reserved (use the default stack without creating it)');
38
+ }
39
+
40
+ if (!out.serverComponent) {
41
+ const server = (await rl.question('Server component [happy-server-light|happy-server] (default: happy-server-light): ')).trim();
42
+ out.serverComponent = server || 'happy-server-light';
43
+ }
44
+
45
+ if (!out.port) {
46
+ const want = (await rl.question('Port (empty = ephemeral): ')).trim();
47
+ out.port = want ? Number(want) : null;
48
+ }
49
+
50
+ if (!out.createRemote) {
51
+ out.createRemote = await promptFn(rl, 'Git remote for creating new worktrees (default: upstream): ', { defaultValue: 'upstream' });
52
+ }
53
+
54
+ if (out.components.happy == null) {
55
+ out.components.happy = await promptWorktreeSourceFn({
56
+ rl,
57
+ rootDir,
58
+ component: 'happy',
59
+ stackName: out.stackName,
60
+ createRemote: out.createRemote,
61
+ });
62
+ }
63
+
64
+ const happyIsCreate = Boolean(out.components.happy && typeof out.components.happy === 'object' && out.components.happy.create);
65
+ const happyMonoRoot = coerceHappyMonorepoRootFromPath(resolveHappySpecDir({ rootDir, spec: out.components.happy }));
66
+ const canDeriveMonorepoGroup = Boolean(happyMonoRoot) || happyIsCreate;
67
+ let deriveMonorepoGroup = false;
68
+
69
+ if (canDeriveMonorepoGroup) {
70
+ const ans = await promptFn(rl, 'Detected happy monorepo checkout. Derive happy-cli + happy-server from it? [Y/n]: ', {
71
+ defaultValue: 'y',
72
+ });
73
+ deriveMonorepoGroup = !wantsNo(ans);
74
+ }
75
+
76
+ if (deriveMonorepoGroup) {
77
+ out.components['happy-cli'] = null;
78
+ out.components['happy-server'] = null;
79
+ // In monorepo mode, happy-server-light is derived when supported by the monorepo server checkout.
80
+ // If not supported, the stack env will keep the default separate happy-server-light checkout.
81
+ out.components['happy-server-light'] = null;
82
+ } else if (out.components['happy-cli'] == null) {
83
+ out.components['happy-cli'] = await promptWorktreeSourceFn({
84
+ rl,
85
+ rootDir,
86
+ component: 'happy-cli',
87
+ stackName: out.stackName,
88
+ createRemote: out.createRemote,
89
+ });
90
+ }
91
+
92
+ const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
93
+ if (serverComponent === 'happy-server-light' && deriveMonorepoGroup) {
94
+ out.components['happy-server-light'] = null;
95
+ return out;
96
+ }
97
+ if (serverComponent === 'happy-server' && deriveMonorepoGroup) {
98
+ out.components['happy-server'] = null;
99
+ } else if (out.components[serverComponent] == null) {
100
+ out.components[serverComponent] = await promptWorktreeSourceFn({
101
+ rl,
102
+ rootDir,
103
+ component: serverComponent,
104
+ stackName: out.stackName,
105
+ createRemote: out.createRemote,
106
+ });
107
+ }
108
+
109
+ return out;
110
+ }
111
+
112
+ export async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults, deps = {} }) {
113
+ const promptFn = deps.prompt ?? prompt;
114
+ const promptWorktreeSourceFn = deps.promptWorktreeSource ?? promptWorktreeSource;
115
+
116
+ const out = { ...defaults, stackName };
117
+
118
+ const currentServer = existingEnv.HAPPY_STACKS_SERVER_COMPONENT ?? existingEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '';
119
+ const server = await promptFn(rl, `Server component [happy-server-light|happy-server] (default: ${currentServer || 'happy-server-light'}): `, {
120
+ defaultValue: currentServer || 'happy-server-light',
121
+ });
122
+ out.serverComponent = server || 'happy-server-light';
123
+
124
+ const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
125
+ const wantPort = await promptFn(rl, `Port (empty = keep ${currentPort || 'ephemeral'}; type 'ephemeral' to unpin): `, { defaultValue: '' });
126
+ const wantTrimmed = wantPort.trim().toLowerCase();
127
+ out.port = wantTrimmed === 'ephemeral' ? null : wantPort ? Number(wantPort) : currentPort ? Number(currentPort) : null;
128
+
129
+ const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
130
+ out.createRemote = await promptFn(rl, `Git remote for creating new worktrees (default: ${currentRemote || 'upstream'}): `, {
131
+ defaultValue: currentRemote || 'upstream',
132
+ });
133
+
134
+ out.components.happy = await promptWorktreeSourceFn({
135
+ rl,
136
+ rootDir,
137
+ component: 'happy',
138
+ stackName,
139
+ createRemote: out.createRemote,
140
+ });
141
+
142
+ const happyIsCreate = Boolean(out.components.happy && typeof out.components.happy === 'object' && out.components.happy.create);
143
+ const happyMonoRoot = coerceHappyMonorepoRootFromPath(resolveHappySpecDir({ rootDir, spec: out.components.happy }));
144
+ const canDeriveMonorepoGroup = Boolean(happyMonoRoot) || happyIsCreate;
145
+ let deriveMonorepoGroup = false;
146
+ if (canDeriveMonorepoGroup) {
147
+ const ans = await promptFn(rl, 'Detected happy monorepo checkout. Derive happy-cli + happy-server from it? [Y/n]: ', {
148
+ defaultValue: 'y',
149
+ });
150
+ deriveMonorepoGroup = !wantsNo(ans);
151
+ }
152
+
153
+ if (deriveMonorepoGroup) {
154
+ out.components['happy-cli'] = null;
155
+ out.components['happy-server'] = null;
156
+ out.components['happy-server-light'] = null;
157
+ } else if (out.components['happy-cli'] == null) {
158
+ out.components['happy-cli'] = await promptWorktreeSourceFn({
159
+ rl,
160
+ rootDir,
161
+ component: 'happy-cli',
162
+ stackName,
163
+ createRemote: out.createRemote,
164
+ });
165
+ }
166
+
167
+ const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
168
+ if (serverComponent === 'happy-server-light' && deriveMonorepoGroup) {
169
+ out.components['happy-server-light'] = null;
170
+ return out;
171
+ }
172
+ if (serverComponent === 'happy-server' && deriveMonorepoGroup) {
173
+ out.components['happy-server'] = null;
174
+ } else {
175
+ out.components[serverComponent] = await promptWorktreeSourceFn({
176
+ rl,
177
+ rootDir,
178
+ component: serverComponent,
179
+ stackName,
180
+ createRemote: out.createRemote,
181
+ });
182
+ }
183
+
184
+ return out;
185
+ }
@@ -0,0 +1,16 @@
1
+ import { parseGithubPullRequest } from '../git/refs.mjs';
2
+ import { sanitizeStackName } from './names.mjs';
3
+
4
+ export function inferPrStackBaseName({ happy, happyCli, server, serverLight, fallback = 'pr' }) {
5
+ const parts = [];
6
+ const hn = parseGithubPullRequest(happy)?.number ?? null;
7
+ const cn = parseGithubPullRequest(happyCli)?.number ?? null;
8
+ const sn = parseGithubPullRequest(server)?.number ?? null;
9
+ const sln = parseGithubPullRequest(serverLight)?.number ?? null;
10
+ if (hn) parts.push(`happy${hn}`);
11
+ if (cn) parts.push(`cli${cn}`);
12
+ if (sn) parts.push(`server${sn}`);
13
+ if (sln) parts.push(`light${sln}`);
14
+ return sanitizeStackName(parts.length ? `pr-${parts.join('-')}` : fallback, { fallback, maxLen: 64 });
15
+ }
16
+
@@ -51,7 +51,7 @@ export async function updateStackRuntimeStateFile(statePath, patch) {
51
51
  return next;
52
52
  }
53
53
 
54
- export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports } = {}) {
54
+ export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports, ...rest } = {}) {
55
55
  const now = new Date().toISOString();
56
56
  const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
57
57
  const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
@@ -64,6 +64,7 @@ export async function recordStackRuntimeStart(statePath, { stackName, script, ep
64
64
  ports: ports ?? {},
65
65
  startedAt,
66
66
  updatedAt: now,
67
+ ...(rest ?? {}),
67
68
  });
68
69
  await writeStackRuntimeStateFile(statePath, next);
69
70
  return next;
@@ -1,18 +1,53 @@
1
1
  import { runCapture } from '../proc/proc.mjs';
2
2
  import { ensureDepsInstalled, pmExecBin } from '../proc/pm.mjs';
3
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
3
4
  import { existsSync } from 'node:fs';
4
5
  import { join } from 'node:path';
6
+ import { mkdir } from 'node:fs/promises';
7
+ import { resolvePrismaClientImportForServerComponent, resolveServerLightPrismaMigrateDeployArgs, resolveServerLightPrismaSchemaArgs } from '../server/flavor_scripts.mjs';
5
8
 
6
9
  function looksLikeMissingTableError(msg) {
7
10
  const s = String(msg ?? '').toLowerCase();
8
11
  return s.includes('does not exist') || s.includes('no such table');
9
12
  }
10
13
 
11
- async function probeAccountCount({ serverDir, env }) {
14
+ function looksLikeAlreadyExistsError(msg) {
15
+ const s = String(msg ?? '').toLowerCase();
16
+ return s.includes('already exists') || s.includes('duplicate') || s.includes('constraint failed');
17
+ }
18
+
19
+ function looksLikeMissingGeneratedSqliteClientError(err) {
20
+ const code = err && typeof err === 'object' ? err.code : '';
21
+ if (code !== 'ERR_MODULE_NOT_FOUND') return false;
22
+ const msg = err instanceof Error ? err.message : String(err ?? '');
23
+ return msg.includes('/generated/sqlite-client/') || msg.includes('\\generated\\sqlite-client\\');
24
+ }
25
+
26
+ async function findSqliteBaselineMigrationDir({ serverDir }) {
27
+ try {
28
+ // Unified monorepo server-light migrations live under prisma/sqlite/migrations.
29
+ // For legacy schema.sqlite.prisma setups, migrations use the default prisma/migrations folder.
30
+ const migrationsDir = existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
31
+ ? join(serverDir, 'prisma', 'sqlite', 'migrations')
32
+ : join(serverDir, 'prisma', 'migrations');
33
+ const { readdir } = await import('node:fs/promises');
34
+ const entries = await readdir(migrationsDir, { withFileTypes: true });
35
+ const dirs = entries
36
+ .filter((e) => e.isDirectory())
37
+ .map((e) => e.name)
38
+ .sort();
39
+ return dirs[0] || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ async function probeAccountCount({ serverComponentName, serverDir, env }) {
46
+ const clientImport = resolvePrismaClientImportForServerComponent({ serverComponentName, serverDir });
12
47
  const probe = `
13
48
  let db;
14
49
  try {
15
- const { PrismaClient } = await import('@prisma/client');
50
+ const { PrismaClient } = await import(${JSON.stringify(clientImport)});
16
51
  db = new PrismaClient();
17
52
  const accountCount = await db.account.count();
18
53
  console.log(JSON.stringify({ accountCount }));
@@ -47,6 +82,12 @@ async function probeAccountCount({ serverDir, env }) {
47
82
  }
48
83
 
49
84
  export function resolveAutoCopyFromMainEnabled({ env, stackName, isInteractive }) {
85
+ // Sandboxes should be isolated by default.
86
+ // Auto auth seeding can copy credentials/account rows from another stack (global state),
87
+ // which breaks isolation and can confuse guided auth flows (setup-pr/review-pr).
88
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
89
+ return false;
90
+ }
50
91
  const raw = (env.HAPPY_STACKS_AUTO_AUTH_SEED ?? env.HAPPY_LOCAL_AUTO_AUTH_SEED ?? '').toString().trim();
51
92
  if (raw) return raw !== '0';
52
93
 
@@ -83,27 +124,93 @@ export function resolveAuthSeedFromEnv(env) {
83
124
  }
84
125
 
85
126
  export async function ensureServerLightSchemaReady({ serverDir, env }) {
86
- await ensureDepsInstalled(serverDir, 'happy-server-light');
127
+ await ensureDepsInstalled(serverDir, 'happy-server-light', { env });
87
128
 
129
+ const dataDir = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').toString().trim();
130
+ const filesDir = (env?.HAPPY_SERVER_LIGHT_FILES_DIR ?? '').toString().trim() || (dataDir ? join(dataDir, 'files') : '');
131
+ if (dataDir) {
132
+ try {
133
+ await mkdir(dataDir, { recursive: true });
134
+ } catch {
135
+ // best-effort
136
+ }
137
+ }
138
+ if (filesDir) {
139
+ try {
140
+ await mkdir(filesDir, { recursive: true });
141
+ } catch {
142
+ // best-effort
143
+ }
144
+ }
145
+
146
+ const probe = async () => await probeAccountCount({ serverComponentName: 'happy-server-light', serverDir, env });
147
+ const schemaArgs = resolveServerLightPrismaSchemaArgs({ serverDir });
148
+
149
+ const isUnified = schemaArgs.length > 0;
150
+
151
+ // Unified server-light (monorepo): ensure deterministic migrations are applied (idempotent).
152
+ // Legacy server-light (single schema.prisma with db push): do NOT run `prisma migrate deploy`,
153
+ // because it commonly fails with P3005 when the DB was created by `prisma db push` and no migrations exist.
154
+ if (isUnified) {
155
+ try {
156
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
157
+ } catch (e) {
158
+ const msg = e instanceof Error ? e.message : String(e);
159
+ // If the SQLite DB was created before migrations existed (historical db push era),
160
+ // `migrate deploy` can fail because tables already exist. Best-effort: baseline-resolve
161
+ // the first migration, then retry deploy.
162
+ if (looksLikeAlreadyExistsError(msg)) {
163
+ const baseline = await findSqliteBaselineMigrationDir({ serverDir });
164
+ if (baseline) {
165
+ await pmExecBin({
166
+ dir: serverDir,
167
+ bin: 'prisma',
168
+ args: ['migrate', 'resolve', ...schemaArgs, '--applied', baseline],
169
+ env,
170
+ }).catch(() => {});
171
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: resolveServerLightPrismaMigrateDeployArgs({ serverDir }), env });
172
+ } else {
173
+ throw e;
174
+ }
175
+ } else {
176
+ throw e;
177
+ }
178
+ }
179
+ }
180
+
181
+ // 2) Probe account count (used for auth seeding heuristics).
88
182
  try {
89
- const accountCount = await probeAccountCount({ serverDir, env });
90
- return { ok: true, pushed: false, accountCount };
183
+ const accountCount = await probe();
184
+ return { ok: true, migrated: isUnified, accountCount };
91
185
  } catch (e) {
186
+ if (looksLikeMissingGeneratedSqliteClientError(e)) {
187
+ await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['generate', ...schemaArgs], env });
188
+ const accountCount = await probe();
189
+ return { ok: true, migrated: isUnified, accountCount };
190
+ }
92
191
  const msg = e instanceof Error ? e.message : String(e);
93
- if (!looksLikeMissingTableError(msg)) {
94
- throw e;
192
+ if (looksLikeMissingTableError(msg)) {
193
+ if (isUnified) {
194
+ // Tables still missing after migrate deploy; fail closed with a clear error.
195
+ throw new Error(`[server-light] sqlite schema not ready after prisma migrate deploy (missing tables).`);
196
+ }
197
+ // Legacy server-light: schema is typically applied via `prisma db push` in the component's dev/start scripts.
198
+ // Best-effort: don't fail the whole stack startup just because we can't probe here.
199
+ return { ok: true, migrated: false, accountCount: 0 };
200
+ }
201
+ if (!isUnified) {
202
+ // Legacy server-light: probing is best-effort (don't make stack dev fail closed here).
203
+ return { ok: true, migrated: false, accountCount: 0 };
95
204
  }
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 };
205
+ throw e;
99
206
  }
100
207
  }
101
208
 
102
209
  export async function ensureHappyServerSchemaReady({ serverDir, env }) {
103
- await ensureDepsInstalled(serverDir, 'happy-server');
210
+ await ensureDepsInstalled(serverDir, 'happy-server', { env });
104
211
 
105
212
  try {
106
- const accountCount = await probeAccountCount({ serverDir, env });
213
+ const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
107
214
  return { ok: true, migrated: false, accountCount };
108
215
  } catch (e) {
109
216
  const msg = e instanceof Error ? e.message : String(e);
@@ -112,7 +219,7 @@ export async function ensureHappyServerSchemaReady({ serverDir, env }) {
112
219
  }
113
220
  // If tables are missing, try migrations (safe for postgres). Then re-probe.
114
221
  await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['migrate', 'deploy'], env });
115
- const accountCount = await probeAccountCount({ serverDir, env });
222
+ const accountCount = await probeAccountCount({ serverComponentName: 'happy-server', serverDir, env });
116
223
  return { ok: true, migrated: true, accountCount };
117
224
  }
118
225
  }
@@ -0,0 +1,64 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+
8
+ import { ensureServerLightSchemaReady } from './startup.mjs';
9
+
10
+ async function writeJson(path, obj) {
11
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
12
+ }
13
+
14
+ test('ensureServerLightSchemaReady creates stack sqlite data dirs before probing', async (t) => {
15
+ const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-dirs-'));
16
+ t.after(async () => {
17
+ await rm(root, { recursive: true, force: true });
18
+ });
19
+
20
+ const serverDir = join(root, 'server');
21
+ await mkdir(serverDir, { recursive: true });
22
+ await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
23
+ await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
24
+ await mkdir(join(serverDir, 'node_modules'), { recursive: true });
25
+ await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
26
+
27
+ await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
28
+ await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
29
+
30
+ // Provide the generated client so we don't need to run prisma generate in this test.
31
+ await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
32
+ await writeFile(
33
+ join(serverDir, 'generated', 'sqlite-client', 'index.js'),
34
+ ['export class PrismaClient {', ' constructor() { this.account = { count: async () => 0 }; }', ' async $disconnect() {}', '}'].join('\n') +
35
+ '\n',
36
+ 'utf-8'
37
+ );
38
+
39
+ // Minimal stub `yarn` so commandExists('yarn') succeeds.
40
+ const binDir = join(root, 'bin');
41
+ await mkdir(binDir, { recursive: true });
42
+ const yarnPath = join(binDir, 'yarn');
43
+ await writeFile(yarnPath, ['#!/usr/bin/env node', "console.log('1.22.22');"].join('\n') + '\n', 'utf-8');
44
+ await chmod(yarnPath, 0o755);
45
+
46
+ const dataDir = join(root, 'data');
47
+ const filesDir = join(dataDir, 'files');
48
+ const env = {
49
+ ...process.env,
50
+ PATH: `${binDir}:${process.env.PATH ?? ''}`,
51
+ HAPPY_SERVER_LIGHT_DATA_DIR: dataDir,
52
+ HAPPY_SERVER_LIGHT_FILES_DIR: filesDir,
53
+ DATABASE_URL: `file:${join(dataDir, 'happy-server-light.sqlite')}`,
54
+ };
55
+
56
+ assert.equal(existsSync(dataDir), false);
57
+ assert.equal(existsSync(filesDir), false);
58
+
59
+ await ensureServerLightSchemaReady({ serverDir, env });
60
+
61
+ assert.equal(existsSync(dataDir), true);
62
+ assert.equal(existsSync(filesDir), true);
63
+ });
64
+
@@ -0,0 +1,70 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile, chmod } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { ensureServerLightSchemaReady } from './startup.mjs';
8
+
9
+ async function writeJson(path, obj) {
10
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
11
+ }
12
+
13
+ test('ensureServerLightSchemaReady runs prisma generate when unified sqlite client is missing', async (t) => {
14
+ const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-generate-'));
15
+ t.after(async () => {
16
+ await rm(root, { recursive: true, force: true });
17
+ });
18
+
19
+ const serverDir = join(root, 'server');
20
+ await mkdir(serverDir, { recursive: true });
21
+ await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0' });
22
+ await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
23
+
24
+ // Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
25
+ await mkdir(join(serverDir, 'node_modules'), { recursive: true });
26
+ await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
27
+
28
+ // Unified light detection + expected schema path.
29
+ await mkdir(join(serverDir, 'prisma', 'sqlite'), { recursive: true });
30
+ await writeFile(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
31
+
32
+ // generated/sqlite-client exists, but the entrypoint is missing (this triggers ERR_MODULE_NOT_FOUND).
33
+ await mkdir(join(serverDir, 'generated', 'sqlite-client'), { recursive: true });
34
+
35
+ // Provide a stub `yarn` in PATH so pmExecBin("prisma", ...) succeeds without real dependencies.
36
+ const binDir = join(root, 'bin');
37
+ await mkdir(binDir, { recursive: true });
38
+ const yarnPath = join(binDir, 'yarn');
39
+ await writeFile(
40
+ yarnPath,
41
+ [
42
+ '#!/usr/bin/env node',
43
+ "const { writeFileSync } = require('node:fs');",
44
+ "const { join } = require('node:path');",
45
+ 'const cwd = process.cwd();',
46
+ 'const out = join(cwd, "generated", "sqlite-client", "index.js");',
47
+ 'const text = [',
48
+ " 'export class PrismaClient {',",
49
+ " ' constructor() { this.account = { count: async () => 0 }; }',",
50
+ " ' async $disconnect() {}',",
51
+ " '}',",
52
+ "].join('\\n') + '\\n';",
53
+ 'writeFileSync(out, text, "utf-8");',
54
+ 'process.exit(0);',
55
+ ].join('\n') + '\n',
56
+ 'utf-8'
57
+ );
58
+ await chmod(yarnPath, 0o755);
59
+
60
+ const oldPath = process.env.PATH;
61
+ try {
62
+ process.env.PATH = `${binDir}:${oldPath ?? ''}`;
63
+ const res = await ensureServerLightSchemaReady({ serverDir, env: process.env });
64
+ assert.equal(res.ok, true);
65
+ assert.equal(res.migrated, true);
66
+ assert.equal(res.accountCount, 0);
67
+ } finally {
68
+ process.env.PATH = oldPath;
69
+ }
70
+ });
@@ -0,0 +1,88 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+
8
+ import { ensureServerLightSchemaReady } from './startup.mjs';
9
+
10
+ async function writeJson(path, obj) {
11
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
12
+ }
13
+
14
+ test('ensureServerLightSchemaReady does not run prisma migrate deploy for legacy happy-server-light checkouts', async (t) => {
15
+ const root = await mkdtemp(join(tmpdir(), 'hs-startup-sqlite-legacy-'));
16
+ t.after(async () => {
17
+ await rm(root, { recursive: true, force: true });
18
+ });
19
+
20
+ const serverDir = join(root, 'server');
21
+ await mkdir(serverDir, { recursive: true });
22
+ await writeJson(join(serverDir, 'package.json'), { name: 'server', version: '0.0.0', type: 'module' });
23
+ await writeFile(join(serverDir, 'yarn.lock'), '# yarn\n', 'utf-8');
24
+
25
+ // Mark deps as installed so ensureDepsInstalled doesn't attempt a real install.
26
+ await mkdir(join(serverDir, 'node_modules'), { recursive: true });
27
+ await writeFile(join(serverDir, 'node_modules', '.yarn-integrity'), 'ok\n', 'utf-8');
28
+
29
+ // Legacy checkout: no prisma/sqlite/schema.prisma and no prisma/schema.sqlite.prisma.
30
+ // Provide a minimal node_modules @prisma/client so probeAccountCount can succeed.
31
+ await mkdir(join(serverDir, 'node_modules', '@prisma', 'client'), { recursive: true });
32
+ await writeJson(join(serverDir, 'node_modules', '@prisma', 'client', 'package.json'), {
33
+ name: '@prisma/client',
34
+ type: 'module',
35
+ main: './index.js',
36
+ });
37
+ await writeFile(
38
+ join(serverDir, 'node_modules', '@prisma', 'client', 'index.js'),
39
+ [
40
+ 'export class PrismaClient {',
41
+ ' constructor() { this.account = { count: async () => 0 }; }',
42
+ ' async $disconnect() {}',
43
+ '}',
44
+ ].join('\n') + '\n',
45
+ 'utf-8'
46
+ );
47
+
48
+ const marker = join(root, 'called-prisma.txt');
49
+
50
+ // Provide a stub `yarn` so ensureYarnReady + pmExecBin are controllable.
51
+ const binDir = join(root, 'bin');
52
+ await mkdir(binDir, { recursive: true });
53
+ const yarnPath = join(binDir, 'yarn');
54
+ await writeFile(
55
+ yarnPath,
56
+ [
57
+ '#!/usr/bin/env node',
58
+ "const fs = require('node:fs');",
59
+ "const path = require('node:path');",
60
+ "const args = process.argv.slice(2);",
61
+ // ensureYarnReady calls: yarn --version
62
+ "if (args.includes('--version')) { console.log('1.22.22'); process.exit(0); }",
63
+ // pmExecBin calls: yarn run prisma ...
64
+ "if (args[0] === 'run' && args[1] === 'prisma') {",
65
+ ` fs.writeFileSync(${JSON.stringify(marker)}, args.join(' ') + '\\n', 'utf-8');`,
66
+ ' process.exit(0);',
67
+ '}',
68
+ "console.log('ok');",
69
+ 'process.exit(0);',
70
+ ].join('\n') + '\n',
71
+ 'utf-8'
72
+ );
73
+ await chmod(yarnPath, 0o755);
74
+
75
+ const env = {
76
+ ...process.env,
77
+ PATH: `${binDir}:${process.env.PATH ?? ''}`,
78
+ DATABASE_URL: `file:${join(root, 'happy-server-light.sqlite')}`,
79
+ };
80
+
81
+ const res = await ensureServerLightSchemaReady({ serverDir, env });
82
+ assert.equal(res.ok, true);
83
+ assert.equal(res.migrated, false);
84
+ assert.equal(res.accountCount, 0);
85
+
86
+ assert.equal(existsSync(marker), false, `expected no prisma migrate deploy call, but saw: ${marker}`);
87
+ });
88
+
@@ -126,6 +126,7 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
126
126
  daemonSessionsStopped: null,
127
127
  daemonStopped: false,
128
128
  killedPorts: [],
129
+ expoDev: [],
129
130
  uiDev: [],
130
131
  mobile: [],
131
132
  infra: null,
@@ -136,7 +137,13 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
136
137
  const port = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
137
138
  const backendPort = coercePort(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
138
139
  const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
139
- const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
140
+ // IMPORTANT:
141
+ // When stopping a stack, always prefer the stack's pinned happy-cli checkout/worktree.
142
+ // Otherwise, PR stacks can accidentally run the base checkout's CLI bin, which may not be built
143
+ // (we intentionally skip building base checkouts in some sandbox PR flows).
144
+ const pinnedCliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim();
145
+ const cliDir = pinnedCliDir || getComponentDir(rootDir, 'happy-cli');
146
+ const cliBin = join(cliDir, 'bin', 'happy.mjs');
140
147
  const envPath = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
141
148
 
142
149
  // Preferred: stop stack-started processes (by PID) recorded in stack.runtime.json.
@@ -194,12 +201,16 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
194
201
  }
195
202
 
196
203
  try {
197
- actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
204
+ actions.expoDev = await stopExpoStateDir({ stackName, baseDir, kind: 'expo-dev', stateFileName: 'expo.state.json', envPath, json });
198
205
  } catch (e) {
199
- actions.errors.push({ step: 'expo-ui', error: e instanceof Error ? e.message : String(e) });
206
+ actions.errors.push({ step: 'expo-dev', error: e instanceof Error ? e.message : String(e) });
200
207
  }
201
208
  try {
202
- actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
209
+ // Legacy cleanups (best-effort): older runs used separate state dirs.
210
+ actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
211
+ const killedDev = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile-dev', stateFileName: 'mobile.state.json', envPath, json });
212
+ const killedLegacy = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
213
+ actions.mobile = [...killedDev, ...killedLegacy];
203
214
  } catch (e) {
204
215
  actions.errors.push({ step: 'expo-mobile', error: e instanceof Error ? e.message : String(e) });
205
216
  }