happy-stacks 0.4.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 (104) hide show
  1. package/README.md +64 -33
  2. package/bin/happys.mjs +44 -1
  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 +1 -2
  10. package/docs/monorepo-migration.md +286 -0
  11. package/docs/server-flavors.md +19 -3
  12. package/docs/stacks.md +35 -0
  13. package/package.json +1 -1
  14. package/scripts/auth.mjs +21 -3
  15. package/scripts/build.mjs +1 -1
  16. package/scripts/dev.mjs +20 -7
  17. package/scripts/doctor.mjs +0 -4
  18. package/scripts/edison.mjs +2 -2
  19. package/scripts/env.mjs +150 -0
  20. package/scripts/env_cmd.test.mjs +128 -0
  21. package/scripts/init.mjs +5 -2
  22. package/scripts/install.mjs +99 -57
  23. package/scripts/migrate.mjs +3 -12
  24. package/scripts/monorepo.mjs +1096 -0
  25. package/scripts/monorepo_port.test.mjs +1470 -0
  26. package/scripts/review.mjs +715 -24
  27. package/scripts/review_pr.mjs +5 -20
  28. package/scripts/run.mjs +21 -15
  29. package/scripts/setup.mjs +147 -25
  30. package/scripts/setup_pr.mjs +19 -28
  31. package/scripts/stack.mjs +493 -157
  32. package/scripts/stack_archive_cmd.test.mjs +91 -0
  33. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  34. package/scripts/stack_env_cmd.test.mjs +87 -0
  35. package/scripts/stack_happy_cmd.test.mjs +126 -0
  36. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  37. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  38. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  39. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  40. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  41. package/scripts/stack_wt_list.test.mjs +128 -0
  42. package/scripts/tui.mjs +88 -2
  43. package/scripts/utils/cli/cli_registry.mjs +20 -5
  44. package/scripts/utils/cli/cwd_scope.mjs +56 -2
  45. package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
  46. package/scripts/utils/cli/prereqs.mjs +8 -5
  47. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  48. package/scripts/utils/cli/wizard.mjs +17 -9
  49. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  50. package/scripts/utils/dev/daemon.mjs +14 -1
  51. package/scripts/utils/dev/expo_dev.mjs +188 -4
  52. package/scripts/utils/dev/server.mjs +21 -17
  53. package/scripts/utils/edison/git_roots.mjs +29 -0
  54. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  55. package/scripts/utils/env/env.mjs +7 -3
  56. package/scripts/utils/env/env_file.mjs +4 -2
  57. package/scripts/utils/env/env_file.test.mjs +44 -0
  58. package/scripts/utils/git/worktrees.mjs +63 -12
  59. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  60. package/scripts/utils/net/tcp_forward.mjs +162 -0
  61. package/scripts/utils/paths/paths.mjs +118 -3
  62. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  63. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  64. package/scripts/utils/proc/commands.mjs +2 -3
  65. package/scripts/utils/proc/pm.mjs +113 -16
  66. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  67. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  68. package/scripts/utils/proc/proc.mjs +68 -10
  69. package/scripts/utils/proc/proc.test.mjs +77 -0
  70. package/scripts/utils/review/chunks.mjs +55 -0
  71. package/scripts/utils/review/chunks.test.mjs +51 -0
  72. package/scripts/utils/review/findings.mjs +165 -0
  73. package/scripts/utils/review/findings.test.mjs +85 -0
  74. package/scripts/utils/review/head_slice.mjs +153 -0
  75. package/scripts/utils/review/head_slice.test.mjs +91 -0
  76. package/scripts/utils/review/instructions/deep.md +20 -0
  77. package/scripts/utils/review/runners/coderabbit.mjs +56 -14
  78. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  79. package/scripts/utils/review/runners/codex.mjs +32 -22
  80. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  81. package/scripts/utils/review/slices.mjs +140 -0
  82. package/scripts/utils/review/slices.test.mjs +32 -0
  83. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  84. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  85. package/scripts/utils/server/prisma_import.mjs +37 -0
  86. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  87. package/scripts/utils/server/ui_env.mjs +14 -0
  88. package/scripts/utils/server/ui_env.test.mjs +46 -0
  89. package/scripts/utils/server/validate.mjs +53 -16
  90. package/scripts/utils/server/validate.test.mjs +89 -0
  91. package/scripts/utils/stack/editor_workspace.mjs +4 -4
  92. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  93. package/scripts/utils/stack/startup.mjs +113 -13
  94. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  95. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  96. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  97. package/scripts/utils/tailscale/ip.mjs +116 -0
  98. package/scripts/utils/ui/ansi.mjs +39 -0
  99. package/scripts/where.mjs +2 -2
  100. package/scripts/worktrees.mjs +627 -137
  101. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  102. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  103. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  104. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
