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
package/scripts/dev.mjs CHANGED
@@ -17,7 +17,7 @@ import { resolveStackContext } from './utils/stack/context.mjs';
17
17
  import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
18
18
  import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev/daemon.mjs';
19
19
  import { startDevServer, watchDevServerAndRestart } from './utils/dev/server.mjs';
20
- import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
20
+ import { ensureDevExpoServer, resolveExpoTailscaleEnabled } from './utils/dev/expo_dev.mjs';
21
21
  import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
22
22
  import { openUrlInBrowser } from './utils/ui/browser.mjs';
23
23
  import { waitForHttpOk } from './utils/server/server.mjs';
@@ -41,18 +41,22 @@ async function main() {
41
41
  if (wantsHelp(argv, { flags })) {
42
42
  printResult({
43
43
  json,
44
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser', '--mobile'], json: true },
44
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser', '--mobile', '--expo-tailscale'], json: true },
45
45
  text: [
46
46
  '[dev] usage:',
47
47
  ' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
48
- ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
49
- ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
50
- ' happys dev --no-browser # do not open the UI in your browser automatically',
51
- ' happys dev --mobile # also start Expo dev-client Metro for mobile',
48
+ ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
49
+ ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
50
+ ' happys dev --no-browser # do not open the UI in your browser automatically',
51
+ ' happys dev --mobile # also start Expo dev-client Metro for mobile',
52
+ ' happys dev --expo-tailscale # forward Expo to Tailscale interface for remote access',
52
53
  ' note: --json prints the resolved config (dry-run) and exits.',
53
54
  '',
54
55
  'note:',
55
56
  ' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
57
+ '',
58
+ 'env:',
59
+ ' HAPPY_STACKS_EXPO_TAILSCALE=1 # enable Expo Tailscale forwarding via env var',
56
60
  ].join('\n'),
57
61
  });
58
62
  return;
@@ -82,6 +86,7 @@ async function main() {
82
86
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
83
87
  const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
84
88
  const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
89
+ const expoTailscale = flags.has('--expo-tailscale') || resolveExpoTailscaleEnabled({ env: process.env });
85
90
 
86
91
  const serverDir = getComponentDir(rootDir, serverComponentName);
87
92
  const uiDir = getComponentDir(rootDir, 'happy');
@@ -231,7 +236,7 @@ async function main() {
231
236
  );
232
237
 
233
238
  // Reliability before daemon start:
234
- // - Ensure schema exists (server-light: db push; happy-server: migrate deploy if tables missing)
239
+ // - Ensure schema exists (server-light: prisma migrate deploy; happy-server: migrate deploy if tables missing)
235
240
  // - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
236
241
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
237
242
  const accountProbe = await getAccountCountForServerComponent({
@@ -267,6 +272,7 @@ async function main() {
267
272
  envPath,
268
273
  children,
269
274
  spawnOptions: { stdio: ['ignore', 'ignore', 'ignore'] },
275
+ expoTailscale,
270
276
  });
271
277
  }
272
278
  await maybeRunInteractiveStackAuthSetup({
@@ -300,6 +306,7 @@ async function main() {
300
306
  stackName,
301
307
  envPath,
302
308
  children,
309
+ expoTailscale,
303
310
  });
304
311
  },
305
312
  });
@@ -404,6 +411,7 @@ async function main() {
404
411
  stackName,
405
412
  envPath,
406
413
  children,
414
+ expoTailscale,
407
415
  }));
