happy-stacks 0.2.0 → 0.4.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 (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -1,14 +1,17 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { resolveStackEnvPath, getComponentDir, getRootDir } from './utils/paths.mjs';
5
- import { parseDotenv } from './utils/dotenv.mjs';
6
- import { pathExists } from './utils/fs.mjs';
7
- import { run, runCapture } from './utils/proc.mjs';
8
- import { resolveLocalhostHost } from './utils/localhost_host.mjs';
4
+ import { resolveStackEnvPath, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { parseEnvToObject } from './utils/env/dotenv.mjs';
6
+ import { pathExists } from './utils/fs/fs.mjs';
7
+ import { readTextOrEmpty } from './utils/fs/ops.mjs';
8
+ import { readJsonIfExists } from './utils/fs/json.mjs';
9
+ import { isPidAlive } from './utils/proc/pids.mjs';
10
+ import { run, runCapture } from './utils/proc/proc.mjs';
11
+ import { preferStackLocalhostHost, resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
12
+ import { sanitizeStackName } from './utils/stack/names.mjs';
9
13
  import { join } from 'node:path';
10
14
  import { spawn } from 'node:child_process';
11
- import { readFile } from 'node:fs/promises';
12
15
  import { mkdir, lstat, rename, symlink, writeFile, readdir, chmod } from 'node:fs/promises';
13
16
  import os from 'node:os';
14
17
 
@@ -26,25 +29,7 @@ function cleanHappyStacksEnv(baseEnv) {
26
29
  return cleaned;
27
30
  }
28
31
 
29
- async function readExistingEnv(path) {
30
- try {
31
- const raw = await readFile(path, 'utf-8');
32
- return raw;
33
- } catch {
34
- return '';
35
- }
36
- }
37
-
38
- async function readJsonIfExists(path) {
39
- try {
40
- if (!path || !(await pathExists(path))) return null;
41
- const raw = await readFile(path, 'utf-8');
42
- const parsed = JSON.parse(raw);
43
- return parsed && typeof parsed === 'object' ? parsed : null;
44
- } catch {
45
- return null;
46
- }
47
- }
32
+ const readExistingEnv = readTextOrEmpty;
48
33
 
49
34
  function inferServerPortFromRuntimeState(runtimeState) {
50
35
  try {
@@ -56,17 +41,6 @@ function inferServerPortFromRuntimeState(runtimeState) {
56
41
  }
57
42
  }
58
43
 
59
- function isPidAlive(pid) {
60
- const n = Number(pid);
61
- if (!Number.isFinite(n) || n <= 1) return false;
62
- try {
63
- process.kill(n, 0);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
44
  function isRuntimeStateAlive(runtimeState) {
71
45
  try {
72
46
  const ownerPid = runtimeState?.ownerPid;
@@ -376,11 +350,6 @@ async function inferTaskIdFromArgs({ rootDir, edisonArgs }) {
376
350
  return '';
377
351
  }
378
352
 
379
- function parseEnvToObject(raw) {
380
- const parsed = parseDotenv(raw);
381
- return Object.fromEntries(parsed.entries());
382
- }
383
-
384
353
  function resolveComponentDirsFromStackEnv({ rootDir, stackEnv }) {
385
354
  const out = [];
386
355
 
@@ -452,17 +421,6 @@ function resolveComponentsFromFrontmatter(fm) {
452
421
  return [];
453
422
  }
454
423
 
455
- function sanitizeStackName(raw) {
456
- return String(raw ?? '')
457
- .trim()
458
- .toLowerCase()
459
- .replace(/[^a-z0-9-]+/g, '-')
460
- .replace(/-+/g, '-')
461
- .replace(/^-+/, '')
462
- .replace(/-+$/, '')
463
- .slice(0, 64);
464
- }
465
-
466
424
  function yamlQuote(v) {
467
425
  const s = String(v ?? '');
468
426
  return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
@@ -608,12 +566,13 @@ async function cmdTaskScaffold({ rootDir, argv, json }) {
608
566
  const taskId = positionals[1]?.trim?.() ? positionals[1].trim() : '';
609
567
  if (!taskId) {
610
568
  throw new Error(
611
- '[edison] usage: happys edison task:scaffold <task-id> [--mode=upstream|fork|both] [--tracks=upstream,fork] [--yes] [--json]'
569
+ '[edison] usage: happys edison task:scaffold <task-id> [--mode=upstream|fork|both] [--tracks=upstream,fork] [--yes] [--reuse-only] [--json]'
612
570
  );
613
571
  }
614
572
 
615
573
  const mode = (kv.get('--mode') ?? '').trim().toLowerCase() || 'upstream';
616
574
  const yes = flags.has('--yes');
575
+ const reuseOnly = flags.has('--reuse-only') || (kv.get('--reuse-only') ?? '').trim() === '1';
617
576
 
618
577
  const taskPath = join(rootDir, '.project', 'tasks');
619
578
  const taskGlobRoots = ['todo', 'wip', 'done', 'validated', 'blocked'];
@@ -722,12 +681,32 @@ async function cmdTaskScaffold({ rootDir, argv, json }) {
722
681
  createdTasks.push({ id: trackTaskId, kind: 'track', stack, path: trackRes.path, created: trackRes.created });
723
682
 
724
683
  const stackExists = Array.isArray(stacks) && stacks.some((s) => String(s?.name ?? '') === stack);
684
+ const expectedStackRemote = track === 'fork' || track === 'integration' ? 'origin' : 'upstream';
725
685
  if (!stackExists) {
726
- await run('node', ['./bin/happys.mjs', 'stack', 'new', stack, '--json'], { cwd: rootDir });
686
+ await run('node', ['./bin/happys.mjs', 'stack', 'new', stack, `--remote=${expectedStackRemote}`, '--json'], { cwd: rootDir });
727
687
  createdStacks.push({ stack });
728
688
  stacks.push({ name: stack });
729
689
  }
730
690
 
691
+ // If the stack already exists, reuse pinned worktrees when possible.
692
+ let stackInfo = null;
693
+ if (stackExists) {
694
+ const raw = await runCapture('node', ['./bin/happys.mjs', 'stack', 'info', stack, '--json'], { cwd: rootDir });
695
+ stackInfo = JSON.parse(raw);
696
+ const actualRemote = String(stackInfo?.stackRemote ?? '').trim() || 'upstream';
697
+ if (actualRemote !== expectedStackRemote) {
698
+ throw new Error(
699
+ `[edison] stack remote mismatch for track "${track}".\n` +
700
+ `- stack: ${stack}\n` +
701
+ `- expected: ${expectedStackRemote}\n` +
702
+ `- actual: ${actualRemote}\n\n` +
703
+ `Fix:\n` +
704
+ `- run: happys stack edit ${stack} --interactive\n` +
705
+ `- set: Git remote for creating new worktrees = ${expectedStackRemote}\n`
706
+ );
707
+ }
708
+ }
709
+
731
710
  const qaRes = await ensureQaFile({
732
711
  rootDir,
733
712
  taskId: trackTaskId,
@@ -750,7 +729,11 @@ async function cmdTaskScaffold({ rootDir, argv, json }) {
750
729
  const compTaskId = existingComp?.id || nextChildId(trackTaskId, existingIds);
751
730
  existingIds.add(compTaskId);
752
731
  const compTitle = `Component: ${c} (${track})`;
753
- const baseWorktree = String(existingComp?.fm?.base_worktree ?? '').trim() || `edison/${compTaskId}`;
732
+ const pinnedSpec =
733
+ stackInfo && Array.isArray(stackInfo.components)
734
+ ? String(stackInfo.components.find((x) => x?.component === c)?.worktreeSpec ?? '').trim()
735
+ : '';
736
+ const baseWorktree = String(existingComp?.fm?.base_worktree ?? '').trim() || pinnedSpec || `edison/${compTaskId}`;
754
737
  const compFm = {
755
738
  id: compTaskId,
756
739
  title: compTitle,
@@ -785,16 +768,32 @@ async function cmdTaskScaffold({ rootDir, argv, json }) {
785
768
  });
786
769
  createdQas.push({ id: `${compTaskId}-qa`, path: qa2.path, created: qa2.created });
787
770
 
788
- const from = track === 'fork' ? 'origin' : 'upstream';
789
- const stdout = await runCapture(
790
- 'node',
791
- ['./bin/happys.mjs', 'wt', 'new', c, baseWorktree, `--from=${from}`, '--json'],
792
- { cwd: rootDir }
793
- );
794
- const res = JSON.parse(stdout);
795
- createdWorktrees.push({ component: c, variant: from, taskId: compTaskId, path: res.path, branch: res.branch });
796
- await run('node', ['./bin/happys.mjs', 'stack', 'wt', stack, '--', 'use', c, res.path, '--json'], { cwd: rootDir });
797
- pinned.push({ stack, component: c, taskId: compTaskId, path: res.path });
771
+ if (pinnedSpec) {
772
+ // Stack already pins an existing worktree; reuse it rather than creating a new one.
773
+ const pinnedDir = String(stackInfo.components.find((x) => x?.component === c)?.dir ?? '').trim();
774
+ createdWorktrees.push({ component: c, variant: 'reuse', taskId: compTaskId, path: pinnedDir, branch: null, worktreeSpec: pinnedSpec });
775
+ pinned.push({ stack, component: c, taskId: compTaskId, path: pinnedDir });
776
+ } else if (reuseOnly && stackExists) {
777
+ throw new Error(
778
+ `[edison] --reuse-only: stack "${stack}" is not pinned to a worktree for component "${c}".\n` +
779
+ `Fix:\n` +
780
+ `- pin an existing worktree to the stack:\n` +
781
+ ` happys stack wt ${stack} -- use ${c} <owner/branch|/abs/path>\n` +
782
+ `- then re-run:\n` +
783
+ ` happys edison task:scaffold ${taskId} --yes --reuse-only\n`
784
+ );
785
+ } else {
786
+ const from = track === 'fork' ? 'origin' : 'upstream';
787
+ const stdout = await runCapture(
788
+ 'node',
789
+ ['./bin/happys.mjs', 'wt', 'new', c, baseWorktree, `--from=${from}`, '--json'],
790
+ { cwd: rootDir }
791
+ );
792
+ const res = JSON.parse(stdout);
793
+ createdWorktrees.push({ component: c, variant: from, taskId: compTaskId, path: res.path, branch: res.branch });
794
+ await run('node', ['./bin/happys.mjs', 'stack', 'wt', stack, '--', 'use', c, res.path, '--json'], { cwd: rootDir });
795
+ pinned.push({ stack, component: c, taskId: compTaskId, path: res.path });
796
+ }
798
797
  }
799
798
  }
800
799
  } else {
@@ -817,22 +816,48 @@ async function cmdTaskScaffold({ rootDir, argv, json }) {
817
816
  ` happys edison task:scaffold ${taskId} --yes\n`
818
817
  );
819
818
  }
820
- await run('node', ['./bin/happys.mjs', 'stack', 'new', stack, '--json'], { cwd: rootDir });
819
+ const stackRemote = mode === 'fork' ? 'origin' : 'upstream';
820
+ await run('node', ['./bin/happys.mjs', 'stack', 'new', stack, `--remote=${stackRemote}`, '--json'], { cwd: rootDir });
821
821
  createdStacks.push({ stack });
822
822
  }
823
823
 
824
+ let stackInfo = null;
825
+ if (stackExists) {
826
+ const raw = await runCapture('node', ['./bin/happys.mjs', 'stack', 'info', stack, '--json'], { cwd: rootDir });
827
+ stackInfo = JSON.parse(raw);
828
+ }
829
+
824
830
  for (const c of components) {
825
- const baseWorktree = `edison/${taskId}`;
831
+ const pinnedSpec =
832
+ stackInfo && Array.isArray(stackInfo.components)
833
+ ? String(stackInfo.components.find((x) => x?.component === c)?.worktreeSpec ?? '').trim()
834
+ : '';
835
+ const baseWorktree = pinnedSpec || `edison/${taskId}`;
826
836
  const from = mode === 'fork' ? 'origin' : 'upstream';
827
- const stdout = await runCapture(
828
- 'node',
829
- ['./bin/happys.mjs', 'wt', 'new', c, baseWorktree, `--from=${from}`, '--json'],
830
- { cwd: rootDir }
831
- );
832
- const res = JSON.parse(stdout);
833
- createdWorktrees.push({ component: c, variant: from, taskId, path: res.path, branch: res.branch });
834
- await run('node', ['./bin/happys.mjs', 'stack', 'wt', stack, '--', 'use', c, res.path, '--json'], { cwd: rootDir });
835
- pinned.push({ stack, component: c, taskId, path: res.path });
837
+ if (pinnedSpec) {
838
+ const pinnedDir = String(stackInfo.components.find((x) => x?.component === c)?.dir ?? '').trim();
839
+ createdWorktrees.push({ component: c, variant: 'reuse', taskId, path: pinnedDir, branch: null, worktreeSpec: pinnedSpec });
840
+ pinned.push({ stack, component: c, taskId, path: pinnedDir });
841
+ } else if (reuseOnly && stackExists) {
842
+ throw new Error(
843
+ `[edison] --reuse-only: stack "${stack}" is not pinned to a worktree for component "${c}".\n` +
844
+ `Fix:\n` +
845
+ `- pin an existing worktree to the stack:\n` +
846
+ ` happys stack wt ${stack} -- use ${c} <owner/branch|/abs/path>\n` +
847
+ `- then re-run:\n` +
848
+ ` happys edison task:scaffold ${taskId} --yes --reuse-only\n`
849
+ );
850
+ } else {
851
+ const stdout = await runCapture(
852
+ 'node',
853
+ ['./bin/happys.mjs', 'wt', 'new', c, baseWorktree, `--from=${from}`, '--json'],
854
+ { cwd: rootDir }
855
+ );
856
+ const res = JSON.parse(stdout);
857
+ createdWorktrees.push({ component: c, variant: from, taskId, path: res.path, branch: res.branch });
858
+ await run('node', ['./bin/happys.mjs', 'stack', 'wt', stack, '--', 'use', c, res.path, '--json'], { cwd: rootDir });
859
+ pinned.push({ stack, component: c, taskId, path: res.path });
860
+ }
836
861
  }
837
862
  }
838
863
 
@@ -1756,7 +1781,9 @@ async function main() {
1756
1781
  env.HAPPY_STACKS_EDISON_WRAPPER = '1';
1757
1782
  // Provide a stack-scoped localhost hostname for validators and browser flows.
1758
1783
  // This ensures origin isolation even if ports are reused later (common with ephemeral ports).
1759
- const localhostHost = resolveLocalhostHost({ stackMode: Boolean(stackName), stackName: stackName || 'main' });
1784
+ const localhostHost = Boolean(stackName)
1785
+ ? await preferStackLocalhostHost({ stackName })
1786
+ : resolveLocalhostHost({ stackMode: false, stackName: 'main' });
1760
1787
  env.HAPPY_STACKS_LOCALHOST_HOST = localhostHost;
1761
1788
  env.HAPPY_LOCAL_LOCALHOST_HOST = localhostHost;
1762
1789
 
package/scripts/happy.mjs CHANGED
@@ -1,23 +1,12 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { parseArgs } from './utils/cli/args.mjs';
6
6
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
7
- import { expandHome } from './utils/canonical_home.mjs';
8
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
9
-
10
- function resolveCliHomeDir() {
11
- const fromExplicit = (process.env.HAPPY_HOME_DIR ?? '').trim();
12
- if (fromExplicit) {
13
- return expandHome(fromExplicit);
14
- }
15
- const fromStacks = (process.env.HAPPY_STACKS_CLI_HOME_DIR ?? process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
16
- if (fromStacks) {
17
- return expandHome(fromStacks);
18
- }
19
- return join(getDefaultAutostartPaths().baseDir, 'cli');
20
- }
7
+ import { getComponentDir, getRootDir, getStackName } from './utils/paths/paths.mjs';
8
+ import { resolveCliHomeDir } from './utils/stack/dirs.mjs';
9
+ import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv } from './utils/server/urls.mjs';
21
10
 
22
11
  async function main() {
23
12
  const argv = process.argv.slice(2);
@@ -42,12 +31,12 @@ async function main() {
42
31
 
43
32
  const rootDir = getRootDir(import.meta.url);
44
33
 
45
- const portRaw = (process.env.HAPPY_STACKS_SERVER_PORT ?? process.env.HAPPY_LOCAL_SERVER_PORT ?? '').trim();
46
- const port = portRaw ? Number(portRaw) : 3005;
47
- const serverPort = Number.isFinite(port) ? port : 3005;
34
+ const stackName =
35
+ (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').toString().trim() || getStackName();
36
+ const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
48
37
 
49
38
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
50
- const publicServerUrl = (process.env.HAPPY_STACKS_SERVER_URL ?? process.env.HAPPY_LOCAL_SERVER_URL ?? '').trim() || `http://localhost:${serverPort}`;
39
+ const { publicServerUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort, stackName });
51
40
 
52
41
  const cliHomeDir = resolveCliHomeDir();
53
42
 
package/scripts/init.mjs CHANGED
@@ -4,19 +4,11 @@ import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawnSync } from 'node:child_process';
7
- import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/config.mjs';
8
- import { parseDotenv } from './utils/dotenv.mjs';
9
- import { expandHome } from './utils/canonical_home.mjs';
10
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
11
-
12
- async function readJsonIfExists(path) {
13
- try {
14
- const raw = await readFile(path, 'utf-8');
15
- return JSON.parse(raw);
16
- } catch {
17
- return null;
18
- }
19
- }
7
+ import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/env/config.mjs';
8
+ import { parseDotenv } from './utils/env/dotenv.mjs';
9
+ import { expandHome } from './utils/paths/canonical_home.mjs';
10
+ import { readJsonIfExists } from './utils/fs/json.mjs';
11
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
20
12
 
21
13
  function getCliRootDir() {
22
14
  return dirname(dirname(fileURLToPath(import.meta.url)));
@@ -207,7 +199,9 @@ async function main() {
207
199
  const storageDirRaw = parseArgValue(argv, 'storage-dir');
208
200
  const storageDirOverride = expandHome((storageDirRaw ?? '').trim());
209
201
  if (storageDirOverride) {
210
- process.env.HAPPY_STACKS_STORAGE_DIR = process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDirOverride;
202
+ // In sandbox mode, storage dir MUST be isolated and must override any pre-existing env.
203
+ process.env.HAPPY_STACKS_STORAGE_DIR = isSandboxed() ? storageDirOverride : (process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDirOverride);
204
+ process.env.HAPPY_LOCAL_STORAGE_DIR = process.env.HAPPY_LOCAL_STORAGE_DIR ?? process.env.HAPPY_STACKS_STORAGE_DIR;
211
205
  }
212
206
 
213
207
  const cliRootDirRaw = parseArgValue(argv, 'cli-root-dir');
@@ -1,17 +1,17 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { pathExists } from './utils/fs.mjs';
4
- import { run } from './utils/proc.mjs';
5
- import { getComponentDir, getRootDir } from './utils/paths.mjs';
6
- import { getServerComponentName } from './utils/server.mjs';
7
- import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
3
+ import { pathExists } from './utils/fs/fs.mjs';
4
+ import { run, runCapture } from './utils/proc/proc.mjs';
5
+ import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
6
+ import { getServerComponentName } from './utils/server/server.mjs';
7
+ import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } from './utils/proc/pm.mjs';
8
8
  import { dirname, join } from 'node:path';
9
9
  import { mkdir } from 'node:fs/promises';
10
10
  import { installService, uninstallService } from './service.mjs';
11
11
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
12
- import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
12
+ import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
13
13
  import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
14
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
15
15
 
16
16
  /**
17
17
  * Install/setup the local stack:
@@ -24,8 +24,10 @@ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs
24
24
 
25
25
  const DEFAULT_FORK_REPOS = {
26
26
  serverLight: 'https://github.com/leeroybrun/happy-server-light.git',
27
- // We don't currently maintain a separate fork for full happy-server; default to upstream.
28
- serverFull: 'https://github.com/slopus/happy-server.git',
27
+ // Both server flavors live as branches in the same fork repo:
28
+ // - happy-server-light (sqlite)
29
+ // - happy-server (full)
30
+ serverFull: 'https://github.com/leeroybrun/happy-server-light.git',
29
31
  cli: 'https://github.com/leeroybrun/happy-cli.git',
30
32
  ui: 'https://github.com/leeroybrun/happy.git',
31
33
  };
@@ -44,7 +46,8 @@ function repoUrlsFromOwners({ forkOwner, upstreamOwner }) {
44
46
  return {
45
47
  forks: {
46
48
  serverLight: fork('happy-server-light'),
47
- serverFull: fork('happy-server') /* best-effort; user can override */,
49
+ // Fork convention: server full is a branch in happy-server-light repo (not a separate repo).
50
+ serverFull: fork('happy-server-light'),
48
51
  cli: fork('happy-cli'),
49
52
  ui: fork('happy'),
50
53
  },
@@ -86,6 +89,51 @@ function getRepoUrls({ repoSource }) {
86
89
  };
87
90
  }
88
91
 
92
+ async function ensureGitBranchCheckedOut({ repoDir, branch, label }) {
93
+ if (!(await pathExists(join(repoDir, '.git')))) return;
94
+ const b = String(branch ?? '').trim();
95
+ if (!b) return;
96
+
97
+ try {
98
+ const head = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir })).trim();
99
+ if (head && head === b) return;
100
+ } catch {
101
+ // ignore
102
+ }
103
+
104
+ // Ensure branch exists locally, otherwise fetch it from origin.
105
+ let hasLocal = true;
106
+ try {
107
+ await run('git', ['show-ref', '--verify', '--quiet', `refs/heads/${b}`], { cwd: repoDir });
108
+ } catch {
109
+ hasLocal = false;
110
+ }
111
+ if (!hasLocal) {
112
+ try {
113
+ await run('git', ['fetch', '--quiet', 'origin', b], { cwd: repoDir });
114
+ } catch {
115
+ throw new Error(
116
+ `[local] ${label}: expected branch "${b}" to exist in ${repoDir}.\n` +
117
+ `[local] Fix: use --forks for happy-server-light (sqlite), or use --server=happy-server with --upstream.`
118
+ );
119
+ }
120
+ }
121
+
122
+ try {
123
+ await run('git', ['checkout', '-q', b], { cwd: repoDir });
124
+ } catch {
125
+ // If remote-tracking branch exists but local doesn't, create it.
126
+ try {
127
+ await run('git', ['checkout', '-q', '-B', b, `origin/${b}`], { cwd: repoDir });
128
+ } catch {
129
+ throw new Error(
130
+ `[local] ${label}: failed to checkout branch "${b}" in ${repoDir}.\n` +
131
+ `[local] Fix: re-run with --force in worktree flows, or delete the checkout and re-run install/bootstrap.`
132
+ );
133
+ }
134
+ }
135
+ }
136
+
89
137
  async function ensureComponentPresent({ dir, label, repoUrl, allowClone }) {
90
138
  if (await pathExists(dir)) {
91
139
  return;
@@ -201,7 +249,22 @@ async function main() {
201
249
  if (wantsHelp(argv, { flags })) {
202
250
  printResult({
203
251
  json,
204
- data: { flags: ['--forks', '--upstream', '--clone', '--no-clone', '--autostart', '--no-autostart', '--server=...'], json: true },
252
+ data: {
253
+ flags: [
254
+ '--forks',
255
+ '--upstream',
256
+ '--clone',
257
+ '--no-clone',
258
+ '--autostart',
259
+ '--no-autostart',
260
+ '--server=...',
261
+ '--no-ui-build',
262
+ '--no-ui-deps',
263
+ '--no-cli-deps',
264
+ '--no-cli-build',
265
+ ],
266
+ json: true,
267
+ },
205
268
  text: [
206
269
  '[bootstrap] usage:',
207
270
  ' happys bootstrap [--forks|--upstream] [--server=happy-server|happy-server-light|both] [--json]',
@@ -270,6 +333,17 @@ async function main() {
270
333
  const disableAutostart = flags.has('--no-autostart');
271
334
 
272
335
  const serverComponentName = (wizard?.serverComponentName ?? getServerComponentName({ kv })).trim();
336
+ // Safety: upstream server-light is not a separate upstream repo/branch today.
337
+ // Upstream slopus/happy-server is Postgres-only, while happy-server-light requires sqlite.
338
+ if (repoSource === 'upstream' && (serverComponentName === 'happy-server-light' || serverComponentName === 'both')) {
339
+ throw new Error(
340
+ `[bootstrap] --upstream is not supported for happy-server-light (sqlite).\n` +
341
+ `Reason: upstream ${DEFAULT_UPSTREAM_REPOS.serverLight} does not provide a happy-server-light branch.\n` +
342
+ `Fix:\n` +
343
+ `- use --forks (recommended), OR\n` +
344
+ `- use --server=happy-server with --upstream`
345
+ );
346
+ }
273
347
  const serverLightDir = getComponentDir(rootDir, 'happy-server-light');
274
348
  const serverFullDir = getComponentDir(rootDir, 'happy-server');
275
349
  const cliDir = getComponentDir(rootDir, 'happy-cli');
@@ -305,35 +379,57 @@ async function main() {
305
379
  allowClone,
306
380
  });
307
381
 
382
+ // Ensure expected branches are checked out for server flavors (avoids "server-light directory contains full server" mistakes).
383
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
384
+ await ensureGitBranchCheckedOut({ repoDir: serverLightDir, branch: 'happy-server-light', label: 'SERVER' });
385
+ }
386
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
387
+ // In fork mode, full server is a branch in the fork server repo. In upstream mode, use upstream main.
388
+ const serverFullBranch = repoSource === 'upstream' ? 'main' : 'happy-server';
389
+ await ensureGitBranchCheckedOut({ repoDir: serverFullDir, branch: serverFullBranch, label: 'SERVER_FULL' });
390
+ }
391
+
308
392
  const cliDirFinal = cliDir;
309
393
  const uiDirFinal = uiDir;
310
394
 
311
395
  // Install deps
396
+ const skipUiDeps = flags.has('--no-ui-deps') || (process.env.HAPPY_STACKS_INSTALL_NO_UI_DEPS ?? '').trim() === '1';
397
+ const skipCliDeps = flags.has('--no-cli-deps') || (process.env.HAPPY_STACKS_INSTALL_NO_CLI_DEPS ?? '').trim() === '1';
312
398
  if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
313
399
  await ensureDepsInstalled(serverLightDir, 'happy-server-light');
314
400
  }
315
401
  if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
316
402
  await ensureDepsInstalled(serverFullDir, 'happy-server');
317
403
  }
318
- await ensureDepsInstalled(uiDirFinal, 'happy');
319
- await ensureDepsInstalled(cliDirFinal, 'happy-cli');
404
+ if (!skipUiDeps) {
405
+ await ensureDepsInstalled(uiDirFinal, 'happy');
406
+ }
407
+ if (!skipCliDeps) {
408
+ await ensureDepsInstalled(cliDirFinal, 'happy-cli');
409
+ }
320
410
 
321
411
  // CLI build + link
322
- const buildCli = (process.env.HAPPY_LOCAL_CLI_BUILD ?? '1') !== '0';
323
- const npmLinkCli = (process.env.HAPPY_LOCAL_NPM_LINK ?? '1') !== '0';
324
- await ensureCliBuilt(cliDirFinal, { buildCli });
325
- await ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli });
412
+ const skipCliBuild = flags.has('--no-cli-build') || (process.env.HAPPY_STACKS_INSTALL_NO_CLI_BUILD ?? '').trim() === '1';
413
+ if (!skipCliBuild) {
414
+ const buildCli = (process.env.HAPPY_LOCAL_CLI_BUILD ?? '1') !== '0';
415
+ const npmLinkCli = (process.env.HAPPY_LOCAL_NPM_LINK ?? '1') !== '0';
416
+ await ensureCliBuilt(cliDirFinal, { buildCli });
417
+ await ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli });
418
+ }
326
419
 
327
420
  // Build UI (so run works without expo dev server)
421
+ const skipUiBuild = flags.has('--no-ui-build') || (process.env.HAPPY_STACKS_INSTALL_NO_UI_BUILD ?? '').trim() === '1';
328
422
  const buildArgs = [join(rootDir, 'scripts', 'build.mjs')];
329
423
  // Tauri builds are opt-in (slow + requires additional toolchain).
330
424
  const buildTauri = wizard?.buildTauri ?? (flags.has('--tauri') && !flags.has('--no-tauri'));
331
- if (buildTauri) {
332
- buildArgs.push('--tauri');
333
- } else if (flags.has('--no-tauri')) {
334
- buildArgs.push('--no-tauri');
425
+ if (!skipUiBuild) {
426
+ if (buildTauri) {
427
+ buildArgs.push('--tauri');
428
+ } else if (flags.has('--no-tauri')) {
429
+ buildArgs.push('--no-tauri');
430
+ }
431
+ await run(process.execPath, buildArgs, { cwd: rootDir });
335
432
  }
336
- await run(process.execPath, buildArgs, { cwd: rootDir });
337
433
 
338
434
  // Optional autostart (macOS)
339
435
  if (disableAutostart) {