happy-stacks 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import { getComponentsDir } from '../paths/paths.mjs';
4
+
5
+ function isInside(path, dir) {
6
+ const p = resolve(path);
7
+ const d = resolve(dir);
8
+ return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
9
+ }
10
+
11
+ export function detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir }) {
12
+ const componentsDir = getComponentsDir(rootDir);
13
+
14
+ const other = serverComponentName === 'happy-server-light' ? 'happy-server' : serverComponentName === 'happy-server' ? 'happy-server-light' : null;
15
+ if (!other) {
16
+ return null;
17
+ }
18
+
19
+ const otherRepo = resolve(componentsDir, other);
20
+ const otherWts = resolve(componentsDir, '.worktrees', other);
21
+
22
+ if (isInside(serverDir, otherRepo) || isInside(serverDir, otherWts)) {
23
+ return { expected: serverComponentName, actual: other, serverDir };
24
+ }
25
+
26
+ return null;
27
+ }
28
+
29
+ export function assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir }) {
30
+ const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir });
31
+ if (!mismatch) {
32
+ return;
33
+ }
34
+
35
+ const hint =
36
+ mismatch.expected === 'happy-server-light'
37
+ ? 'Fix: either switch flavor (`happys srv use happy-server`) or switch the active checkout for happy-server-light (`happys wt use happy-server-light default` or a worktree under .worktrees/happy-server-light/).'
38
+ : 'Fix: either switch flavor (`happys srv use happy-server-light`) or switch the active checkout for happy-server (`happys wt use happy-server default` or a worktree under .worktrees/happy-server/).';
39
+
40
+ throw new Error(
41
+ `[server] server component dir mismatch:\n` +
42
+ `- selected flavor: ${mismatch.expected}\n` +
43
+ `- but HAPPY_STACKS_COMPONENT_DIR_* points inside: ${mismatch.actual}\n` +
44
+ `- path: ${mismatch.serverDir}\n` +
45
+ `${hint}`
46
+ );
47
+ }
48
+
49
+ function detectPrismaProvider(schemaText) {
50
+ // Best-effort parse of:
51
+ // datasource db { provider = "sqlite" ... }
52
+ const m = schemaText.match(/datasource\s+db\s*\{[\s\S]*?\bprovider\s*=\s*\"([a-zA-Z0-9_-]+)\"/m);
53
+ return m?.[1] ?? '';
54
+ }
55
+
56
+ export function assertServerPrismaProviderMatches({ serverComponentName, serverDir }) {
57
+ const schemaPath = join(serverDir, 'prisma', 'schema.prisma');
58
+ let schemaText = '';
59
+ try {
60
+ schemaText = readFileSync(schemaPath, 'utf-8');
61
+ } catch {
62
+ // If it doesn't exist, skip validation; not every server component necessarily uses Prisma.
63
+ return;
64
+ }
65
+
66
+ const provider = detectPrismaProvider(schemaText);
67
+ if (!provider) {
68
+ return;
69
+ }
70
+
71
+ if (serverComponentName === 'happy-server-light' && provider !== 'sqlite') {
72
+ throw new Error(
73
+ `[server] happy-server-light expects Prisma datasource provider \"sqlite\", but found \"${provider}\" in:\n` +
74
+ `- ${schemaPath}\n` +
75
+ `This usually means you're pointing happy-server-light at an upstream happy-server checkout/PR (Postgres).\n` +
76
+ `Fix: either switch server flavor to happy-server, or point happy-server-light at a fork checkout that keeps sqlite support.`
77
+ );
78
+ }
79
+
80
+ if (serverComponentName === 'happy-server' && provider === 'sqlite') {
81
+ throw new Error(
82
+ `[server] happy-server expects Prisma datasource provider \"postgresql\", but found \"sqlite\" in:\n` +
83
+ `- ${schemaPath}\n` +
84
+ `Fix: either switch server flavor to happy-server-light, or point happy-server at the full-server checkout.`
85
+ );
86
+ }
87
+ }
88
+
@@ -0,0 +1,182 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, join } from 'node:path';
3
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ import { runCapture } from '../proc/proc.mjs';
7
+ import { getDefaultAutostartPaths } from '../paths/paths.mjs';
8
+ import { resolveInstalledCliRoot, resolveInstalledPath } from '../paths/runtime.mjs';
9
+ import { getCanonicalHomeDir } from '../env/config.mjs';
10
+
11
+ function plistPathForLabel(label) {
12
+ return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
13
+ }
14
+
15
+ function splitPath(p) {
16
+ return String(p ?? '')
17
+ .split(':')
18
+ .map((s) => s.trim())
19
+ .filter(Boolean);
20
+ }
21
+
22
+ export function buildLaunchdPath({ execPath = process.execPath, basePath = process.env.PATH } = {}) {
23
+ // launchd starts with a minimal environment; ensure common tool paths exist,
24
+ // and include the current Node binary directory so shell shims that exec `node`
25
+ // still work (e.g. nvm-managed installs).
26
+ const nodeDir = execPath ? dirname(execPath) : '';
27
+ const defaults = splitPath('/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin');
28
+ const fromNode = nodeDir ? [nodeDir] : [];
29
+ const fromEnv = splitPath(basePath);
30
+
31
+ const seen = new Set();
32
+ const out = [];
33
+ for (const part of [...fromNode, ...fromEnv, ...defaults]) {
34
+ if (seen.has(part)) continue;
35
+ seen.add(part);
36
+ out.push(part);
37
+ }
38
+ return out.join(':') || '/usr/bin:/bin:/usr/sbin:/sbin';
39
+ }
40
+
41
+ export function pickLaunchdProgramArgs({ rootDir, execPath = process.execPath } = {}) {
42
+ // Prefer the stable shim under the canonical home dir (used by selfhost installs).
43
+ // This keeps the LaunchAgent pointing at a stable path while allowing runtime updates.
44
+ const happysShim = join(getCanonicalHomeDir(), 'bin', 'happys');
45
+ if (existsSync(happysShim)) {
46
+ return [happysShim, 'start'];
47
+ }
48
+ // Fallback: call the Node entry directly (works in repo-only installs).
49
+ return [execPath, resolveInstalledPath(rootDir, 'bin/happys.mjs'), 'start'];
50
+ }
51
+
52
+ function xmlEscape(s) {
53
+ return String(s ?? '')
54
+ .replaceAll('&', '&')
55
+ .replaceAll('<', '&lt;')
56
+ .replaceAll('>', '&gt;')
57
+ .replaceAll('"', '&quot;')
58
+ .replaceAll("'", '&apos;');
59
+ }
60
+
61
+ function plistXml({ label, programArgs, env = {}, stdoutPath, stderrPath, workingDirectory }) {
62
+ const envEntries = Object.entries(env ?? {}).filter(([k, v]) => String(k).trim() && String(v ?? '').trim());
63
+ const programArgsXml = programArgs.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
64
+ const envXml = envEntries
65
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
66
+ .join('\n');
67
+ const workingDirXml = workingDirectory
68
+ ? `\n <key>WorkingDirectory</key>\n <string>${xmlEscape(workingDirectory)}</string>\n`
69
+ : '\n';
70
+
71
+ return `<?xml version="1.0" encoding="UTF-8"?>
72
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
73
+ <plist version="1.0">
74
+ <dict>
75
+ <key>Label</key>
76
+ <string>${xmlEscape(label)}</string>
77
+
78
+ <key>ProgramArguments</key>
79
+ <array>
80
+ ${programArgsXml}
81
+ </array>
82
+
83
+ <key>RunAtLoad</key>
84
+ <true/>
85
+ <key>KeepAlive</key>
86
+ <true/>
87
+ ${workingDirXml} <key>StandardOutPath</key>
88
+ <string>${xmlEscape(stdoutPath)}</string>
89
+ <key>StandardErrorPath</key>
90
+ <string>${xmlEscape(stderrPath)}</string>
91
+
92
+ <key>EnvironmentVariables</key>
93
+ <dict>
94
+ ${envXml}
95
+ </dict>
96
+ </dict>
97
+ </plist>
98
+ `;
99
+ }
100
+
101
+ export async function ensureMacAutostartEnabled({ rootDir, label, env }) {
102
+ if (process.platform !== 'darwin') {
103
+ throw new Error('[local] macOS autostart is only supported on Darwin');
104
+ }
105
+ const l = String(label ?? '').trim();
106
+ if (!l) throw new Error('[local] missing launchd label');
107
+
108
+ const plistPath = plistPathForLabel(l);
109
+ const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
110
+ await mkdir(dirname(plistPath), { recursive: true }).catch(() => {});
111
+ await mkdir(dirname(stdoutPath), { recursive: true }).catch(() => {});
112
+ await mkdir(dirname(stderrPath), { recursive: true }).catch(() => {});
113
+
114
+ const programArgs = pickLaunchdProgramArgs({ rootDir, execPath: process.execPath });
115
+ const mergedEnv = {
116
+ ...(env ?? {}),
117
+ // Ensure a reasonable PATH for subprocesses (git/docker/etc) in launchd’s minimal environment.
118
+ // Also ensure Node is on PATH for shell shims that exec `node` (common with nvm installs).
119
+ PATH: buildLaunchdPath({ execPath: process.execPath, basePath: process.env.PATH }),
120
+ };
121
+
122
+ const xml = plistXml({
123
+ label: l,
124
+ programArgs,
125
+ env: mergedEnv,
126
+ stdoutPath,
127
+ stderrPath,
128
+ workingDirectory: resolveInstalledCliRoot(rootDir),
129
+ });
130
+
131
+ const tmp = join(dirname(plistPath), `.tmp.${l}.${Date.now()}.plist`);
132
+ await writeFile(tmp, xml, 'utf-8');
133
+ await rename(tmp, plistPath);
134
+
135
+ // Best-effort load/enable; `scripts/service.mjs` has a more robust bootstrap fallback.
136
+ try {
137
+ await runCapture('launchctl', ['load', '-w', plistPath]);
138
+ } catch {
139
+ // ignore
140
+ }
141
+ }
142
+
143
+ export async function ensureMacAutostartDisabled({ label }) {
144
+ if (process.platform !== 'darwin') {
145
+ return;
146
+ }
147
+ const l = String(label ?? '').trim();
148
+ if (!l) return;
149
+ const plistPath = plistPathForLabel(l);
150
+
151
+ const uidRaw = Number(process.env.UID);
152
+ const uid = Number.isFinite(uidRaw) ? uidRaw : null;
153
+
154
+ try {
155
+ await runCapture('launchctl', ['unload', '-w', plistPath]);
156
+ } catch {
157
+ // ignore
158
+ }
159
+ try {
160
+ await runCapture('launchctl', ['unload', plistPath]);
161
+ } catch {
162
+ // ignore
163
+ }
164
+ if (uid != null) {
165
+ try {
166
+ await runCapture('launchctl', ['disable', `gui/${uid}/${l}`]);
167
+ } catch {
168
+ // ignore
169
+ }
170
+ try {
171
+ await runCapture('launchctl', ['bootout', `gui/${uid}`, plistPath]);
172
+ } catch {
173
+ // ignore
174
+ }
175
+ }
176
+ try {
177
+ await runCapture('launchctl', ['remove', l]);
178
+ } catch {
179
+ // ignore
180
+ }
181
+ }
182
+
@@ -0,0 +1,50 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ import { buildLaunchdPath, pickLaunchdProgramArgs } from './autostart_darwin.mjs';
8
+
9
+ test('buildLaunchdPath includes node dir and common tool paths', () => {
10
+ const execPath = '/Users/me/.nvm/versions/node/v22.14.0/bin/node';
11
+ const p = buildLaunchdPath({ execPath, basePath: '' });
12
+
13
+ assert.ok(p.includes('/Users/me/.nvm/versions/node/v22.14.0/bin'), 'includes node dir');
14
+ assert.ok(p.includes('/usr/bin'), 'includes /usr/bin');
15
+ assert.ok(p.includes('/bin'), 'includes /bin');
16
+ });
17
+
18
+ test('pickLaunchdProgramArgs uses stable happys shim when present', async () => {
19
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-home-'));
20
+ const shim = join(dir, 'bin', 'happys');
21
+ await mkdir(join(dir, 'bin'), { recursive: true });
22
+ await writeFile(shim, '#!/bin/sh\necho ok\n', { encoding: 'utf-8' });
23
+
24
+ // Temporarily point canonical home at our temp dir via env var used by getCanonicalHomeDirFromEnv().
25
+ const prev = process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
26
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = dir;
27
+ try {
28
+ const args = pickLaunchdProgramArgs({ rootDir: '/fake/root' });
29
+ assert.deepEqual(args, [shim, 'start']);
30
+ } finally {
31
+ if (prev == null) delete process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
32
+ else process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = prev;
33
+ }
34
+ });
35
+
36
+ test('pickLaunchdProgramArgs falls back to node + happys.mjs when shim missing', () => {
37
+ const prev = process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
38
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = '/definitely-not-a-real-path';
39
+ try {
40
+ const execPath = '/usr/local/bin/node';
41
+ const args = pickLaunchdProgramArgs({ rootDir: '/cli/root', execPath });
42
+ assert.equal(args[0], execPath);
43
+ assert.ok(String(args[1]).endsWith('/bin/happys.mjs'));
44
+ assert.equal(args[2], 'start');
45
+ } finally {
46
+ if (prev == null) delete process.env.HAPPY_STACKS_CANONICAL_HOME_DIR;
47
+ else process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = prev;
48
+ }
49
+ });
50
+
@@ -0,0 +1,23 @@
1
+ import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
2
+ import { getStackRuntimeStatePath } from './runtime_state.mjs';
3
+
4
+ export function resolveStackContext({ env = process.env, autostart = null } = {}) {
5
+ const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
6
+ const stackName = explicitStack || (autostart?.stackName ?? '') || getStackName(env);
7
+ const stackMode = Boolean(explicitStack);
8
+
9
+ const envPath =
10
+ (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
11
+ resolveStackEnvPath(stackName, env).envPath;
12
+
13
+ const runtimeStatePath =
14
+ (env.HAPPY_STACKS_RUNTIME_STATE_PATH ?? env.HAPPY_LOCAL_RUNTIME_STATE_PATH ?? '').toString().trim() ||
15
+ getStackRuntimeStatePath(stackName);
16
+
17
+ const explicitEphemeral =
18
+ (env.HAPPY_STACKS_EPHEMERAL_PORTS ?? env.HAPPY_LOCAL_EPHEMERAL_PORTS ?? '').toString().trim() === '1';
19
+ const ephemeral = explicitEphemeral || (stackMode && stackName !== 'main');
20
+
21
+ return { stackMode, stackName, envPath, runtimeStatePath, ephemeral };
22
+ }
23
+
@@ -0,0 +1,27 @@
1
+ import { join } from 'node:path';
2
+
3
+ import { expandHome } from '../paths/canonical_home.mjs';
4
+ import { getDefaultAutostartPaths } from '../paths/paths.mjs';
5
+
6
+ export function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
7
+ const fromEnv = (env?.HAPPY_STACKS_CLI_HOME_DIR ?? env?.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
8
+ return fromEnv || join(stackBaseDir, 'cli');
9
+ }
10
+
11
+ export function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
12
+ const fromEnv = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
13
+ return fromEnv || join(stackBaseDir, 'server-light');
14
+ }
15
+
16
+ export function resolveCliHomeDir(env = process.env) {
17
+ const fromExplicit = (env.HAPPY_HOME_DIR ?? '').trim();
18
+ if (fromExplicit) {
19
+ return expandHome(fromExplicit);
20
+ }
21
+ const fromStacks = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
22
+ if (fromStacks) {
23
+ return expandHome(fromStacks);
24
+ }
25
+ return join(getDefaultAutostartPaths().baseDir, 'cli');
26
+ }
27
+
@@ -0,0 +1,152 @@
1
+ import { join, resolve } from 'node:path';
2
+ import { writeFile } from 'node:fs/promises';
3
+
4
+ import { expandHome } from '../paths/canonical_home.mjs';
5
+ import { getComponentDir, getWorkspaceDir, resolveStackEnvPath } from '../paths/paths.mjs';
6
+ import { ensureDir } from '../fs/ops.mjs';
7
+ import { getEnvValueAny } from '../env/values.mjs';
8
+ import { readEnvObjectFromFile } from '../env/read.mjs';
9
+ import { resolveCommandPath } from '../proc/commands.mjs';
10
+ import { run, runCapture } from '../proc/proc.mjs';
11
+ import { getCliHomeDirFromEnvOrDefault } from './dirs.mjs';
12
+
13
+ function resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv }) {
14
+ const raw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_WORKSPACE_DIR', 'HAPPY_LOCAL_WORKSPACE_DIR']);
15
+ if (!raw) {
16
+ return getWorkspaceDir(rootDir, stackEnv);
17
+ }
18
+ const expanded = expandHome(raw);
19
+ return expanded.startsWith('/') ? expanded : resolve(rootDir, expanded);
20
+ }
21
+
22
+ function resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component }) {
23
+ const raw = getEnvValueAny(stackEnv, keys);
24
+ if (!raw) return getComponentDir(rootDir, component, stackEnv);
25
+ const expanded = expandHome(raw);
26
+ if (expanded.startsWith('/')) return expanded;
27
+ const workspaceDir = resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv });
28
+ return resolve(workspaceDir, expanded);
29
+ }
30
+
31
+ export async function isCursorInstalled({ cwd, env } = {}) {
32
+ if (await resolveCommandPath('cursor', { cwd, env })) return true;
33
+ if (process.platform !== 'darwin') return false;
34
+ try {
35
+ await runCapture('open', ['-Ra', 'Cursor'], { cwd, env });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ export async function openWorkspaceInEditor({ rootDir, editor, workspacePath }) {
43
+ if (editor === 'code') {
44
+ const codePath = await resolveCommandPath('code', { cwd: rootDir, env: process.env });
45
+ if (!codePath) {
46
+ throw new Error(
47
+ "[stack] VS Code CLI 'code' not found on PATH. In VS Code: Cmd+Shift+P → 'Shell Command: Install code command in PATH'."
48
+ );
49
+ }
50
+ await run(codePath, ['-n', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
51
+ return;
52
+ }
53
+
54
+ const cursorPath = await resolveCommandPath('cursor', { cwd: rootDir, env: process.env });
55
+ if (cursorPath) {
56
+ try {
57
+ await run(cursorPath, ['-n', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
58
+ } catch {
59
+ await run(cursorPath, [workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
60
+ }
61
+ return;
62
+ }
63
+
64
+ if (process.platform === 'darwin') {
65
+ // Cursor installed but CLI missing is common on macOS.
66
+ await run('open', ['-na', 'Cursor', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
67
+ return;
68
+ }
69
+
70
+ throw new Error("[stack] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
71
+ }
72
+
73
+ export async function writeStackCodeWorkspace({
74
+ rootDir,
75
+ stackName,
76
+ includeStackDir,
77
+ includeAllComponents,
78
+ includeCliHome,
79
+ }) {
80
+ const { baseDir, envPath } = resolveStackEnvPath(stackName);
81
+ const stackEnv = await readEnvObjectFromFile(envPath);
82
+
83
+ const serverComponent =
84
+ getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
85
+
86
+ const selectedComponents = includeAllComponents
87
+ ? ['happy', 'happy-cli', 'happy-server-light', 'happy-server']
88
+ : ['happy', 'happy-cli', serverComponent];
89
+
90
+ const componentSpecs = [
91
+ { component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
92
+ { component: 'happy-cli', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI'] },
93
+ {
94
+ component: 'happy-server-light',
95
+ keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT'],
96
+ },
97
+ { component: 'happy-server', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER'] },
98
+ ];
99
+ const byName = new Map(componentSpecs.map((c) => [c.component, c.keys]));
100
+
101
+ const folders = [];
102
+ if (includeStackDir) {
103
+ folders.push({ name: `stack:${stackName}`, path: baseDir });
104
+ }
105
+ if (includeCliHome) {
106
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir: baseDir, env: stackEnv });
107
+ folders.push({ name: `cli:${stackName}`, path: expandHome(cliHomeDir) });
108
+ }
109
+ for (const component of selectedComponents) {
110
+ const keys = byName.get(component) ?? [];
111
+ const dir = resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component });
112
+ folders.push({ name: component, path: dir });
113
+ }
114
+
115
+ // Deduplicate by path (can happen if multiple components are pointed at the same dir).
116
+ const uniqFolders = folders.filter((f, i, arr) => arr.findIndex((x) => x.path === f.path) === i);
117
+
118
+ await ensureDir(baseDir);
119
+ const workspacePath = join(baseDir, `stack.${stackName}.code-workspace`);
120
+ const payload = {
121
+ folders: uniqFolders,
122
+ settings: {
123
+ 'search.exclude': {
124
+ '**/node_modules/**': true,
125
+ '**/.git/**': true,
126
+ '**/logs/**': true,
127
+ '**/cli/logs/**': true,
128
+ },
129
+ 'files.watcherExclude': {
130
+ '**/node_modules/**': true,
131
+ '**/.git/**': true,
132
+ '**/logs/**': true,
133
+ '**/cli/logs/**': true,
134
+ },
135
+ },
136
+ };
137
+ await writeFile(workspacePath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
138
+
139
+ return {
140
+ workspacePath,
141
+ baseDir,
142
+ envPath,
143
+ serverComponent,
144
+ folders: uniqFolders,
145
+ flags: {
146
+ includeStackDir: Boolean(includeStackDir),
147
+ includeCliHome: Boolean(includeCliHome),
148
+ includeAllComponents: Boolean(includeAllComponents),
149
+ },
150
+ };
151
+ }
152
+
@@ -0,0 +1,12 @@
1
+ export function sanitizeStackName(raw, { fallback = 'stack', maxLen = 64 } = {}) {
2
+ const s = String(raw ?? '')
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9-]+/g, '-')
6
+ .replace(/-+/g, '-')
7
+ .replace(/^-+/, '')
8
+ .replace(/-+$/, '');
9
+ const out = s || String(fallback ?? 'stack');
10
+ return Number.isFinite(maxLen) && maxLen > 0 ? out.slice(0, maxLen) : out;
11
+ }
12
+
@@ -0,0 +1,16 @@
1
+ import { parseGithubPullRequest } from '../git/refs.mjs';
2
+ import { sanitizeStackName } from './names.mjs';
3
+
4
+ export function inferPrStackBaseName({ happy, happyCli, server, serverLight, fallback = 'pr' }) {
5
+ const parts = [];
6
+ const hn = parseGithubPullRequest(happy)?.number ?? null;
7
+ const cn = parseGithubPullRequest(happyCli)?.number ?? null;
8
+ const sn = parseGithubPullRequest(server)?.number ?? null;
9
+ const sln = parseGithubPullRequest(serverLight)?.number ?? null;
10
+ if (hn) parts.push(`happy${hn}`);
11
+ if (cn) parts.push(`cli${cn}`);
12
+ if (sn) parts.push(`server${sn}`);
13
+ if (sln) parts.push(`light${sln}`);
14
+ return sanitizeStackName(parts.length ? `pr-${parts.join('-')}` : fallback, { fallback, maxLen: 64 });
15
+ }
16
+
@@ -0,0 +1,88 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ import { resolveStackEnvPath } from '../paths/paths.mjs';
6
+ import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
7
+ import { isPidAlive } from '../proc/pids.mjs';
8
+
9
+ export { isPidAlive };
10
+
11
+ export function getStackRuntimeStatePath(stackName) {
12
+ const { baseDir } = resolveStackEnvPath(stackName);
13
+ return join(baseDir, 'stack.runtime.json');
14
+ }
15
+
16
+ export async function readStackRuntimeStateFile(statePath) {
17
+ const parsed = await readJsonIfExists(statePath, { defaultValue: null });
18
+ return parsed && typeof parsed === 'object' ? parsed : null;
19
+ }
20
+
21
+ export async function writeStackRuntimeStateFile(statePath, state) {
22
+ if (!statePath) {
23
+ throw new Error('[stack] missing runtime state path');
24
+ }
25
+ await writeJsonAtomic(statePath, state);
26
+ }
27
+
28
+ function isPlainObject(v) {
29
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
30
+ }
31
+
32
+ function deepMerge(a, b) {
33
+ if (!isPlainObject(a) || !isPlainObject(b)) {
34
+ return b;
35
+ }
36
+ const out = { ...a };
37
+ for (const [k, v] of Object.entries(b)) {
38
+ if (isPlainObject(out[k]) && isPlainObject(v)) {
39
+ out[k] = deepMerge(out[k], v);
40
+ } else {
41
+ out[k] = v;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export async function updateStackRuntimeStateFile(statePath, patch) {
48
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
49
+ const next = deepMerge(existing, patch ?? {});
50
+ await writeStackRuntimeStateFile(statePath, next);
51
+ return next;
52
+ }
53
+
54
+ export async function recordStackRuntimeStart(statePath, { stackName, script, ephemeral, ownerPid, ports, ...rest } = {}) {
55
+ const now = new Date().toISOString();
56
+ const existing = (await readStackRuntimeStateFile(statePath)) ?? {};
57
+ const startedAt = typeof existing.startedAt === 'string' && existing.startedAt.trim() ? existing.startedAt : now;
58
+ const next = deepMerge(existing, {
59
+ version: 1,
60
+ stackName,
61
+ script,
62
+ ephemeral: Boolean(ephemeral),
63
+ ownerPid,
64
+ ports: ports ?? {},
65
+ startedAt,
66
+ updatedAt: now,
67
+ ...(rest ?? {}),
68
+ });
69
+ await writeStackRuntimeStateFile(statePath, next);
70
+ return next;
71
+ }
72
+
73
+ export async function recordStackRuntimeUpdate(statePath, patch = {}) {
74
+ return await updateStackRuntimeStateFile(statePath, {
75
+ ...(patch ?? {}),
76
+ updatedAt: new Date().toISOString(),
77
+ });
78
+ }
79
+
80
+ export async function deleteStackRuntimeStateFile(statePath) {
81
+ try {
82
+ if (!statePath || !existsSync(statePath)) return;
83
+ await unlink(statePath);
84
+ } catch {
85
+ // ignore
86
+ }
87
+ }
88
+