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,91 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, stat, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runCmd(cmd, args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const cleanEnv = {};
12
+ for (const [k, v] of Object.entries(env ?? {})) {
13
+ if (v == null) continue;
14
+ cleanEnv[k] = String(v);
15
+ }
16
+ const proc = spawn(cmd, args, { cwd, env: cleanEnv, stdio: ['ignore', 'pipe', 'pipe'] });
17
+ let stdout = '';
18
+ let stderr = '';
19
+ proc.stdout.on('data', (d) => (stdout += String(d)));
20
+ proc.stderr.on('data', (d) => (stderr += String(d)));
21
+ proc.on('error', reject);
22
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
23
+ });
24
+ }
25
+
26
+ function runNode(args, { cwd, env }) {
27
+ return runCmd(process.execPath, args, { cwd, env });
28
+ }
29
+
30
+ async function runOk(cmd, args, { cwd, env }) {
31
+ const res = await runCmd(cmd, args, { cwd, env });
32
+ assert.equal(res.code, 0, `expected exit 0 for ${cmd} ${args.join(' ')}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
33
+ return res;
34
+ }
35
+
36
+ test('happys stack archive moves the stack and archives its referenced worktrees', async () => {
37
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
38
+ const rootDir = dirname(scriptsDir);
39
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-archive-'));
40
+
41
+ const storageDir = join(tmp, 'storage');
42
+ const homeDir = join(tmp, 'home');
43
+ const workspaceDir = join(tmp, 'workspace');
44
+ const componentsDir = join(workspaceDir, 'components');
45
+
46
+ const baseEnv = {
47
+ ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('HAPPY_STACKS_') && !k.startsWith('HAPPY_LOCAL_'))),
48
+ GIT_TERMINAL_PROMPT: '0',
49
+ HAPPY_STACKS_HOME_DIR: homeDir,
50
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
51
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
52
+ };
53
+
54
+ // Create a minimal git repo and a worktree under components/.worktrees.
55
+ const repoDir = join(componentsDir, 'happy');
56
+ await mkdir(repoDir, { recursive: true });
57
+ await runOk('git', ['init', '-b', 'main'], { cwd: repoDir, env: baseEnv });
58
+ await runOk('git', ['config', 'user.name', 'Test'], { cwd: repoDir, env: baseEnv });
59
+ await runOk('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, env: baseEnv });
60
+ await writeFile(join(repoDir, 'README.md'), 'hello\n', 'utf-8');
61
+ await runOk('git', ['add', 'README.md'], { cwd: repoDir, env: baseEnv });
62
+ await runOk('git', ['commit', '-m', 'init'], { cwd: repoDir, env: baseEnv });
63
+
64
+ const worktreeDir = join(componentsDir, '.worktrees', 'happy', 'slopus', 'pr', 'archived-by-stack');
65
+ await mkdir(dirname(worktreeDir), { recursive: true });
66
+ await runOk('git', ['worktree', 'add', '-b', 'slopus/pr/archived-by-stack', worktreeDir, 'main'], { cwd: repoDir, env: baseEnv });
67
+ await writeFile(join(worktreeDir, 'untracked.txt'), 'untracked\n', 'utf-8');
68
+
69
+ const stackName = 'exp-test';
70
+ const envPath = join(storageDir, stackName, 'env');
71
+ await mkdir(dirname(envPath), { recursive: true });
72
+ await writeFile(envPath, [`HAPPY_STACKS_STACK=${stackName}`, `HAPPY_STACKS_COMPONENT_DIR_HAPPY=${worktreeDir}`, ''].join('\n'), 'utf-8');
73
+
74
+ const date = '2000-01-04';
75
+ const nodeEnv = { ...baseEnv, PATH: '' };
76
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'archive', stackName, `--date=${date}`, '--json'], {
77
+ cwd: rootDir,
78
+ env: nodeEnv,
79
+ });
80
+ assert.equal(res.code, 0, `expected stack archive exit 0\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
81
+ const parsed = JSON.parse(res.stdout);
82
+ assert.equal(parsed.ok, true, `expected ok=true JSON output\n${res.stdout}`);
83
+
84
+ const archivedStackDir = join(storageDir, '.archived', date, stackName);
85
+ assert.equal(parsed.archivedStackDir, archivedStackDir, `expected archivedStackDir in JSON output\n${res.stdout}`);
86
+ await stat(join(archivedStackDir, 'env'));
87
+
88
+ const archivedWorktreeDir = join(componentsDir, '.worktrees-archive', date, 'happy', 'slopus', 'pr', 'archived-by-stack');
89
+ const gitStat = await stat(join(archivedWorktreeDir, '.git'));
90
+ assert.ok(gitStat.isDirectory(), 'expected archived worktree to be detached (standalone .git dir)');
91
+ });
@@ -0,0 +1,65 @@
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 { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
9
+
10
+ test('stack code workspace groups monorepo components to the monorepo root', async () => {
11
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
12
+ const rootDir = dirname(scriptsDir);
13
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-workspace-mono-'));
14
+
15
+ const storageDir = join(tmp, 'storage');
16
+ const homeDir = join(tmp, 'home');
17
+ const stackName = 'exp-test';
18
+
19
+ const prevStorage = process.env.HAPPY_STACKS_STORAGE_DIR;
20
+ const prevHome = process.env.HAPPY_STACKS_HOME_DIR;
21
+ process.env.HAPPY_STACKS_STORAGE_DIR = storageDir;
22
+ process.env.HAPPY_STACKS_HOME_DIR = homeDir;
23
+
24
+ try {
25
+ const monoRoot = join(tmp, 'mono');
26
+ await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
27
+ await mkdir(join(monoRoot, 'cli'), { recursive: true });
28
+ await mkdir(join(monoRoot, 'server'), { recursive: true });
29
+ await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
30
+ await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
31
+ await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
32
+
33
+ const envPath = join(storageDir, stackName, 'env');
34
+ await mkdir(dirname(envPath), { recursive: true });
35
+ await writeFile(
36
+ envPath,
37
+ [
38
+ 'HAPPY_STACKS_SERVER_COMPONENT=happy-server',
39
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY=${join(monoRoot, 'expo-app')}`,
40
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${join(monoRoot, 'cli')}`,
41
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER=${join(monoRoot, 'server')}`,
42
+ '',
43
+ ].join('\n'),
44
+ 'utf-8'
45
+ );
46
+
47
+ const ws = await writeStackCodeWorkspace({
48
+ rootDir,
49
+ stackName,
50
+ includeStackDir: false,
51
+ includeAllComponents: false,
52
+ includeCliHome: false,
53
+ });
54
+
55
+ assert.equal(ws.folders.length, 1);
56
+ assert.equal(ws.folders[0].path, monoRoot);
57
+ } finally {
58
+ if (prevStorage == null) delete process.env.HAPPY_STACKS_STORAGE_DIR;
59
+ else process.env.HAPPY_STACKS_STORAGE_DIR = prevStorage;
60
+ if (prevHome == null) delete process.env.HAPPY_STACKS_HOME_DIR;
61
+ else process.env.HAPPY_STACKS_HOME_DIR = prevHome;
62
+ await rm(tmp, { recursive: true, force: true });
63
+ }
64
+ });
65
+
@@ -0,0 +1,87 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ test('happys stack env set/unset writes to stack env file', async () => {
22
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
23
+ const rootDir = dirname(scriptsDir);
24
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-env-'));
25
+
26
+ const storageDir = join(tmp, 'storage');
27
+ const homeDir = join(tmp, 'home');
28
+ const stackName = 'exp-test';
29
+
30
+ const envPath = join(storageDir, stackName, 'env');
31
+ await mkdir(dirname(envPath), { recursive: true });
32
+ await writeFile(envPath, 'FOO=bar\n', 'utf-8');
33
+
34
+ const baseEnv = {
35
+ ...process.env,
36
+ // Prevent loading the user's real ~/.happy-stacks/.env via canonical discovery.
37
+ HAPPY_STACKS_HOME_DIR: homeDir,
38
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
39
+ };
40
+
41
+ const setRes = await runNode(
42
+ [join(rootDir, 'scripts', 'stack.mjs'), 'env', stackName, 'set', 'OPENAI_API_KEY=sk-test'],
43
+ { cwd: rootDir, env: baseEnv }
44
+ );
45
+ assert.equal(setRes.code, 0, `expected exit 0, got ${setRes.code}\nstdout:\n${setRes.stdout}\nstderr:\n${setRes.stderr}`);
46
+
47
+ const afterSet = await readFile(envPath, 'utf-8');
48
+ assert.ok(afterSet.includes('OPENAI_API_KEY=sk-test\n'), `expected env file to include OPENAI_API_KEY\n${afterSet}`);
49
+
50
+ const unsetRes = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'env', stackName, 'unset', 'FOO'], {
51
+ cwd: rootDir,
52
+ env: baseEnv,
53
+ });
54
+ assert.equal(
55
+ unsetRes.code,
56
+ 0,
57
+ `expected exit 0, got ${unsetRes.code}\nstdout:\n${unsetRes.stdout}\nstderr:\n${unsetRes.stderr}`
58
+ );
59
+
60
+ const afterUnset = await readFile(envPath, 'utf-8');
61
+ assert.ok(!afterUnset.includes('FOO=bar'), `expected env file to remove FOO\n${afterUnset}`);
62
+ });
63
+
64
+ test('happys stack env <name> defaults to list', async () => {
65
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
66
+ const rootDir = dirname(scriptsDir);
67
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-env-'));
68
+
69
+ const storageDir = join(tmp, 'storage');
70
+ const homeDir = join(tmp, 'home');
71
+ const stackName = 'exp-test';
72
+
73
+ const envPath = join(storageDir, stackName, 'env');
74
+ await mkdir(dirname(envPath), { recursive: true });
75
+ await writeFile(envPath, 'FOO=bar\n', 'utf-8');
76
+
77
+ const baseEnv = {
78
+ ...process.env,
79
+ HAPPY_STACKS_HOME_DIR: homeDir,
80
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
81
+ };
82
+
83
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'env', stackName], { cwd: rootDir, env: baseEnv });
84
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
85
+ assert.ok(res.stdout.includes('FOO=bar'), `expected stdout to include FOO=bar\nstdout:\n${res.stdout}`);
86
+ });
87
+
@@ -0,0 +1,126 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ async function writeStubHappyCli({ root, message }) {
22
+ const cliDir = join(root, 'happy-cli');
23
+ await mkdir(join(cliDir, 'dist'), { recursive: true });
24
+ await writeFile(
25
+ join(cliDir, 'dist', 'index.mjs'),
26
+ [
27
+ `console.log(JSON.stringify({`,
28
+ ` message: ${JSON.stringify(message)},`,
29
+ ` stack: process.env.HAPPY_STACKS_STACK || process.env.HAPPY_LOCAL_STACK || null,`,
30
+ ` envFile: process.env.HAPPY_STACKS_ENV_FILE || process.env.HAPPY_LOCAL_ENV_FILE || null,`,
31
+ ` homeDir: process.env.HAPPY_HOME_DIR || null,`,
32
+ ` serverUrl: process.env.HAPPY_SERVER_URL || null,`,
33
+ ` webappUrl: process.env.HAPPY_WEBAPP_URL || null,`,
34
+ `}));`,
35
+ ].join('\n'),
36
+ 'utf-8'
37
+ );
38
+ return cliDir;
39
+ }
40
+
41
+ test('happys stack happy <name> runs happy-cli under that stack env', async () => {
42
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
43
+ const rootDir = dirname(scriptsDir);
44
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-happy-'));
45
+
46
+ const storageDir = join(tmp, 'storage');
47
+ const homeDir = join(tmp, 'home');
48
+ const stackName = 'exp-test';
49
+
50
+ const stubRoot = join(tmp, 'stub-components');
51
+ const cliDir = await writeStubHappyCli({ root: stubRoot, message: 'hello' });
52
+
53
+ const stackCliHome = join(storageDir, stackName, 'cli');
54
+ const envPath = join(storageDir, stackName, 'env');
55
+ await mkdir(dirname(envPath), { recursive: true });
56
+ await writeFile(
57
+ envPath,
58
+ [
59
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
60
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
61
+ `HAPPY_STACKS_SERVER_PORT=3999`,
62
+ '',
63
+ ].join('\n'),
64
+ 'utf-8'
65
+ );
66
+
67
+ const baseEnv = {
68
+ ...process.env,
69
+ HAPPY_STACKS_HOME_DIR: homeDir,
70
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
71
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
72
+ };
73
+
74
+ const res = await runNode([join(rootDir, 'bin', 'happys.mjs'), 'stack', 'happy', stackName], { cwd: rootDir, env: baseEnv });
75
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
76
+
77
+ const out = JSON.parse(res.stdout.trim());
78
+ assert.equal(out.message, 'hello');
79
+ assert.equal(out.stack, stackName);
80
+ assert.ok(String(out.envFile).endsWith(`/${stackName}/env`), `expected envFile to end with /${stackName}/env, got: ${out.envFile}`);
81
+ assert.equal(out.homeDir, stackCliHome);
82
+ assert.equal(out.serverUrl, 'http://127.0.0.1:3999');
83
+ });
84
+
85
+ test('happys <stack> happy ... shorthand runs happy-cli under that stack env', async () => {
86
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
87
+ const rootDir = dirname(scriptsDir);
88
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-happy-'));
89
+
90
+ const storageDir = join(tmp, 'storage');
91
+ const homeDir = join(tmp, 'home');
92
+ const stackName = 'exp-test';
93
+
94
+ const stubRoot = join(tmp, 'stub-components');
95
+ const cliDir = await writeStubHappyCli({ root: stubRoot, message: 'shorthand' });
96
+
97
+ const stackCliHome = join(storageDir, stackName, 'cli');
98
+ const envPath = join(storageDir, stackName, 'env');
99
+ await mkdir(dirname(envPath), { recursive: true });
100
+ await writeFile(
101
+ envPath,
102
+ [
103
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
104
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
105
+ `HAPPY_STACKS_SERVER_PORT=4101`,
106
+ '',
107
+ ].join('\n'),
108
+ 'utf-8'
109
+ );
110
+
111
+ const baseEnv = {
112
+ ...process.env,
113
+ HAPPY_STACKS_HOME_DIR: homeDir,
114
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
115
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
116
+ };
117
+
118
+ const res = await runNode([join(rootDir, 'bin', 'happys.mjs'), stackName, 'happy'], { cwd: rootDir, env: baseEnv });
119
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
120
+
121
+ const out = JSON.parse(res.stdout.trim());
122
+ assert.equal(out.message, 'shorthand');
123
+ assert.equal(out.stack, stackName);
124
+ assert.equal(out.serverUrl, 'http://127.0.0.1:4101');
125
+ });
126
+
@@ -0,0 +1,71 @@
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 { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { interactiveNew } from './utils/stack/interactive_stack_config.mjs';
9
+
10
+ function mkRl() {
11
+ return { question: async () => '' };
12
+ }
13
+
14
+ test('interactive stack new in monorepo mode does not prompt for happy-server-light worktree', async () => {
15
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
16
+ const rootDir = dirname(scriptsDir);
17
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-interactive-new-mono-'));
18
+
19
+ const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
20
+ try {
21
+ const workspaceDir = join(tmp, 'workspace');
22
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = workspaceDir;
23
+
24
+ const monoRoot = join(workspaceDir, 'components', '.worktrees', 'happy', 'slopus', 'tmp', 'mono-wt');
25
+ await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
26
+ await mkdir(join(monoRoot, 'cli'), { recursive: true });
27
+ await mkdir(join(monoRoot, 'server'), { recursive: true });
28
+ await writeFile(join(monoRoot, '.git'), 'gitdir: dummy\n', 'utf-8');
29
+ await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
30
+ await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
31
+ await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
32
+
33
+ const prompted = [];
34
+ const out = await interactiveNew({
35
+ rootDir,
36
+ rl: mkRl(),
37
+ defaults: {
38
+ stackName: 'exp-mono-int',
39
+ port: 1,
40
+ serverComponent: 'happy-server-light',
41
+ createRemote: 'upstream',
42
+ components: { happy: null, 'happy-cli': null, 'happy-server-light': null, 'happy-server': null },
43
+ },
44
+ deps: {
45
+ prompt: async (_rl, question, { defaultValue } = {}) => {
46
+ if (question.includes('Derive happy-cli + happy-server')) return 'y';
47
+ return defaultValue ?? '';
48
+ },
49
+ promptWorktreeSource: async ({ component }) => {
50
+ prompted.push(component);
51
+ if (component === 'happy') return 'slopus/tmp/mono-wt';
52
+ throw new Error(`unexpected promptWorktreeSource call: ${component}`);
53
+ },
54
+ },
55
+ });
56
+
57
+ assert.deepEqual(prompted, ['happy']);
58
+ assert.equal(out.components.happy, 'slopus/tmp/mono-wt');
59
+ assert.equal(out.components['happy-cli'], null);
60
+ assert.equal(out.components['happy-server'], null);
61
+ assert.equal(out.components['happy-server-light'], null);
62
+ } finally {
63
+ if (prevWorkspace == null) {
64
+ delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
65
+ } else {
66
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
67
+ }
68
+ await rm(tmp, { recursive: true, force: true });
69
+ }
70
+ });
71
+
@@ -0,0 +1,62 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ test('happys stack new defaults to monorepo package dirs when happy is a monorepo', async () => {
22
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
23
+ const rootDir = dirname(scriptsDir);
24
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-monorepo-defaults-'));
25
+
26
+ const workspaceDir = join(tmp, 'workspace');
27
+ const storageDir = join(tmp, 'storage');
28
+ const homeDir = join(tmp, 'home');
29
+ const sandboxDir = join(tmp, 'sandbox');
30
+ const stackName = 'exp-test';
31
+
32
+ const monoRoot = join(workspaceDir, 'components', 'happy');
33
+ await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
34
+ await mkdir(join(monoRoot, 'cli'), { recursive: true });
35
+ await mkdir(join(monoRoot, 'server'), { recursive: true });
36
+ await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
37
+ await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
38
+ await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
39
+ // Monorepo server-light support: sqlite schema lives under prisma/sqlite/.
40
+ await mkdir(join(monoRoot, 'server', 'prisma', 'sqlite'), { recursive: true });
41
+ await writeFile(join(monoRoot, 'server', 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
42
+
43
+ const env = {
44
+ ...process.env,
45
+ HAPPY_STACKS_HOME_DIR: homeDir,
46
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
47
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
48
+ HAPPY_STACKS_SANDBOX_DIR: sandboxDir,
49
+ };
50
+
51
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'new', stackName, '--json'], { cwd: rootDir, env });
52
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
53
+
54
+ const envPath = join(storageDir, stackName, 'env');
55
+ const contents = await readFile(envPath, 'utf-8');
56
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY=${join(monoRoot, 'expo-app')}\n`), contents);
57
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${join(monoRoot, 'cli')}\n`), contents);
58
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER=${join(monoRoot, 'server')}\n`), contents);
59
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT=${join(monoRoot, 'server')}\n`), contents);
60
+
61
+ await rm(tmp, { recursive: true, force: true });
62
+ });
@@ -0,0 +1,66 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ test('happys stack new derives monorepo server-light dirs from --happy spec', async () => {
22
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
23
+ const rootDir = dirname(scriptsDir);
24
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-monorepo-spec-'));
25
+
26
+ const workspaceDir = join(tmp, 'workspace');
27
+ const storageDir = join(tmp, 'storage');
28
+ const homeDir = join(tmp, 'home');
29
+ const sandboxDir = join(tmp, 'sandbox');
30
+ const stackName = 'exp-mono-spec';
31
+
32
+ // Create a monorepo worktree somewhere other than components/happy to simulate a new stack
33
+ // created from a --happy spec when the default checkout isn't a monorepo.
34
+ const monoRoot = join(workspaceDir, 'components', '.worktrees', 'happy', 'slopus', 'tmp', 'leeroy-wip');
35
+ await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
36
+ await mkdir(join(monoRoot, 'cli'), { recursive: true });
37
+ await mkdir(join(monoRoot, 'server', 'prisma', 'sqlite'), { recursive: true });
38
+ await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
39
+ await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
40
+ await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
41
+ await writeFile(join(monoRoot, 'server', 'prisma', 'schema.prisma'), 'datasource db { provider = "postgresql" }\n', 'utf-8');
42
+ await writeFile(join(monoRoot, 'server', 'prisma', 'sqlite', 'schema.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
43
+
44
+ const env = {
45
+ ...process.env,
46
+ HAPPY_STACKS_HOME_DIR: homeDir,
47
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
48
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
49
+ HAPPY_STACKS_SANDBOX_DIR: sandboxDir,
50
+ };
51
+
52
+ const res = await runNode(
53
+ [join(rootDir, 'scripts', 'stack.mjs'), 'new', stackName, `--happy=${monoRoot}`, '--server=happy-server-light', '--no-copy-auth', '--json'],
54
+ { cwd: rootDir, env },
55
+ );
56
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
57
+
58
+ const envPath = join(storageDir, stackName, 'env');
59
+ const contents = await readFile(envPath, 'utf-8');
60
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY=${join(monoRoot, 'expo-app')}\n`), contents);
61
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${join(monoRoot, 'cli')}\n`), contents);
62
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER=${join(monoRoot, 'server')}\n`), contents);
63
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT=${join(monoRoot, 'server')}\n`), contents);
64
+
65
+ await rm(tmp, { recursive: true, force: true });
66
+ });
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ test('happys stack new pins happy-server-light dir to happy-server when unified schema exists', async () => {
22
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
23
+ const rootDir = dirname(scriptsDir);
24
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-server-flavors-'));
25
+
26
+ const workspaceDir = join(tmp, 'workspace');
27
+ const storageDir = join(tmp, 'storage');
28
+ const homeDir = join(tmp, 'home');
29
+ const sandboxDir = join(tmp, 'sandbox');
30
+ const stackName = 'exp-flavors';
31
+
32
+ const fullDir = join(workspaceDir, 'components', 'happy-server');
33
+ await mkdir(join(fullDir, 'prisma'), { recursive: true });
34
+ await writeFile(join(fullDir, 'prisma', 'schema.sqlite.prisma'), 'datasource db { provider = "sqlite" }\n', 'utf-8');
35
+ await writeFile(join(fullDir, 'package.json'), '{}\n', 'utf-8');
36
+
37
+ const env = {
38
+ ...process.env,
39
+ HAPPY_STACKS_HOME_DIR: homeDir,
40
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
41
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
42
+ HAPPY_STACKS_SANDBOX_DIR: sandboxDir,
43
+ };
44
+
45
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'new', stackName, '--json'], { cwd: rootDir, env });
46
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
47
+
48
+ const envPath = join(storageDir, stackName, 'env');
49
+ const contents = await readFile(envPath, 'utf-8');
50
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER=${fullDir}\n`), contents);
51
+ assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT=${fullDir}\n`), contents);
52
+
53
+ await rm(tmp, { recursive: true, force: true });
54
+ });
55
+