happy-stacks 0.1.2 → 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 (116) hide show
  1. package/README.md +164 -89
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -0
  64. package/scripts/utils/dev/daemon.mjs +104 -0
  65. package/scripts/utils/dev/expo_web.mjs +112 -0
  66. package/scripts/utils/dev/server.mjs +183 -0
  67. package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
package/scripts/stack.mjs CHANGED
@@ -1,34 +1,66 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { spawn } from 'node:child_process';
2
3
  import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
3
4
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
- import net from 'node:net';
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
-
9
- import { parseArgs } from './utils/args.mjs';
10
- import { run, runCapture } from './utils/proc.mjs';
11
- import { getComponentDir, getComponentsDir, getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
12
- import { createWorktree, resolveComponentSpecToDir } from './utils/worktrees.mjs';
13
- import { isTty, prompt, promptWorktreeSource, withRl } from './utils/wizard.mjs';
14
- import { parseDotenv } from './utils/dotenv.mjs';
15
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
16
- import { ensureEnvFileUpdated } from './utils/env_file.mjs';
17
- import { stopStackWithEnv } from './utils/stack_stop.mjs';
8
+ import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs';
9
+
10
+ import { parseArgs } from './utils/cli/args.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';
14
+ import {
15
+ createWorktree,
16
+ createWorktreeFromBaseWorktree,
17
+ inferRemoteNameForOwner,
18
+ isComponentWorktreePath,
19
+ resolveComponentSpecToDir,
20
+ worktreeSpecFromDir,
21
+ } from './utils/git/worktrees.mjs';
22
+ import { isTty, prompt, promptWorktreeSource, withRl } from './utils/cli/wizard.mjs';
23
+ import { parseEnvToObject } from './utils/env/dotenv.mjs';
24
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.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';
45
+ import {
46
+ deleteStackRuntimeStateFile,
47
+ getStackRuntimeStatePath,
48
+ isPidAlive,
49
+ recordStackRuntimeStart,
50
+ readStackRuntimeStateFile,
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';
18
58
 
19
59
  function stackNameFromArg(positionals, idx) {
20
60
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
21
61
  return name;
22
62
  }
23
63
 
24
- function getStackDir(stackName) {
25
- return resolveStackEnvPath(stackName).baseDir;
26
- }
27
-
28
- function getStackEnvPath(stackName) {
29
- return resolveStackEnvPath(stackName).envPath;
30
- }
31
-
32
64
  function getDefaultPortStart() {
33
65
  const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
34
66
  ? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
@@ -40,57 +72,27 @@ function getDefaultPortStart() {
40
72
  }
41
73
 
42
74
  async function isPortFree(port) {
43
- return await new Promise((resolvePromise) => {
44
- const srv = net.createServer();
45
- srv.unref();
46
- srv.on('error', () => resolvePromise(false));
47
- srv.listen({ port, host: '127.0.0.1' }, () => {
48
- srv.close(() => resolvePromise(true));
49
- });
50
- });
75
+ return await isTcpPortFree(port, { host: '127.0.0.1' });
51
76
  }
52
77
 
53
78
  async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
54
- let port = startPort;
55
- for (let i = 0; i < 200; i++) {
56
- // eslint-disable-next-line no-await-in-loop
57
- if (!reservedPorts.has(port) && (await isPortFree(port))) {
58
- return port;
59
- }
60
- port += 1;
79
+ try {
80
+ return await pickNextFreeTcpPort(startPort, { reservedPorts, host: '127.0.0.1' });
81
+ } catch (e) {
82
+ const msg = e instanceof Error ? e.message : String(e);
83
+ throw new Error(msg.replace(/^\[local\]/, '[stack]'));
61
84
  }
62
- throw new Error(`[stack] unable to find a free port starting at ${startPort}`);
63
85
  }
64
86
 
65
87
  async function readPortFromEnvFile(envPath) {
66
- const raw = await readExistingEnv(envPath);
67
- if (!raw.trim()) return null;
68
- const parsed = parseEnvToObject(raw);
69
- const portRaw = (parsed.HAPPY_STACKS_SERVER_PORT ?? parsed.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
70
- const n = portRaw ? Number(portRaw) : NaN;
71
- return Number.isFinite(n) && n > 0 ? n : null;
88
+ return await readPinnedServerPortFromEnvFile(envPath);
72
89
  }
73
90
 
74
91
  async function readPortsFromEnvFile(envPath) {
75
92
  const raw = await readExistingEnv(envPath);
76
93
  if (!raw.trim()) return [];
77
94
  const parsed = parseEnvToObject(raw);
78
- const keys = [
79
- 'HAPPY_STACKS_SERVER_PORT',
80
- 'HAPPY_LOCAL_SERVER_PORT',
81
- 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
82
- 'HAPPY_STACKS_PG_PORT',
83
- 'HAPPY_STACKS_REDIS_PORT',
84
- 'HAPPY_STACKS_MINIO_PORT',
85
- 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
86
- ];
87
- const ports = [];
88
- for (const k of keys) {
89
- const rawV = (parsed[k] ?? '').toString().trim();
90
- const n = rawV ? Number(rawV) : NaN;
91
- if (Number.isFinite(n) && n > 0) ports.push(n);
92
- }
93
- return ports;
95
+ return listPortsFromEnvObject(parsed, STACK_RESERVED_PORT_KEYS);
94
96
  }
95
97
 
96
98
  async function collectReservedStackPorts({ excludeStackName = null } = {}) {
@@ -125,111 +127,22 @@ async function collectReservedStackPorts({ excludeStackName = null } = {}) {
125
127
  return reserved;
126
128
  }
127
129
 
128
- function base64Url(buf) {
129
- return Buffer.from(buf)
130
- .toString('base64')
131
- .replaceAll('+', '-')
132
- .replaceAll('/', '_')
133
- .replaceAll('=', '');
134
- }
135
-
136
- function randomToken(lenBytes = 24) {
137
- return base64Url(randomBytes(lenBytes));
138
- }
139
-
140
- function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
141
- const s = String(raw ?? '')
142
- .toLowerCase()
143
- .replace(/[^a-z0-9-]+/g, '-')
144
- .replace(/-+/g, '-')
145
- .replace(/^-+/, '')
146
- .replace(/-+$/, '');
147
- return s || fallback;
148
- }
149
-
150
- async function ensureDir(p) {
151
- await mkdir(p, { recursive: true });
152
- }
153
-
154
- async function readTextIfExists(path) {
155
- try {
156
- if (!existsSync(path)) return null;
157
- const raw = await readFile(path, 'utf-8');
158
- const t = raw.trim();
159
- return t ? t : null;
160
- } catch {
161
- return null;
162
- }
163
- }
164
-
165
- async function writeSecretFileIfMissing({ path, secret }) {
166
- if (existsSync(path)) return false;
167
- await ensureDir(dirname(path));
168
- await writeFile(path, secret, { encoding: 'utf-8', mode: 0o600 });
169
- return true;
170
- }
171
-
172
- async function copyFileIfMissing({ from, to, mode }) {
173
- if (existsSync(to)) return false;
174
- if (!existsSync(from)) return false;
175
- await ensureDir(dirname(to));
176
- await copyFile(from, to);
177
- if (mode) {
178
- await chmod(to, mode).catch(() => {});
179
- }
180
- return true;
181
- }
182
-
183
- function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
184
- const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
185
- return fromEnv || join(stackBaseDir, 'cli');
186
- }
187
-
188
- function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
189
- const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
190
- return fromEnv || join(stackBaseDir, 'server-light');
191
- }
192
-
193
- async function resolveHandyMasterSecretFromStack({ stackName, requireStackExists }) {
194
- if (requireStackExists && !stackExistsSync(stackName)) {
195
- throw new Error(`[stack] cannot copy auth: source stack "${stackName}" does not exist`);
196
- }
197
-
198
- const sourceBaseDir = getStackDir(stackName);
199
- const sourceEnvPath = getStackEnvPath(stackName);
200
- const raw = await readExistingEnv(sourceEnvPath);
201
- const env = parseEnvToObject(raw);
202
-
203
- const inline = (env.HANDY_MASTER_SECRET ?? '').trim();
204
- if (inline) {
205
- return { secret: inline, source: `${sourceEnvPath} (HANDY_MASTER_SECRET)` };
206
- }
207
-
208
- const secretFile = (env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim();
209
- if (secretFile) {
210
- const secret = await readTextIfExists(secretFile);
211
- if (secret) return { secret, source: secretFile };
212
- }
213
-
214
- const dataDir = getServerLightDataDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env });
215
- const secretPath = join(dataDir, 'handy-master-secret.txt');
216
- const secret = await readTextIfExists(secretPath);
217
- if (secret) return { secret, source: secretPath };
218
-
219
- // Last-resort legacy: if main has never been migrated to stack dirs.
220
- if (stackName === 'main') {
221
- const legacy = join(homedir(), '.happy', 'server-light', 'handy-master-secret.txt');
222
- const legacySecret = await readTextIfExists(legacy);
223
- if (legacySecret) return { secret: legacySecret, source: legacy };
224
- }
225
-
226
- return { secret: null, source: null };
227
- }
228
-
229
- async function copyAuthFromStackIntoNewStack({ fromStackName, stackName, stackEnv, serverComponent, json, requireSourceStackExists }) {
130
+ // auth file copy/link helpers live in scripts/utils/auth/files.mjs
131
+
132
+ async function copyAuthFromStackIntoNewStack({
133
+ fromStackName,
134
+ stackName,
135
+ stackEnv,
136
+ serverComponent,
137
+ json,
138
+ requireSourceStackExists,
139
+ linkMode = false,
140
+ }) {
230
141
  const { secret, source } = await resolveHandyMasterSecretFromStack({
231
142
  stackName: fromStackName,
232
143
  requireStackExists: requireSourceStackExists,
144
+ allowLegacyAuthSource: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
145
+ allowLegacyMainFallback: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
233
146
  });
234
147
 
235
148
  const copied = { secret: false, accessKey: false, settings: false, sourceStack: fromStackName };
@@ -238,31 +151,52 @@ async function copyAuthFromStackIntoNewStack({ fromStackName, stackName, stackEn
238
151
  if (serverComponent === 'happy-server-light') {
239
152
  const dataDir = stackEnv.HAPPY_SERVER_LIGHT_DATA_DIR;
240
153
  const target = join(dataDir, 'handy-master-secret.txt');
241
- copied.secret = await writeSecretFileIfMissing({ path: target, secret });
154
+ const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
155
+ copied.secret =
156
+ linkMode && sourcePath && existsSync(sourcePath)
157
+ ? await linkFileIfMissing({ from: sourcePath, to: target })
158
+ : await writeSecretFileIfMissing({ path: target, secret });
242
159
  } else if (serverComponent === 'happy-server') {
243
160
  const target = stackEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE;
244
161
  if (target) {
245
- copied.secret = await writeSecretFileIfMissing({ path: target, secret });
162
+ const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
163
+ copied.secret =
164
+ linkMode && sourcePath && existsSync(sourcePath)
165
+ ? await linkFileIfMissing({ from: sourcePath, to: target })
166
+ : await writeSecretFileIfMissing({ path: target, secret });
246
167
  }
247
168
  }
248
169
  }
249
170
 
250
- const sourceBaseDir = getStackDir(fromStackName);
251
- const sourceEnvRaw = await readExistingEnv(getStackEnvPath(fromStackName));
171
+ const legacy = isLegacyAuthSourceName(fromStackName);
172
+ if (legacy && isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
173
+ throw new Error(
174
+ '[stack] auth copy-from: legacy auth source is disabled in sandbox mode.\n' +
175
+ 'Reason: it reads from ~/.happy (global user state).\n' +
176
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
177
+ );
178
+ }
179
+ const sourceBaseDir = legacy ? getLegacyHappyBaseDir() : resolveStackEnvPath(fromStackName).baseDir;
180
+ const sourceEnvRaw = legacy ? '' : await readExistingEnv(resolveStackEnvPath(fromStackName).envPath);
252
181
  const sourceEnv = parseEnvToObject(sourceEnvRaw);
253
- const sourceCli = getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
182
+ const sourceCli = legacy ? join(sourceBaseDir, 'cli') : getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
254
183
  const targetCli = stackEnv.HAPPY_STACKS_CLI_HOME_DIR;
255
184
 
256
- copied.accessKey = await copyFileIfMissing({
257
- from: join(sourceCli, 'access.key'),
258
- to: join(targetCli, 'access.key'),
259
- mode: 0o600,
260
- });
261
- copied.settings = await copyFileIfMissing({
262
- from: join(sourceCli, 'settings.json'),
263
- to: join(targetCli, 'settings.json'),
264
- mode: 0o600,
265
- });
185
+ if (linkMode) {
186
+ copied.accessKey = await linkFileIfMissing({ from: join(sourceCli, 'access.key'), to: join(targetCli, 'access.key') });
187
+ copied.settings = await linkFileIfMissing({ from: join(sourceCli, 'settings.json'), to: join(targetCli, 'settings.json') });
188
+ } else {
189
+ copied.accessKey = await copyFileIfMissing({
190
+ from: join(sourceCli, 'access.key'),
191
+ to: join(targetCli, 'access.key'),
192
+ mode: 0o600,
193
+ });
194
+ copied.settings = await copyFileIfMissing({
195
+ from: join(sourceCli, 'settings.json'),
196
+ to: join(targetCli, 'settings.json'),
197
+ mode: 0o600,
198
+ });
199
+ }
266
200
 
267
201
  if (!json) {
268
202
  const any = copied.secret || copied.accessKey || copied.settings;
@@ -289,25 +223,7 @@ function stringifyEnv(env) {
289
223
  return lines.join('\n') + '\n';
290
224
  }
291
225
 
292
- async function readExistingEnv(path) {
293
- try {
294
- const raw = await readFile(path, 'utf-8');
295
- return raw;
296
- } catch {
297
- return '';
298
- }
299
- }
300
-
301
- function parseEnvToObject(raw) {
302
- const parsed = parseDotenv(raw);
303
- return Object.fromEntries(parsed.entries());
304
- }
305
-
306
- function stackExistsSync(stackName) {
307
- if (stackName === 'main') return true;
308
- const envPath = getStackEnvPath(stackName);
309
- return existsSync(envPath);
310
- }
226
+ const readExistingEnv = readTextOrEmpty;
311
227
 
312
228
  function resolveDefaultComponentDirs({ rootDir }) {
313
229
  const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
@@ -322,9 +238,9 @@ function resolveDefaultComponentDirs({ rootDir }) {
322
238
  }
323
239
 
324
240
  async function writeStackEnv({ stackName, env }) {
325
- const stackDir = getStackDir(stackName);
241
+ const stackDir = resolveStackEnvPath(stackName).baseDir;
326
242
  await ensureDir(stackDir);
327
- const envPath = getStackEnvPath(stackName);
243
+ const envPath = resolveStackEnvPath(stackName).envPath;
328
244
  const next = stringifyEnv(env);
329
245
  const existing = await readExistingEnv(envPath);
330
246
  if (existing !== next) {
@@ -334,7 +250,7 @@ async function writeStackEnv({ stackName, env }) {
334
250
  }
335
251
 
336
252
  async function withStackEnv({ stackName, fn, extraEnv = {} }) {
337
- const envPath = getStackEnvPath(stackName);
253
+ const envPath = resolveStackEnvPath(stackName).envPath;
338
254
  if (!stackExistsSync(stackName)) {
339
255
  throw new Error(
340
256
  `[stack] stack "${stackName}" does not exist yet.\n` +
@@ -355,17 +271,77 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
355
271
  delete cleaned[k];
356
272
  }
357
273
  }
358
- return await fn({
359
- env: {
360
- ...cleaned,
361
- HAPPY_STACKS_STACK: stackName,
362
- HAPPY_STACKS_ENV_FILE: envPath,
363
- HAPPY_LOCAL_STACK: stackName,
364
- HAPPY_LOCAL_ENV_FILE: envPath,
365
- ...extraEnv,
366
- },
367
- envPath,
368
- });
274
+ const raw = await readExistingEnv(envPath);
275
+ const stackEnv = parseEnvToObject(raw);
276
+
277
+ // Mirror HAPPY_STACKS_* and HAPPY_LOCAL_* prefixes so callers can use either.
278
+ // (Matches scripts/utils/env.mjs behavior.)
279
+ const applyPrefixMapping = (obj) => {
280
+ const keys = new Set(Object.keys(obj));
281
+ const suffixes = new Set();
282
+ for (const k of keys) {
283
+ if (k.startsWith('HAPPY_STACKS_')) suffixes.add(k.slice('HAPPY_STACKS_'.length));
284
+ if (k.startsWith('HAPPY_LOCAL_')) suffixes.add(k.slice('HAPPY_LOCAL_'.length));
285
+ }
286
+ for (const suffix of suffixes) {
287
+ const stacksKey = `HAPPY_STACKS_${suffix}`;
288
+ const localKey = `HAPPY_LOCAL_${suffix}`;
289
+ const stacksVal = (obj[stacksKey] ?? '').toString().trim();
290
+ const localVal = (obj[localKey] ?? '').toString().trim();
291
+ if (stacksVal) {
292
+ obj[stacksKey] = stacksVal;
293
+ obj[localKey] = stacksVal;
294
+ } else if (localVal) {
295
+ obj[localKey] = localVal;
296
+ obj[stacksKey] = localVal;
297
+ }
298
+ }
299
+ };
300
+
301
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
302
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
303
+
304
+ const env = {
305
+ ...cleaned,
306
+ HAPPY_STACKS_STACK: stackName,
307
+ HAPPY_STACKS_ENV_FILE: envPath,
308
+ HAPPY_LOCAL_STACK: stackName,
309
+ HAPPY_LOCAL_ENV_FILE: envPath,
310
+ // Expose runtime state path so scripts can find it if needed.
311
+ HAPPY_STACKS_RUNTIME_STATE_PATH: runtimeStatePath,
312
+ HAPPY_LOCAL_RUNTIME_STATE_PATH: runtimeStatePath,
313
+ // Stack env is authoritative by default.
314
+ ...stackEnv,
315
+ // One-shot overrides (e.g. --happy=...) win over stack env file.
316
+ ...extraEnv,
317
+ };
318
+ applyPrefixMapping(env);
319
+
320
+ // Runtime-only port overlay (ephemeral stacks): only trust it when the owner pid is still alive.
321
+ const ownerPid = Number(runtimeState?.ownerPid);
322
+ if (isPidAlive(ownerPid)) {
323
+ const ports = runtimeState?.ports && typeof runtimeState.ports === 'object' ? runtimeState.ports : {};
324
+ const applyPort = (suffix, value) => {
325
+ const n = Number(value);
326
+ if (!Number.isFinite(n) || n <= 0) return;
327
+ env[`HAPPY_STACKS_${suffix}`] = String(n);
328
+ env[`HAPPY_LOCAL_${suffix}`] = String(n);
329
+ };
330
+ applyPort('SERVER_PORT', ports.server);
331
+ applyPort('HAPPY_SERVER_BACKEND_PORT', ports.backend);
332
+ applyPort('PG_PORT', ports.pg);
333
+ applyPort('REDIS_PORT', ports.redis);
334
+ applyPort('MINIO_PORT', ports.minio);
335
+ applyPort('MINIO_CONSOLE_PORT', ports.minioConsole);
336
+
337
+ // Mark ephemeral mode for downstream helpers (e.g. infra should not persist ports).
338
+ if (runtimeState?.ephemeral) {
339
+ env.HAPPY_STACKS_EPHEMERAL_PORTS = '1';
340
+ env.HAPPY_LOCAL_EPHEMERAL_PORTS = '1';
341
+ }
342
+ }
343
+
344
+ return await fn({ env, envPath, stackEnv, runtimeStatePath, runtimeState });
369
345
  }
370
346
 
371
347
  async function interactiveNew({ rootDir, rl, defaults }) {
@@ -389,7 +365,7 @@ async function interactiveNew({ rootDir, rl, defaults }) {
389
365
 
390
366
  // Port
391
367
  if (!out.port) {
392
- const want = (await rl.question('Port (empty = auto-pick): ')).trim();
368
+ const want = (await rl.question('Port (empty = ephemeral): ')).trim();
393
369
  out.port = want ? Number(want) : null;
394
370
  }
395
371
 
@@ -439,8 +415,9 @@ async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }
439
415
 
440
416
  // Port
441
417
  const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
442
- const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'auto'}): `, { defaultValue: '' });
443
- out.port = wantPort ? Number(wantPort) : (currentPort ? Number(currentPort) : null);
418
+ const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'ephemeral'}; type 'ephemeral' to unpin): `, { defaultValue: '' });
419
+ const wantTrimmed = wantPort.trim().toLowerCase();
420
+ out.port = wantTrimmed === 'ephemeral' ? null : wantPort ? Number(wantPort) : (currentPort ? Number(currentPort) : null);
444
421
 
445
422
  // Remote for creating new worktrees
446
423
  const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
@@ -471,12 +448,25 @@ async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }
471
448
  return out;
472
449
  }
473
450
 
474
- async function cmdNew({ rootDir, argv }) {
451
+ async function cmdNew({ rootDir, argv, emit = true }) {
475
452
  const { flags, kv } = parseArgs(argv);
476
453
  const positionals = argv.filter((a) => !a.startsWith('--'));
477
454
  const json = wantsJson(argv, { flags });
478
455
  const copyAuth = !(flags.has('--no-copy-auth') || flags.has('--fresh-auth'));
479
- const copyAuthFrom = (kv.get('--copy-auth-from') ?? '').trim() || 'main';
456
+ const copyAuthFrom =
457
+ (kv.get('--copy-auth-from') ?? '').trim() ||
458
+ (process.env.HAPPY_STACKS_AUTH_SEED_FROM ?? process.env.HAPPY_LOCAL_AUTH_SEED_FROM ?? '').trim() ||
459
+ 'main';
460
+ const linkAuth =
461
+ flags.has('--link-auth') ||
462
+ flags.has('--link') ||
463
+ flags.has('--symlink-auth') ||
464
+ (kv.get('--link-auth') ?? '').trim() === '1' ||
465
+ (kv.get('--auth-mode') ?? '').trim() === 'link' ||
466
+ (kv.get('--copy-auth-mode') ?? '').trim() === 'link' ||
467
+ (process.env.HAPPY_STACKS_AUTH_LINK ?? process.env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
468
+ (process.env.HAPPY_STACKS_AUTH_MODE ?? process.env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
469
+ const forcePort = flags.has('--force-port');
480
470
 
481
471
  // argv here is already "args after 'new'", so the first positional is the stack name.
482
472
  let stackName = stackNameFromArg(positionals, 0);
@@ -505,7 +495,7 @@ async function cmdNew({ rootDir, argv }) {
505
495
  throw new Error(
506
496
  '[stack] usage: happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] ' +
507
497
  '[--happy=default|<owner/...>|<path>] [--happy-cli=...] [--happy-server=...] [--happy-server-light=...] ' +
508
- '[--copy-auth-from=main] [--no-copy-auth] [--interactive]'
498
+ '[--copy-auth-from=<stack|legacy>] [--link-auth] [--no-copy-auth] [--interactive] [--force-port]'
509
499
  );
510
500
  }
511
501
  if (stackName === 'main') {
@@ -517,14 +507,37 @@ async function cmdNew({ rootDir, argv }) {
517
507
  throw new Error(`[stack] invalid server component: ${serverComponent}`);
518
508
  }
519
509
 
520
- const baseDir = getStackDir(stackName);
510
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
521
511
  const uiBuildDir = join(baseDir, 'ui');
522
512
  const cliHomeDir = join(baseDir, 'cli');
523
513
 
514
+ // Port strategy:
515
+ // - If --port is provided, we treat it as a pinned port and persist it in the stack env.
516
+ // - Otherwise, ports are ephemeral and chosen at stack start time (stored only in stack.runtime.json).
524
517
  let port = config.port;
525
- if (!port || !Number.isFinite(port)) {
518
+ if (!Number.isFinite(port) || port <= 0) {
519
+ port = null;
520
+ }
521
+ if (port != null) {
522
+ // If user picked a port explicitly, fail-closed on collisions by default.
526
523
  const reservedPorts = await collectReservedStackPorts();
527
- port = await pickNextFreePort(getDefaultPortStart(), { reservedPorts });
524
+ if (!forcePort && reservedPorts.has(port)) {
525
+ throw new Error(
526
+ `[stack] port ${port} is already reserved by another stack env.\n` +
527
+ `Fix:\n` +
528
+ `- omit --port to use an ephemeral port at start time (recommended)\n` +
529
+ `- or pick a different --port\n` +
530
+ `- or re-run with --force-port (not recommended)\n`
531
+ );
532
+ }
533
+ if (!(await isTcpPortFree(port))) {
534
+ throw new Error(
535
+ `[stack] port ${port} is not free on 127.0.0.1.\n` +
536
+ `Fix:\n` +
537
+ `- omit --port to use an ephemeral port at start time (recommended)\n` +
538
+ `- or stop the process currently using ${port}\n`
539
+ );
540
+ }
528
541
  }
529
542
 
530
543
  // Always pin component dirs explicitly (so stack env is stable even if repo env changes).
@@ -533,13 +546,15 @@ async function cmdNew({ rootDir, argv }) {
533
546
  // Prepare component dirs (may create worktrees).
534
547
  const stackEnv = {
535
548
  HAPPY_STACKS_STACK: stackName,
536
- HAPPY_STACKS_SERVER_PORT: String(port),
537
549
  HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
538
550
  HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
539
551
  HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
540
552
  HAPPY_STACKS_STACK_REMOTE: config.createRemote?.trim() ? config.createRemote.trim() : 'upstream',
541
553
  ...defaultComponentDirs,
542
554
  };
555
+ if (port != null) {
556
+ stackEnv.HAPPY_STACKS_SERVER_PORT = String(port);
557
+ }
543
558
 
544
559
  // Server-light storage isolation: ensure non-main stacks have their own sqlite + local files dir by default.
545
560
  // (This prevents a dev stack from mutating main stack's DB when schema changes.)
@@ -550,50 +565,54 @@ async function cmdNew({ rootDir, argv }) {
550
565
  stackEnv.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
551
566
  }
552
567
  if (serverComponent === 'happy-server') {
553
- const reservedPorts = await collectReservedStackPorts();
554
- reservedPorts.add(port);
555
- const backendPort = await pickNextFreePort(port + 10, { reservedPorts });
556
- reservedPorts.add(backendPort);
557
- const pgPort = await pickNextFreePort(port + 1000, { reservedPorts });
558
- reservedPorts.add(pgPort);
559
- const redisPort = await pickNextFreePort(pgPort + 1, { reservedPorts });
560
- reservedPorts.add(redisPort);
561
- const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts });
562
- reservedPorts.add(minioPort);
563
- const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts });
564
-
568
+ // Persist stable infra credentials in the stack env (ports are ephemeral unless explicitly pinned).
565
569
  const pgUser = 'handy';
566
570
  const pgPassword = randomToken(24);
567
571
  const pgDb = 'handy';
568
- const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
569
-
570
572
  const s3Bucket = sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
571
573
  const s3AccessKey = randomToken(12);
572
574
  const s3SecretKey = randomToken(24);
573
- const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
574
575
 
575
- // Persist infra config in the stack env so restarts are stable/reproducible.
576
576
  stackEnv.HAPPY_STACKS_MANAGED_INFRA = stackEnv.HAPPY_STACKS_MANAGED_INFRA ?? '1';
577
- stackEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
578
- stackEnv.HAPPY_STACKS_PG_PORT = String(pgPort);
579
- stackEnv.HAPPY_STACKS_REDIS_PORT = String(redisPort);
580
- stackEnv.HAPPY_STACKS_MINIO_PORT = String(minioPort);
581
- stackEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
582
577
  stackEnv.HAPPY_STACKS_PG_USER = pgUser;
583
578
  stackEnv.HAPPY_STACKS_PG_PASSWORD = pgPassword;
584
579
  stackEnv.HAPPY_STACKS_PG_DATABASE = pgDb;
585
580
  stackEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE = join(baseDir, 'happy-server', 'handy-master-secret.txt');
586
-
587
- // Vars consumed by happy-server:
588
- stackEnv.DATABASE_URL = databaseUrl;
589
- stackEnv.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
590
- stackEnv.S3_HOST = '127.0.0.1';
591
- stackEnv.S3_PORT = String(minioPort);
592
- stackEnv.S3_USE_SSL = 'false';
593
581
  stackEnv.S3_ACCESS_KEY = s3AccessKey;
594
582
  stackEnv.S3_SECRET_KEY = s3SecretKey;
595
583
  stackEnv.S3_BUCKET = s3Bucket;
596
- stackEnv.S3_PUBLIC_URL = s3PublicUrl;
584
+
585
+ // If user explicitly pinned the server port, also pin the rest of the ports + derived URLs for reproducibility.
586
+ if (port != null) {
587
+ const reservedPorts = await collectReservedStackPorts();
588
+ reservedPorts.add(port);
589
+ const backendPort = await pickNextFreePort(port + 10, { reservedPorts });
590
+ reservedPorts.add(backendPort);
591
+ const pgPort = await pickNextFreePort(port + 1000, { reservedPorts });
592
+ reservedPorts.add(pgPort);
593
+ const redisPort = await pickNextFreePort(pgPort + 1, { reservedPorts });
594
+ reservedPorts.add(redisPort);
595
+ const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts });
596
+ reservedPorts.add(minioPort);
597
+ const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts });
598
+
599
+ const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
600
+ const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
601
+
602
+ stackEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
603
+ stackEnv.HAPPY_STACKS_PG_PORT = String(pgPort);
604
+ stackEnv.HAPPY_STACKS_REDIS_PORT = String(redisPort);
605
+ stackEnv.HAPPY_STACKS_MINIO_PORT = String(minioPort);
606
+ stackEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
607
+
608
+ // Vars consumed by happy-server:
609
+ stackEnv.DATABASE_URL = databaseUrl;
610
+ stackEnv.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
611
+ stackEnv.S3_HOST = '127.0.0.1';
612
+ stackEnv.S3_PORT = String(minioPort);
613
+ stackEnv.S3_USE_SSL = 'false';
614
+ stackEnv.S3_PUBLIC_URL = s3PublicUrl;
615
+ }
597
616
  }
598
617
 
599
618
  // happy
@@ -643,7 +662,8 @@ async function cmdNew({ rootDir, argv }) {
643
662
  }
644
663
 
645
664
  if (copyAuth) {
646
- // Default: inherit main stack auth so creating a new stack doesn't require re-login.
665
+ // Default: inherit seed stack auth so creating a new stack doesn't require re-login.
666
+ // Source: --copy-auth-from (highest), else HAPPY_STACKS_AUTH_SEED_FROM (default: main).
647
667
  // Users can opt out with --no-copy-auth to force a fresh auth / machine identity.
648
668
  await copyAuthFromStackIntoNewStack({
649
669
  fromStackName: copyAuthFrom,
@@ -652,8 +672,9 @@ async function cmdNew({ rootDir, argv }) {
652
672
  serverComponent,
653
673
  json,
654
674
  requireSourceStackExists: kv.has('--copy-auth-from'),
675
+ linkMode: linkAuth,
655
676
  }).catch((err) => {
656
- if (!json) {
677
+ if (!json && emit) {
657
678
  console.warn(`[stack] auth copy skipped: ${err instanceof Error ? err.message : String(err)}`);
658
679
  console.warn(`[stack] tip: you can always run: happys stack auth ${stackName} login`);
659
680
  }
@@ -661,11 +682,20 @@ async function cmdNew({ rootDir, argv }) {
661
682
  }
662
683
 
663
684
  const envPath = await writeStackEnv({ stackName, env: stackEnv });
664
- printResult({
665
- json,
666
- data: { stackName, envPath, port, serverComponent },
667
- text: [`[stack] created ${stackName}`, `[stack] env: ${envPath}`, `[stack] port: ${port}`, `[stack] server: ${serverComponent}`].join('\n'),
668
- });
685
+ const res = { ok: true, stackName, envPath, port: port ?? null, serverComponent, portsMode: port == null ? 'ephemeral' : 'pinned' };
686
+ if (emit) {
687
+ printResult({
688
+ json,
689
+ data: res,
690
+ text: [
691
+ `[stack] created ${stackName}`,
692
+ `[stack] env: ${envPath}`,
693
+ `[stack] port: ${port == null ? 'ephemeral (picked at start)' : String(port)}`,
694
+ `[stack] server: ${serverComponent}`,
695
+ ].join('\n'),
696
+ });
697
+ }
698
+ return res;
669
699
  }
670
700
 
671
701
  async function cmdEdit({ rootDir, argv }) {
@@ -677,7 +707,7 @@ async function cmdEdit({ rootDir, argv }) {
677
707
  throw new Error('[stack] usage: happys stack edit <name> [--interactive]');
678
708
  }
679
709
 
680
- const envPath = getStackEnvPath(stackName);
710
+ const envPath = resolveStackEnvPath(stackName).envPath;
681
711
  const raw = await readExistingEnv(envPath);
682
712
  const existingEnv = parseEnvToObject(raw);
683
713
 
@@ -702,21 +732,19 @@ async function cmdEdit({ rootDir, argv }) {
702
732
  const config = await withRl((rl) => interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }));
703
733
 
704
734
  // Build next env, starting from existing env but enforcing stack-scoped invariants.
705
- const baseDir = getStackDir(stackName);
735
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
706
736
  const uiBuildDir = join(baseDir, 'ui');
707
737
  const cliHomeDir = join(baseDir, 'cli');
708
738
 
709
739
  let port = config.port;
710
- if (!port || !Number.isFinite(port)) {
711
- const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
712
- port = await pickNextFreePort(getDefaultPortStart(), { reservedPorts });
740
+ if (!Number.isFinite(port) || port <= 0) {
741
+ port = null;
713
742
  }
714
743
 
715
744
  const serverComponent = (config.serverComponent || existingEnv.HAPPY_STACKS_SERVER_COMPONENT || existingEnv.HAPPY_LOCAL_SERVER_COMPONENT || 'happy-server-light').trim();
716
745
 
717
746
  const next = {
718
747
  HAPPY_STACKS_STACK: stackName,
719
- HAPPY_STACKS_SERVER_PORT: String(port),
720
748
  HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
721
749
  HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
722
750
  HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
@@ -726,6 +754,9 @@ async function cmdEdit({ rootDir, argv }) {
726
754
  // Always pin defaults; overrides below can replace.
727
755
  ...resolveDefaultComponentDirs({ rootDir }),
728
756
  };
757
+ if (port != null) {
758
+ next.HAPPY_STACKS_SERVER_PORT = String(port);
759
+ }
729
760
 
730
761
  if (serverComponent === 'happy-server-light') {
731
762
  const dataDir = join(baseDir, 'server-light');
@@ -734,52 +765,66 @@ async function cmdEdit({ rootDir, argv }) {
734
765
  next.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
735
766
  }
736
767
  if (serverComponent === 'happy-server') {
737
- const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
738
- reservedPorts.add(port);
739
- const backendPort = existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT?.trim()
740
- ? Number(existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT.trim())
741
- : await pickNextFreePort(port + 10, { reservedPorts });
742
- reservedPorts.add(backendPort);
743
- const pgPort = existingEnv.HAPPY_STACKS_PG_PORT?.trim() ? Number(existingEnv.HAPPY_STACKS_PG_PORT.trim()) : await pickNextFreePort(port + 1000, { reservedPorts });
744
- reservedPorts.add(pgPort);
745
- const redisPort = existingEnv.HAPPY_STACKS_REDIS_PORT?.trim() ? Number(existingEnv.HAPPY_STACKS_REDIS_PORT.trim()) : await pickNextFreePort(pgPort + 1, { reservedPorts });
746
- reservedPorts.add(redisPort);
747
- const minioPort = existingEnv.HAPPY_STACKS_MINIO_PORT?.trim() ? Number(existingEnv.HAPPY_STACKS_MINIO_PORT.trim()) : await pickNextFreePort(redisPort + 1, { reservedPorts });
748
- reservedPorts.add(minioPort);
749
- const minioConsolePort = existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT?.trim()
750
- ? Number(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT.trim())
751
- : await pickNextFreePort(minioPort + 1, { reservedPorts });
752
-
768
+ // Persist stable infra credentials. Ports are ephemeral unless explicitly pinned.
753
769
  const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
754
770
  const pgPassword = (existingEnv.HAPPY_STACKS_PG_PASSWORD ?? '').trim() || randomToken(24);
755
771
  const pgDb = (existingEnv.HAPPY_STACKS_PG_DATABASE ?? 'handy').trim() || 'handy';
756
- const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
757
-
758
- const s3Bucket = (existingEnv.S3_BUCKET ?? sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' })).trim() || sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
772
+ const s3Bucket =
773
+ (existingEnv.S3_BUCKET ?? sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' })).trim() ||
774
+ sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
759
775
  const s3AccessKey = (existingEnv.S3_ACCESS_KEY ?? '').trim() || randomToken(12);
760
776
  const s3SecretKey = (existingEnv.S3_SECRET_KEY ?? '').trim() || randomToken(24);
761
- const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
762
777
 
763
778
  next.HAPPY_STACKS_MANAGED_INFRA = (existingEnv.HAPPY_STACKS_MANAGED_INFRA ?? '1').trim() || '1';
764
- next.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
765
- next.HAPPY_STACKS_PG_PORT = String(pgPort);
766
- next.HAPPY_STACKS_REDIS_PORT = String(redisPort);
767
- next.HAPPY_STACKS_MINIO_PORT = String(minioPort);
768
- next.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
769
779
  next.HAPPY_STACKS_PG_USER = pgUser;
770
780
  next.HAPPY_STACKS_PG_PASSWORD = pgPassword;
771
781
  next.HAPPY_STACKS_PG_DATABASE = pgDb;
772
- next.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE = join(baseDir, 'happy-server', 'handy-master-secret.txt');
773
-
774
- next.DATABASE_URL = databaseUrl;
775
- next.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
776
- next.S3_HOST = '127.0.0.1';
777
- next.S3_PORT = String(minioPort);
778
- next.S3_USE_SSL = 'false';
782
+ next.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE =
783
+ (existingEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(baseDir, 'happy-server', 'handy-master-secret.txt');
779
784
  next.S3_ACCESS_KEY = s3AccessKey;
780
785
  next.S3_SECRET_KEY = s3SecretKey;
781
786
  next.S3_BUCKET = s3Bucket;
782
- next.S3_PUBLIC_URL = s3PublicUrl;
787
+
788
+ if (port != null) {
789
+ // If user pinned the server port, keep ports + derived URLs stable as well.
790
+ const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
791
+ reservedPorts.add(port);
792
+ const backendPort = existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT?.trim()
793
+ ? Number(existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT.trim())
794
+ : await pickNextFreePort(port + 10, { reservedPorts });
795
+ reservedPorts.add(backendPort);
796
+ const pgPort = existingEnv.HAPPY_STACKS_PG_PORT?.trim()
797
+ ? Number(existingEnv.HAPPY_STACKS_PG_PORT.trim())
798
+ : await pickNextFreePort(port + 1000, { reservedPorts });
799
+ reservedPorts.add(pgPort);
800
+ const redisPort = existingEnv.HAPPY_STACKS_REDIS_PORT?.trim()
801
+ ? Number(existingEnv.HAPPY_STACKS_REDIS_PORT.trim())
802
+ : await pickNextFreePort(pgPort + 1, { reservedPorts });
803
+ reservedPorts.add(redisPort);
804
+ const minioPort = existingEnv.HAPPY_STACKS_MINIO_PORT?.trim()
805
+ ? Number(existingEnv.HAPPY_STACKS_MINIO_PORT.trim())
806
+ : await pickNextFreePort(redisPort + 1, { reservedPorts });
807
+ reservedPorts.add(minioPort);
808
+ const minioConsolePort = existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT?.trim()
809
+ ? Number(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT.trim())
810
+ : await pickNextFreePort(minioPort + 1, { reservedPorts });
811
+
812
+ const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
813
+ const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
814
+
815
+ next.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
816
+ next.HAPPY_STACKS_PG_PORT = String(pgPort);
817
+ next.HAPPY_STACKS_REDIS_PORT = String(redisPort);
818
+ next.HAPPY_STACKS_MINIO_PORT = String(minioPort);
819
+ next.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
820
+
821
+ next.DATABASE_URL = databaseUrl;
822
+ next.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
823
+ next.S3_HOST = '127.0.0.1';
824
+ next.S3_PORT = String(minioPort);
825
+ next.S3_USE_SSL = 'false';
826
+ next.S3_PUBLIC_URL = s3PublicUrl;
827
+ }
783
828
  }
784
829
 
785
830
  // Apply selections (create worktrees if needed)
@@ -810,7 +855,230 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
810
855
  await withStackEnv({
811
856
  stackName,
812
857
  extraEnv,
813
- fn: async ({ env }) => {
858
+ fn: async ({ env, envPath, stackEnv, runtimeStatePath, runtimeState }) => {
859
+ const isStartLike = scriptPath === 'dev.mjs' || scriptPath === 'run.mjs';
860
+ if (!isStartLike) {
861
+ await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
862
+ return;
863
+ }
864
+
865
+ const wantsRestart = args.includes('--restart');
866
+ const wantsJson = args.includes('--json');
867
+ const pinnedServerPort = Boolean((stackEnv.HAPPY_STACKS_SERVER_PORT ?? '').trim() || (stackEnv.HAPPY_LOCAL_SERVER_PORT ?? '').trim());
868
+ const serverComponent =
869
+ (stackEnv.HAPPY_STACKS_SERVER_COMPONENT ?? stackEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '').toString().trim() || 'happy-server-light';
870
+ const managedInfra =
871
+ serverComponent === 'happy-server'
872
+ ? ((stackEnv.HAPPY_STACKS_MANAGED_INFRA ?? stackEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0')
873
+ : false;
874
+
875
+ // If this is an ephemeral-port stack and it's already running, avoid spawning a second copy.
876
+ const existingOwnerPid = Number(runtimeState?.ownerPid);
877
+ const existingPort = Number(runtimeState?.ports?.server);
878
+ const existingUiPort = Number(runtimeState?.expo?.webPort);
879
+ const existingPorts =
880
+ runtimeState?.ports && typeof runtimeState.ports === 'object' ? runtimeState.ports : null;
881
+ const wasRunning = isPidAlive(existingOwnerPid);
882
+ // True restart = there was an active runner for this stack. If the stack is not running,
883
+ // `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
884
+ const isTrueRestart = wantsRestart && wasRunning;
885
+ if (wasRunning) {
886
+ if (!wantsRestart) {
887
+ const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
888
+ const uiPart =
889
+ scriptPath === 'dev.mjs' && Number.isFinite(existingUiPort) && existingUiPort > 0 ? ` ui=${existingUiPort}` : '';
890
+ console.log(`[stack] ${stackName}: already running (pid=${existingOwnerPid}${serverPart}${uiPart})`);
891
+
892
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
893
+ const noBrowser =
894
+ args.includes('--no-browser') ||
895
+ (env.HAPPY_STACKS_NO_BROWSER ?? env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
896
+ const openBrowser = isInteractive && !wantsJson && !noBrowser;
897
+
898
+ const host = resolveLocalhostHost({ stackMode: true, stackName });
899
+ const uiUrl =
900
+ scriptPath === 'dev.mjs'
901
+ ? Number.isFinite(existingUiPort) && existingUiPort > 0
902
+ ? `http://${host}:${existingUiPort}`
903
+ : null
904
+ : Number.isFinite(existingPort) && existingPort > 0
905
+ ? `http://${host}:${existingPort}`
906
+ : null;
907
+
908
+ if (uiUrl) {
909
+ console.log(`[stack] ${stackName}: ui: ${uiUrl}`);
910
+ if (openBrowser) {
911
+ await openUrlInBrowser(uiUrl);
912
+ }
913
+ } else if (scriptPath === 'dev.mjs') {
914
+ console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
915
+ }
916
+ return;
917
+ }
918
+ // Restart: stop the existing runner first.
919
+ await killPidOwnedByStack(existingOwnerPid, { stackName, envPath, cliHomeDir: (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').toString(), label: 'runner', json: false });
920
+ // Clear runtime state so we don't keep stale process PIDs; we'll re-create it for the new run below.
921
+ await deleteStackRuntimeStateFile(runtimeStatePath);
922
+ }
923
+
924
+ // Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
925
+ if (!pinnedServerPort) {
926
+ const reserved = await collectReservedStackPorts({ excludeStackName: stackName });
927
+
928
+ // Also avoid ports held by other *running* ephemeral stacks.
929
+ const names = await listAllStackNames();
930
+ for (const n of names) {
931
+ if (n === stackName) continue;
932
+ const p = getStackRuntimeStatePath(n);
933
+ // eslint-disable-next-line no-await-in-loop
934
+ const st = await readStackRuntimeStateFile(p);
935
+ const pid = Number(st?.ownerPid);
936
+ if (!isPidAlive(pid)) continue;
937
+ const ports = st?.ports && typeof st.ports === 'object' ? st.ports : {};
938
+ for (const v of Object.values(ports)) {
939
+ const num = Number(v);
940
+ if (Number.isFinite(num) && num > 0) reserved.add(num);
941
+ }
942
+ }
943
+
944
+ const startPort = getDefaultPortStart();
945
+ const ports = {};
946
+
947
+ const parsePortOrNull = (v) => {
948
+ const n = Number(v);
949
+ return Number.isFinite(n) && n > 0 ? n : null;
950
+ };
951
+ const candidatePorts =
952
+ isTrueRestart && existingPorts
953
+ ? {
954
+ server: parsePortOrNull(existingPorts.server),
955
+ backend: parsePortOrNull(existingPorts.backend),
956
+ pg: parsePortOrNull(existingPorts.pg),
957
+ redis: parsePortOrNull(existingPorts.redis),
958
+ minio: parsePortOrNull(existingPorts.minio),
959
+ minioConsole: parsePortOrNull(existingPorts.minioConsole),
960
+ }
961
+ : null;
962
+
963
+ const canReuse =
964
+ candidatePorts &&
965
+ candidatePorts.server &&
966
+ (serverComponent !== 'happy-server' || candidatePorts.backend) &&
967
+ (!managedInfra ||
968
+ (candidatePorts.pg && candidatePorts.redis && candidatePorts.minio && candidatePorts.minioConsole));
969
+
970
+ if (canReuse) {
971
+ ports.server = candidatePorts.server;
972
+ if (serverComponent === 'happy-server') {
973
+ ports.backend = candidatePorts.backend;
974
+ if (managedInfra) {
975
+ ports.pg = candidatePorts.pg;
976
+ ports.redis = candidatePorts.redis;
977
+ ports.minio = candidatePorts.minio;
978
+ ports.minioConsole = candidatePorts.minioConsole;
979
+ }
980
+ }
981
+
982
+ // Fail-closed if any of the reused ports are unexpectedly occupied (prevents cross-stack collisions).
983
+ const toCheck = Object.values(ports)
984
+ .map((n) => Number(n))
985
+ .filter((n) => Number.isFinite(n) && n > 0);
986
+ for (const p of toCheck) {
987
+ // eslint-disable-next-line no-await-in-loop
988
+ if (!(await isTcpPortFree(p))) {
989
+ throw new Error(
990
+ `[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
991
+ `[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
992
+ );
993
+ }
994
+ }
995
+ } else {
996
+ ports.server = await pickNextFreeTcpPort(startPort, { reservedPorts: reserved });
997
+ reserved.add(ports.server);
998
+
999
+ if (serverComponent === 'happy-server') {
1000
+ ports.backend = await pickNextFreeTcpPort(ports.server + 10, { reservedPorts: reserved });
1001
+ reserved.add(ports.backend);
1002
+ if (managedInfra) {
1003
+ ports.pg = await pickNextFreeTcpPort(ports.server + 1000, { reservedPorts: reserved });
1004
+ reserved.add(ports.pg);
1005
+ ports.redis = await pickNextFreeTcpPort(ports.pg + 1, { reservedPorts: reserved });
1006
+ reserved.add(ports.redis);
1007
+ ports.minio = await pickNextFreeTcpPort(ports.redis + 1, { reservedPorts: reserved });
1008
+ reserved.add(ports.minio);
1009
+ ports.minioConsole = await pickNextFreeTcpPort(ports.minio + 1, { reservedPorts: reserved });
1010
+ reserved.add(ports.minioConsole);
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ // Sanity: if somehow the server port is now occupied, fail closed (avoids killPortListeners nuking random processes).
1016
+ if (!(await isTcpPortFree(Number(ports.server)))) {
1017
+ throw new Error(`[stack] ${stackName}: picked server port ${ports.server} but it is not free`);
1018
+ }
1019
+
1020
+ const childEnv = {
1021
+ ...env,
1022
+ HAPPY_STACKS_EPHEMERAL_PORTS: '1',
1023
+ HAPPY_LOCAL_EPHEMERAL_PORTS: '1',
1024
+ HAPPY_STACKS_SERVER_PORT: String(ports.server),
1025
+ HAPPY_LOCAL_SERVER_PORT: String(ports.server),
1026
+ ...(serverComponent === 'happy-server' && ports.backend
1027
+ ? {
1028
+ HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT: String(ports.backend),
1029
+ HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT: String(ports.backend),
1030
+ }
1031
+ : {}),
1032
+ ...(managedInfra && ports.pg
1033
+ ? {
1034
+ HAPPY_STACKS_PG_PORT: String(ports.pg),
1035
+ HAPPY_LOCAL_PG_PORT: String(ports.pg),
1036
+ HAPPY_STACKS_REDIS_PORT: String(ports.redis),
1037
+ HAPPY_LOCAL_REDIS_PORT: String(ports.redis),
1038
+ HAPPY_STACKS_MINIO_PORT: String(ports.minio),
1039
+ HAPPY_LOCAL_MINIO_PORT: String(ports.minio),
1040
+ HAPPY_STACKS_MINIO_CONSOLE_PORT: String(ports.minioConsole),
1041
+ HAPPY_LOCAL_MINIO_CONSOLE_PORT: String(ports.minioConsole),
1042
+ }
1043
+ : {}),
1044
+ };
1045
+
1046
+ // Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
1047
+ const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
1048
+ cwd: rootDir,
1049
+ env: childEnv,
1050
+ stdio: 'inherit',
1051
+ shell: false,
1052
+ });
1053
+
1054
+ // Record the chosen ports immediately (before the runner finishes booting), so other stack commands
1055
+ // can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
1056
+ await recordStackRuntimeStart(runtimeStatePath, {
1057
+ stackName,
1058
+ script: scriptPath,
1059
+ ephemeral: true,
1060
+ ownerPid: child.pid,
1061
+ ports,
1062
+ }).catch(() => {});
1063
+
1064
+ try {
1065
+ await new Promise((resolvePromise, rejectPromise) => {
1066
+ child.on('error', rejectPromise);
1067
+ child.on('exit', (code, sig) => {
1068
+ if (code === 0) return resolvePromise();
1069
+ return rejectPromise(new Error(`stack ${scriptPath} exited (code=${code ?? 'null'}, sig=${sig ?? 'null'})`));
1070
+ });
1071
+ });
1072
+ } finally {
1073
+ const cur = await readStackRuntimeStateFile(runtimeStatePath);
1074
+ if (Number(cur?.ownerPid) === Number(child.pid)) {
1075
+ await deleteStackRuntimeStateFile(runtimeStatePath);
1076
+ }
1077
+ }
1078
+ return;
1079
+ }
1080
+
1081
+ // Pinned port stack: run normally under the pinned env.
814
1082
  await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
815
1083
  },
816
1084
  });
@@ -836,6 +1104,12 @@ function resolveTransientComponentOverrides({ rootDir, kv }) {
836
1104
  }
837
1105
  }
838
1106
 
1107
+ if (Object.keys(overrides).length > 0) {
1108
+ // Mark these as transient so scripts/utils/env.mjs won't clobber them when it loads the stack env file.
1109
+ overrides.HAPPY_STACKS_TRANSIENT_COMPONENT_OVERRIDES = '1';
1110
+ overrides.HAPPY_LOCAL_TRANSIENT_COMPONENT_OVERRIDES = '1';
1111
+ }
1112
+
839
1113
  return overrides;
840
1114
  }
841
1115
 
@@ -964,26 +1238,8 @@ async function cmdMigrate({ argv }) {
964
1238
  }
965
1239
 
966
1240
  async function cmdListStacks() {
967
- const stacksDir = getStacksStorageRoot();
968
- const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
969
1241
  try {
970
- const namesSet = new Set();
971
- const entries = await readdir(stacksDir, { withFileTypes: true });
972
- for (const e of entries) {
973
- if (!e.isDirectory()) continue;
974
- if (e.name === 'main') continue;
975
- namesSet.add(e.name);
976
- }
977
- try {
978
- const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
979
- for (const e of legacyEntries) {
980
- if (!e.isDirectory()) continue;
981
- namesSet.add(e.name);
982
- }
983
- } catch {
984
- // ignore
985
- }
986
- const names = Array.from(namesSet).sort();
1242
+ const names = (await listAllStackNames()).filter((n) => n !== 'main');
987
1243
  if (!names.length) {
988
1244
  console.log('[stack] no stacks found');
989
1245
  return;
@@ -997,53 +1253,103 @@ async function cmdListStacks() {
997
1253
  }
998
1254
  }
999
1255
 
1000
- async function listAllStackNames() {
1001
- const stacksDir = getStacksStorageRoot();
1002
- const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
1003
- const namesSet = new Set(['main']);
1004
- try {
1005
- const entries = await readdir(stacksDir, { withFileTypes: true });
1006
- for (const e of entries) {
1007
- if (!e.isDirectory()) continue;
1008
- namesSet.add(e.name);
1009
- }
1010
- } catch {
1011
- // ignore
1012
- }
1013
- try {
1014
- const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
1015
- for (const e of legacyEntries) {
1016
- if (!e.isDirectory()) continue;
1017
- namesSet.add(e.name);
1018
- }
1019
- } catch {
1020
- // ignore
1021
- }
1022
- return Array.from(namesSet).sort();
1023
- }
1024
-
1025
- function getEnvValue(obj, key) {
1026
- return (obj?.[key] ?? '').toString().trim();
1027
- }
1028
-
1029
1256
  async function cmdAudit({ rootDir, argv }) {
1030
- const { flags } = parseArgs(argv);
1257
+ const { flags, kv } = parseArgs(argv);
1031
1258
  const json = wantsJson(argv, { flags });
1032
1259
  const fix = flags.has('--fix');
1033
1260
  const fixMain = flags.has('--fix-main');
1261
+ const fixPorts = flags.has('--fix-ports');
1262
+ const fixWorkspace = flags.has('--fix-workspace');
1263
+ const fixPaths = flags.has('--fix-paths');
1264
+ const unpinPorts = flags.has('--unpin-ports');
1265
+ const unpinPortsExceptRaw = (kv.get('--unpin-ports-except') ?? '').trim();
1266
+ const unpinPortsExcept = new Set(
1267
+ unpinPortsExceptRaw
1268
+ .split(',')
1269
+ .map((s) => s.trim())
1270
+ .filter(Boolean)
1271
+ );
1272
+ const wantsEnvRepair = Boolean(fix || fixWorkspace || fixPaths);
1034
1273
 
1035
1274
  const stacks = await listAllStackNames();
1036
1275
 
1037
1276
  const report = [];
1038
1277
  const ports = new Map(); // port -> [stackName]
1278
+ const otherWorkspaceRoot = join(getHappyStacksHomeDir(), 'workspace');
1039
1279
 
1040
1280
  for (const stackName of stacks) {
1041
1281
  const resolved = resolveStackEnvPath(stackName);
1042
1282
  const envPath = resolved.envPath;
1043
1283
  const baseDir = resolved.baseDir;
1044
1284
 
1045
- const raw = await readExistingEnv(envPath);
1046
- const env = parseEnvToObject(raw);
1285
+ let raw = await readExistingEnv(envPath);
1286
+ let env = parseEnvToObject(raw);
1287
+
1288
+ // If the env file is missing/empty, optionally reconstruct a safe baseline env.
1289
+ if (!raw.trim() && wantsEnvRepair && (stackName !== 'main' || fixMain)) {
1290
+ const serverComponent =
1291
+ getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') ||
1292
+ getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') ||
1293
+ 'happy-server-light';
1294
+ const expectedUi = join(baseDir, 'ui');
1295
+ const expectedCli = join(baseDir, 'cli');
1296
+ // Port strategy: main is pinned by convention; non-main stacks default to ephemeral ports.
1297
+ const reservedPorts = stackName === 'main' ? await collectReservedStackPorts({ excludeStackName: stackName }) : new Set();
1298
+ const port = stackName === 'main' ? await pickNextFreePort(getDefaultPortStart(), { reservedPorts }) : null;
1299
+
1300
+ const nextEnv = {
1301
+ HAPPY_STACKS_STACK: stackName,
1302
+ HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
1303
+ HAPPY_STACKS_UI_BUILD_DIR: expectedUi,
1304
+ HAPPY_STACKS_CLI_HOME_DIR: expectedCli,
1305
+ HAPPY_STACKS_STACK_REMOTE: 'upstream',
1306
+ ...resolveDefaultComponentDirs({ rootDir }),
1307
+ };
1308
+ if (port != null) {
1309
+ nextEnv.HAPPY_STACKS_SERVER_PORT = String(port);
1310
+ }
1311
+
1312
+ if (serverComponent === 'happy-server-light') {
1313
+ const dataDir = join(baseDir, 'server-light');
1314
+ nextEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
1315
+ nextEnv.HAPPY_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
1316
+ nextEnv.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
1317
+ }
1318
+
1319
+ await writeStackEnv({ stackName, env: nextEnv });
1320
+ raw = await readExistingEnv(envPath);
1321
+ env = parseEnvToObject(raw);
1322
+ }
1323
+
1324
+ // Optional: unpin ports for non-main stacks (ephemeral port model).
1325
+ if (unpinPorts && stackName !== 'main' && !unpinPortsExcept.has(stackName) && raw.trim()) {
1326
+ const serverComponentTmp =
1327
+ getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1328
+ const remove = [
1329
+ // Always remove pinned public server port.
1330
+ 'HAPPY_STACKS_SERVER_PORT',
1331
+ 'HAPPY_LOCAL_SERVER_PORT',
1332
+ // Happy-server gateway/backend ports.
1333
+ 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
1334
+ 'HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT',
1335
+ // Managed infra ports.
1336
+ 'HAPPY_STACKS_PG_PORT',
1337
+ 'HAPPY_LOCAL_PG_PORT',
1338
+ 'HAPPY_STACKS_REDIS_PORT',
1339
+ 'HAPPY_LOCAL_REDIS_PORT',
1340
+ 'HAPPY_STACKS_MINIO_PORT',
1341
+ 'HAPPY_LOCAL_MINIO_PORT',
1342
+ 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
1343
+ 'HAPPY_LOCAL_MINIO_CONSOLE_PORT',
1344
+ ];
1345
+ if (serverComponentTmp === 'happy-server') {
1346
+ // These are derived from the ports above; safe to re-compute at start time.
1347
+ remove.push('DATABASE_URL', 'REDIS_URL', 'S3_PORT', 'S3_PUBLIC_URL');
1348
+ }
1349
+ await ensureEnvFilePruned({ envPath, removeKeys: remove });
1350
+ raw = await readExistingEnv(envPath);
1351
+ env = parseEnvToObject(raw);
1352
+ }
1047
1353
 
1048
1354
  const serverComponent = getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1049
1355
  const portRaw = getEnvValue(env, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_PORT');
@@ -1066,6 +1372,8 @@ async function cmdAudit({ rootDir, argv }) {
1066
1372
  const expectedUi = join(baseDir, 'ui');
1067
1373
  if (!uiBuildDir) {
1068
1374
  issues.push({ code: 'missing_ui_build_dir', message: `missing UI build dir (expected ${expectedUi})` });
1375
+ } else if (uiBuildDir !== expectedUi) {
1376
+ issues.push({ code: 'ui_build_dir_mismatch', message: `UI build dir points to ${uiBuildDir} (expected ${expectedUi})` });
1069
1377
  }
1070
1378
 
1071
1379
  const stacksCli = getEnvValue(env, 'HAPPY_STACKS_CLI_HOME_DIR');
@@ -1074,6 +1382,8 @@ async function cmdAudit({ rootDir, argv }) {
1074
1382
  const expectedCli = join(baseDir, 'cli');
1075
1383
  if (!cliHomeDir) {
1076
1384
  issues.push({ code: 'missing_cli_home_dir', message: `missing CLI home dir (expected ${expectedCli})` });
1385
+ } else if (cliHomeDir !== expectedCli) {
1386
+ issues.push({ code: 'cli_home_dir_mismatch', message: `CLI home dir points to ${cliHomeDir} (expected ${expectedCli})` });
1077
1387
  }
1078
1388
 
1079
1389
  // Component dirs: require at least server component dir + happy-cli (otherwise stacks can accidentally fall back to some other workspace).
@@ -1090,6 +1400,36 @@ async function cmdAudit({ rootDir, argv }) {
1090
1400
  }
1091
1401
  }
1092
1402
 
1403
+ // Workspace/component dir hygiene checks (best-effort).
1404
+ const componentDirKeys = [
1405
+ { component: 'happy', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY' },
1406
+ { component: 'happy-cli', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI' },
1407
+ { component: 'happy-server-light', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT' },
1408
+ { component: 'happy-server', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER' },
1409
+ ];
1410
+ for (const { component, key } of componentDirKeys) {
1411
+ const legacyKey = key.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
1412
+ const v = getEnvValue(env, key) || getEnvValue(env, legacyKey);
1413
+ if (!v) continue;
1414
+ if (!isAbsolute(v)) {
1415
+ issues.push({ code: 'relative_component_dir', message: `${key} is relative (${v}); prefer absolute paths under this workspace` });
1416
+ continue;
1417
+ }
1418
+ const norm = v.replaceAll('\\', '/');
1419
+ if (norm.startsWith(otherWorkspaceRoot.replaceAll('\\', '/') + '/')) {
1420
+ issues.push({ code: 'foreign_workspace_component_dir', message: `${key} points to another workspace: ${v}` });
1421
+ continue;
1422
+ }
1423
+ const rootNorm = resolve(rootDir).replaceAll('\\', '/') + '/';
1424
+ if (norm.includes('/components/') && !norm.startsWith(rootNorm)) {
1425
+ issues.push({ code: 'external_component_dir', message: `${key} points outside current workspace: ${v}` });
1426
+ }
1427
+ // Optional: fail-closed existence check.
1428
+ if (!existsSync(v)) {
1429
+ issues.push({ code: 'missing_component_path', message: `${key} path does not exist: ${v}` });
1430
+ }
1431
+ }
1432
+
1093
1433
  // Server-light DB/files isolation.
1094
1434
  const isServerLight = serverComponent === 'happy-server-light';
1095
1435
  if (isServerLight) {
@@ -1103,16 +1443,23 @@ async function cmdAudit({ rootDir, argv }) {
1103
1443
  if (!dataDir) issues.push({ code: 'missing_server_light_data_dir', message: `missing HAPPY_SERVER_LIGHT_DATA_DIR (expected ${expectedDataDir})` });
1104
1444
  if (!filesDir) issues.push({ code: 'missing_server_light_files_dir', message: `missing HAPPY_SERVER_LIGHT_FILES_DIR (expected ${expectedFilesDir})` });
1105
1445
  if (!dbUrl) issues.push({ code: 'missing_database_url', message: `missing DATABASE_URL (expected ${expectedDbUrl})` });
1446
+ if (dataDir && dataDir !== expectedDataDir) issues.push({ code: 'server_light_data_dir_mismatch', message: `HAPPY_SERVER_LIGHT_DATA_DIR=${dataDir} (expected ${expectedDataDir})` });
1447
+ if (filesDir && filesDir !== expectedFilesDir) issues.push({ code: 'server_light_files_dir_mismatch', message: `HAPPY_SERVER_LIGHT_FILES_DIR=${filesDir} (expected ${expectedFilesDir})` });
1448
+ if (dbUrl && dbUrl !== expectedDbUrl) issues.push({ code: 'database_url_mismatch', message: `DATABASE_URL=${dbUrl} (expected ${expectedDbUrl})` });
1106
1449
 
1107
1450
  }
1108
1451
 
1109
- // Best-effort env repair (missing keys only).
1110
- if (fix && (stackName !== 'main' || fixMain) && raw.trim()) {
1452
+ // Best-effort env repair (opt-in; non-main stacks only by default).
1453
+ if ((fix || fixWorkspace || fixPaths) && (stackName !== 'main' || fixMain) && raw.trim()) {
1111
1454
  const updates = [];
1112
1455
 
1113
1456
  // Always ensure stack directories are explicitly pinned when missing.
1114
1457
  if (!stacksUi && !localUi) updates.push({ key: 'HAPPY_STACKS_UI_BUILD_DIR', value: expectedUi });
1115
1458
  if (!stacksCli && !localCli) updates.push({ key: 'HAPPY_STACKS_CLI_HOME_DIR', value: expectedCli });
1459
+ if (fixPaths) {
1460
+ if (uiBuildDir && uiBuildDir !== expectedUi) updates.push({ key: 'HAPPY_STACKS_UI_BUILD_DIR', value: expectedUi });
1461
+ if (cliHomeDir && cliHomeDir !== expectedCli) updates.push({ key: 'HAPPY_STACKS_CLI_HOME_DIR', value: expectedCli });
1462
+ }
1116
1463
 
1117
1464
  // Pin component dirs if missing (best-effort).
1118
1465
  if (missingComponentKeys.length) {
@@ -1132,9 +1479,59 @@ async function cmdAudit({ rootDir, argv }) {
1132
1479
  const expectedDataDir = join(baseDir, 'server-light');
1133
1480
  const expectedFilesDir = join(expectedDataDir, 'files');
1134
1481
  const expectedDbUrl = `file:${join(expectedDataDir, 'happy-server-light.sqlite')}`;
1135
- if (!dataDir) updates.push({ key: 'HAPPY_SERVER_LIGHT_DATA_DIR', value: expectedDataDir });
1136
- if (!filesDir) updates.push({ key: 'HAPPY_SERVER_LIGHT_FILES_DIR', value: expectedFilesDir });
1137
- if (!dbUrl) updates.push({ key: 'DATABASE_URL', value: expectedDbUrl });
1482
+ if (!dataDir || (fixPaths && dataDir !== expectedDataDir)) updates.push({ key: 'HAPPY_SERVER_LIGHT_DATA_DIR', value: expectedDataDir });
1483
+ if (!filesDir || (fixPaths && filesDir !== expectedFilesDir)) updates.push({ key: 'HAPPY_SERVER_LIGHT_FILES_DIR', value: expectedFilesDir });
1484
+ if (!dbUrl || (fixPaths && dbUrl !== expectedDbUrl)) updates.push({ key: 'DATABASE_URL', value: expectedDbUrl });
1485
+ }
1486
+
1487
+ if (fixWorkspace) {
1488
+ const otherNorm = otherWorkspaceRoot.replaceAll('\\', '/') + '/';
1489
+ for (const { component, key } of componentDirKeys) {
1490
+ const legacyKey = key.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
1491
+ const current = getEnvValue(env, key) || getEnvValue(env, legacyKey);
1492
+ if (!current) continue;
1493
+
1494
+ let next = current;
1495
+ if (!isAbsolute(next) && next.startsWith('components/')) {
1496
+ next = resolve(rootDir, next);
1497
+ }
1498
+ const norm = next.replaceAll('\\', '/');
1499
+ if (norm.startsWith(otherNorm)) {
1500
+ // Map any path under ~/.happy-stacks/workspace/... back into this repo root.
1501
+ const rel = norm.slice(otherNorm.length);
1502
+ const candidate = resolve(rootDir, rel);
1503
+ if (existsSync(candidate)) {
1504
+ next = candidate;
1505
+ } else if (rel.includes('/components/.worktrees/')) {
1506
+ // Attempt to recreate the referenced worktree inside this workspace.
1507
+ const marker = '/components/.worktrees/';
1508
+ const idx = rel.indexOf(marker);
1509
+ const rest = rel.slice(idx + marker.length); // <component>/<owner>/<slug...>
1510
+ const parts = rest.split('/').filter(Boolean);
1511
+ if (parts.length >= 3) {
1512
+ const comp = parts[0];
1513
+ const owner = parts[1];
1514
+ const slug = parts.slice(2).join('/');
1515
+ const remoteName = owner === 'slopus' ? 'upstream' : 'origin';
1516
+ try {
1517
+ // eslint-disable-next-line no-await-in-loop
1518
+ next = await createWorktree({ rootDir, component: comp, slug, remoteName });
1519
+ } catch {
1520
+ // Fall back to candidate path (even if missing) and let other checks surface it.
1521
+ next = candidate;
1522
+ }
1523
+ } else {
1524
+ next = candidate;
1525
+ }
1526
+ } else {
1527
+ next = candidate;
1528
+ }
1529
+ }
1530
+
1531
+ if (next !== current) {
1532
+ updates.push({ key, value: next });
1533
+ }
1534
+ }
1138
1535
  }
1139
1536
 
1140
1537
  if (updates.length) {
@@ -1155,7 +1552,136 @@ async function cmdAudit({ rootDir, argv }) {
1155
1552
  }
1156
1553
 
1157
1554
  // Port collisions (post-pass)
1555
+ const collisions = [];
1158
1556
  for (const [port, names] of ports.entries()) {
1557
+ if (names.length <= 1) continue;
1558
+ collisions.push({ port, names: Array.from(names) });
1559
+ }
1560
+
1561
+ // Optional: fix collisions by reassigning ports (non-main stacks only by default).
1562
+ if (fixPorts) {
1563
+ const allowMain = Boolean(fixMain);
1564
+ const planned = await collectReservedStackPorts();
1565
+ const byName = new Map(report.map((r) => [r.stackName, r]));
1566
+
1567
+ const parsePg = (url) => {
1568
+ try {
1569
+ const u = new URL(url);
1570
+ const db = u.pathname?.replace(/^\//, '') || '';
1571
+ return {
1572
+ user: decodeURIComponent(u.username || ''),
1573
+ password: decodeURIComponent(u.password || ''),
1574
+ db,
1575
+ host: u.hostname || '127.0.0.1',
1576
+ };
1577
+ } catch {
1578
+ return null;
1579
+ }
1580
+ };
1581
+
1582
+ for (const c of collisions) {
1583
+ const names = c.names.slice().sort();
1584
+ // Keep the first stack stable; reassign others to reduce churn.
1585
+ const keep = names[0];
1586
+ for (const stackName of names.slice(1)) {
1587
+ if (stackName === 'main' && !allowMain) {
1588
+ continue;
1589
+ }
1590
+ const entry = byName.get(stackName);
1591
+ if (!entry) continue;
1592
+ if (!entry.envPath) continue;
1593
+ const raw = await readExistingEnv(entry.envPath);
1594
+ if (!raw.trim()) continue;
1595
+ const env = parseEnvToObject(raw);
1596
+
1597
+ const serverComponent =
1598
+ getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1599
+ const portRaw = getEnvValue(env, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_PORT');
1600
+ const currentPort = portRaw ? Number(portRaw) : NaN;
1601
+ if (Number.isFinite(currentPort) && currentPort > 0) {
1602
+ // Fail-safe: don't rewrite ports for a stack that appears to be actively running.
1603
+ // Otherwise we can strand a running server/daemon on a now-stale port.
1604
+ // eslint-disable-next-line no-await-in-loop
1605
+ const free = await isPortFree(currentPort);
1606
+ if (!free) {
1607
+ entry.issues.push({
1608
+ code: 'port_fix_skipped_running',
1609
+ message: `skipped port reassignment because port ${currentPort} is currently in use (stop the stack and re-run --fix-ports)`,
1610
+ });
1611
+ continue;
1612
+ }
1613
+ }
1614
+ const startFrom = Number.isFinite(currentPort) && currentPort > 0 ? currentPort + 1 : getDefaultPortStart();
1615
+
1616
+ const updates = [];
1617
+ const newServerPort = await pickNextFreePort(startFrom, { reservedPorts: planned });
1618
+ planned.add(newServerPort);
1619
+ updates.push({ key: 'HAPPY_STACKS_SERVER_PORT', value: String(newServerPort) });
1620
+
1621
+ if (serverComponent === 'happy-server') {
1622
+ planned.add(newServerPort);
1623
+ const backendPort = await pickNextFreePort(newServerPort + 10, { reservedPorts: planned });
1624
+ planned.add(backendPort);
1625
+ const pgPort = await pickNextFreePort(newServerPort + 1000, { reservedPorts: planned });
1626
+ planned.add(pgPort);
1627
+ const redisPort = await pickNextFreePort(pgPort + 1, { reservedPorts: planned });
1628
+ planned.add(redisPort);
1629
+ const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts: planned });
1630
+ planned.add(minioPort);
1631
+ const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts: planned });
1632
+ planned.add(minioConsolePort);
1633
+
1634
+ updates.push({ key: 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT', value: String(backendPort) });
1635
+ updates.push({ key: 'HAPPY_STACKS_PG_PORT', value: String(pgPort) });
1636
+ updates.push({ key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) });
1637
+ updates.push({ key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) });
1638
+ updates.push({ key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) });
1639
+
1640
+ // Update URLs while preserving existing credentials.
1641
+ const pgUser = getEnvValue(env, 'HAPPY_STACKS_PG_USER') || 'handy';
1642
+ const pgPassword = getEnvValue(env, 'HAPPY_STACKS_PG_PASSWORD') || '';
1643
+ const pgDb = getEnvValue(env, 'HAPPY_STACKS_PG_DATABASE') || 'handy';
1644
+ let user = pgUser;
1645
+ let pass = pgPassword;
1646
+ let db = pgDb;
1647
+ const parsed = parsePg(getEnvValue(env, 'DATABASE_URL'));
1648
+ if (parsed) {
1649
+ if (parsed.user) user = parsed.user;
1650
+ if (parsed.password) pass = parsed.password;
1651
+ if (parsed.db) db = parsed.db;
1652
+ }
1653
+ const databaseUrl = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@127.0.0.1:${pgPort}/${encodeURIComponent(db)}`;
1654
+ updates.push({ key: 'DATABASE_URL', value: databaseUrl });
1655
+ updates.push({ key: 'REDIS_URL', value: `redis://127.0.0.1:${redisPort}` });
1656
+ updates.push({ key: 'S3_PORT', value: String(minioPort) });
1657
+ const bucket = getEnvValue(env, 'S3_BUCKET') || sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
1658
+ updates.push({ key: 'S3_PUBLIC_URL', value: `http://127.0.0.1:${minioPort}/${bucket}` });
1659
+ }
1660
+
1661
+ await ensureEnvFileUpdated({ envPath: entry.envPath, updates });
1662
+
1663
+ // Update in-memory report for follow-up collision recomputation.
1664
+ entry.serverPort = newServerPort;
1665
+ entry.issues.push({ code: 'port_reassigned', message: `server port reassigned -> ${newServerPort} (was ${currentPort || 'unknown'})` });
1666
+ }
1667
+ // Ensure the "kept" one remains reserved in planned as well.
1668
+ const keptEntry = byName.get(keep);
1669
+ if (keptEntry?.serverPort) planned.add(keptEntry.serverPort);
1670
+ }
1671
+ }
1672
+
1673
+ // Recompute port collisions after optional fixes.
1674
+ for (const r of report) {
1675
+ r.issues = (r.issues ?? []).filter((i) => i.code !== 'port_collision');
1676
+ }
1677
+ const portsNow = new Map();
1678
+ for (const r of report) {
1679
+ if (!Number.isFinite(r.serverPort) || r.serverPort == null) continue;
1680
+ const existing = portsNow.get(r.serverPort) ?? [];
1681
+ existing.push(r.stackName);
1682
+ portsNow.set(r.serverPort, existing);
1683
+ }
1684
+ for (const [port, names] of portsNow.entries()) {
1159
1685
  if (names.length <= 1) continue;
1160
1686
  for (const r of report) {
1161
1687
  if (r.serverPort === port) {
@@ -1166,7 +1692,7 @@ async function cmdAudit({ rootDir, argv }) {
1166
1692
 
1167
1693
  const out = {
1168
1694
  ok: true,
1169
- fixed: fix,
1695
+ fixed: Boolean(fix || fixPorts || fixWorkspace || fixPaths || unpinPorts),
1170
1696
  stacks: report,
1171
1697
  summary: {
1172
1698
  total: report.length,
@@ -1198,6 +1724,863 @@ async function cmdAudit({ rootDir, argv }) {
1198
1724
  }
1199
1725
  }
1200
1726
 
1727
+ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1728
+ const { flags, kv } = parseArgs(argv);
1729
+ const json = wantsJson(argv, { flags });
1730
+
1731
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1732
+ const name = (positionals[1] ?? '').trim() || 'dev-auth';
1733
+ const serverComponent = (kv.get('--server') ?? '').trim() || 'happy-server-light';
1734
+ const interactive = !flags.has('--non-interactive') && (flags.has('--interactive') || isTty());
1735
+
1736
+ if (json) {
1737
+ // Keep JSON mode non-interactive and stable by using the existing stack command output.
1738
+ // (We intentionally don't run the guided login flow in JSON mode.)
1739
+ const createArgs = ['new', name, '--no-copy-auth', '--server', serverComponent, '--json'];
1740
+ const created = await runCapture(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), ...createArgs], { cwd: rootDir, env: process.env }).catch((e) => {
1741
+ throw new Error(
1742
+ `[stack] create-dev-auth-seed: failed to create auth seed stack "${name}": ${e instanceof Error ? e.message : String(e)}`
1743
+ );
1744
+ });
1745
+
1746
+ printResult({
1747
+ json,
1748
+ data: {
1749
+ ok: true,
1750
+ seedStack: name,
1751
+ serverComponent,
1752
+ created: created.trim() ? JSON.parse(created.trim()) : { ok: true },
1753
+ next: {
1754
+ login: `happys stack auth ${name} login`,
1755
+ setEnv: `# add to ${getHomeEnvLocalPath()}:\nHAPPY_STACKS_AUTH_SEED_FROM=${name}\nHAPPY_STACKS_AUTO_AUTH_SEED=1`,
1756
+ reseedAll: `happys auth copy-from ${name} --all --except=main,${name}`,
1757
+ },
1758
+ },
1759
+ });
1760
+ return;
1761
+ }
1762
+
1763
+ // Create the seed stack as fresh auth (no copy) so it doesn't share main identity.
1764
+ // IMPORTANT: do this in-process (no recursive spawn) so the env file is definitely written
1765
+ // before we run any guided steps (withStackEnv/login).
1766
+ if (!stackExistsSync(name)) {
1767
+ await cmdNew({
1768
+ rootDir,
1769
+ argv: [name, '--no-copy-auth', '--server', serverComponent],
1770
+ });
1771
+ } else {
1772
+ console.log(`[stack] auth seed stack already exists: ${name}`);
1773
+ }
1774
+
1775
+ if (!stackExistsSync(name)) {
1776
+ throw new Error(`[stack] create-dev-auth-seed: expected stack "${name}" to exist after creation, but it does not`);
1777
+ }
1778
+
1779
+ // Interactive convenience: guide login first, then configure env.local + store dev key.
1780
+ if (interactive) {
1781
+ await withRl(async (rl) => {
1782
+ let savedDevKey = false;
1783
+ const wantLoginRaw = (await prompt(
1784
+ rl,
1785
+ `Run guided login now? (starts the seed server temporarily for this stack) (Y/n): `,
1786
+ { defaultValue: 'y' }
1787
+ ))
1788
+ .trim()
1789
+ .toLowerCase();
1790
+ const wantLogin = wantLoginRaw === 'y' || wantLoginRaw === 'yes' || wantLoginRaw === '';
1791
+
1792
+ if (wantLogin) {
1793
+ console.log('');
1794
+ console.log(`[stack] starting ${serverComponent} temporarily so we can log in...`);
1795
+
1796
+ const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
1797
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
1798
+ const publicServerUrl = `http://localhost:${serverPort}`;
1799
+
1800
+ const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
1801
+ const children = [];
1802
+
1803
+ await withStackEnv({
1804
+ stackName: name,
1805
+ extraEnv: {
1806
+ // Make sure stack auth login uses the same port we just picked, and avoid inheriting
1807
+ // any global/public URL (e.g. main stack’s Tailscale URL) for this guided flow.
1808
+ HAPPY_STACKS_SERVER_PORT: String(serverPort),
1809
+ HAPPY_LOCAL_SERVER_PORT: String(serverPort),
1810
+ HAPPY_STACKS_SERVER_URL: '',
1811
+ HAPPY_LOCAL_SERVER_URL: '',
1812
+ },
1813
+ fn: async ({ env }) => {
1814
+ const serverDir =
1815
+ serverComponent === 'happy-server'
1816
+ ? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
1817
+ : env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT;
1818
+ const resolvedServerDir = serverDir || getComponentDir(rootDir, serverComponent);
1819
+ const resolvedCliDir = env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI || getComponentDir(rootDir, 'happy-cli');
1820
+ const resolvedUiDir = env.HAPPY_STACKS_COMPONENT_DIR_HAPPY || getComponentDir(rootDir, 'happy');
1821
+
1822
+ await requireDir(serverComponent, resolvedServerDir);
1823
+ await requireDir('happy-cli', resolvedCliDir);
1824
+ await requireDir('happy', resolvedUiDir);
1825
+
1826
+ let serverProc = null;
1827
+ let uiProc = null;
1828
+ try {
1829
+ const started = await startDevServer({
1830
+ serverComponentName: serverComponent,
1831
+ serverDir: resolvedServerDir,
1832
+ autostart,
1833
+ baseEnv: env,
1834
+ serverPort,
1835
+ internalServerUrl,
1836
+ publicServerUrl,
1837
+ envPath: env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '',
1838
+ stackMode: true,
1839
+ runtimeStatePath: null,
1840
+ serverAlreadyRunning: false,
1841
+ restart: true,
1842
+ children,
1843
+ spawnOptions: { stdio: 'ignore' },
1844
+ });
1845
+ serverProc = started.serverProc;
1846
+
1847
+ // Start Expo web UI so /terminal/connect exists for happy-cli web auth.
1848
+ const uiRes = await startDevExpoWebUi({
1849
+ startUi: true,
1850
+ uiDir: resolvedUiDir,
1851
+ autostart,
1852
+ baseEnv: env,
1853
+ // In the browser, prefer localhost for API calls.
1854
+ apiServerUrl: publicServerUrl,
1855
+ restart: false,
1856
+ stackMode: true,
1857
+ runtimeStatePath: null,
1858
+ stackName: name,
1859
+ envPath: env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '',
1860
+ children,
1861
+ spawnOptions: { stdio: 'ignore' },
1862
+ });
1863
+ if (uiRes?.skipped === false && uiRes.proc) {
1864
+ uiProc = uiRes.proc;
1865
+ }
1866
+
1867
+ console.log('');
1868
+ const uiHost = resolveLocalhostHost({ stackMode: true, stackName: name });
1869
+ const uiPort = uiRes?.port;
1870
+ const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
1871
+ const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
1872
+ const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
1873
+
1874
+ console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
1875
+ if (uiRoot) {
1876
+ console.log(`[stack] waiting for UI to be ready...`);
1877
+ // Prefer localhost for readiness checks (faster/more reliable), even though we
1878
+ // instruct the user to use the stack-scoped *.localhost origin for storage isolation.
1879
+ await waitForHttpOk(uiRootLocalhost || uiRoot, { timeoutMs: 30_000 });
1880
+ console.log(`- open: ${uiRoot}`);
1881
+ console.log(`- click: "Create Account"`);
1882
+ console.log(`- then open: ${uiSettings}`);
1883
+ console.log(`- tap: "Secret Key" to reveal + copy it`);
1884
+ } else {
1885
+ console.log(`- UI is running but the port was not detected; rerun with DEBUG logs if needed`);
1886
+ }
1887
+ await prompt(rl, `Press Enter once you've created the account in the UI... `);
1888
+
1889
+ console.log('');
1890
+ console.log('[stack] step 2/3: save the dev key locally (for agents / Playwright)');
1891
+ const keyInput = (await prompt(
1892
+ rl,
1893
+ `Paste the Secret Key now (from Settings → Account → Secret Key). Leave empty to skip: `
1894
+ )).trim();
1895
+ if (keyInput) {
1896
+ const res = await writeDevAuthKey({ env: process.env, input: keyInput });
1897
+ savedDevKey = true;
1898
+ console.log(`[stack] dev key saved: ${res.path}`);
1899
+ } else {
1900
+ console.log(`[stack] dev key not saved; you can do it later with: happys auth dev-key --set="<key>"`);
1901
+ }
1902
+
1903
+ console.log('');
1904
+ console.log('[stack] step 3/3: authenticate the CLI against this stack (web auth)');
1905
+ console.log(`[stack] launching: happys stack auth ${name} login`);
1906
+ await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), 'login', '--no-force'], {
1907
+ cwd: rootDir,
1908
+ env,
1909
+ });
1910
+ } finally {
1911
+ if (uiProc) {
1912
+ console.log('');
1913
+ console.log(`[stack] stopping temporary UI (pid=${uiProc.pid})...`);
1914
+ killProcessTree(uiProc, 'SIGINT');
1915
+ await Promise.race([
1916
+ new Promise((resolve) => uiProc.on('exit', resolve)),
1917
+ new Promise((resolve) => setTimeout(resolve, 15_000)),
1918
+ ]);
1919
+ }
1920
+ if (serverProc) {
1921
+ console.log('');
1922
+ console.log(`[stack] stopping temporary server (pid=${serverProc.pid})...`);
1923
+ killProcessTree(serverProc, 'SIGINT');
1924
+ await Promise.race([
1925
+ new Promise((resolve) => serverProc.on('exit', resolve)),
1926
+ new Promise((resolve) => setTimeout(resolve, 15_000)),
1927
+ ]);
1928
+ }
1929
+ }
1930
+ },
1931
+ });
1932
+
1933
+ console.log('');
1934
+ console.log('[stack] login step complete.');
1935
+ } else {
1936
+ console.log(`[stack] skipping guided login. You can do it later with: happys stack auth ${name} login`);
1937
+ }
1938
+
1939
+ const wantEnvRaw = (await prompt(
1940
+ rl,
1941
+ `Set this as the default auth seed (writes ${getHomeEnvLocalPath()})? (Y/n): `,
1942
+ { defaultValue: 'y' }
1943
+ ))
1944
+ .trim()
1945
+ .toLowerCase();
1946
+ const wantEnv = wantEnvRaw === 'y' || wantEnvRaw === 'yes' || wantEnvRaw === '';
1947
+ if (wantEnv) {
1948
+ const envLocalPath = getHomeEnvLocalPath();
1949
+ await ensureEnvFileUpdated({
1950
+ envPath: envLocalPath,
1951
+ updates: [
1952
+ { key: 'HAPPY_STACKS_AUTH_SEED_FROM', value: name },
1953
+ { key: 'HAPPY_STACKS_AUTO_AUTH_SEED', value: '1' },
1954
+ ],
1955
+ });
1956
+ console.log(`[stack] updated: ${envLocalPath}`);
1957
+ } else {
1958
+ console.log(`[stack] tip: set in ${getHomeEnvLocalPath()}: HAPPY_STACKS_AUTH_SEED_FROM=${name} and HAPPY_STACKS_AUTO_AUTH_SEED=1`);
1959
+ }
1960
+
1961
+ if (!savedDevKey) {
1962
+ const wantKey = (await prompt(rl, `Save the dev auth key for Playwright/UI logins now? (y/N): `)).trim().toLowerCase();
1963
+ if (wantKey === 'y' || wantKey === 'yes') {
1964
+ console.log(`[stack] paste the secret key (base64url OR backup-format like XXXXX-XXXXX-...):`);
1965
+ const input = (await prompt(rl, `dev key: `)).trim();
1966
+ if (input) {
1967
+ try {
1968
+ const res = await writeDevAuthKey({ env: process.env, input });
1969
+ console.log(`[stack] dev key saved: ${res.path}`);
1970
+ } catch (e) {
1971
+ console.warn(`[stack] dev key not saved: ${e instanceof Error ? e.message : String(e)}`);
1972
+ }
1973
+ } else {
1974
+ console.log('[stack] dev key not provided; skipping');
1975
+ }
1976
+ } else {
1977
+ console.log(`[stack] tip: you can set it later with: happys auth dev-key --set="<key>"`);
1978
+ }
1979
+ }
1980
+ });
1981
+ } else {
1982
+ console.log(`- set as default seed (recommended) in ${getHomeEnvLocalPath()}:`);
1983
+ console.log(` HAPPY_STACKS_AUTH_SEED_FROM=${name}`);
1984
+ console.log(` HAPPY_STACKS_AUTO_AUTH_SEED=1`);
1985
+ console.log(`- (optional) seed existing stacks: happys auth copy-from ${name} --all --except=main,${name}`);
1986
+ console.log(`- (optional) store dev key for UI automation: happys auth dev-key --set="<key>"`);
1987
+ }
1988
+ }
1989
+
1990
+ function parseServerComponentFromEnv(env) {
1991
+ const v =
1992
+ (env.HAPPY_STACKS_SERVER_COMPONENT ?? env.HAPPY_LOCAL_SERVER_COMPONENT ?? '').toString().trim() ||
1993
+ 'happy-server-light';
1994
+ return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
1995
+ }
1996
+
1997
+ async function readStackEnvObject(stackName) {
1998
+ const envPath = resolveStackEnvPath(stackName).envPath;
1999
+ const raw = await readExistingEnv(envPath);
2000
+ const env = raw ? parseEnvToObject(raw) : {};
2001
+ return { envPath, env };
2002
+ }
2003
+
2004
+ function envKeyForComponentDir({ serverComponent, component }) {
2005
+ if (component === 'happy') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY';
2006
+ if (component === 'happy-cli') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI';
2007
+ if (component === 'happy-server') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER';
2008
+ if (component === 'happy-server-light') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT';
2009
+ // Fallback; caller should not use.
2010
+ return `HAPPY_STACKS_COMPONENT_DIR_${component.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
2011
+ }
2012
+
2013
+ async function cmdDuplicate({ rootDir, argv }) {
2014
+ const { flags, kv } = parseArgs(argv);
2015
+ const json = wantsJson(argv, { flags });
2016
+
2017
+ const positionals = argv.filter((a) => !a.startsWith('--'));
2018
+ const fromStack = (positionals[1] ?? '').trim();
2019
+ const toStack = (positionals[2] ?? '').trim();
2020
+ if (!fromStack || !toStack) {
2021
+ throw new Error('[stack] usage: happys stack duplicate <from> <to> [--duplicate-worktrees] [--deps=...] [--json]');
2022
+ }
2023
+ if (toStack === 'main') {
2024
+ throw new Error('[stack] refusing to duplicate into stack name "main"');
2025
+ }
2026
+ if (!stackExistsSync(fromStack)) {
2027
+ throw new Error(`[stack] duplicate: source stack does not exist: ${fromStack}`);
2028
+ }
2029
+ if (stackExistsSync(toStack)) {
2030
+ throw new Error(`[stack] duplicate: destination stack already exists: ${toStack}`);
2031
+ }
2032
+
2033
+ const duplicateWorktrees =
2034
+ flags.has('--duplicate-worktrees') ||
2035
+ flags.has('--with-worktrees') ||
2036
+ (kv.get('--duplicate-worktrees') ?? '').trim() === '1';
2037
+ const depsMode = (kv.get('--deps') ?? '').trim(); // forwarded to wt new when duplicating worktrees
2038
+
2039
+ const { env: fromEnv } = await readStackEnvObject(fromStack);
2040
+ const serverComponent = parseServerComponentFromEnv(fromEnv);
2041
+
2042
+ // Create the destination stack env with the correct baseDir and defaults (do not copy auth/data).
2043
+ await cmdNew({
2044
+ rootDir,
2045
+ argv: [toStack, '--no-copy-auth', '--server', serverComponent],
2046
+ });
2047
+
2048
+ // Build component dir updates (copy overrides; optionally duplicate worktrees).
2049
+ // Copy all component directory overrides, not just the currently-selected server flavor.
2050
+ // This keeps the duplicated stack fully self-contained even if you later switch server flavor.
2051
+ const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
2052
+
2053
+ const updates = [];
2054
+ for (const component of components) {
2055
+ const key = envKeyForComponentDir({ serverComponent, component });
2056
+ const legacyKey = key.replace('HAPPY_STACKS_', 'HAPPY_LOCAL_');
2057
+ const rawDir = (fromEnv[key] ?? fromEnv[legacyKey] ?? '').toString().trim();
2058
+ if (!rawDir) continue;
2059
+
2060
+ let nextDir = rawDir;
2061
+ if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
2062
+ const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir });
2063
+ if (spec) {
2064
+ const [owner, ...restParts] = spec.split('/').filter(Boolean);
2065
+ const rest = restParts.join('/');
2066
+ const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
2067
+
2068
+ const repoDir = join(getComponentsDir(rootDir), component);
2069
+ const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
2070
+ // Base on the existing worktree's HEAD/branch so we get the same commit.
2071
+ nextDir = await createWorktreeFromBaseWorktree({
2072
+ rootDir,
2073
+ component,
2074
+ slug,
2075
+ baseWorktreeSpec: spec,
2076
+ remoteName,
2077
+ depsMode,
2078
+ });
2079
+ }
2080
+ }
2081
+
2082
+ updates.push({ key, value: nextDir });
2083
+ }
2084
+
2085
+ // Apply component dir overrides to the destination stack env file.
2086
+ const toEnvPath = resolveStackEnvPath(toStack).envPath;
2087
+ if (updates.length) {
2088
+ await ensureEnvFileUpdated({ envPath: toEnvPath, updates });
2089
+ }
2090
+
2091
+ const out = {
2092
+ ok: true,
2093
+ from: fromStack,
2094
+ to: toStack,
2095
+ serverComponent,
2096
+ duplicatedWorktrees: duplicateWorktrees,
2097
+ updatedKeys: updates.map((u) => u.key),
2098
+ envPath: toEnvPath,
2099
+ };
2100
+
2101
+ if (json) {
2102
+ printResult({ json, data: out });
2103
+ return;
2104
+ }
2105
+
2106
+ console.log(`[stack] duplicated: ${fromStack} -> ${toStack}`);
2107
+ console.log(`[stack] env: ${toEnvPath}`);
2108
+ if (duplicateWorktrees) {
2109
+ console.log(`[stack] worktrees: duplicated (deps=${depsMode || 'none'})`);
2110
+ } else {
2111
+ console.log('[stack] worktrees: not duplicated (reusing existing component dirs)');
2112
+ }
2113
+ }
2114
+
2115
+ async function cmdInfo({ rootDir, argv }) {
2116
+ const { flags } = parseArgs(argv);
2117
+ const json = wantsJson(argv, { flags });
2118
+ const positionals = argv.filter((a) => !a.startsWith('--'));
2119
+ const stackName = (positionals[1] ?? '').trim();
2120
+ if (!stackName) {
2121
+ throw new Error('[stack] usage: happys stack info <name> [--json]');
2122
+ }
2123
+ if (!stackExistsSync(stackName)) {
2124
+ throw new Error(`[stack] info: stack does not exist: ${stackName}`);
2125
+ }
2126
+
2127
+ const out = await cmdInfoInternal({ rootDir, stackName });
2128
+ if (json) {
2129
+ printResult({ json, data: out });
2130
+ return;
2131
+ }
2132
+
2133
+ console.log(`[stack] info: ${stackName}`);
2134
+ console.log(`- env: ${out.envPath}`);
2135
+ console.log(`- runtime: ${out.runtimeStatePath}`);
2136
+ console.log(`- server: ${out.serverComponent}`);
2137
+ console.log(`- running: ${out.runtime.running ? 'yes' : 'no'}${out.runtime.ownerPid ? ` (pid=${out.runtime.ownerPid})` : ''}`);
2138
+ if (out.ports.server) console.log(`- port: server=${out.ports.server}${out.ports.backend ? ` backend=${out.ports.backend}` : ''}`);
2139
+ if (out.ports.ui) console.log(`- port: ui=${out.ports.ui}`);
2140
+ if (out.urls.uiUrl) console.log(`- ui: ${out.urls.uiUrl}`);
2141
+ if (out.urls.internalServerUrl) console.log(`- internal: ${out.urls.internalServerUrl}`);
2142
+ if (out.pinned.serverPort) console.log(`- pinned: serverPort=${out.pinned.serverPort}`);
2143
+ console.log('- components:');
2144
+ for (const c of out.components) {
2145
+ console.log(` - ${c.component}: ${c.dir}${c.worktreeSpec ? ` (${c.worktreeSpec})` : ''}`);
2146
+ }
2147
+ }
2148
+
2149
+ async function cmdPrStack({ rootDir, argv }) {
2150
+ // Supports passing args to the eventual `stack dev/start` via `-- ...`.
2151
+ const sep = argv.indexOf('--');
2152
+ const argv0 = sep >= 0 ? argv.slice(0, sep) : argv;
2153
+ const passthrough = sep >= 0 ? argv.slice(sep + 1) : [];
2154
+
2155
+ const { flags, kv } = parseArgs(argv0);
2156
+ const json = wantsJson(argv0, { flags });
2157
+
2158
+ if (wantsHelp(argv0, { flags })) {
2159
+ printResult({
2160
+ json,
2161
+ data: {
2162
+ usage:
2163
+ 'happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--server=happy-server|happy-server-light] [--remote=upstream] [--deps=none|link|install|link-or-install] [--seed-auth] [--copy-auth-from=<stack>] [--with-infra] [--auth-force] [--dev|--start] [--json] [-- <stack dev/start args...>]',
2164
+ },
2165
+ text: [
2166
+ '[stack] usage:',
2167
+ ' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start]',
2168
+ ' [--seed-auth] [--copy-auth-from=<stack|legacy>] [--link-auth] [--with-infra] [--auth-force]',
2169
+ ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force]',
2170
+ ' [--json] [-- <stack dev/start args...>]',
2171
+ '',
2172
+ 'examples:',
2173
+ ' # Create stack + check out PRs + start dev UI',
2174
+ ' happys stack pr pr123 \\',
2175
+ ' --happy=https://github.com/slopus/happy/pull/123 \\',
2176
+ ' --happy-cli=https://github.com/slopus/happy-cli/pull/456 \\',
2177
+ ' --seed-auth --copy-auth-from=dev-auth \\',
2178
+ ' --dev',
2179
+ '',
2180
+ ' # Use numeric PR refs (remote defaults to upstream)',
2181
+ ' happys stack pr pr123 --happy=123 --happy-cli=456 --seed-auth --copy-auth-from=dev-auth --dev',
2182
+ '',
2183
+ ' # Reuse an existing non-stacks Happy install for auth seeding',
2184
+ ' happys stack pr pr123 --happy=123 --seed-auth --copy-auth-from=legacy --link-auth --dev',
2185
+ '',
2186
+ 'notes:',
2187
+ ' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
2188
+ ' - For auth seeding, pass `--seed-auth` and optionally `--copy-auth-from=dev-auth` (or legacy/main)',
2189
+ ' - `--link-auth` symlinks auth files instead of copying (keeps credentials in sync, but reduces isolation)',
2190
+ ].join('\n'),
2191
+ });
2192
+ return;
2193
+ }
2194
+
2195
+ const positionals = argv0.filter((a) => !a.startsWith('--'));
2196
+ const stackName = (positionals[1] ?? '').trim();
2197
+ if (!stackName) {
2198
+ throw new Error('[stack] pr: missing stack name. Usage: happys stack pr <name> --happy=<pr>');
2199
+ }
2200
+ if (stackName === 'main') {
2201
+ throw new Error('[stack] pr: stack name "main" is reserved; pick a unique name for this PR stack');
2202
+ }
2203
+ const reuseExisting = flags.has('--reuse') || flags.has('--update-existing') || (kv.get('--reuse') ?? '').trim() === '1';
2204
+ const stackExists = stackExistsSync(stackName);
2205
+ if (stackExists && !reuseExisting) {
2206
+ throw new Error(
2207
+ `[stack] pr: stack already exists: ${stackName}\n` +
2208
+ `[stack] tip: re-run with --reuse to update the existing PR worktrees and keep the stack wiring intact`
2209
+ );
2210
+ }
2211
+
2212
+ const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
2213
+ const depsMode = (kv.get('--deps') ?? '').trim();
2214
+
2215
+ const prHappy = (kv.get('--happy') ?? '').trim();
2216
+ const prCli = (kv.get('--happy-cli') ?? '').trim();
2217
+ const prServerLight = (kv.get('--happy-server-light') ?? '').trim();
2218
+ const prServer = (kv.get('--happy-server') ?? '').trim();
2219
+
2220
+ if (!prHappy && !prCli && !prServerLight && !prServer) {
2221
+ throw new Error(
2222
+ '[stack] pr: missing PR inputs. Provide at least one of: --happy, --happy-cli, --happy-server-light, --happy-server'
2223
+ );
2224
+ }
2225
+ if (prServerLight && prServer) {
2226
+ throw new Error('[stack] pr: cannot specify both --happy-server and --happy-server-light');
2227
+ }
2228
+
2229
+ const serverFromArg = (kv.get('--server') ?? '').trim();
2230
+ const inferredServer = prServer ? 'happy-server' : prServerLight ? 'happy-server-light' : '';
2231
+ const serverComponent = (serverFromArg || inferredServer || 'happy-server-light').trim();
2232
+ if (serverComponent !== 'happy-server' && serverComponent !== 'happy-server-light') {
2233
+ throw new Error(`[stack] pr: invalid --server: ${serverFromArg || serverComponent}`);
2234
+ }
2235
+
2236
+ const wantsDev = flags.has('--dev') || flags.has('--start-dev');
2237
+ const wantsStart = flags.has('--start') || flags.has('--prod');
2238
+ if (wantsDev && wantsStart) {
2239
+ throw new Error('[stack] pr: choose either --dev or --start (not both)');
2240
+ }
2241
+
2242
+ const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
2243
+ const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
2244
+ const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
2245
+ const authForce = flags.has('--auth-force') || flags.has('--force-auth');
2246
+ const authLinkFlag = flags.has('--link-auth') || flags.has('--link') || flags.has('--symlink-auth') ? true : null;
2247
+ const authLinkEnv =
2248
+ (process.env.HAPPY_STACKS_AUTH_LINK ?? process.env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
2249
+ (process.env.HAPPY_STACKS_AUTH_MODE ?? process.env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
2250
+
2251
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !json;
2252
+
2253
+ const mainAccessKeyPath = join(resolveStackEnvPath('main').baseDir, 'cli', 'access.key');
2254
+ const legacyAccessKeyPath = join(getLegacyHappyBaseDir(), 'cli', 'access.key');
2255
+ const devAuthAccessKeyPath = join(resolveStackEnvPath('dev-auth').baseDir, 'cli', 'access.key');
2256
+
2257
+ const hasMainAccessKey = existsSync(mainAccessKeyPath);
2258
+ const allowGlobal = sandboxAllowsGlobalSideEffects();
2259
+ const hasLegacyAccessKey = (!isSandboxed() || allowGlobal) && existsSync(legacyAccessKeyPath);
2260
+ const hasDevAuthAccessKey = existsSync(devAuthAccessKeyPath) && existsSync(resolveStackEnvPath('dev-auth').envPath);
2261
+
2262
+ const inferredSeedFromEnv = resolveAuthSeedFromEnv(process.env);
2263
+ const inferredSeedFromAvailability = hasDevAuthAccessKey ? 'dev-auth' : hasMainAccessKey ? 'main' : hasLegacyAccessKey ? 'legacy' : 'main';
2264
+ const defaultAuthFrom = authFromFlag || inferredSeedFromEnv || inferredSeedFromAvailability;
2265
+
2266
+ // Default behavior for stack pr:
2267
+ // - if user explicitly flags --seed-auth/--no-seed-auth, obey
2268
+ // - otherwise in interactive mode: prompt when we have *some* plausible source, default yes
2269
+ // - in non-interactive mode: follow HAPPY_STACKS_AUTO_AUTH_SEED (if set), else default false
2270
+ const envAutoSeed =
2271
+ (process.env.HAPPY_STACKS_AUTO_AUTH_SEED ?? process.env.HAPPY_LOCAL_AUTO_AUTH_SEED ?? '').toString().trim();
2272
+ const autoSeedEnabled = envAutoSeed ? envAutoSeed !== '0' : false;
2273
+
2274
+ let seedAuth = seedAuthFlag != null ? seedAuthFlag : autoSeedEnabled;
2275
+ let authFrom = defaultAuthFrom;
2276
+ let authLink = authLinkFlag != null ? authLinkFlag : authLinkEnv;
2277
+
2278
+ if (seedAuthFlag == null && isInteractive) {
2279
+ const anySource = hasDevAuthAccessKey || hasMainAccessKey || hasLegacyAccessKey;
2280
+ if (anySource) {
2281
+ seedAuth = await withRl(async (rl) => {
2282
+ return await promptSelect(rl, {
2283
+ title: 'Seed authentication into this PR stack so it works without a re-login?',
2284
+ options: [
2285
+ { label: 'yes (recommended)', value: true },
2286
+ { label: 'no (I will login manually for this stack)', value: false },
2287
+ ],
2288
+ defaultIndex: 0,
2289
+ });
2290
+ });
2291
+ } else {
2292
+ seedAuth = false;
2293
+ }
2294
+ }
2295
+
2296
+ if (seedAuth && !authFromFlag && isInteractive) {
2297
+ const options = [];
2298
+ if (hasDevAuthAccessKey) {
2299
+ options.push({ label: 'dev-auth (recommended) — use your dedicated dev auth seed stack', value: 'dev-auth' });
2300
+ }
2301
+ if (hasMainAccessKey) {
2302
+ options.push({ label: 'main — use Happy Stacks main credentials', value: 'main' });
2303
+ }
2304
+ if (hasLegacyAccessKey) {
2305
+ options.push({ label: 'legacy — use ~/.happy credentials (best-effort)', value: 'legacy' });
2306
+ }
2307
+ options.push({ label: 'skip seeding (manual login)', value: 'skip' });
2308
+
2309
+ const defaultIdx = Math.max(
2310
+ 0,
2311
+ options.findIndex((o) => o.value === (hasDevAuthAccessKey ? 'dev-auth' : hasMainAccessKey ? 'main' : hasLegacyAccessKey ? 'legacy' : 'skip'))
2312
+ );
2313
+ const picked = await withRl(async (rl) => {
2314
+ return await promptSelect(rl, {
2315
+ title: 'Which auth source should this PR stack use?',
2316
+ options,
2317
+ defaultIndex: defaultIdx,
2318
+ });
2319
+ });
2320
+ if (picked === 'skip') {
2321
+ seedAuth = false;
2322
+ } else {
2323
+ authFrom = String(picked);
2324
+ }
2325
+ }
2326
+
2327
+ if (seedAuth && authLinkFlag == null && isInteractive) {
2328
+ authLink = await withRl(async (rl) => {
2329
+ return await promptSelect(rl, {
2330
+ title: 'When seeding, reuse credentials via symlink or copy?',
2331
+ options: [
2332
+ { label: 'symlink (recommended) — stays up to date', value: true },
2333
+ { label: 'copy — more isolated per stack', value: false },
2334
+ ],
2335
+ defaultIndex: authLink ? 0 : 1,
2336
+ });
2337
+ });
2338
+ }
2339
+
2340
+ const progress = (line) => {
2341
+ // In JSON mode, never pollute stdout (reserved for final JSON).
2342
+ // eslint-disable-next-line no-console
2343
+ (json ? console.error : console.log)(line);
2344
+ };
2345
+
2346
+ // 1) Create (or reuse) the stack.
2347
+ let created = null;
2348
+ if (!stackExists) {
2349
+ progress(`[stack] pr: creating stack "${stackName}" (server=${serverComponent})...`);
2350
+ created = await cmdNew({
2351
+ rootDir,
2352
+ argv: [stackName, '--no-copy-auth', `--server=${serverComponent}`, ...(json ? ['--json'] : [])],
2353
+ // Prevent cmdNew from printing in JSON mode (we’ll print the final combined object below).
2354
+ emit: !json,
2355
+ });
2356
+ } else {
2357
+ progress(`[stack] pr: reusing existing stack "${stackName}"...`);
2358
+ // Ensure requested server flavor is compatible with the existing stack.
2359
+ const existing = await cmdInfoInternal({ rootDir, stackName });
2360
+ if (existing.serverComponent !== serverComponent) {
2361
+ throw new Error(
2362
+ `[stack] pr: existing stack "${stackName}" uses server=${existing.serverComponent}, but command requested server=${serverComponent}.\n` +
2363
+ `Fix: create a new stack name, or switch the stack's server flavor first (happys stack srv ${stackName} -- use ...).`
2364
+ );
2365
+ }
2366
+ created = { ok: true, stackName, reused: true, serverComponent: existing.serverComponent };
2367
+ }
2368
+
2369
+ // 2) Checkout PR worktrees and pin them to the stack env file.
2370
+ const prSpecs = [
2371
+ { component: 'happy', pr: prHappy },
2372
+ { component: 'happy-cli', pr: prCli },
2373
+ ...(serverComponent === 'happy-server' ? [{ component: 'happy-server', pr: prServer }] : []),
2374
+ ...(serverComponent === 'happy-server-light' ? [{ component: 'happy-server-light', pr: prServerLight }] : []),
2375
+ ].filter((x) => x.pr);
2376
+
2377
+ const worktrees = [];
2378
+ for (const { component, pr } of prSpecs) {
2379
+ progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
2380
+ const out = await withStackEnv({
2381
+ stackName,
2382
+ fn: async ({ env }) => {
2383
+ const doUpdate = reuseExisting || flags.has('--update');
2384
+ const args = [
2385
+ 'pr',
2386
+ component,
2387
+ pr,
2388
+ `--remote=${remoteName}`,
2389
+ '--use',
2390
+ ...(depsMode ? [`--deps=${depsMode}`] : []),
2391
+ ...(doUpdate ? ['--update'] : []),
2392
+ ...(flags.has('--force') ? ['--force'] : []),
2393
+ '--json',
2394
+ ];
2395
+ const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
2396
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
2397
+ },
2398
+ });
2399
+ if (json) {
2400
+ worktrees.push(out);
2401
+ } else if (out) {
2402
+ const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
2403
+ const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
2404
+ if (changed) {
2405
+ // eslint-disable-next-line no-console
2406
+ console.log(`[stack] pr: ${stackName}: ${component}: updated ${short(out.oldHead)} -> ${short(out.newHead)}`);
2407
+ } else if (out.updated) {
2408
+ // eslint-disable-next-line no-console
2409
+ console.log(`[stack] pr: ${stackName}: ${component}: already up to date (${short(out.newHead)})`);
2410
+ } else {
2411
+ // eslint-disable-next-line no-console
2412
+ console.log(`[stack] pr: ${stackName}: ${component}: checked out (${short(out.newHead)})`);
2413
+ }
2414
+ }
2415
+ }
2416
+
2417
+ // 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
2418
+ let auth = null;
2419
+ if (seedAuth) {
2420
+ progress(`[stack] pr: ${stackName}: seeding auth from "${authFrom}"...`);
2421
+ const args = [
2422
+ 'copy-from',
2423
+ authFrom,
2424
+ ...(authForce ? ['--force'] : []),
2425
+ ...(withInfra ? ['--with-infra'] : []),
2426
+ ...(authLink ? ['--link'] : []),
2427
+ ];
2428
+ if (json) {
2429
+ auth = await withStackEnv({
2430
+ stackName,
2431
+ fn: async ({ env }) => {
2432
+ const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
2433
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
2434
+ },
2435
+ });
2436
+ } else {
2437
+ await cmdAuth({ rootDir, stackName, args });
2438
+ auth = { ok: true, from: authFrom };
2439
+ }
2440
+ }
2441
+
2442
+ // 4) Optional: start dev / start.
2443
+ if (wantsDev) {
2444
+ progress(`[stack] pr: ${stackName}: starting dev...`);
2445
+ const args = passthrough.length ? ['--', ...passthrough] : [];
2446
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args });
2447
+ } else if (wantsStart) {
2448
+ progress(`[stack] pr: ${stackName}: starting...`);
2449
+ const args = passthrough.length ? ['--', ...passthrough] : [];
2450
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args });
2451
+ }
2452
+
2453
+ const info = await cmdInfoInternal({ rootDir, stackName });
2454
+
2455
+ const out = {
2456
+ ok: true,
2457
+ stackName,
2458
+ created,
2459
+ worktrees: worktrees.length ? worktrees : null,
2460
+ auth,
2461
+ info,
2462
+ };
2463
+
2464
+ if (json) {
2465
+ printResult({ json, data: out });
2466
+ return;
2467
+ }
2468
+ // Non-JSON mode already streamed output.
2469
+ }
2470
+
2471
+ async function cmdInfoInternal({ rootDir, stackName }) {
2472
+ // Minimal extraction from cmdInfo to avoid re-parsing argv/printing. Used by cmdPrStack.
2473
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
2474
+ const envPath = resolveStackEnvPath(stackName).envPath;
2475
+ const envRaw = await readExistingEnv(envPath);
2476
+ const stackEnv = envRaw ? parseEnvToObject(envRaw) : {};
2477
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
2478
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
2479
+
2480
+ const serverComponent =
2481
+ getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
2482
+
2483
+ const stackRemote =
2484
+ getEnvValueAny(stackEnv, ['HAPPY_STACKS_STACK_REMOTE', 'HAPPY_LOCAL_STACK_REMOTE']) || 'upstream';
2485
+
2486
+ const pinnedServerPortRaw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_PORT', 'HAPPY_LOCAL_SERVER_PORT']);
2487
+ const pinnedServerPort = pinnedServerPortRaw ? Number(pinnedServerPortRaw) : null;
2488
+
2489
+ const ownerPid = Number(runtimeState?.ownerPid);
2490
+ const running = isPidAlive(ownerPid);
2491
+ const runtimePorts = runtimeState?.ports && typeof runtimeState.ports === 'object' ? runtimeState.ports : {};
2492
+ const serverPort =
2493
+ Number.isFinite(pinnedServerPort) && pinnedServerPort > 0
2494
+ ? pinnedServerPort
2495
+ : Number(runtimePorts?.server) > 0
2496
+ ? Number(runtimePorts.server)
2497
+ : null;
2498
+ const backendPort = Number(runtimePorts?.backend) > 0 ? Number(runtimePorts.backend) : null;
2499
+ const uiPort =
2500
+ runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
2501
+ ? Number(runtimeState.expo.webPort)
2502
+ : null;
2503
+
2504
+ const host = resolveLocalhostHost({ stackMode: true, stackName });
2505
+ const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
2506
+ const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
2507
+
2508
+ const componentSpecs = [
2509
+ { component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
2510
+ { component: 'happy-cli', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI'] },
2511
+ {
2512
+ component: 'happy-server-light',
2513
+ keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT'],
2514
+ },
2515
+ { component: 'happy-server', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER'] },
2516
+ ];
2517
+
2518
+ const components = componentSpecs.map((c) => {
2519
+ const dir = getEnvValueAny(stackEnv, c.keys) || getComponentDir(rootDir, c.component);
2520
+ const spec = worktreeSpecFromDir({ rootDir, component: c.component, dir }) || null;
2521
+ return { component: c.component, dir, worktreeSpec: spec };
2522
+ });
2523
+
2524
+ return {
2525
+ ok: true,
2526
+ stackName,
2527
+ baseDir,
2528
+ envPath,
2529
+ runtimeStatePath,
2530
+ serverComponent,
2531
+ stackRemote,
2532
+ pinned: {
2533
+ serverPort: Number.isFinite(pinnedServerPort) && pinnedServerPort > 0 ? pinnedServerPort : null,
2534
+ },
2535
+ runtime: {
2536
+ script: typeof runtimeState?.script === 'string' ? runtimeState.script : null,
2537
+ ownerPid: Number.isFinite(ownerPid) && ownerPid > 1 ? ownerPid : null,
2538
+ running,
2539
+ ports: runtimePorts,
2540
+ expo: runtimeState?.expo ?? null,
2541
+ processes: runtimeState?.processes ?? null,
2542
+ startedAt: runtimeState?.startedAt ?? null,
2543
+ updatedAt: runtimeState?.updatedAt ?? null,
2544
+ },
2545
+ urls: {
2546
+ host,
2547
+ internalServerUrl,
2548
+ uiUrl,
2549
+ },
2550
+ ports: {
2551
+ server: serverPort,
2552
+ backend: backendPort,
2553
+ ui: uiPort,
2554
+ },
2555
+ components,
2556
+ };
2557
+ }
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
+
1201
2584
  async function main() {
1202
2585
  const rootDir = getRootDir(import.meta.url);
1203
2586
  // pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
@@ -1210,7 +2593,13 @@ async function main() {
1210
2593
  const cmd = positionals[0] || 'help';
1211
2594
  const json = wantsJson(argv, { flags });
1212
2595
 
1213
- if (wantsHelp(argv, { flags }) || cmd === 'help') {
2596
+ const wantsHelpFlag = wantsHelp(argv, { flags });
2597
+ // Allow subcommand-specific help (so `happys stack pr --help` shows PR stack flags).
2598
+ if (wantsHelpFlag && cmd === 'pr') {
2599
+ await cmdPrStack({ rootDir, argv });
2600
+ return;
2601
+ }
2602
+ if (wantsHelpFlag || cmd === 'help') {
1214
2603
  printResult({
1215
2604
  json,
1216
2605
  data: {
@@ -1220,14 +2609,24 @@ async function main() {
1220
2609
  'list',
1221
2610
  'migrate',
1222
2611
  'audit',
2612
+ 'duplicate',
2613
+ 'info',
2614
+ 'pr',
2615
+ 'create-dev-auth-seed',
1223
2616
  'auth',
1224
2617
  'dev',
1225
2618
  'start',
1226
2619
  'build',
1227
2620
  'typecheck',
2621
+ 'lint',
2622
+ 'test',
1228
2623
  'doctor',
1229
2624
  'mobile',
2625
+ 'resume',
1230
2626
  'stop',
2627
+ 'code',
2628
+ 'cursor',
2629
+ 'open',
1231
2630
  'srv',
1232
2631
  'wt',
1233
2632
  'tailscale:*',
@@ -1236,19 +2635,29 @@ async function main() {
1236
2635
  },
1237
2636
  text: [
1238
2637
  '[stack] usage:',
1239
- ' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--happy-cli=...] [--interactive] [--copy-auth-from=main] [--no-copy-auth] [--json]',
2638
+ ' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--happy-cli=...] [--interactive] [--copy-auth-from=<stack>] [--no-copy-auth] [--force-port] [--json]',
1240
2639
  ' happys stack edit <name> --interactive [--json]',
1241
2640
  ' happys stack list [--json]',
1242
2641
  ' happys stack migrate [--json] # copy legacy env files from ~/.happy/local/stacks/* -> ~/.happy/stacks/*',
1243
- ' happys stack audit [--fix] [--fix-main] [--json]',
1244
- ' happys stack auth <name> status|login [--json]',
2642
+ ' happys stack audit [--fix] [--fix-main] [--fix-ports] [--fix-workspace] [--fix-paths] [--unpin-ports] [--unpin-ports-except=stack1,stack2] [--json]',
2643
+ ' happys stack duplicate <from> <to> [--duplicate-worktrees] [--deps=none|link|install|link-or-install] [--json]',
2644
+ ' happys stack info <name> [--json]',
2645
+ ' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
2646
+ ' happys stack create-dev-auth-seed [name] [--server=happy-server|happy-server-light] [--non-interactive] [--json]',
2647
+ ' happys stack auth <name> status|login|copy-from [--json]',
1245
2648
  ' happys stack dev <name> [-- ...]',
1246
2649
  ' happys stack start <name> [-- ...]',
1247
2650
  ' happys stack build <name> [-- ...]',
1248
2651
  ' happys stack typecheck <name> [component...] [--json]',
2652
+ ' happys stack lint <name> [component...] [--json]',
2653
+ ' happys stack test <name> [component...] [--json]',
1249
2654
  ' happys stack doctor <name> [-- ...]',
1250
2655
  ' happys stack mobile <name> [-- ...]',
1251
- ' happys stack stop <name> [--aggressive] [--no-docker] [--json]',
2656
+ ' happys stack resume <name> <sessionId...> [--json]',
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',
1252
2661
  ' happys stack srv <name> -- status|use ...',
1253
2662
  ' happys stack wt <name> -- <wt args...>',
1254
2663
  ' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
@@ -1272,6 +2681,7 @@ async function main() {
1272
2681
  try {
1273
2682
  const stacksDir = getStacksStorageRoot();
1274
2683
  const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
2684
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
1275
2685
  const namesSet = new Set();
1276
2686
  const entries = await readdir(stacksDir, { withFileTypes: true });
1277
2687
  for (const e of entries) {
@@ -1280,10 +2690,12 @@ async function main() {
1280
2690
  namesSet.add(e.name);
1281
2691
  }
1282
2692
  try {
1283
- const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
1284
- for (const e of legacyEntries) {
1285
- if (!e.isDirectory()) continue;
1286
- namesSet.add(e.name);
2693
+ if (allowLegacy) {
2694
+ const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
2695
+ for (const e of legacyEntries) {
2696
+ if (!e.isDirectory()) continue;
2697
+ namesSet.add(e.name);
2698
+ }
1287
2699
  }
1288
2700
  } catch {
1289
2701
  // ignore
@@ -1308,6 +2720,22 @@ async function main() {
1308
2720
  await cmdAudit({ rootDir, argv });
1309
2721
  return;
1310
2722
  }
2723
+ if (cmd === 'duplicate') {
2724
+ await cmdDuplicate({ rootDir, argv });
2725
+ return;
2726
+ }
2727
+ if (cmd === 'info') {
2728
+ await cmdInfo({ rootDir, argv });
2729
+ return;
2730
+ }
2731
+ if (cmd === 'pr') {
2732
+ await cmdPrStack({ rootDir, argv });
2733
+ return;
2734
+ }
2735
+ if (cmd === 'create-dev-auth-seed') {
2736
+ await cmdCreateDevAuthSeed({ rootDir, argv });
2737
+ return;
2738
+ }
1311
2739
 
1312
2740
  // Commands that need a stack name.
1313
2741
  const stackName = stackNameFromArg(positionals, 1);
@@ -1377,6 +2805,18 @@ async function main() {
1377
2805
  await cmdRunScript({ rootDir, stackName, scriptPath: 'typecheck.mjs', args: passthrough, extraEnv: overrides });
1378
2806
  return;
1379
2807
  }
2808
+ if (cmd === 'lint') {
2809
+ const { kv } = parseArgs(passthrough);
2810
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
2811
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'lint.mjs', args: passthrough, extraEnv: overrides });
2812
+ return;
2813
+ }
2814
+ if (cmd === 'test') {
2815
+ const { kv } = parseArgs(passthrough);
2816
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
2817
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
2818
+ return;
2819
+ }
1380
2820
  if (cmd === 'doctor') {
1381
2821
  await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
1382
2822
  return;
@@ -1385,22 +2825,70 @@ async function main() {
1385
2825
  await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
1386
2826
  return;
1387
2827
  }
2828
+ if (cmd === 'resume') {
2829
+ const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
2830
+ if (sessionIds.length === 0) {
2831
+ printResult({
2832
+ json,
2833
+ data: { ok: false, error: 'missing_session_ids' },
2834
+ text: [
2835
+ '[stack] usage:',
2836
+ ' happys stack resume <name> <sessionId...>',
2837
+ ].join('\n'),
2838
+ });
2839
+ process.exit(1);
2840
+ }
2841
+ const out = await withStackEnv({
2842
+ stackName,
2843
+ fn: async ({ env }) => {
2844
+ const cliDir = getComponentDir(rootDir, 'happy-cli');
2845
+ const happyBin = join(cliDir, 'bin', 'happy.mjs');
2846
+ // Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
2847
+ return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });
2848
+ },
2849
+ });
2850
+ if (json) printResult({ json, data: { ok: true, resumed: sessionIds, out } });
2851
+ return;
2852
+ }
1388
2853
 
1389
2854
  if (cmd === 'stop') {
1390
2855
  const { flags: stopFlags } = parseArgs(passthrough);
1391
2856
  const noDocker = stopFlags.has('--no-docker');
1392
2857
  const aggressive = stopFlags.has('--aggressive');
1393
- const baseDir = getStackDir(stackName);
2858
+ const sweepOwned = stopFlags.has('--sweep-owned');
2859
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
1394
2860
  const out = await withStackEnv({
1395
2861
  stackName,
1396
2862
  fn: async ({ env }) => {
1397
- return await stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker, aggressive });
2863
+ return await stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker, aggressive, sweepOwned });
1398
2864
  },
1399
2865
  });
1400
2866
  if (json) printResult({ json, data: { ok: true, stopped: out } });
1401
2867
  return;
1402
2868
  }
1403
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
+
1404
2892
  if (cmd === 'srv') {
1405
2893
  await cmdSrv({ rootDir, stackName, args: passthrough });
1406
2894
  return;