408
416
  if (startUi) {
409
417
  const uiPort = expoRes?.port;
@@ -435,6 +443,11 @@ async function main() {
435
443
  console.log(`[local] mobile: metro ${metroUrl}`);
436
444
  }
437
445
 
446
+ // Show Tailscale URL if forwarder is running
447
+ if (expoRes?.tailscale?.ok && expoRes.tailscale.tailscaleIp && expoRes.port) {
448
+ console.log(`[local] expo tailscale: http://${expoRes.tailscale.tailscaleIp}:${expoRes.port}`);
449
+ }
450
+
438
451
  const shutdown = async () => {
439
452
  if (shuttingDown) {
440
453
  return;
@@ -202,10 +202,6 @@ async function main() {
202
202
 
203
203
  // UI build dir check
204
204
  if (serveUi) {
205
- if (serverComponentName !== 'happy-server-light') {
206
- report.checks.uiServing = { ok: false, reason: `requires happy-server-light (current: ${serverComponentName})` };
207
- if (!json) console.log(`ℹ️ ui serving requires happy-server-light (current: ${serverComponentName})`);
208
- }
209
205
  if (await pathExists(uiBuildDir)) {
210
206
  report.checks.uiBuildDir = { ok: true, path: uiBuildDir };
211
207
  if (!json) console.log('✅ ui build dir present');
@@ -14,6 +14,7 @@ import { join } from 'node:path';
14
14
  import { spawn } from 'node:child_process';
15
15
  import { mkdir, lstat, rename, symlink, writeFile, readdir, chmod } from 'node:fs/promises';
16
16
  import os from 'node:os';
17
+ import { normalizeGitRoots } from './utils/edison/git_roots.mjs';
17
18
 
18
19
  const COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
19
20
 
@@ -1343,7 +1344,7 @@ async function resolveFingerprintGitRoots({ rootDir, stackEnv, edisonArgs }) {
1343
1344
  const d = resolveComponentDirFromStackEnv({ rootDir, stackEnv, component: c });
1344
1345
  if (d) dirs.push(d);
1345
1346
  }
1346
- return dirs.length ? dirs : fallback;
1347
+ return normalizeGitRoots(dirs.length ? dirs : fallback);
1347
1348
  }
1348
1349
 
1349
1350
  async function cmdTrackCoherence({ rootDir, argv, json }) {
@@ -1852,4 +1853,3 @@ main().catch((err) => {
1852
1853
  console.error('[edison] failed:', err);
1853
1854
  process.exit(1);
1854
1855
  });
1855
-
@@ -0,0 +1,150 @@
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
+ import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
5
+ import { parseEnvToObject } from './utils/env/dotenv.mjs';
6
+ import { resolveStackEnvPath } from './utils/paths/paths.mjs';
7
+ import { readTextOrEmpty } from './utils/fs/ops.mjs';
8
+
9
+ function resolveTargetEnvPath() {
10
+ // If we're already running under a stack wrapper, respect it.
11
+ const explicit = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
12
+ if (explicit) return explicit;
13
+
14
+ // Self-host default: no stacks knowledge required; persist in the main stack env file.
15
+ return resolveStackEnvPath('main').envPath;
16
+ }
17
+
18
+ async function main() {
19
+ const argv = process.argv.slice(2);
20
+ const { flags } = parseArgs(argv);
21
+ const json = wantsJson(argv, { flags });
22
+
23
+ const helpText = [
24
+ '[env] usage:',
25
+ ' happys env set KEY=VALUE [KEY2=VALUE2...]',
26
+ ' happys env unset KEY [KEY2...]',
27
+ ' happys env get KEY',
28
+ ' happys env list',
29
+ ' happys env path',
30
+ '',
31
+ 'defaults:',
32
+ ' - If running under a stack wrapper (HAPPY_STACKS_ENV_FILE is set), edits that stack env file.',
33
+ ' - Otherwise, edits the main stack env file (~/.happy/stacks/main/env).',
34
+ '',
35
+ 'notes:',
36
+ ' - Changes take effect on next stack/daemon start (restart to apply).',
37
+ ].join('\n');
38
+
39
+ if (wantsHelp(argv, { flags })) {
40
+ printResult({
41
+ json,
42
+ data: {
43
+ usage:
44
+ 'happys env set KEY=VALUE [KEY2=VALUE2...] | unset KEY [KEY2...] | get KEY | list | path [--json]',
45
+ },
46
+ text: helpText,
47
+ });
48
+ return;
49
+ }
50
+
51
+ const positionals = argv.filter((a) => !a.startsWith('--'));
52
+ const subcmd = (positionals[0] ?? '').trim() || 'help';
53
+ const envPath = resolveTargetEnvPath();
54
+
55
+ if (subcmd === 'help') {
56
+ printResult({
57
+ json,
58
+ data: {
59
+ usage: 'happys env set|unset|get|list|path [--json]',
60
+ },
61
+ text: helpText,
62
+ });
63
+ return;
64
+ }
65
+
66
+ if (subcmd === 'path') {
67
+ printResult({
68
+ json,
69
+ data: { ok: true, envPath },
70
+ text: envPath,
71
+ });
72
+ return;
73
+ }
74
+
75
+ const raw = await readTextOrEmpty(envPath);
76
+ const parsed = parseEnvToObject(raw);
77
+
78
+ if (subcmd === 'list') {
79
+ const keys = Object.keys(parsed ?? {}).sort((a, b) => a.localeCompare(b));
80
+ const text = [
81
+ `[env] path: ${envPath}`,
82
+ ...keys.map((k) => `${k}=${parsed[k] ?? ''}`),
83
+ ].join('\n');
84
+ printResult({ json, data: { ok: true, envPath, env: parsed }, text });
85
+ return;
86
+ }
87
+
88
+ if (subcmd === 'get') {
89
+ const key = (positionals[1] ?? '').trim();
90
+ if (!key) {
91
+ throw new Error('[env] usage: happys env get KEY');
92
+ }
93
+ const value = Object.prototype.hasOwnProperty.call(parsed, key) ? parsed[key] : null;
94
+ printResult({
95
+ json,
96
+ data: { ok: true, envPath, key, value },
97
+ text: value == null ? '' : String(value),
98
+ });
99
+ return;
100
+ }
101
+
102
+ if (subcmd === 'set') {
103
+ const pairs = positionals.slice(1);
104
+ if (!pairs.length) {
105
+ throw new Error('[env] usage: happys env set KEY=VALUE [KEY2=VALUE2...]');
106
+ }
107
+ const updates = pairs.map((p) => {
108
+ const idx = p.indexOf('=');
109
+ if (idx <= 0) {
110
+ throw new Error(`[env] set: expected KEY=VALUE, got: ${p}`);
111
+ }
112
+ const key = p.slice(0, idx).trim();
113
+ const value = p.slice(idx + 1);
114
+ if (!key) {
115
+ throw new Error(`[env] set: invalid key in: ${p}`);
116
+ }
117
+ return { key, value };
118
+ });
119
+ await ensureEnvFileUpdated({ envPath, updates });
120
+ const updatedKeys = updates.map((u) => u.key);
121
+ printResult({
122
+ json,
123
+ data: { ok: true, envPath, updatedKeys },
124
+ text: `[env] ok: set ${updatedKeys.join(', ')}\n[env] path: ${envPath}`,
125
+ });
126
+ return;
127
+ }
128
+
129
+ if (subcmd === 'unset' || subcmd === 'remove' || subcmd === 'rm') {
130
+ const keys = positionals.slice(1).map((k) => k.trim()).filter(Boolean);
131
+ if (!keys.length) {
132
+ throw new Error('[env] usage: happys env unset KEY [KEY2...]');
133
+ }
134
+ await ensureEnvFilePruned({ envPath, removeKeys: keys });
135
+ printResult({
136
+ json,
137
+ data: { ok: true, envPath, removedKeys: keys },
138
+ text: `[env] ok: unset ${keys.join(', ')}\n[env] path: ${envPath}`,
139
+ });
140
+ return;
141
+ }
142
+
143
+ throw new Error(`[env] unknown subcommand: ${subcmd}\n[env] usage: happys env set|unset|get|list|path`);
144
+ }
145
+
146
+ main().catch((err) => {
147
+ console.error('[env] failed:', err);
148
+ process.exit(1);
149
+ });
150
+
@@ -0,0 +1,128 @@
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 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(process.execPath, 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
+ test('happys env path defaults to main stack env file when no explicit env file is set', async () => {
27
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
28
+ const rootDir = dirname(scriptsDir);
29
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
30
+
31
+ const storageDir = join(tmp, 'storage');
32
+ const homeDir = join(tmp, 'home');
33
+ await mkdir(storageDir, { recursive: true });
34
+ await mkdir(homeDir, { recursive: true });
35
+
36
+ const baseEnv = {
37
+ ...process.env,
38
+ // Prevent loading the user's real ~/.happy-stacks/.env via canonical discovery.
39
+ HAPPY_STACKS_HOME_DIR: homeDir,
40
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
41
+ };
42
+
43
+ const res = await runNode([join(rootDir, 'scripts', 'env.mjs'), 'path', '--json'], {
44
+ cwd: rootDir,
45
+ env: baseEnv,
46
+ });
47
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
48
+
49
+ const out = JSON.parse(res.stdout || '{}');
50
+ assert.equal(out.ok, true);
51
+ assert.ok(
52
+ typeof out.envPath === 'string' && out.envPath.endsWith('/main/env'),
53
+ `expected main env path to end with /main/env, got: ${out.envPath}`
54
+ );
55
+ });
56
+
57
+ test('happys env edits the explicit stack env file when HAPPY_STACKS_ENV_FILE is set', async () => {
58
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
59
+ const rootDir = dirname(scriptsDir);
60
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
61
+
62
+ const storageDir = join(tmp, 'storage');
63
+ const homeDir = join(tmp, 'home');
64
+ const stackName = 'exp1';
65
+ const envPath = join(storageDir, stackName, 'env');
66
+ await mkdir(dirname(envPath), { recursive: true });
67
+ await mkdir(homeDir, { recursive: true });
68
+
69
+ const baseEnv = {
70
+ ...process.env,
71
+ HAPPY_STACKS_HOME_DIR: homeDir,
72
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
73
+ HAPPY_STACKS_ENV_FILE: envPath,
74
+ };
75
+
76
+ const res = await runNode([join(rootDir, 'scripts', 'env.mjs'), 'set', 'FOO=bar'], { cwd: rootDir, env: baseEnv });
77
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
78
+
79
+ const raw = await readFile(envPath, 'utf-8');
80
+ assert.ok(raw.includes('FOO=bar'), `expected FOO in explicit env file\n${raw}`);
81
+ });
82
+
83
+ test('happys env (no subcommand) prints usage and exits 0', async () => {
84
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
85
+ const rootDir = dirname(scriptsDir);
86
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
87
+
88
+ const storageDir = join(tmp, 'storage');
89
+ const homeDir = join(tmp, 'home');
90
+ await mkdir(storageDir, { recursive: true });
91
+ await mkdir(homeDir, { recursive: true });
92
+
93
+ const baseEnv = {
94
+ ...process.env,
95
+ HAPPY_STACKS_HOME_DIR: homeDir,
96
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
97
+ };
98
+
99
+ const res = await runNode([join(rootDir, 'scripts', 'env.mjs')], { cwd: rootDir, env: baseEnv });
100
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
101
+ assert.ok(res.stdout.includes('[env] usage:'), `expected usage output\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
102
+ });
103
+
104
+ test('happys env list prints keys in text mode', async () => {
105
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
106
+ const rootDir = dirname(scriptsDir);
107
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-env-cmd-'));
108
+
109
+ const storageDir = join(tmp, 'storage');
110
+ const homeDir = join(tmp, 'home');
111
+ const stackName = 'exp1';
112
+ const envPath = join(storageDir, stackName, 'env');
113
+ await mkdir(dirname(envPath), { recursive: true });
114
+ await mkdir(homeDir, { recursive: true });
115
+ await writeFile(envPath, 'FOO=bar\n', 'utf-8');
116
+
117
+ const baseEnv = {
118
+ ...process.env,
119
+ HAPPY_STACKS_HOME_DIR: homeDir,
120
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
121
+ HAPPY_STACKS_ENV_FILE: envPath,
122
+ };
123
+
124
+ const res = await runNode([join(rootDir, 'scripts', 'env.mjs'), 'list'], { cwd: rootDir, env: baseEnv });
125
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
126
+ assert.ok(res.stdout.includes('FOO=bar'), `expected list output to include FOO=bar\nstdout:\n${res.stdout}`);
127
+ });
128
+
package/scripts/init.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, writeFile, readFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/env/config.mjs';
@@ -177,12 +177,15 @@ async function main() {
177
177
  process.env.HAPPY_LOCAL_HOME_DIR = process.env.HAPPY_LOCAL_HOME_DIR ?? homeDir;
178
178
 
179
179
  const workspaceDirRaw = parseArgValue(argv, 'workspace-dir');
180
- const workspaceDir = expandHome(firstNonEmpty(
180
+ const workspaceDirExpanded = expandHome(firstNonEmpty(
181
181
  workspaceDirRaw,
182
182
  process.env.HAPPY_STACKS_WORKSPACE_DIR,
183
183
  process.env.HAPPY_LOCAL_WORKSPACE_DIR,
184
184
  join(homeDir, 'workspace'),
185
185
  ));
186
+ // If the user passes a relative --workspace-dir, interpret it as relative to the home dir
187
+ // (not the current cwd). This keeps setup predictable, especially when invoked via `npx`.
188
+ const workspaceDir = workspaceDirExpanded.startsWith('/') ? workspaceDirExpanded : resolve(homeDir, workspaceDirExpanded);
186
189
  process.env.HAPPY_STACKS_WORKSPACE_DIR = workspaceDir;
187
190
  process.env.HAPPY_LOCAL_WORKSPACE_DIR = process.env.HAPPY_LOCAL_WORKSPACE_DIR ?? workspaceDir;
188
191