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
package/scripts/stack.mjs CHANGED
@@ -1,15 +1,16 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { existsSync } from 'node:fs';
6
- import { randomBytes } from 'node:crypto';
6
+ // NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
7
7
  import { homedir } from 'node:os';
8
+ import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs';
8
9
 
9
10
  import { parseArgs } from './utils/cli/args.mjs';
10
- import { killProcessTree, run, runCapture } from './utils/proc.mjs';
11
- import { getComponentDir, getComponentsDir, getHappyStacksHomeDir, getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
12
- import { isTcpPortFree, pickNextFreeTcpPort } from './utils/ports.mjs';
11
+ import { killProcessTree, run, runCapture } from './utils/proc/proc.mjs';
12
+ import { getComponentDir, getComponentsDir, getHappyStacksHomeDir, getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths/paths.mjs';
13
+ import { isTcpPortFree, pickNextFreeTcpPort } from './utils/net/ports.mjs';
13
14
  import {
14
15
  createWorktree,
15
16
  createWorktreeFromBaseWorktree,
@@ -17,62 +18,49 @@ import {
17
18
  isComponentWorktreePath,
18
19
  resolveComponentSpecToDir,
19
20
  worktreeSpecFromDir,
20
- } from './utils/worktrees.mjs';
21
+ } from './utils/git/worktrees.mjs';
21
22
  import { isTty, prompt, promptWorktreeSource, withRl } from './utils/cli/wizard.mjs';
22
- import { parseDotenv } from './utils/dotenv.mjs';
23
+ import { parseEnvToObject } from './utils/env/dotenv.mjs';
23
24
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
24
- import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env_file.mjs';
25
- import { listAllStackNames } from './utils/stacks.mjs';
26
- import { stopStackWithEnv } from './utils/stack_stop.mjs';
27
- import { writeDevAuthKey } from './utils/dev_auth_key.mjs';
28
- import { startDevServer } from './utils/dev_server.mjs';
29
- import { startDevExpoWebUi } from './utils/dev_expo_web.mjs';
30
- import { requireDir } from './utils/pm.mjs';
31
- import { waitForHttpOk } from './utils/server.mjs';
32
- import { resolveLocalhostHost } from './utils/localhost_host.mjs';
33
- import { openUrlInBrowser } from './utils/browser.mjs';
34
- import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth_files.mjs';
35
- import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth_sources.mjs';
36
- import { resolveAuthSeedFromEnv } from './utils/stack_startup.mjs';
37
- import { getHomeEnvLocalPath } from './utils/config.mjs';
38
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
39
- import { resolveHandyMasterSecretFromStack } from './utils/handy_master_secret.mjs';
25
+ import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
26
+ import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
27
+ import { stopStackWithEnv } from './utils/stack/stop.mjs';
28
+ import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
29
+ import { startDevServer } from './utils/dev/server.mjs';
30
+ import { startDevExpoWebUi } from './utils/dev/expo_web.mjs';
31
+ import { requireDir } from './utils/proc/pm.mjs';
32
+ import { waitForHttpOk } from './utils/server/server.mjs';
33
+ import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
34
+ import { openUrlInBrowser } from './utils/ui/browser.mjs';
35
+ import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth/files.mjs';
36
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
37
+ import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
38
+ import { getHomeEnvLocalPath } from './utils/env/config.mjs';
39
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
40
+ import { resolveHandyMasterSecretFromStack } from './utils/auth/handy_master_secret.mjs';
41
+ import { readPinnedServerPortFromEnvFile } from './utils/server/port.mjs';
42
+ import { getEnvValue, getEnvValueAny } from './utils/env/values.mjs';
43
+ import { sanitizeDnsLabel } from './utils/net/dns.mjs';
44
+ import { coercePort, listPortsFromEnvObject, STACK_RESERVED_PORT_KEYS } from './utils/server/port.mjs';
40
45
  import {
41
46
  deleteStackRuntimeStateFile,
42
47
  getStackRuntimeStatePath,
43
48
  isPidAlive,
44
49
  recordStackRuntimeStart,
45
50
  readStackRuntimeStateFile,
46
- } from './utils/stack_runtime_state.mjs';
47
- import { killPid } from './utils/expo.mjs';
48
- import { killPidOwnedByStack } from './utils/ownership.mjs';
49
-
50
- function getEnvValue(obj, key) {
51
- const v = (obj?.[key] ?? '').toString().trim();
52
- return v || '';
53
- }
54
-
55
- function getEnvValueAny(obj, keys) {
56
- for (const k of keys) {
57
- const v = getEnvValue(obj, k);
58
- if (v) return v;
59
- }
60
- return '';
61
- }
51
+ } from './utils/stack/runtime_state.mjs';
52
+ import { killPid } from './utils/expo/expo.mjs';
53
+ import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
54
+ import { randomToken } from './utils/crypto/tokens.mjs';
55
+ import { killPidOwnedByStack } from './utils/proc/ownership.mjs';
56
+ import { sanitizeSlugPart } from './utils/git/refs.mjs';
57
+ import { isCursorInstalled, openWorkspaceInEditor, writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
62
58
 
63
59
  function stackNameFromArg(positionals, idx) {
64
60
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
65
61
  return name;
66
62
  }
67
63
 
68
- function getStackDir(stackName) {
69
- return resolveStackEnvPath(stackName).baseDir;
70
- }
71
-
72
- function getStackEnvPath(stackName) {
73
- return resolveStackEnvPath(stackName).envPath;
74
- }
75
-
76
64
  function getDefaultPortStart() {
77
65
  const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
78
66
  ? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
@@ -97,34 +85,14 @@ async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
97
85
  }
98
86
 
99
87
  async function readPortFromEnvFile(envPath) {
100
- const raw = await readExistingEnv(envPath);
101
- if (!raw.trim()) return null;
102
- const parsed = parseEnvToObject(raw);
103
- const portRaw = (parsed.HAPPY_STACKS_SERVER_PORT ?? parsed.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
104
- const n = portRaw ? Number(portRaw) : NaN;
105
- return Number.isFinite(n) && n > 0 ? n : null;
88
+ return await readPinnedServerPortFromEnvFile(envPath);
106
89
  }
107
90
 
108
91
  async function readPortsFromEnvFile(envPath) {
109
92
  const raw = await readExistingEnv(envPath);
110
93
  if (!raw.trim()) return [];
111
94
  const parsed = parseEnvToObject(raw);
112
- const keys = [
113
- 'HAPPY_STACKS_SERVER_PORT',
114
- 'HAPPY_LOCAL_SERVER_PORT',
115
- 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
116
- 'HAPPY_STACKS_PG_PORT',
117
- 'HAPPY_STACKS_REDIS_PORT',
118
- 'HAPPY_STACKS_MINIO_PORT',
119
- 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
120
- ];
121
- const ports = [];
122
- for (const k of keys) {
123
- const rawV = (parsed[k] ?? '').toString().trim();
124
- const n = rawV ? Number(rawV) : NaN;
125
- if (Number.isFinite(n) && n > 0) ports.push(n);
126
- }
127
- return ports;
95
+ return listPortsFromEnvObject(parsed, STACK_RESERVED_PORT_KEYS);
128
96
  }
129
97
 
130
98
  async function collectReservedStackPorts({ excludeStackName = null } = {}) {
@@ -159,54 +127,7 @@ async function collectReservedStackPorts({ excludeStackName = null } = {}) {
159
127
  return reserved;
160
128
  }
161
129
 
162
- function base64Url(buf) {
163
- return Buffer.from(buf)
164
- .toString('base64')
165
- .replaceAll('+', '-')
166
- .replaceAll('/', '_')
167
- .replaceAll('=', '');
168
- }
169
-
170
- function randomToken(lenBytes = 24) {
171
- return base64Url(randomBytes(lenBytes));
172
- }
173
-
174
- function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
175
- const s = String(raw ?? '')
176
- .toLowerCase()
177
- .replace(/[^a-z0-9-]+/g, '-')
178
- .replace(/-+/g, '-')
179
- .replace(/^-+/, '')
180
- .replace(/-+$/, '');
181
- return s || fallback;
182
- }
183
-
184
- async function ensureDir(p) {
185
- await mkdir(p, { recursive: true });
186
- }
187
-
188
- async function readTextIfExists(path) {
189
- try {
190
- if (!existsSync(path)) return null;
191
- const raw = await readFile(path, 'utf-8');
192
- const t = raw.trim();
193
- return t ? t : null;
194
- } catch {
195
- return null;
196
- }
197
- }
198
-
199
- // auth file copy/link helpers live in scripts/utils/auth_files.mjs
200
-
201
- function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
202
- const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
203
- return fromEnv || join(stackBaseDir, 'cli');
204
- }
205
-
206
- function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
207
- const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
208
- return fromEnv || join(stackBaseDir, 'server-light');
209
- }
130
+ // auth file copy/link helpers live in scripts/utils/auth/files.mjs
210
131
 
211
132
  async function copyAuthFromStackIntoNewStack({
212
133
  fromStackName,
@@ -255,8 +176,8 @@ async function copyAuthFromStackIntoNewStack({
255
176
  'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
256
177
  );
257
178
  }
258
- const sourceBaseDir = legacy ? getLegacyHappyBaseDir() : getStackDir(fromStackName);
259
- const sourceEnvRaw = legacy ? '' : await readExistingEnv(getStackEnvPath(fromStackName));
179
+ const sourceBaseDir = legacy ? getLegacyHappyBaseDir() : resolveStackEnvPath(fromStackName).baseDir;
180
+ const sourceEnvRaw = legacy ? '' : await readExistingEnv(resolveStackEnvPath(fromStackName).envPath);
260
181
  const sourceEnv = parseEnvToObject(sourceEnvRaw);
261
182
  const sourceCli = legacy ? join(sourceBaseDir, 'cli') : getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
262
183
  const targetCli = stackEnv.HAPPY_STACKS_CLI_HOME_DIR;
@@ -302,25 +223,7 @@ function stringifyEnv(env) {
302
223
  return lines.join('\n') + '\n';
303
224
  }
304
225
 
305
- async function readExistingEnv(path) {
306
- try {
307
- const raw = await readFile(path, 'utf-8');
308
- return raw;
309
- } catch {
310
- return '';
311
- }
312
- }
313
-
314
- function parseEnvToObject(raw) {
315
- const parsed = parseDotenv(raw);
316
- return Object.fromEntries(parsed.entries());
317
- }
318
-
319
- function stackExistsSync(stackName) {
320
- if (stackName === 'main') return true;
321
- const envPath = getStackEnvPath(stackName);
322
- return existsSync(envPath);
323
- }
226
+ const readExistingEnv = readTextOrEmpty;
324
227
 
325
228
  function resolveDefaultComponentDirs({ rootDir }) {
326
229
  const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
@@ -335,9 +238,9 @@ function resolveDefaultComponentDirs({ rootDir }) {
335
238
  }
336
239
 
337
240
  async function writeStackEnv({ stackName, env }) {
338
- const stackDir = getStackDir(stackName);
241
+ const stackDir = resolveStackEnvPath(stackName).baseDir;
339
242
  await ensureDir(stackDir);
340
- const envPath = getStackEnvPath(stackName);
243
+ const envPath = resolveStackEnvPath(stackName).envPath;
341
244
  const next = stringifyEnv(env);
342
245
  const existing = await readExistingEnv(envPath);
343
246
  if (existing !== next) {
@@ -347,7 +250,7 @@ async function writeStackEnv({ stackName, env }) {
347
250
  }
348
251
 
349
252
  async function withStackEnv({ stackName, fn, extraEnv = {} }) {
350
- const envPath = getStackEnvPath(stackName);
253
+ const envPath = resolveStackEnvPath(stackName).envPath;
351
254
  if (!stackExistsSync(stackName)) {
352
255
  throw new Error(
353
256
  `[stack] stack "${stackName}" does not exist yet.\n` +
@@ -604,7 +507,7 @@ async function cmdNew({ rootDir, argv, emit = true }) {
604
507
  throw new Error(`[stack] invalid server component: ${serverComponent}`);
605
508
  }
606
509
 
607
- const baseDir = getStackDir(stackName);
510
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
608
511
  const uiBuildDir = join(baseDir, 'ui');
609
512
  const cliHomeDir = join(baseDir, 'cli');
610
513
 
@@ -804,7 +707,7 @@ async function cmdEdit({ rootDir, argv }) {
804
707
  throw new Error('[stack] usage: happys stack edit <name> [--interactive]');
805
708
  }
806
709
 
807
- const envPath = getStackEnvPath(stackName);
710
+ const envPath = resolveStackEnvPath(stackName).envPath;
808
711
  const raw = await readExistingEnv(envPath);
809
712
  const existingEnv = parseEnvToObject(raw);
810
713
 
@@ -829,7 +732,7 @@ async function cmdEdit({ rootDir, argv }) {
829
732
  const config = await withRl((rl) => interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }));
830
733
 
831
734
  // Build next env, starting from existing env but enforcing stack-scoped invariants.
832
- const baseDir = getStackDir(stackName);
735
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
833
736
  const uiBuildDir = join(baseDir, 'ui');
834
737
  const cliHomeDir = join(baseDir, 'cli');
835
738
 
@@ -1894,7 +1797,7 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1894
1797
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
1895
1798
  const publicServerUrl = `http://localhost:${serverPort}`;
1896
1799
 
1897
- const autostart = { stackName: name, baseDir: getStackDir(name) };
1800
+ const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
1898
1801
  const children = [];
1899
1802
 
1900
1803
  await withStackEnv({
@@ -1962,7 +1865,7 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1962
1865
  }
1963
1866
 
1964
1867
  console.log('');
1965
- const uiHost = `happy-${sanitizeDnsLabel(name)}.localhost`;
1868
+ const uiHost = resolveLocalhostHost({ stackMode: true, stackName: name });
1966
1869
  const uiPort = uiRes?.port;
1967
1870
  const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
1968
1871
  const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
@@ -2092,7 +1995,7 @@ function parseServerComponentFromEnv(env) {
2092
1995
  }
2093
1996
 
2094
1997
  async function readStackEnvObject(stackName) {
2095
- const envPath = getStackEnvPath(stackName);
1998
+ const envPath = resolveStackEnvPath(stackName).envPath;
2096
1999
  const raw = await readExistingEnv(envPath);
2097
2000
  const env = raw ? parseEnvToObject(raw) : {};
2098
2001
  return { envPath, env };
@@ -2107,16 +2010,6 @@ function envKeyForComponentDir({ serverComponent, component }) {
2107
2010
  return `HAPPY_STACKS_COMPONENT_DIR_${component.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
2108
2011
  }
2109
2012
 
2110
- function sanitizeSlugPart(s) {
2111
- return String(s ?? '')
2112
- .trim()
2113
- .toLowerCase()
2114
- .replace(/[^a-z0-9._/-]+/g, '-')
2115
- .replace(/-+/g, '-')
2116
- .replace(/^-+/, '')
2117
- .replace(/-+$/, '');
2118
- }
2119
-
2120
2013
  async function cmdDuplicate({ rootDir, argv }) {
2121
2014
  const { flags, kv } = parseArgs(argv);
2122
2015
  const json = wantsJson(argv, { flags });
@@ -2190,7 +2083,7 @@ async function cmdDuplicate({ rootDir, argv }) {
2190
2083
  }
2191
2084
 
2192
2085
  // Apply component dir overrides to the destination stack env file.
2193
- const toEnvPath = getStackEnvPath(toStack);
2086
+ const toEnvPath = resolveStackEnvPath(toStack).envPath;
2194
2087
  if (updates.length) {
2195
2088
  await ensureEnvFileUpdated({ envPath: toEnvPath, updates });
2196
2089
  }
@@ -2577,8 +2470,8 @@ async function cmdPrStack({ rootDir, argv }) {
2577
2470
 
2578
2471
  async function cmdInfoInternal({ rootDir, stackName }) {
2579
2472
  // Minimal extraction from cmdInfo to avoid re-parsing argv/printing. Used by cmdPrStack.
2580
- const baseDir = getStackDir(stackName);
2581
- const envPath = getStackEnvPath(stackName);
2473
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
2474
+ const envPath = resolveStackEnvPath(stackName).envPath;
2582
2475
  const envRaw = await readExistingEnv(envPath);
2583
2476
  const stackEnv = envRaw ? parseEnvToObject(envRaw) : {};
2584
2477
  const runtimeStatePath = getStackRuntimeStatePath(stackName);
@@ -2587,6 +2480,9 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2587
2480
  const serverComponent =
2588
2481
  getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
2589
2482
 
2483
+ const stackRemote =
2484
+ getEnvValueAny(stackEnv, ['HAPPY_STACKS_STACK_REMOTE', 'HAPPY_LOCAL_STACK_REMOTE']) || 'upstream';
2485
+
2590
2486
  const pinnedServerPortRaw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_PORT', 'HAPPY_LOCAL_SERVER_PORT']);
2591
2487
  const pinnedServerPort = pinnedServerPortRaw ? Number(pinnedServerPortRaw) : null;
2592
2488
 
@@ -2632,6 +2528,7 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2632
2528
  envPath,
2633
2529
  runtimeStatePath,
2634
2530
  serverComponent,
2531
+ stackRemote,
2635
2532
  pinned: {
2636
2533
  serverPort: Number.isFinite(pinnedServerPort) && pinnedServerPort > 0 ? pinnedServerPort : null,
2637
2534
  },
@@ -2659,6 +2556,31 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2659
2556
  };
2660
2557
  }
2661
2558
 
2559
+ async function cmdStackCodeOrCursor({ rootDir, stackName, json, editor, includeStackDir, includeAllComponents, includeCliHome }) {
2560
+ const ws = await writeStackCodeWorkspace({ rootDir, stackName, includeStackDir, includeAllComponents, includeCliHome });
2561
+
2562
+ if (json) {
2563
+ printResult({
2564
+ json,
2565
+ data: {
2566
+ ok: true,
2567
+ stackName,
2568
+ editor,
2569
+ ...ws,
2570
+ },
2571
+ });
2572
+ return;
2573
+ }
2574
+
2575
+ await openWorkspaceInEditor({ rootDir, editor, workspacePath: ws.workspacePath });
2576
+ console.log(`[stack] opened ${editor === 'code' ? 'VS Code' : 'Cursor'} workspace for "${stackName}": ${ws.workspacePath}`);
2577
+ }
2578
+
2579
+ async function cmdStackOpen({ rootDir, stackName, json, includeStackDir, includeAllComponents, includeCliHome }) {
2580
+ const editor = (await isCursorInstalled({ cwd: rootDir, env: process.env })) ? 'cursor' : 'code';
2581
+ await cmdStackCodeOrCursor({ rootDir, stackName, json, editor, includeStackDir, includeAllComponents, includeCliHome });
2582
+ }
2583
+
2662
2584
  async function main() {
2663
2585
  const rootDir = getRootDir(import.meta.url);
2664
2586
  // pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
@@ -2702,6 +2624,9 @@ async function main() {
2702
2624
  'mobile',
2703
2625
  'resume',
2704
2626
  'stop',
2627
+ 'code',
2628
+ 'cursor',
2629
+ 'open',
2705
2630
  'srv',
2706
2631
  'wt',
2707
2632
  'tailscale:*',
@@ -2730,6 +2655,9 @@ async function main() {
2730
2655
  ' happys stack mobile <name> [-- ...]',
2731
2656
  ' happys stack resume <name> <sessionId...> [--json]',
2732
2657
  ' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
2658
+ ' happys stack code <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
2659
+ ' happys stack cursor <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
2660
+ ' happys stack open <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json] # prefer Cursor, else VS Code',
2733
2661
  ' happys stack srv <name> -- status|use ...',
2734
2662
  ' happys stack wt <name> -- <wt args...>',
2735
2663
  ' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
@@ -2928,7 +2856,7 @@ async function main() {
2928
2856
  const noDocker = stopFlags.has('--no-docker');
2929
2857
  const aggressive = stopFlags.has('--aggressive');
2930
2858
  const sweepOwned = stopFlags.has('--sweep-owned');
2931
- const baseDir = getStackDir(stackName);
2859
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
2932
2860
  const out = await withStackEnv({
2933
2861
  stackName,
2934
2862
  fn: async ({ env }) => {
@@ -2939,6 +2867,28 @@ async function main() {
2939
2867
  return;
2940
2868
  }
2941
2869
 
2870
+ if (cmd === 'code') {
2871
+ const includeStackDir = !flags.has('--no-stack-dir');
2872
+ const includeAllComponents = flags.has('--include-all-components');
2873
+ const includeCliHome = flags.has('--include-cli-home');
2874
+ await cmdStackCodeOrCursor({ rootDir, stackName, json, editor: 'code', includeStackDir, includeAllComponents, includeCliHome });
2875
+ return;
2876
+ }
2877
+ if (cmd === 'cursor') {
2878
+ const includeStackDir = !flags.has('--no-stack-dir');
2879
+ const includeAllComponents = flags.has('--include-all-components');
2880
+ const includeCliHome = flags.has('--include-cli-home');
2881
+ await cmdStackCodeOrCursor({ rootDir, stackName, json, editor: 'cursor', includeStackDir, includeAllComponents, includeCliHome });
2882
+ return;
2883
+ }
2884
+ if (cmd === 'open') {
2885
+ const includeStackDir = !flags.has('--no-stack-dir');
2886
+ const includeAllComponents = flags.has('--include-all-components');
2887
+ const includeCliHome = flags.has('--include-cli-home');
2888
+ await cmdStackOpen({ rootDir, stackName, json, includeStackDir, includeAllComponents, includeCliHome });
2889
+ return;
2890
+ }
2891
+
2942
2892
  if (cmd === 'srv') {
2943
2893
  await cmdSrv({ rootDir, stackName, args: passthrough });
2944
2894
  return;
package/scripts/stop.mjs CHANGED
@@ -1,11 +1,11 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { join } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
4
 
5
5
  import { parseArgs } from './utils/cli/args.mjs';
6
6
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
7
- import { run, runCapture } from './utils/proc.mjs';
8
- import { getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
7
+ import { run, runCapture } from './utils/proc/proc.mjs';
8
+ import { getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
9
9
 
10
10
  function usage() {
11
11
  return [
@@ -1,8 +1,10 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { run, runCapture } from './utils/proc.mjs';
3
+ import { run, runCapture } from './utils/proc/proc.mjs';
4
4
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
5
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
5
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
6
+ import { getInternalServerUrl } from './utils/server/urls.mjs';
7
+ import { resolveCommandPath } from './utils/proc/commands.mjs';
6
8
  import { constants } from 'node:fs';
7
9
  import { access } from 'node:fs/promises';
8
10
 
@@ -21,11 +23,6 @@ import { access } from 'node:fs/promises';
21
23
  * - url (print the first https:// URL from status output)
22
24
  */
23
25
 
24
- function getInternalServerUrl() {
25
- const port = process.env.HAPPY_LOCAL_SERVER_PORT?.trim() ? Number(process.env.HAPPY_LOCAL_SERVER_PORT) : 3005;
26
- return `http://127.0.0.1:${port}`;
27
- }
28
-
29
26
  function getServeConfig(internalServerUrl) {
30
27
  const upstream = process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM?.trim()
31
28
  ? process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM.trim()
@@ -134,7 +131,7 @@ async function resolveTailscaleCmd() {
134
131
 
135
132
  // Try PATH first (without executing `tailscale`, which can hang in some environments).
136
133
  try {
137
- const found = (await runCapture('sh', ['-lc', 'command -v tailscale 2>/dev/null || true'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() })).trim();
134
+ const found = await resolveCommandPath('tailscale', { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() });
138
135
  if (found) {
139
136
  return found;
140
137
  }
@@ -342,7 +339,7 @@ async function main() {
342
339
  return;
343
340
  }
344
341
 
345
- const internalServerUrl = getInternalServerUrl();
342
+ const internalServerUrl = getInternalServerUrl({ env: process.env, defaultPort: 3005 }).internalServerUrl;
346
343
  if (flags.has('--upstream') || kv.get('--upstream')) {
347
344
  process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM = kv.get('--upstream') ?? internalServerUrl;
348
345
  }
package/scripts/test.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 pickTestScript(scripts) {
33
- if (!scripts) return null;
34
13
  const candidates = [
35
14
  'test',
36
15
  'tst',
@@ -38,7 +17,7 @@ function pickTestScript(scripts) {
38
17
  'test:unit',
39
18
  'check:test',
40
19
  ];
41
- return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
20
+ return pickFirstScript(scripts, candidates);
42
21
  }
43
22
 
44
23
  async function main() {
@@ -85,7 +64,7 @@ async function main() {
85
64
  continue;
86
65
  }
87
66
 
88
- const scripts = await readScripts(dir);
67
+ const scripts = await readPackageJsonScripts(dir);
89
68
  if (!scripts) {
90
69
  results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
91
70
  continue;
package/scripts/tui.mjs CHANGED
@@ -1,29 +1,13 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
4
- import { readFile } from 'node:fs/promises';
5
3
  import { join, resolve, sep } from 'node:path';
6
4
 
7
- import { parseDotenv } from './utils/dotenv.mjs';
8
5
  import { printResult } from './utils/cli/cli.mjs';
9
- import { getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
10
- import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack_runtime_state.mjs';
11
-
12
- function stripAnsi(s) {
13
- // eslint-disable-next-line no-control-regex
14
- return String(s ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
15
- }
16
-
17
- function padRight(s, n) {
18
- const str = String(s ?? '');
19
- if (str.length >= n) return str.slice(0, n);
20
- return str + ' '.repeat(n - str.length);
21
- }
22
-
23
- function parsePrefixedLabel(line) {
24
- const m = String(line ?? '').match(/^\[([^\]]+)\]\s*/);
25
- return m ? m[1] : null;
26
- }
6
+ import { readEnvObjectFromFile } from './utils/env/read.mjs';
7
+ import { getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
8
+ import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack/runtime_state.mjs';
9
+ import { getEnvValueAny } from './utils/env/values.mjs';
10
+ import { padRight, parsePrefixedLabel, stripAnsi } from './utils/ui/text.mjs';
27
11
 
28
12
  function nowTs() {
29
13
  const d = new Date();
@@ -107,15 +91,7 @@ function inferStackNameFromForwardedArgs(args) {
107
91
  return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
108
92
  }
109
93
 
110
- async function readEnvObject(path) {
111
- try {
112
- if (!path || !existsSync(path)) return {};
113
- const raw = await readFile(path, 'utf-8');
114
- return Object.fromEntries(parseDotenv(raw).entries());
115
- } catch {
116
- return {};
117
- }
118
- }
94
+ const readEnvObject = readEnvObjectFromFile;
119
95
 
120
96
  function formatComponentRef({ rootDir, component, dir }) {
121
97
  const raw = String(dir ?? '').trim();
@@ -132,12 +108,6 @@ function formatComponentRef({ rootDir, component, dir }) {
132
108
  return abs;
133
109
  }
134
110
 
135
- function getEnvVal(env, k1, k2) {
136
- const a = String(env?.[k1] ?? '').trim();
137
- if (a) return a;
138
- return String(env?.[k2] ?? '').trim();
139
- }
140
-
141
111
  async function buildStackSummaryLines({ rootDir, stackName }) {
142
112
  const { envPath, baseDir } = resolveStackEnvPath(stackName);
143
113
  const env = await readEnvObject(envPath);
@@ -145,7 +115,7 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
145
115
  const runtime = await readStackRuntimeStateFile(runtimePath);
146
116
 
147
117
  const serverComponent =
148
- getEnvVal(env, 'HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
118
+ getEnvValueAny(env, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
149
119
 
150
120
  const ports = runtime?.ports && typeof runtime.ports === 'object' ? runtime.ports : {};
151
121
  const expoWebPort = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo.webPort : null;