happy-stacks 0.2.0 → 0.3.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 (94) hide show
  1. package/README.md +59 -22
  2. package/bin/happys.mjs +2 -2
  3. package/package.json +1 -1
  4. package/scripts/auth.mjs +49 -202
  5. package/scripts/build.mjs +5 -6
  6. package/scripts/cli-link.mjs +3 -3
  7. package/scripts/completion.mjs +5 -5
  8. package/scripts/daemon.mjs +9 -17
  9. package/scripts/dev.mjs +18 -27
  10. package/scripts/doctor.mjs +20 -36
  11. package/scripts/edison.mjs +102 -77
  12. package/scripts/happy.mjs +8 -19
  13. package/scripts/init.mjs +5 -13
  14. package/scripts/install.mjs +8 -8
  15. package/scripts/lint.mjs +8 -29
  16. package/scripts/menubar.mjs +6 -13
  17. package/scripts/migrate.mjs +11 -21
  18. package/scripts/mobile.mjs +13 -12
  19. package/scripts/run.mjs +15 -15
  20. package/scripts/self.mjs +11 -29
  21. package/scripts/server_flavor.mjs +4 -4
  22. package/scripts/service.mjs +18 -28
  23. package/scripts/setup.mjs +26 -122
  24. package/scripts/setup_pr.mjs +11 -28
  25. package/scripts/stack.mjs +111 -161
  26. package/scripts/stop.mjs +3 -3
  27. package/scripts/tailscale.mjs +7 -10
  28. package/scripts/test.mjs +8 -29
  29. package/scripts/tui.mjs +8 -38
  30. package/scripts/typecheck.mjs +8 -29
  31. package/scripts/ui_gateway.mjs +1 -1
  32. package/scripts/uninstall.mjs +6 -6
  33. package/scripts/utils/{dev_auth_key.mjs → auth/dev_key.mjs} +2 -8
  34. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  35. package/scripts/utils/{handy_master_secret.mjs → auth/handy_master_secret.mjs} +6 -32
  36. package/scripts/utils/cli/flags.mjs +17 -0
  37. package/scripts/utils/cli/normalize.mjs +16 -0
  38. package/scripts/utils/cli/smoke_help.mjs +2 -2
  39. package/scripts/utils/cli/wizard.mjs +1 -1
  40. package/scripts/utils/crypto/tokens.mjs +14 -0
  41. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +4 -4
  42. package/scripts/utils/{dev_expo_web.mjs → dev/expo_web.mjs} +5 -5
  43. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +7 -7
  44. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  45. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  46. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  47. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  48. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  49. package/scripts/utils/env/read.mjs +30 -0
  50. package/scripts/utils/env/values.mjs +13 -0
  51. package/scripts/utils/{expo.mjs → expo/expo.mjs} +3 -9
  52. package/scripts/utils/fs/json.mjs +25 -0
  53. package/scripts/utils/fs/ops.mjs +29 -0
  54. package/scripts/utils/fs/package_json.mjs +8 -0
  55. package/scripts/utils/fs/tail.mjs +12 -0
  56. package/scripts/utils/git/refs.mjs +26 -0
  57. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +3 -3
  58. package/scripts/utils/net/dns.mjs +10 -0
  59. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  60. package/scripts/utils/{localhost_host.mjs → paths/localhost_host.mjs} +2 -10
  61. package/scripts/utils/{paths.mjs → paths/paths.mjs} +10 -7
  62. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  63. package/scripts/utils/proc/commands.mjs +34 -0
  64. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  65. package/scripts/utils/proc/package_scripts.mjs +31 -0
  66. package/scripts/utils/proc/pids.mjs +11 -0
  67. package/scripts/utils/{pm.mjs → proc/pm.mjs} +65 -152
  68. package/scripts/utils/{proc.mjs → proc/proc.mjs} +1 -0
  69. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  70. package/scripts/utils/server/port.mjs +68 -0
  71. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  72. package/scripts/utils/server/urls.mjs +91 -0
  73. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  74. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  75. package/scripts/utils/{stack_context.mjs → stack/context.mjs} +2 -2
  76. package/scripts/utils/stack/dirs.mjs +27 -0
  77. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  78. package/scripts/utils/stack/names.mjs +12 -0
  79. package/scripts/utils/{stack_runtime_state.mjs → stack/runtime_state.mjs} +10 -27
  80. package/scripts/utils/{stacks.mjs → stack/stacks.mjs} +9 -2
  81. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +2 -2
  82. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +9 -15
  83. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  84. package/scripts/utils/ui/text.mjs +16 -0
  85. package/scripts/where.mjs +6 -6
  86. package/scripts/worktrees.mjs +30 -58
  87. package/scripts/utils/server_port.mjs +0 -9
  88. package/scripts/utils/server_urls.mjs +0 -54
  89. /package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +0 -0
  90. /package/scripts/utils/{auth_sources.mjs → auth/sources.mjs} +0 -0
  91. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  92. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  93. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  94. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -1,21 +1,24 @@
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 { runCapture } from './utils/proc.mjs';
5
- import { getComponentDir, getDefaultAutostartPaths, getHappyStacksHomeDir, getRootDir, getWorkspaceDir, resolveStackEnvPath } from './utils/paths.mjs';
6
- import { killPortListeners } from './utils/ports.mjs';
7
- import { getServerComponentName } from './utils/server.mjs';
3
+ import { pathExists } from './utils/fs/fs.mjs';
4
+ import { runCapture } from './utils/proc/proc.mjs';
5
+ import { resolveCommandPath } from './utils/proc/commands.mjs';
6
+ import { getComponentDir, getDefaultAutostartPaths, getHappyStacksHomeDir, getRootDir, getWorkspaceDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
7
+ import { killPortListeners } from './utils/net/ports.mjs';
8
+ import { getServerComponentName } from './utils/server/server.mjs';
9
+ import { fetchHappyHealth } from './utils/server/server.mjs';
8
10
  import { daemonStatusSummary } from './daemon.mjs';
9
11
  import { tailscaleServeStatus } from './tailscale.mjs';
10
12
  import { homedir } from 'node:os';
11
13
  import { join } from 'node:path';
12
14
  import { existsSync } from 'node:fs';
13
- import { readFile } from 'node:fs/promises';
14
15
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
- import { getRuntimeDir } from './utils/runtime.mjs';
16
- import { assertServerComponentDirMatches } from './utils/validate.mjs';
17
- import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server_urls.mjs';
18
- import { resolveStackContext } from './utils/stack_context.mjs';
16
+ import { getRuntimeDir } from './utils/paths/runtime.mjs';
17
+ import { assertServerComponentDirMatches } from './utils/server/validate.mjs';
18
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
19
+ import { resolveStackContext } from './utils/stack/context.mjs';
20
+ import { readJsonIfExists } from './utils/fs/json.mjs';
21
+ import { readPackageJsonVersion } from './utils/fs/package_json.mjs';
19
22
 
20
23
  /**
21
24
  * Doctor script for common happy-stacks failure modes.
@@ -43,7 +46,8 @@ async function fetchHealth(url) {
43
46
  };
44
47
 
45
48
  // Prefer /health when available, but fall back to / (matches waitForServerReady).
46
- const health = await tryGet('/health');
49
+ const healthRaw = await fetchHappyHealth(url);
50
+ const health = { ok: healthRaw.ok, status: healthRaw.status, body: healthRaw.text ? healthRaw.text.trim() : null };
47
51
  if (health.ok) {
48
52
  return health;
49
53
  }
@@ -54,26 +58,6 @@ async function fetchHealth(url) {
54
58
  return health.ok ? health : root;
55
59
  }
56
60
 
57
- async function readJsonSafe(path) {
58
- try {
59
- const raw = await readFile(path, 'utf-8');
60
- return JSON.parse(raw);
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- async function readPkgVersion(path) {
67
- try {
68
- const raw = await readFile(path, 'utf-8');
69
- const pkg = JSON.parse(raw);
70
- const v = String(pkg.version ?? '').trim();
71
- return v || null;
72
- } catch {
73
- return null;
74
- }
75
- }
76
-
77
61
  async function resolveSwiftbarPluginsDir() {
78
62
  if (process.platform !== 'darwin') {
79
63
  return null;
@@ -114,8 +98,8 @@ async function main() {
114
98
  const workspaceDir = getWorkspaceDir(rootDir);
115
99
  const updateCachePath = join(homeDir, 'cache', 'update.json');
116
100
  const runtimePkgJson = join(runtimeDir, 'node_modules', 'happy-stacks', 'package.json');
117
- const runtimeVersion = await readPkgVersion(runtimePkgJson);
118
- const updateCache = existsSync(updateCachePath) ? await readJsonSafe(updateCachePath) : null;
101
+ const runtimeVersion = await readPackageJsonVersion(runtimePkgJson);
102
+ const updateCache = await readJsonIfExists(updateCachePath, { defaultValue: null });
119
103
 
120
104
  const autostart = getDefaultAutostartPaths();
121
105
  const stackCtx = resolveStackContext({ env: process.env, autostart });
@@ -302,7 +286,7 @@ async function main() {
302
286
 
303
287
  // happy wrapper
304
288
  try {
305
- const happyPath = (await runCapture('sh', ['-lc', 'command -v happy'])).trim();
289
+ const happyPath = await resolveCommandPath('happy');
306
290
  if (happyPath) {
307
291
  report.checks.happyOnPath = { ok: true, path: happyPath };
308
292
  if (!json) console.log(`✅ happy on PATH: ${happyPath}`);
@@ -314,7 +298,7 @@ async function main() {
314
298
 
315
299
  // happys on PATH
316
300
  try {
317
- const happysPath = (await runCapture('sh', ['-lc', 'command -v happys'])).trim();
301
+ const happysPath = await resolveCommandPath('happys');
318
302
  if (happysPath) {
319
303
  report.checks.happysOnPath = { ok: true, path: happysPath };
320
304
  if (!json) console.log(`✅ happys on PATH: ${happysPath}`);
@@ -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 { 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
 
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)));
@@ -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 } 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:
package/scripts/lint.mjs CHANGED
@@ -1,36 +1,15 @@
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 { getComponentDir, getRootDir } from './utils/paths.mjs';
5
- import { ensureDepsInstalled, requirePnpm } from './utils/pm.mjs';
6
- import { pathExists } from './utils/fs.mjs';
7
- import { run } from './utils/proc.mjs';
8
- import { join } from 'node:path';
9
- import { readFile } from 'node:fs/promises';
4
+ import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
+ import { pathExists } from './utils/fs/fs.mjs';
7
+ import { run } from './utils/proc/proc.mjs';
8
+ import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
10
9
 
11
10
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
12
11
 
13
- async function detectPackageManagerCmd(dir) {
14
- if (await pathExists(join(dir, 'yarn.lock'))) {
15
- return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
16
- }
17
- await requirePnpm();
18
- return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
19
- }
20
-
21
- async function readScripts(dir) {
22
- try {
23
- const raw = await readFile(join(dir, 'package.json'), 'utf-8');
24
- const pkg = JSON.parse(raw);
25
- const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
26
- return scripts;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
12
  function pickLintScript(scripts) {
33
- if (!scripts) return null;
34
13
  const candidates = [
35
14
  'lint',
36
15
  'lint:ci',
@@ -39,7 +18,7 @@ function pickLintScript(scripts) {
39
18
  'eslint',
40
19
  'eslint:check',
41
20
  ];
42
- return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
21
+ return pickFirstScript(scripts, candidates);
43
22
  }
44
23
 
45
24
  async function main() {
@@ -86,7 +65,7 @@ async function main() {
86
65
  continue;
87
66
  }
88
67
 
89
- const scripts = await readScripts(dir);
68
+ const scripts = await readPackageJsonScripts(dir);
90
69
  if (!scripts) {
91
70
  results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
92
71
  continue;
@@ -1,14 +1,15 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { cp, mkdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { createHash } from 'node:crypto';
7
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
7
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths/paths.mjs';
8
8
  import { parseArgs } from './utils/cli/args.mjs';
9
9
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
- import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
11
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
10
+ import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
11
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
12
+ import { normalizeProfile } from './utils/cli/normalize.mjs';
12
13
 
13
14
  async function ensureSwiftbarAssets({ cliRootDir }) {
14
15
  const homeDir = getHappyStacksHomeDir();
@@ -59,14 +60,6 @@ function removeSwiftbarPlugins({ patterns }) {
59
60
  return out || null;
60
61
  }
61
62
 
62
- function normalizeMenubarMode(raw) {
63
- const v = String(raw ?? '').trim().toLowerCase();
64
- if (!v) return '';
65
- if (v === 'selfhost' || v === 'self-host' || v === 'self_host' || v === 'host') return 'selfhost';
66
- if (v === 'dev' || v === 'developer') return 'dev';
67
- return '';
68
- }
69
-
70
63
  async function main() {
71
64
  const rawArgv = process.argv.slice(2);
72
65
  const argv = rawArgv[0] === 'menubar' ? rawArgv.slice(1) : rawArgv;
@@ -128,7 +121,7 @@ async function main() {
128
121
  if (cmd === 'mode') {
129
122
  const positionals = argv.filter((a) => !a.startsWith('--'));
130
123
  const raw = positionals[1] ?? '';
131
- const mode = normalizeMenubarMode(raw);
124
+ const mode = normalizeProfile(raw);
132
125
  if (!mode) {
133
126
  throw new Error('[menubar] usage: happys menubar mode <selfhost|dev> [--json]');
134
127
  }
@@ -1,17 +1,18 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { copyFile, mkdir, readFile } from 'node:fs/promises';
3
3
  import { basename, join } from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
5
 
6
6
  import { parseArgs } from './utils/cli/args.mjs';
7
7
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
- import { parseDotenv } from './utils/dotenv.mjs';
9
- import { ensureEnvFileUpdated } from './utils/env_file.mjs';
10
- import { resolveStackEnvPath } from './utils/paths.mjs';
11
- import { ensureDepsInstalled } from './utils/pm.mjs';
12
- import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/happy_server_infra.mjs';
13
- import { runCapture } from './utils/proc.mjs';
14
- import { pickNextFreeTcpPort } from './utils/ports.mjs';
8
+ import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
9
+ import { readEnvObjectFromFile } from './utils/env/read.mjs';
10
+ import { resolveStackEnvPath } from './utils/paths/paths.mjs';
11
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
12
+ import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/server/infra/happy_server_infra.mjs';
13
+ import { runCapture } from './utils/proc/proc.mjs';
14
+ import { pickNextFreeTcpPort } from './utils/net/ports.mjs';
15
+ import { getEnvValue } from './utils/env/values.mjs';
15
16
 
16
17
  function usage() {
17
18
  return [
@@ -25,18 +26,7 @@ function usage() {
25
26
  ].join('\n');
26
27
  }
27
28
 
28
- async function readEnvObject(envPath) {
29
- try {
30
- const raw = await readFile(envPath, 'utf-8');
31
- return Object.fromEntries(parseDotenv(raw).entries());
32
- } catch {
33
- return {};
34
- }
35
- }
36
-
37
- function getEnvValue(env, key) {
38
- return (env?.[key] ?? '').toString().trim();
39
- }
29
+ const readEnvObject = readEnvObjectFromFile;
40
30
 
41
31
  function parseFileDatabaseUrl(url) {
42
32
  const raw = String(url ?? '').trim();
@@ -290,7 +280,7 @@ async function main() {
290
280
  throw new Error('[migrate] --to-stack is required');
291
281
  }
292
282
 
293
- const rootDir = (await import('./utils/paths.mjs')).getRootDir(import.meta.url);
283
+ const rootDir = (await import('./utils/paths/paths.mjs')).getRootDir(import.meta.url);
294
284
  await migrateLightToServer({ rootDir, fromStack, toStack, includeFiles, force, json });
295
285
  }
296
286