@@ -0,0 +1,37 @@
1
+ import { createRequire } from 'node:module';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ import { isUnifiedHappyServerLight } from './flavor_scripts.mjs';
6
+
7
+ function extractPrismaClient(mod) {
8
+ return mod?.PrismaClient ?? mod?.default?.PrismaClient ?? null;
9
+ }
10
+
11
+ async function importPrismaClientFromFile(path) {
12
+ const mod = await import(pathToFileURL(path).href);
13
+ const PrismaClient = extractPrismaClient(mod);
14
+ if (!PrismaClient) {
15
+ throw new Error(`[prisma] PrismaClient export not found in: ${path}`);
16
+ }
17
+ return PrismaClient;
18
+ }
19
+
20
+ export async function importPrismaClientFromNodeModules({ dir }) {
21
+ const req = createRequire(import.meta.url);
22
+ const resolved = req.resolve('@prisma/client', { paths: [dir] });
23
+ return await importPrismaClientFromFile(resolved);
24
+ }
25
+
26
+ export async function importPrismaClientFromGeneratedSqlite({ dir }) {
27
+ const path = join(dir, 'generated', 'sqlite-client', 'index.js');
28
+ return await importPrismaClientFromFile(path);
29
+ }
30
+
31
+ export async function importPrismaClientForHappyServerLight({ serverDir }) {
32
+ if (isUnifiedHappyServerLight({ serverDir })) {
33
+ return await importPrismaClientFromGeneratedSqlite({ dir: serverDir });
34
+ }
35
+ return await importPrismaClientFromNodeModules({ dir: serverDir });
36
+ }
37
+
@@ -0,0 +1,70 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ importPrismaClientForHappyServerLight,
9
+ importPrismaClientFromGeneratedSqlite,
10
+ importPrismaClientFromNodeModules,
11
+ } from './prisma_import.mjs';
12
+
13
+ async function writeJson(path, obj) {
14
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
15
+ }
16
+
17
+ test('importPrismaClientFromNodeModules imports PrismaClient via node_modules resolution', async () => {
18
+ const dir = await mkdtemp(join(tmpdir(), 'hs-prisma-import-'));
19
+ try {
20
+ await mkdir(join(dir, 'node_modules', '@prisma', 'client'), { recursive: true });
21
+ await writeJson(join(dir, 'node_modules', '@prisma', 'client', 'package.json'), { name: '@prisma/client', type: 'module', main: './index.js' });
22
+ await writeFile(join(dir, 'node_modules', '@prisma', 'client', 'index.js'), 'export class PrismaClient {}\n', 'utf-8');
23
+
24
+ const PrismaClient = await importPrismaClientFromNodeModules({ dir });
25
+ assert.equal(typeof PrismaClient, 'function');
26
+ assert.equal(PrismaClient.name, 'PrismaClient');
27
+ } finally {
28
+ await rm(dir, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ test('importPrismaClientFromGeneratedSqlite imports PrismaClient from generated/sqlite-client', async () => {
33
+ const dir = await mkdtemp(join(tmpdir(), 'hs-prisma-import-'));
34
+ try {
35
+ await writeJson(join(dir, 'package.json'), { type: 'module' });
36
+ await mkdir(join(dir, 'generated', 'sqlite-client'), { recursive: true });
37
+ await writeFile(join(dir, 'generated', 'sqlite-client', 'index.js'), 'export class PrismaClient {}\n', 'utf-8');
38
+
39
+ const PrismaClient = await importPrismaClientFromGeneratedSqlite({ dir });
40
+ assert.equal(typeof PrismaClient, 'function');
41
+ assert.equal(PrismaClient.name, 'PrismaClient');
42
+ } finally {
43
+ await rm(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test('importPrismaClientForHappyServerLight prefers generated sqlite client when prisma/sqlite/schema.prisma exists', async () => {
48
+ const dir = await mkdtemp(join(tmpdir(), 'hs-prisma-import-'));
49
+ try {
50
+ await writeJson(join(dir, 'package.json'), { type: 'module' });
51
+ await mkdir(join(dir, 'prisma', 'sqlite'), { recursive: true });
52
+ await writeFile(join(dir, 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
53
+
54
+ await mkdir(join(dir, 'generated', 'sqlite-client'), { recursive: true });
55
+ await writeFile(join(dir, 'generated', 'sqlite-client', 'index.js'), 'export class PrismaClient {}\n', 'utf-8');
56
+
57
+ // Also create a node_modules PrismaClient, but unified should prefer generated.
58
+ await mkdir(join(dir, 'node_modules', '@prisma', 'client'), { recursive: true });
59
+ await writeJson(join(dir, 'node_modules', '@prisma', 'client', 'package.json'), { name: '@prisma/client', type: 'module', main: './index.js' });
60
+ await writeFile(join(dir, 'node_modules', '@prisma', 'client', 'index.js'), 'export class PrismaClient { static which = "node_modules"; }\n', 'utf-8');
61
+
62
+ const PrismaClient = await importPrismaClientForHappyServerLight({ serverDir: dir });
63
+ assert.equal(typeof PrismaClient, 'function');
64
+ assert.equal(PrismaClient.name, 'PrismaClient');
65
+ // If we imported node_modules we'd have this static property.
66
+ assert.equal(PrismaClient.which, undefined);
67
+ } finally {
68
+ await rm(dir, { recursive: true, force: true });
69
+ }
70
+ });
@@ -0,0 +1,14 @@
1
+ export function resolveServerUiEnv({ serveUi, uiBuildDir, uiPrefix, uiBuildDirExists }) {
2
+ if (!serveUi) return {};
3
+ if (!uiBuildDirExists) return {};
4
+ if (!uiBuildDir) return {};
5
+
6
+ // Set both the canonical env vars (new) and legacy keys (for older server builds).
7
+ return {
8
+ HAPPY_SERVER_UI_DIR: uiBuildDir,
9
+ HAPPY_SERVER_UI_PREFIX: uiPrefix,
10
+ HAPPY_SERVER_LIGHT_UI_DIR: uiBuildDir,
11
+ HAPPY_SERVER_LIGHT_UI_PREFIX: uiPrefix,
12
+ };
13
+ }
14
+
@@ -0,0 +1,46 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { resolveServerUiEnv } from './ui_env.mjs';
5
+
6
+ test('resolveServerUiEnv returns empty when UI serving is disabled', () => {
7
+ assert.deepEqual(
8
+ resolveServerUiEnv({
9
+ serveUi: false,
10
+ uiBuildDir: '/tmp/ui',
11
+ uiPrefix: '/',
12
+ uiBuildDirExists: true,
13
+ }),
14
+ {}
15
+ );
16
+ });
17
+
18
+ test('resolveServerUiEnv returns empty when UI build dir is missing', () => {
19
+ assert.deepEqual(
20
+ resolveServerUiEnv({
21
+ serveUi: true,
22
+ uiBuildDir: '/tmp/ui',
23
+ uiPrefix: '/',
24
+ uiBuildDirExists: false,
25
+ }),
26
+ {}
27
+ );
28
+ });
29
+
30
+ test('resolveServerUiEnv sets both canonical and legacy env keys when enabled', () => {
31
+ assert.deepEqual(
32
+ resolveServerUiEnv({
33
+ serveUi: true,
34
+ uiBuildDir: '/tmp/ui',
35
+ uiPrefix: '/ui',
36
+ uiBuildDirExists: true,
37
+ }),
38
+ {
39
+ HAPPY_SERVER_UI_DIR: '/tmp/ui',
40
+ HAPPY_SERVER_UI_PREFIX: '/ui',
41
+ HAPPY_SERVER_LIGHT_UI_DIR: '/tmp/ui',
42
+ HAPPY_SERVER_LIGHT_UI_PREFIX: '/ui',
43
+ }
44
+ );
45
+ });
46
+
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join, resolve, sep } from 'node:path';
3
- import { getComponentsDir } from '../paths/paths.mjs';
3
+ import { getComponentRepoDir, getComponentsDir, isHappyMonorepoRoot } from '../paths/paths.mjs';
4
4
 
5
5
  function isInside(path, dir) {
6
6
  const p = resolve(path);
@@ -16,8 +16,14 @@ export function detectServerComponentDirMismatch({ rootDir, serverComponentName,
16
16
  return null;
17
17
  }
18
18
 
19
- const otherRepo = resolve(componentsDir, other);
20
- const otherWts = resolve(componentsDir, '.worktrees', other);
19
+ const expectedRepo = resolve(getComponentRepoDir(rootDir, serverComponentName));
20
+ const otherRepo = resolve(getComponentRepoDir(rootDir, other));
21
+ // Unified server flavors can legitimately share a single repo/dir.
22
+ if (expectedRepo === otherRepo) {
23
+ return null;
24
+ }
25
+ const otherKey = isHappyMonorepoRoot(otherRepo) ? 'happy' : other;
26
+ const otherWts = resolve(componentsDir, '.worktrees', otherKey);
21
27
 
22
28
  if (isInside(serverDir, otherRepo) || isInside(serverDir, otherWts)) {
23
29
  return { expected: serverComponentName, actual: other, serverDir };
@@ -32,10 +38,13 @@ export function assertServerComponentDirMatches({ rootDir, serverComponentName,
32
38
  return;
33
39
  }
34
40
 
41
+ const expectedRepoDir = getComponentRepoDir(rootDir, mismatch.expected);
42
+ const expectedRepoKey = isHappyMonorepoRoot(expectedRepoDir) ? 'happy' : mismatch.expected;
43
+
35
44
  const hint =
36
45
  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/).';
46
+ ? `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/${expectedRepoKey}/).`
47
+ : `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/${expectedRepoKey}/).`;
39
48
 
40
49
  throw new Error(
41
50
  `[server] server component dir mismatch:\n` +
@@ -55,6 +64,11 @@ function detectPrismaProvider(schemaText) {
55
64
 
56
65
  export function assertServerPrismaProviderMatches({ serverComponentName, serverDir }) {
57
66
  const schemaPath = join(serverDir, 'prisma', 'schema.prisma');
67
+ const sqliteSchemaPaths = [
68
+ join(serverDir, 'prisma', 'sqlite', 'schema.prisma'),
69
+ join(serverDir, 'prisma', 'schema.sqlite.prisma'),
70
+ ];
71
+
58
72
  let schemaText = '';
59
73
  try {
60
74
  schemaText = readFileSync(schemaPath, 'utf-8');
@@ -64,17 +78,41 @@ export function assertServerPrismaProviderMatches({ serverComponentName, serverD
64
78
  }
65
79
 
66
80
  const provider = detectPrismaProvider(schemaText);
67
- if (!provider) {
68
- return;
69
- }
81
+ if (!provider) return;
70
82
 
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
- );
83
+ // Unified happy-server flavors:
84
+ // - full: prisma/schema.prisma (postgresql)
85
+ // - light: prisma/sqlite/schema.prisma (sqlite) (legacy: prisma/schema.sqlite.prisma)
86
+ if (serverComponentName === 'happy-server-light') {
87
+ for (const sqliteSchemaPath of sqliteSchemaPaths) {
88
+ try {
89
+ const sqliteSchemaText = readFileSync(sqliteSchemaPath, 'utf-8');
90
+ const sqliteProvider = detectPrismaProvider(sqliteSchemaText);
91
+ if (sqliteProvider && sqliteProvider !== 'sqlite') {
92
+ throw new Error(
93
+ `[server] happy-server-light expects Prisma datasource provider \"sqlite\", but found \"${sqliteProvider}\" in:\n` +
94
+ `- ${sqliteSchemaPath}\n` +
95
+ `Fix: point happy-server-light at a checkout that includes sqlite support, or switch server flavor to happy-server.`
96
+ );
97
+ }
98
+ if (sqliteProvider === 'sqlite') {
99
+ return;
100
+ }
101
+ // Exists, but could not parse provider: keep checking other variants and fall through to legacy behavior.
102
+ } catch {
103
+ // missing/unreadable: try other variants and then fall through to legacy behavior below
104
+ }
105
+ }
106
+
107
+ if (provider !== 'sqlite') {
108
+ throw new Error(
109
+ `[server] happy-server-light expects Prisma datasource provider \"sqlite\", but found \"${provider}\" in:\n` +
110
+ `- ${schemaPath}\n` +
111
+ `This usually means you're pointing happy-server-light at a postgres-only happy-server checkout/PR.\n` +
112
+ `Fix: either switch server flavor to happy-server, or use a checkout that supports the light flavor (e.g. one that contains prisma/sqlite/schema.prisma or prisma/schema.sqlite.prisma).`
113
+ );
114
+ }
115
+ return;
78
116
  }
79
117
 
80
118
  if (serverComponentName === 'happy-server' && provider === 'sqlite') {
@@ -85,4 +123,3 @@ export function assertServerPrismaProviderMatches({ serverComponentName, serverD
85
123
  );
86
124
  }
87
125
  }
88
-
@@ -0,0 +1,89 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { assertServerPrismaProviderMatches, detectServerComponentDirMismatch } from './validate.mjs';
8
+
9
+ const PG_SCHEMA = `
10
+ datasource db {
11
+ provider = "postgresql"
12
+ url = env("DATABASE_URL")
13
+ }
14
+ `.trim();
15
+
16
+ const SQLITE_SCHEMA = `
17
+ datasource db {
18
+ provider = "sqlite"
19
+ url = env("DATABASE_URL")
20
+ }
21
+ `.trim();
22
+
23
+ async function writeSchemas({ dir, schemaPrisma, schemaSqlitePrisma }) {
24
+ const prismaDir = join(dir, 'prisma');
25
+ await mkdir(prismaDir, { recursive: true });
26
+ if (schemaPrisma != null) {
27
+ await writeFile(join(prismaDir, 'schema.prisma'), schemaPrisma + '\n', 'utf-8');
28
+ }
29
+ if (schemaSqlitePrisma != null) {
30
+ await mkdir(join(prismaDir, 'sqlite'), { recursive: true });
31
+ await writeFile(join(prismaDir, 'sqlite', 'schema.prisma'), schemaSqlitePrisma + '\n', 'utf-8');
32
+ }
33
+ }
34
+
35
+ test('assertServerPrismaProviderMatches accepts unified light flavor (prisma/sqlite/schema.prisma)', async () => {
36
+ const dir = await mkdtemp(join(tmpdir(), 'hs-validate-'));
37
+ try {
38
+ await writeSchemas({ dir, schemaPrisma: PG_SCHEMA, schemaSqlitePrisma: SQLITE_SCHEMA });
39
+ assert.doesNotThrow(() => assertServerPrismaProviderMatches({ serverComponentName: 'happy-server-light', serverDir: dir }));
40
+ } finally {
41
+ await rm(dir, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ test('assertServerPrismaProviderMatches rejects happy-server-light when only postgres schema exists', async () => {
46
+ const dir = await mkdtemp(join(tmpdir(), 'hs-validate-'));
47
+ try {
48
+ await writeSchemas({ dir, schemaPrisma: PG_SCHEMA, schemaSqlitePrisma: null });
49
+ assert.throws(() => assertServerPrismaProviderMatches({ serverComponentName: 'happy-server-light', serverDir: dir }));
50
+ } finally {
51
+ await rm(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('assertServerPrismaProviderMatches rejects happy-server when schema.prisma is sqlite', async () => {
56
+ const dir = await mkdtemp(join(tmpdir(), 'hs-validate-'));
57
+ try {
58
+ await writeSchemas({ dir, schemaPrisma: SQLITE_SCHEMA, schemaSqlitePrisma: null });
59
+ assert.throws(() => assertServerPrismaProviderMatches({ serverComponentName: 'happy-server', serverDir: dir }));
60
+ } finally {
61
+ await rm(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ test('detectServerComponentDirMismatch allows unified happy-server-light pointing at happy-server dir', async () => {
66
+ const rootDir = await mkdtemp(join(tmpdir(), 'hs-validate-root-'));
67
+ const envKeys = ['HAPPY_STACKS_WORKSPACE_DIR', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT'];
68
+ const old = Object.fromEntries(envKeys.map((k) => [k, process.env[k]]));
69
+ try {
70
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
71
+ const unifiedDir = join(rootDir, 'components', 'happy-server');
72
+ await mkdir(unifiedDir, { recursive: true });
73
+ process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = unifiedDir;
74
+ process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = unifiedDir;
75
+
76
+ const mismatch = detectServerComponentDirMismatch({
77
+ rootDir,
78
+ serverComponentName: 'happy-server-light',
79
+ serverDir: unifiedDir,
80
+ });
81
+ assert.equal(mismatch, null);
82
+ } finally {
83
+ for (const k of envKeys) {
84
+ if (old[k] == null) delete process.env[k];
85
+ else process.env[k] = old[k];
86
+ }
87
+ await rm(rootDir, { recursive: true, force: true });
88
+ }
89
+ });
@@ -2,7 +2,7 @@ import { join, resolve } from 'node:path';
2
2
  import { writeFile } from 'node:fs/promises';
3
3
 
4
4
  import { expandHome } from '../paths/canonical_home.mjs';
5
- import { getComponentDir, getWorkspaceDir, resolveStackEnvPath } from '../paths/paths.mjs';
5
+ import { coerceHappyMonorepoRootFromPath, getComponentDir, getWorkspaceDir, resolveStackEnvPath } from '../paths/paths.mjs';
6
6
  import { ensureDir } from '../fs/ops.mjs';
7
7
  import { getEnvValueAny } from '../env/values.mjs';
8
8
  import { readEnvObjectFromFile } from '../env/read.mjs';
@@ -108,8 +108,9 @@ export async function writeStackCodeWorkspace({
108
108
  }
109
109
  for (const component of selectedComponents) {
110
110
  const keys = byName.get(component) ?? [];
111
- const dir = resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component });
112
- folders.push({ name: component, path: dir });
111
+ const componentDir = resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component });
112
+ const monoRoot = coerceHappyMonorepoRootFromPath(componentDir);
113
+ folders.push({ name: component, path: monoRoot || componentDir });
113
114
  }
114
115
 
115
116
  // Deduplicate by path (can happen if multiple components are pointed at the same dir).
@@ -149,4 +150,3 @@ export async function writeStackCodeWorkspace({
149
150
  },
150
151
  };
151
152
  }
152
-
@@ -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
+ }