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,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 pickTypecheckScript(scripts) {
33
- if (!scripts) return null;
34
13
  const candidates = [
35
14
  'typecheck',
36
15
  'type-check',
@@ -39,7 +18,7 @@ function pickTypecheckScript(scripts) {
39
18
  'tsc',
40
19
  'typescript',
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,4 +1,4 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import http from 'node:http';
3
3
  import net from 'node:net';
4
4
  import { extname, resolve, sep } from 'node:path';
@@ -1,4 +1,4 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
 
3
3
  import { rm } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
@@ -7,11 +7,11 @@ import { spawnSync } from 'node:child_process';
7
7
 
8
8
  import { parseArgs } from './utils/cli/args.mjs';
9
9
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
- import { expandHome } from './utils/canonical_home.mjs';
11
- import { getHappyStacksHomeDir, getRootDir, getStacksStorageRoot } from './utils/paths.mjs';
12
- import { getRuntimeDir } from './utils/runtime.mjs';
13
- import { getCanonicalHomeEnvPath } from './utils/config.mjs';
14
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
10
+ import { expandHome } from './utils/paths/canonical_home.mjs';
11
+ import { getHappyStacksHomeDir, getRootDir, getStacksStorageRoot } from './utils/paths/paths.mjs';
12
+ import { getRuntimeDir } from './utils/paths/runtime.mjs';
13
+ import { getCanonicalHomeEnvPath } from './utils/env/config.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
15
15
 
16
16
  function resolveWorkspaceDir({ rootDir, homeDir }) {
17
17
  // Uninstall should never default to deleting the repo root (getWorkspaceDir() can fall back to cliRootDir).
@@ -1,17 +1,11 @@
1
1
  import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import { dirname, join } from 'node:path';
5
4
 
6
- import { expandHome } from './canonical_home.mjs';
7
-
8
- export function resolveHappyStacksHomeDir(env = process.env) {
9
- const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').toString().trim();
10
- return fromEnv ? expandHome(fromEnv) : join(homedir(), '.happy-stacks');
11
- }
5
+ import { getHappyStacksHomeDir } from '../paths/paths.mjs';
12
6
 
13
7
  export function getDevAuthKeyPath(env = process.env) {
14
- return join(resolveHappyStacksHomeDir(env), 'keys', 'dev-auth.json');
8
+ return join(getHappyStacksHomeDir(env), 'keys', 'dev-auth.json');
15
9
  }
16
10
 
17
11
  function base64UrlToBytes(s) {
@@ -1,10 +1,8 @@
1
- import { chmod, copyFile, lstat, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
1
+ import { chmod, copyFile, lstat, symlink, unlink, writeFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
 
5
- async function ensureDir(p) {
6
- await mkdir(p, { recursive: true });
7
- }
5
+ import { ensureDir } from '../fs/ops.mjs';
8
6
 
9
7
  export async function removeFileOrSymlinkIfExists(path) {
10
8
  try {
@@ -1,38 +1,12 @@
1
- import { existsSync } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
3
1
  import { homedir } from 'node:os';
4
2
  import { join } from 'node:path';
5
3
 
6
- import { parseDotenv } from './dotenv.mjs';
7
- import { resolveStackEnvPath } from './paths.mjs';
8
- import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './auth_sources.mjs';
9
-
10
- async function readTextIfExists(path) {
11
- try {
12
- if (!path || !existsSync(path)) return null;
13
- const raw = await readFile(path, 'utf-8');
14
- const t = raw.trim();
15
- return t ? t : null;
16
- } catch {
17
- return null;
18
- }
19
- }
20
-
21
- function parseEnvToObject(raw) {
22
- const parsed = parseDotenv(raw ?? '');
23
- return Object.fromEntries(parsed.entries());
24
- }
25
-
26
- function getEnvValue(env, key) {
27
- const v = (env?.[key] ?? '').toString().trim();
28
- return v || '';
29
- }
30
-
31
- function stackExistsSync(stackName) {
32
- if (stackName === 'main') return true;
33
- const envPath = resolveStackEnvPath(stackName).envPath;
34
- return existsSync(envPath);
35
- }
4
+ import { parseEnvToObject } from '../env/dotenv.mjs';
5
+ import { resolveStackEnvPath } from '../paths/paths.mjs';
6
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './sources.mjs';
7
+ import { getEnvValue } from '../env/values.mjs';
8
+ import { readTextIfExists } from '../fs/ops.mjs';
9
+ import { stackExistsSync } from '../stack/stacks.mjs';
36
10
 
37
11
  export async function resolveHandyMasterSecretFromStack({
38
12
  stackName,
@@ -0,0 +1,17 @@
1
+ export function boolFromFlags({ flags, onFlag, offFlag, defaultValue }) {
2
+ if (flags.has(offFlag)) return false;
3
+ if (flags.has(onFlag)) return true;
4
+ return defaultValue;
5
+ }
6
+
7
+ export function boolFromFlagsOrKv({ flags, kv, onFlag, offFlag, key, defaultValue }) {
8
+ if (flags.has(offFlag)) return false;
9
+ if (flags.has(onFlag)) return true;
10
+ if (key && kv.has(key)) {
11
+ const raw = String(kv.get(key) ?? '').trim().toLowerCase();
12
+ if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y') return true;
13
+ if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'n') return false;
14
+ }
15
+ return defaultValue;
16
+ }
17
+
@@ -0,0 +1,16 @@
1
+ export function normalizeProfile(raw) {
2
+ const v = (raw ?? '').trim().toLowerCase();
3
+ if (!v) return '';
4
+ if (v === 'selfhost' || v === 'self-host' || v === 'self_host' || v === 'host') return 'selfhost';
5
+ if (v === 'dev' || v === 'developer' || v === 'develop' || v === 'development') return 'dev';
6
+ return '';
7
+ }
8
+
9
+ export function normalizeServerComponent(raw) {
10
+ const v = (raw ?? '').trim().toLowerCase();
11
+ if (!v) return '';
12
+ if (v === 'light' || v === 'server-light' || v === 'happy-server-light') return 'happy-server-light';
13
+ if (v === 'server' || v === 'full' || v === 'happy-server') return 'happy-server';
14
+ return '';
15
+ }
16
+
@@ -6,8 +6,8 @@ import { dirname } from 'node:path';
6
6
  import { getHappysRegistry } from './cli_registry.mjs';
7
7
 
8
8
  function cliRootDir() {
9
- // scripts/utils/* -> scripts -> repo root
10
- return dirname(dirname(dirname(fileURLToPath(import.meta.url))));
9
+ // scripts/utils/cli/* -> scripts/utils -> scripts -> repo root
10
+ return dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))));
11
11
  }
12
12
 
13
13
  function runOrThrow(label, args) {
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
- import { listWorktreeSpecs } from '../worktrees.mjs';
2
+ import { listWorktreeSpecs } from '../git/worktrees.mjs';
3
3
 
4
4
  export function isTty() {
5
5
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -0,0 +1,14 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ export function base64Url(buf) {
4
+ return Buffer.from(buf)
5
+ .toString('base64')
6
+ .replaceAll('+', '-')
7
+ .replaceAll('/', '_')
8
+ .replaceAll('=', '');
9
+ }
10
+
11
+ export function randomToken(lenBytes = 24) {
12
+ return base64Url(randomBytes(lenBytes));
13
+ }
14
+
@@ -1,9 +1,9 @@
1
1
  import { resolve } from 'node:path';
2
2
 
3
- import { ensureCliBuilt, ensureDepsInstalled } from './pm.mjs';
4
- import { watchDebounced } from './watch.mjs';
5
- import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './stack_startup.mjs';
6
- import { startLocalDaemonWithAuth } from '../daemon.mjs';
3
+ import { ensureCliBuilt, ensureDepsInstalled } from '../proc/pm.mjs';
4
+ import { watchDebounced } from '../proc/watch.mjs';
5
+ import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from '../stack/startup.mjs';
6
+ import { startLocalDaemonWithAuth } from '../../daemon.mjs';
7
7
 
8
8
  export async function ensureDevCliReady({ cliDir, buildCli }) {
9
9
  await ensureDepsInstalled(cliDir, 'happy-cli');
@@ -1,8 +1,8 @@
1
- import { ensureDepsInstalled, pmSpawnBin } from './pm.mjs';
2
- import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from './expo.mjs';
3
- import { pickDevMetroPort, resolveStackUiDevPortStart } from './dev_server.mjs';
4
- import { recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
5
- import { killProcessGroupOwnedByStack } from './ownership.mjs';
1
+ import { ensureDepsInstalled, pmSpawnBin } from '../proc/pm.mjs';
2
+ import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from '../expo/expo.mjs';
3
+ import { pickDevMetroPort, resolveStackUiDevPortStart } from './server.mjs';
4
+ import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
5
+ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
6
6
 
7
7
  export async function startDevExpoWebUi({
8
8
  startUi,
@@ -1,12 +1,12 @@
1
1
  import { join, resolve } from 'node:path';
2
2
 
3
- import { ensureDepsInstalled, pmSpawnScript } from './pm.mjs';
4
- import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './happy_server_infra.mjs';
5
- import { waitForServerReady } from './server.mjs';
6
- import { isTcpPortFree, pickNextFreeTcpPort } from './ports.mjs';
7
- import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
8
- import { killProcessGroupOwnedByStack } from './ownership.mjs';
9
- import { watchDebounced } from './watch.mjs';
3
+ import { ensureDepsInstalled, pmSpawnScript } from '../proc/pm.mjs';
4
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
5
+ import { waitForServerReady } from '../server/server.mjs';
6
+ import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
7
+ import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
8
+ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
9
+ import { watchDebounced } from '../proc/watch.mjs';
10
10
 
11
11
  function hashStringToInt(s) {
12
12
  let h = 0;
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { ensureEnvFileUpdated } from './env_file.mjs';
3
- import { getHappyStacksHomeDir, resolveStackEnvPath } from './paths.mjs';
4
- import { getCanonicalHomeDirFromEnv } from './canonical_home.mjs';
3
+ import { getHappyStacksHomeDir, resolveStackEnvPath } from '../paths/paths.mjs';
4
+ import { getCanonicalHomeDirFromEnv } from '../paths/canonical_home.mjs';
5
5
 
6
6
  export function getHomeEnvPath() {
7
7
  return join(getHappyStacksHomeDir(), '.env');
@@ -50,3 +50,4 @@ export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
50
50
  await ensureEnvFileUpdated({ envPath, updates });
51
51
  return envPath;
52
52
  }
53
+
@@ -28,3 +28,6 @@ export function parseDotenv(contents) {
28
28
  return out;
29
29
  }
30
30
 
31
+ export function parseEnvToObject(contents) {
32
+ return Object.fromEntries(parseDotenv(contents));
33
+ }
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseDotenv } from './dotenv.mjs';
7
- import { expandHome, getCanonicalHomeEnvPathFromEnv } from './canonical_home.mjs';
7
+ import { expandHome, getCanonicalHomeEnvPathFromEnv } from '../paths/canonical_home.mjs';
8
8
  import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
9
9
 
10
10
  async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
@@ -57,8 +57,9 @@ async function loadEnvFileIgnoringPrefixes(path, { ignorePrefixes = [] } = {}) {
57
57
  }
58
58
 
59
59
  // Load happy-stacks env (optional). This is intentionally lightweight and does not require extra deps.
60
- // This file lives under scripts/utils/, so repo root is two directories up.
61
- const __utilsDir = dirname(fileURLToPath(import.meta.url));
60
+ // This file lives under scripts/utils/env, so repo root is three directories up.
61
+ const __envDir = dirname(fileURLToPath(import.meta.url));
62
+ const __utilsDir = dirname(__envDir);
62
63
  const __scriptsDir = dirname(__utilsDir);
63
64
  const __cliRootDir = dirname(__scriptsDir);
64
65
 
@@ -207,3 +208,4 @@ process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_M
207
208
  const next = [...want.filter((p) => p && !current.includes(p)), ...current];
208
209
  process.env.PATH = next.join(delimiter);
209
210
  })();
211
+
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { pathExists } from './fs.mjs';
3
+ import { pathExists } from '../fs/fs.mjs';
4
4
 
5
5
  export async function ensureEnvFileUpdated({ envPath, updates }) {
6
6
  if (!updates.length) {
@@ -93,3 +93,4 @@ async function writeFileIfChanged(existingContent, nextContent, path) {
93
93
  }
94
94
  await writeFile(path, normalizedNext, 'utf-8');
95
95
  }
96
+
@@ -23,3 +23,4 @@ export async function ensureEnvLocalUpdated({ rootDir, updates }) {
23
23
 
24
24
  await ensureEnvFileUpdated({ envPath: join(rootDir, 'env.local'), updates });
25
25
  }
26
+
@@ -0,0 +1,30 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ import { parseDotenv } from './dotenv.mjs';
5
+
6
+ export async function readEnvValueFromFile(envPath, key, { defaultValue = '' } = {}) {
7
+ try {
8
+ const p = String(envPath ?? '').trim();
9
+ const k = String(key ?? '').trim();
10
+ if (!p || !k) return defaultValue;
11
+ if (!existsSync(p)) return defaultValue;
12
+ const raw = await readFile(p, 'utf-8');
13
+ const parsed = parseDotenv(raw ?? '');
14
+ return String(parsed.get(k) ?? '').trim();
15
+ } catch {
16
+ return defaultValue;
17
+ }
18
+ }
19
+
20
+ export async function readEnvObjectFromFile(envPath) {
21
+ try {
22
+ const p = String(envPath ?? '').trim();
23
+ if (!p || !existsSync(p)) return {};
24
+ const raw = await readFile(p, 'utf-8');
25
+ return Object.fromEntries(parseDotenv(raw ?? '').entries());
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
@@ -0,0 +1,13 @@
1
+ export function getEnvValue(obj, key) {
2
+ const v = (obj?.[key] ?? '').toString().trim();
3
+ return v || '';
4
+ }
5
+
6
+ export function getEnvValueAny(obj, keys) {
7
+ for (const k of keys) {
8
+ const v = getEnvValue(obj, k);
9
+ if (v) return v;
10
+ }
11
+ return '';
12
+ }
13
+
@@ -3,6 +3,9 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
+ import { isPidAlive } from '../proc/pids.mjs';
7
+
8
+ export { isPidAlive };
6
9
 
7
10
  function hashDir(dir) {
8
11
  return createHash('sha1').update(String(dir ?? '')).digest('hex').slice(0, 12);
@@ -56,15 +59,6 @@ export async function readPidState(statePath) {
56
59
  }
57
60
  }
58
61
 
59
- export function isPidAlive(pid) {
60
- try {
61
- process.kill(pid, 0);
62
- return true;
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
62
  export async function isStateProcessRunning(statePath) {
69
63
  const state = await readPidState(statePath);
70
64
  if (!state) return { running: false, state: null };
@@ -0,0 +1,25 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+
5
+ export async function readJsonIfExists(path, { defaultValue = null } = {}) {
6
+ try {
7
+ const p = String(path ?? '').trim();
8
+ if (!p || !existsSync(p)) return defaultValue;
9
+ const raw = await readFile(p, 'utf-8');
10
+ return JSON.parse(raw);
11
+ } catch {
12
+ return defaultValue;
13
+ }
14
+ }
15
+
16
+ export async function writeJsonAtomic(path, value) {
17
+ const p = String(path ?? '').trim();
18
+ if (!p) throw new Error('writeJsonAtomic: path is required');
19
+ const dir = dirname(p);
20
+ await mkdir(dir, { recursive: true }).catch(() => {});
21
+ const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
22
+ await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
23
+ await rename(tmp, p);
24
+ }
25
+
@@ -0,0 +1,29 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+
4
+ export async function ensureDir(path) {
5
+ await mkdir(path, { recursive: true });
6
+ }
7
+
8
+ export async function readTextIfExists(path) {
9
+ try {
10
+ const p = String(path ?? '').trim();
11
+ if (!p || !existsSync(p)) return null;
12
+ const raw = await readFile(p, 'utf-8');
13
+ const t = raw.trim();
14
+ return t ? t : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export async function readTextOrEmpty(path) {
21
+ try {
22
+ const p = String(path ?? '').trim();
23
+ if (!p || !existsSync(p)) return '';
24
+ return await readFile(p, 'utf-8');
25
+ } catch {
26
+ return '';
27
+ }
28
+ }
29
+
@@ -0,0 +1,8 @@
1
+ import { readJsonIfExists } from './json.mjs';
2
+
3
+ export async function readPackageJsonVersion(path) {
4
+ const pkg = await readJsonIfExists(path, { defaultValue: null });
5
+ const v = String(pkg?.version ?? '').trim();
6
+ return v || null;
7
+ }
8
+
@@ -0,0 +1,12 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ export async function readLastLines(path, lines = 60) {
4
+ try {
5
+ const raw = await readFile(path, 'utf-8');
6
+ const parts = raw.split('\n');
7
+ return parts.slice(Math.max(0, parts.length - lines)).join('\n');
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
@@ -0,0 +1,26 @@
1
+ export function parseGithubPullRequest(input) {
2
+ const raw = (input ?? '').trim();
3
+ if (!raw) return null;
4
+ if (/^\d+$/.test(raw)) {
5
+ return { number: Number(raw), owner: null, repo: null };
6
+ }
7
+ // https://github.com/<owner>/<repo>/pull/<num>
8
+ const m = raw.match(/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<num>\d+)/);
9
+ if (!m?.groups?.num) return null;
10
+ return {
11
+ number: Number(m.groups.num),
12
+ owner: m.groups.owner ?? null,
13
+ repo: m.groups.repo ?? null,
14
+ };
15
+ }
16
+
17
+ export function sanitizeSlugPart(s) {
18
+ return (s ?? '')
19
+ .toString()
20
+ .trim()
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9._/-]+/g, '-')
23
+ .replace(/-+/g, '-')
24
+ .replace(/^-+|-+$/g, '');
25
+ }
26
+
@@ -1,8 +1,8 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  import { isAbsolute, join, resolve } from 'node:path';
3
- import { getComponentsDir } from './paths.mjs';
4
- import { pathExists } from './fs.mjs';
5
- import { run, runCapture } from './proc.mjs';
3
+ import { getComponentsDir } from '../paths/paths.mjs';
4
+ import { pathExists } from '../fs/fs.mjs';
5
+ import { run, runCapture } from '../proc/proc.mjs';
6
6
 
7
7
  export function parseGithubOwner(remoteUrl) {
8
8
  const raw = (remoteUrl ?? '').trim();
@@ -0,0 +1,10 @@
1
+ export function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
2
+ const s = String(raw ?? '')
3
+ .toLowerCase()
4
+ .replace(/[^a-z0-9-]+/g, '-')
5
+ .replace(/-+/g, '-')
6
+ .replace(/^-+/, '')
7
+ .replace(/-+$/, '');
8
+ return s || fallback;
9
+ }
10
+
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import net from 'node:net';
3
- import { runCapture } from './proc.mjs';
3
+ import { runCaptureIfCommandExists } from '../proc/commands.mjs';
4
4
 
5
5
  async function listListenPids(port) {
6
6
  if (!Number.isFinite(port) || port <= 0) return [];
@@ -9,10 +9,7 @@ async function listListenPids(port) {
9
9
  let raw = '';
10
10
  try {
11
11
  // `lsof` exits non-zero if no matches; normalize to empty output.
12
- raw = await runCapture('sh', [
13
- '-lc',
14
- `command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`,
15
- ]);
12
+ raw = await runCaptureIfCommandExists('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
16
13
  } catch {
17
14
  raw = '';
18
15
  }
@@ -102,3 +99,4 @@ export async function pickNextFreeTcpPort(startPort, { reservedPorts = new Set()
102
99
  }
103
100
  throw new Error(`[local] unable to find a free TCP port starting at ${startPort}`);
104
101
  }
102
+
@@ -1,17 +1,9 @@
1
1
  import { getStackName } from './paths.mjs';
2
-
3
- function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
4
- const s = String(raw ?? '')
5
- .toLowerCase()
6
- .replace(/[^a-z0-9-]+/g, '-')
7
- .replace(/-+/g, '-')
8
- .replace(/^-+/, '')
9
- .replace(/-+$/, '');
10
- return s || fallback;
11
- }
2
+ import { sanitizeDnsLabel } from '../net/dns.mjs';
12
3
 
13
4
  export function resolveLocalhostHost({ stackMode, stackName = getStackName() } = {}) {
14
5
  if (!stackMode) return 'localhost';
15
6
  if (!stackName || stackName === 'main') return 'localhost';
16
7
  return `happy-${sanitizeDnsLabel(stackName)}.localhost`;
17
8
  }
9
+