happy-stacks 0.1.2 → 0.2.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 (91) hide show
  1. package/README.md +121 -83
  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 +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/stack.mjs CHANGED
@@ -1,20 +1,64 @@
1
1
  import './utils/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
6
  import { randomBytes } from 'node:crypto';
7
7
  import { homedir } from 'node:os';
8
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';
9
+ import { parseArgs } from './utils/cli/args.mjs';
10
+ import { killProcessTree, run, runCapture } from './utils/proc.mjs';
11
+ import { getComponentDir, getComponentsDir, getHappyStacksHomeDir, getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
12
+ import { isTcpPortFree, pickNextFreeTcpPort } from './utils/ports.mjs';
13
+ import {
14
+ createWorktree,
15
+ createWorktreeFromBaseWorktree,
16
+ inferRemoteNameForOwner,
17
+ isComponentWorktreePath,
18
+ resolveComponentSpecToDir,
19
+ worktreeSpecFromDir,
20
+ } from './utils/worktrees.mjs';
21
+ import { isTty, prompt, promptWorktreeSource, withRl } from './utils/cli/wizard.mjs';
14
22
  import { parseDotenv } from './utils/dotenv.mjs';
15
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
16
- import { ensureEnvFileUpdated } from './utils/env_file.mjs';
23
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
24
+ import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env_file.mjs';
25
+ import { listAllStackNames } from './utils/stacks.mjs';
17
26
  import { stopStackWithEnv } from './utils/stack_stop.mjs';
27
+ import { writeDevAuthKey } from './utils/dev_auth_key.mjs';
28
+ import { startDevServer } from './utils/dev_server.mjs';
29
+ import { startDevExpoWebUi } from './utils/dev_expo_web.mjs';
30
+ import { requireDir } from './utils/pm.mjs';
31
+ import { waitForHttpOk } from './utils/server.mjs';
32
+ import { resolveLocalhostHost } from './utils/localhost_host.mjs';
33
+ import { openUrlInBrowser } from './utils/browser.mjs';
34
+ import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth_files.mjs';
35
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth_sources.mjs';
36
+ import { resolveAuthSeedFromEnv } from './utils/stack_startup.mjs';
37
+ import { getHomeEnvLocalPath } from './utils/config.mjs';
38
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
39
+ import { resolveHandyMasterSecretFromStack } from './utils/handy_master_secret.mjs';
40
+ import {
41
+ deleteStackRuntimeStateFile,
42
+ getStackRuntimeStatePath,
43
+ isPidAlive,
44
+ recordStackRuntimeStart,
45
+ readStackRuntimeStateFile,
46
+ } from './utils/stack_runtime_state.mjs';
47
+ import { killPid } from './utils/expo.mjs';
48
+ import { killPidOwnedByStack } from './utils/ownership.mjs';
49
+
50
+ function getEnvValue(obj, key) {
51
+ const v = (obj?.[key] ?? '').toString().trim();
52
+ return v || '';
53
+ }
54
+
55
+ function getEnvValueAny(obj, keys) {
56
+ for (const k of keys) {
57
+ const v = getEnvValue(obj, k);
58
+ if (v) return v;
59
+ }
60
+ return '';
61
+ }
18
62
 
19
63
  function stackNameFromArg(positionals, idx) {
20
64
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
@@ -40,26 +84,16 @@ function getDefaultPortStart() {
40
84
  }
41
85
 
42
86
  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
- });
87
+ return await isTcpPortFree(port, { host: '127.0.0.1' });
51
88
  }
52
89
 
53
90
  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;
91
+ try {
92
+ return await pickNextFreeTcpPort(startPort, { reservedPorts, host: '127.0.0.1' });
93
+ } catch (e) {
94
+ const msg = e instanceof Error ? e.message : String(e);
95
+ throw new Error(msg.replace(/^\[local\]/, '[stack]'));
61
96
  }
62
- throw new Error(`[stack] unable to find a free port starting at ${startPort}`);
63
97
  }
64
98
 
65
99
  async function readPortFromEnvFile(envPath) {
@@ -162,23 +196,7 @@ async function readTextIfExists(path) {
162
196
  }
163
197
  }
164
198
 
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
- }
199
+ // auth file copy/link helpers live in scripts/utils/auth_files.mjs
182
200
 
183
201
  function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
184
202
  const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
@@ -190,46 +208,20 @@ function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
190
208
  return fromEnv || join(stackBaseDir, 'server-light');
191
209
  }
192
210
 
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 }) {
211
+ async function copyAuthFromStackIntoNewStack({
212
+ fromStackName,
213
+ stackName,
214
+ stackEnv,
215
+ serverComponent,
216
+ json,
217
+ requireSourceStackExists,
218
+ linkMode = false,
219
+ }) {
230
220
  const { secret, source } = await resolveHandyMasterSecretFromStack({
231
221
  stackName: fromStackName,
232
222
  requireStackExists: requireSourceStackExists,
223
+ allowLegacyAuthSource: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
224
+ allowLegacyMainFallback: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
233
225
  });
234
226
 
235
227
  const copied = { secret: false, accessKey: false, settings: false, sourceStack: fromStackName };
@@ -238,31 +230,52 @@ async function copyAuthFromStackIntoNewStack({ fromStackName, stackName, stackEn
238
230
  if (serverComponent === 'happy-server-light') {
239
231
  const dataDir = stackEnv.HAPPY_SERVER_LIGHT_DATA_DIR;
240
232
  const target = join(dataDir, 'handy-master-secret.txt');
241
- copied.secret = await writeSecretFileIfMissing({ path: target, secret });
233
+ const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
234
+ copied.secret =
235
+ linkMode && sourcePath && existsSync(sourcePath)
236
+ ? await linkFileIfMissing({ from: sourcePath, to: target })
237
+ : await writeSecretFileIfMissing({ path: target, secret });
242
238
  } else if (serverComponent === 'happy-server') {
243
239
  const target = stackEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE;
244
240
  if (target) {
245
- copied.secret = await writeSecretFileIfMissing({ path: target, secret });
241
+ const sourcePath = source && !String(source).includes('(HANDY_MASTER_SECRET)') ? String(source) : '';
242
+ copied.secret =
243
+ linkMode && sourcePath && existsSync(sourcePath)
244
+ ? await linkFileIfMissing({ from: sourcePath, to: target })
245
+ : await writeSecretFileIfMissing({ path: target, secret });
246
246
  }
247
247
  }
248
248
  }
249
249
 
250
- const sourceBaseDir = getStackDir(fromStackName);
251
- const sourceEnvRaw = await readExistingEnv(getStackEnvPath(fromStackName));
250
+ const legacy = isLegacyAuthSourceName(fromStackName);
251
+ if (legacy && isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
252
+ throw new Error(
253
+ '[stack] auth copy-from: legacy auth source is disabled in sandbox mode.\n' +
254
+ 'Reason: it reads from ~/.happy (global user state).\n' +
255
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
256
+ );
257
+ }
258
+ const sourceBaseDir = legacy ? getLegacyHappyBaseDir() : getStackDir(fromStackName);
259
+ const sourceEnvRaw = legacy ? '' : await readExistingEnv(getStackEnvPath(fromStackName));
252
260
  const sourceEnv = parseEnvToObject(sourceEnvRaw);
253
- const sourceCli = getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
261
+ const sourceCli = legacy ? join(sourceBaseDir, 'cli') : getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
254
262
  const targetCli = stackEnv.HAPPY_STACKS_CLI_HOME_DIR;
255
263
 
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
- });
264
+ if (linkMode) {
265
+ copied.accessKey = await linkFileIfMissing({ from: join(sourceCli, 'access.key'), to: join(targetCli, 'access.key') });
266
+ copied.settings = await linkFileIfMissing({ from: join(sourceCli, 'settings.json'), to: join(targetCli, 'settings.json') });
267
+ } else {
268
+ copied.accessKey = await copyFileIfMissing({
269
+ from: join(sourceCli, 'access.key'),
270
+ to: join(targetCli, 'access.key'),
271
+ mode: 0o600,
272
+ });
273
+ copied.settings = await copyFileIfMissing({
274
+ from: join(sourceCli, 'settings.json'),
275
+ to: join(targetCli, 'settings.json'),
276
+ mode: 0o600,
277
+ });
278
+ }
266
279
 
267
280
  if (!json) {
268
281
  const any = copied.secret || copied.accessKey || copied.settings;
@@ -355,17 +368,77 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
355
368
  delete cleaned[k];
356
369
  }
357
370
  }
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
- });
371
+ const raw = await readExistingEnv(envPath);
372
+ const stackEnv = parseEnvToObject(raw);
373
+
374
+ // Mirror HAPPY_STACKS_* and HAPPY_LOCAL_* prefixes so callers can use either.
375
+ // (Matches scripts/utils/env.mjs behavior.)
376
+ const applyPrefixMapping = (obj) => {
377
+ const keys = new Set(Object.keys(obj));
378
+ const suffixes = new Set();
379
+ for (const k of keys) {
380
+ if (k.startsWith('HAPPY_STACKS_')) suffixes.add(k.slice('HAPPY_STACKS_'.length));
381
+ if (k.startsWith('HAPPY_LOCAL_')) suffixes.add(k.slice('HAPPY_LOCAL_'.length));
382
+ }
383
+ for (const suffix of suffixes) {
384
+ const stacksKey = `HAPPY_STACKS_${suffix}`;
385
+ const localKey = `HAPPY_LOCAL_${suffix}`;
386
+ const stacksVal = (obj[stacksKey] ?? '').toString().trim();
387
+ const localVal = (obj[localKey] ?? '').toString().trim();
388
+ if (stacksVal) {
389
+ obj[stacksKey] = stacksVal;
390
+ obj[localKey] = stacksVal;
391
+ } else if (localVal) {
392
+ obj[localKey] = localVal;
393
+ obj[stacksKey] = localVal;
394
+ }
395
+ }
396
+ };
397
+
398
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
399
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
400
+
401
+ const env = {
402
+ ...cleaned,
403
+ HAPPY_STACKS_STACK: stackName,
404
+ HAPPY_STACKS_ENV_FILE: envPath,
405
+ HAPPY_LOCAL_STACK: stackName,
406
+ HAPPY_LOCAL_ENV_FILE: envPath,
407
+ // Expose runtime state path so scripts can find it if needed.
408
+ HAPPY_STACKS_RUNTIME_STATE_PATH: runtimeStatePath,
409
+ HAPPY_LOCAL_RUNTIME_STATE_PATH: runtimeStatePath,
410
+ // Stack env is authoritative by default.
411
+ ...stackEnv,
412
+ // One-shot overrides (e.g. --happy=...) win over stack env file.
413
+ ...extraEnv,
414
+ };
415
+ applyPrefixMapping(env);
416
+
417
+ // Runtime-only port overlay (ephemeral stacks): only trust it when the owner pid is still alive.
418
+ const ownerPid = Number(runtimeState?.ownerPid);
419
+ if (isPidAlive(ownerPid)) {
420
+ const ports = runtimeState?.ports && typeof runtimeState.ports === 'object' ? runtimeState.ports : {};
421
+ const applyPort = (suffix, value) => {
422
+ const n = Number(value);
423
+ if (!Number.isFinite(n) || n <= 0) return;
424
+ env[`HAPPY_STACKS_${suffix}`] = String(n);
425
+ env[`HAPPY_LOCAL_${suffix}`] = String(n);
426
+ };
427
+ applyPort('SERVER_PORT', ports.server);
428
+ applyPort('HAPPY_SERVER_BACKEND_PORT', ports.backend);
429
+ applyPort('PG_PORT', ports.pg);
430
+ applyPort('REDIS_PORT', ports.redis);
431
+ applyPort('MINIO_PORT', ports.minio);
432
+ applyPort('MINIO_CONSOLE_PORT', ports.minioConsole);
433
+
434
+ // Mark ephemeral mode for downstream helpers (e.g. infra should not persist ports).
435
+ if (runtimeState?.ephemeral) {
436
+ env.HAPPY_STACKS_EPHEMERAL_PORTS = '1';
437
+ env.HAPPY_LOCAL_EPHEMERAL_PORTS = '1';
438
+ }
439
+ }
440
+
441
+ return await fn({ env, envPath, stackEnv, runtimeStatePath, runtimeState });
369
442
  }
370
443
 
371
444
  async function interactiveNew({ rootDir, rl, defaults }) {
@@ -389,7 +462,7 @@ async function interactiveNew({ rootDir, rl, defaults }) {
389
462
 
390
463
  // Port
391
464
  if (!out.port) {
392
- const want = (await rl.question('Port (empty = auto-pick): ')).trim();
465
+ const want = (await rl.question('Port (empty = ephemeral): ')).trim();
393
466
  out.port = want ? Number(want) : null;
394
467
  }
395
468
 
@@ -439,8 +512,9 @@ async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }
439
512
 
440
513
  // Port
441
514
  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);
515
+ const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'ephemeral'}; type 'ephemeral' to unpin): `, { defaultValue: '' });
516
+ const wantTrimmed = wantPort.trim().toLowerCase();
517
+ out.port = wantTrimmed === 'ephemeral' ? null : wantPort ? Number(wantPort) : (currentPort ? Number(currentPort) : null);
444
518
 
445
519
  // Remote for creating new worktrees
446
520
  const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
@@ -471,12 +545,25 @@ async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }
471
545
  return out;
472
546
  }
473
547
 
474
- async function cmdNew({ rootDir, argv }) {
548
+ async function cmdNew({ rootDir, argv, emit = true }) {
475
549
  const { flags, kv } = parseArgs(argv);
476
550
  const positionals = argv.filter((a) => !a.startsWith('--'));
477
551
  const json = wantsJson(argv, { flags });
478
552
  const copyAuth = !(flags.has('--no-copy-auth') || flags.has('--fresh-auth'));
479
- const copyAuthFrom = (kv.get('--copy-auth-from') ?? '').trim() || 'main';
553
+ const copyAuthFrom =
554
+ (kv.get('--copy-auth-from') ?? '').trim() ||
555
+ (process.env.HAPPY_STACKS_AUTH_SEED_FROM ?? process.env.HAPPY_LOCAL_AUTH_SEED_FROM ?? '').trim() ||
556
+ 'main';
557
+ const linkAuth =
558
+ flags.has('--link-auth') ||
559
+ flags.has('--link') ||
560
+ flags.has('--symlink-auth') ||
561
+ (kv.get('--link-auth') ?? '').trim() === '1' ||
562
+ (kv.get('--auth-mode') ?? '').trim() === 'link' ||
563
+ (kv.get('--copy-auth-mode') ?? '').trim() === 'link' ||
564
+ (process.env.HAPPY_STACKS_AUTH_LINK ?? process.env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
565
+ (process.env.HAPPY_STACKS_AUTH_MODE ?? process.env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
566
+ const forcePort = flags.has('--force-port');
480
567
 
481
568
  // argv here is already "args after 'new'", so the first positional is the stack name.
482
569
  let stackName = stackNameFromArg(positionals, 0);
@@ -505,7 +592,7 @@ async function cmdNew({ rootDir, argv }) {
505
592
  throw new Error(
506
593
  '[stack] usage: happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] ' +
507
594
  '[--happy=default|<owner/...>|<path>] [--happy-cli=...] [--happy-server=...] [--happy-server-light=...] ' +
508
- '[--copy-auth-from=main] [--no-copy-auth] [--interactive]'
595
+ '[--copy-auth-from=<stack|legacy>] [--link-auth] [--no-copy-auth] [--interactive] [--force-port]'
509
596
  );
510
597
  }
511
598
  if (stackName === 'main') {
@@ -521,10 +608,33 @@ async function cmdNew({ rootDir, argv }) {
521
608
  const uiBuildDir = join(baseDir, 'ui');
522
609
  const cliHomeDir = join(baseDir, 'cli');
523
610
 
611
+ // Port strategy:
612
+ // - If --port is provided, we treat it as a pinned port and persist it in the stack env.
613
+ // - Otherwise, ports are ephemeral and chosen at stack start time (stored only in stack.runtime.json).
524
614
  let port = config.port;
525
- if (!port || !Number.isFinite(port)) {
615
+ if (!Number.isFinite(port) || port <= 0) {
616
+ port = null;
617
+ }
618
+ if (port != null) {
619
+ // If user picked a port explicitly, fail-closed on collisions by default.
526
620
  const reservedPorts = await collectReservedStackPorts();
527
- port = await pickNextFreePort(getDefaultPortStart(), { reservedPorts });
621
+ if (!forcePort && reservedPorts.has(port)) {
622
+ throw new Error(
623
+ `[stack] port ${port} is already reserved by another stack env.\n` +
624
+ `Fix:\n` +
625
+ `- omit --port to use an ephemeral port at start time (recommended)\n` +
626
+ `- or pick a different --port\n` +
627
+ `- or re-run with --force-port (not recommended)\n`
628
+ );
629
+ }
630
+ if (!(await isTcpPortFree(port))) {
631
+ throw new Error(
632
+ `[stack] port ${port} is not free on 127.0.0.1.\n` +
633
+ `Fix:\n` +
634
+ `- omit --port to use an ephemeral port at start time (recommended)\n` +
635
+ `- or stop the process currently using ${port}\n`
636
+ );
637
+ }
528
638
  }
529
639
 
530
640
  // Always pin component dirs explicitly (so stack env is stable even if repo env changes).
@@ -533,13 +643,15 @@ async function cmdNew({ rootDir, argv }) {
533
643
  // Prepare component dirs (may create worktrees).
534
644
  const stackEnv = {
535
645
  HAPPY_STACKS_STACK: stackName,
536
- HAPPY_STACKS_SERVER_PORT: String(port),
537
646
  HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
538
647
  HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
539
648
  HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
540
649
  HAPPY_STACKS_STACK_REMOTE: config.createRemote?.trim() ? config.createRemote.trim() : 'upstream',
541
650
  ...defaultComponentDirs,
542
651
  };
652
+ if (port != null) {
653
+ stackEnv.HAPPY_STACKS_SERVER_PORT = String(port);
654
+ }
543
655
 
544
656
  // Server-light storage isolation: ensure non-main stacks have their own sqlite + local files dir by default.
545
657
  // (This prevents a dev stack from mutating main stack's DB when schema changes.)
@@ -550,50 +662,54 @@ async function cmdNew({ rootDir, argv }) {
550
662
  stackEnv.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
551
663
  }
552
664
  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
-
665
+ // Persist stable infra credentials in the stack env (ports are ephemeral unless explicitly pinned).
565
666
  const pgUser = 'handy';
566
667
  const pgPassword = randomToken(24);
567
668
  const pgDb = 'handy';
568
- const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
569
-
570
669
  const s3Bucket = sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
571
670
  const s3AccessKey = randomToken(12);
572
671
  const s3SecretKey = randomToken(24);
573
- const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
574
672
 
575
- // Persist infra config in the stack env so restarts are stable/reproducible.
576
673
  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
674
  stackEnv.HAPPY_STACKS_PG_USER = pgUser;
583
675
  stackEnv.HAPPY_STACKS_PG_PASSWORD = pgPassword;
584
676
  stackEnv.HAPPY_STACKS_PG_DATABASE = pgDb;
585
677
  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
678
  stackEnv.S3_ACCESS_KEY = s3AccessKey;
594
679
  stackEnv.S3_SECRET_KEY = s3SecretKey;
595
680
  stackEnv.S3_BUCKET = s3Bucket;
596
- stackEnv.S3_PUBLIC_URL = s3PublicUrl;
681
+
682
+ // If user explicitly pinned the server port, also pin the rest of the ports + derived URLs for reproducibility.
683
+ if (port != null) {
684
+ const reservedPorts = await collectReservedStackPorts();
685
+ reservedPorts.add(port);
686
+ const backendPort = await pickNextFreePort(port + 10, { reservedPorts });
687
+ reservedPorts.add(backendPort);
688
+ const pgPort = await pickNextFreePort(port + 1000, { reservedPorts });
689
+ reservedPorts.add(pgPort);
690
+ const redisPort = await pickNextFreePort(pgPort + 1, { reservedPorts });
691
+ reservedPorts.add(redisPort);
692
+ const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts });
693
+ reservedPorts.add(minioPort);
694
+ const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts });
695
+
696
+ const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
697
+ const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
698
+
699
+ stackEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
700
+ stackEnv.HAPPY_STACKS_PG_PORT = String(pgPort);
701
+ stackEnv.HAPPY_STACKS_REDIS_PORT = String(redisPort);
702
+ stackEnv.HAPPY_STACKS_MINIO_PORT = String(minioPort);
703
+ stackEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
704
+
705
+ // Vars consumed by happy-server:
706
+ stackEnv.DATABASE_URL = databaseUrl;
707
+ stackEnv.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
708
+ stackEnv.S3_HOST = '127.0.0.1';
709
+ stackEnv.S3_PORT = String(minioPort);
710
+ stackEnv.S3_USE_SSL = 'false';
711
+ stackEnv.S3_PUBLIC_URL = s3PublicUrl;
712
+ }
597
713
  }
598
714
 
599
715
  // happy
@@ -643,7 +759,8 @@ async function cmdNew({ rootDir, argv }) {
643
759
  }
644
760
 
645
761
  if (copyAuth) {
646
- // Default: inherit main stack auth so creating a new stack doesn't require re-login.
762
+ // Default: inherit seed stack auth so creating a new stack doesn't require re-login.
763
+ // Source: --copy-auth-from (highest), else HAPPY_STACKS_AUTH_SEED_FROM (default: main).
647
764
  // Users can opt out with --no-copy-auth to force a fresh auth / machine identity.
648
765
  await copyAuthFromStackIntoNewStack({
649
766
  fromStackName: copyAuthFrom,
@@ -652,8 +769,9 @@ async function cmdNew({ rootDir, argv }) {
652
769
  serverComponent,
653
770
  json,
654
771
  requireSourceStackExists: kv.has('--copy-auth-from'),
772
+ linkMode: linkAuth,
655
773
  }).catch((err) => {
656
- if (!json) {
774
+ if (!json && emit) {
657
775
  console.warn(`[stack] auth copy skipped: ${err instanceof Error ? err.message : String(err)}`);
658
776
  console.warn(`[stack] tip: you can always run: happys stack auth ${stackName} login`);
659
777
  }
@@ -661,11 +779,20 @@ async function cmdNew({ rootDir, argv }) {
661
779
  }
662
780
 
663
781
  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
- });
782
+ const res = { ok: true, stackName, envPath, port: port ?? null, serverComponent, portsMode: port == null ? 'ephemeral' : 'pinned' };
783
+ if (emit) {
784
+ printResult({
785
+ json,
786
+ data: res,
787
+ text: [
788
+ `[stack] created ${stackName}`,
789
+ `[stack] env: ${envPath}`,
790
+ `[stack] port: ${port == null ? 'ephemeral (picked at start)' : String(port)}`,
791
+ `[stack] server: ${serverComponent}`,
792
+ ].join('\n'),
793
+ });
794
+ }
795
+ return res;
669
796
  }
670
797
 
671
798
  async function cmdEdit({ rootDir, argv }) {
@@ -707,16 +834,14 @@ async function cmdEdit({ rootDir, argv }) {
707
834
  const cliHomeDir = join(baseDir, 'cli');
708
835
 
709
836
  let port = config.port;
710
- if (!port || !Number.isFinite(port)) {
711
- const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
712
- port = await pickNextFreePort(getDefaultPortStart(), { reservedPorts });
837
+ if (!Number.isFinite(port) || port <= 0) {
838
+ port = null;
713
839
  }
714
840
 
715
841
  const serverComponent = (config.serverComponent || existingEnv.HAPPY_STACKS_SERVER_COMPONENT || existingEnv.HAPPY_LOCAL_SERVER_COMPONENT || 'happy-server-light').trim();
716
842
 
717
843
  const next = {
718
844
  HAPPY_STACKS_STACK: stackName,
719
- HAPPY_STACKS_SERVER_PORT: String(port),
720
845
  HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
721
846
  HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
722
847
  HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
@@ -726,6 +851,9 @@ async function cmdEdit({ rootDir, argv }) {
726
851
  // Always pin defaults; overrides below can replace.
727
852
  ...resolveDefaultComponentDirs({ rootDir }),
728
853
  };
854
+ if (port != null) {
855
+ next.HAPPY_STACKS_SERVER_PORT = String(port);
856
+ }
729
857
 
730
858
  if (serverComponent === 'happy-server-light') {
731
859
  const dataDir = join(baseDir, 'server-light');
@@ -734,52 +862,66 @@ async function cmdEdit({ rootDir, argv }) {
734
862
  next.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
735
863
  }
736
864
  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
-
865
+ // Persist stable infra credentials. Ports are ephemeral unless explicitly pinned.
753
866
  const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
754
867
  const pgPassword = (existingEnv.HAPPY_STACKS_PG_PASSWORD ?? '').trim() || randomToken(24);
755
868
  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' });
869
+ const s3Bucket =
870
+ (existingEnv.S3_BUCKET ?? sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' })).trim() ||
871
+ sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
759
872
  const s3AccessKey = (existingEnv.S3_ACCESS_KEY ?? '').trim() || randomToken(12);
760
873
  const s3SecretKey = (existingEnv.S3_SECRET_KEY ?? '').trim() || randomToken(24);
761
- const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
762
874
 
763
875
  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
876
  next.HAPPY_STACKS_PG_USER = pgUser;
770
877
  next.HAPPY_STACKS_PG_PASSWORD = pgPassword;
771
878
  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';
879
+ next.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE =
880
+ (existingEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(baseDir, 'happy-server', 'handy-master-secret.txt');
779
881
  next.S3_ACCESS_KEY = s3AccessKey;
780
882
  next.S3_SECRET_KEY = s3SecretKey;
781
883
  next.S3_BUCKET = s3Bucket;
782
- next.S3_PUBLIC_URL = s3PublicUrl;
884
+
885
+ if (port != null) {
886
+ // If user pinned the server port, keep ports + derived URLs stable as well.
887
+ const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
888
+ reservedPorts.add(port);
889
+ const backendPort = existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT?.trim()
890
+ ? Number(existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT.trim())
891
+ : await pickNextFreePort(port + 10, { reservedPorts });
892
+ reservedPorts.add(backendPort);
893
+ const pgPort = existingEnv.HAPPY_STACKS_PG_PORT?.trim()
894
+ ? Number(existingEnv.HAPPY_STACKS_PG_PORT.trim())
895
+ : await pickNextFreePort(port + 1000, { reservedPorts });
896
+ reservedPorts.add(pgPort);
897
+ const redisPort = existingEnv.HAPPY_STACKS_REDIS_PORT?.trim()
898
+ ? Number(existingEnv.HAPPY_STACKS_REDIS_PORT.trim())
899
+ : await pickNextFreePort(pgPort + 1, { reservedPorts });
900
+ reservedPorts.add(redisPort);
901
+ const minioPort = existingEnv.HAPPY_STACKS_MINIO_PORT?.trim()
902
+ ? Number(existingEnv.HAPPY_STACKS_MINIO_PORT.trim())
903
+ : await pickNextFreePort(redisPort + 1, { reservedPorts });
904
+ reservedPorts.add(minioPort);
905
+ const minioConsolePort = existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT?.trim()
906
+ ? Number(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT.trim())
907
+ : await pickNextFreePort(minioPort + 1, { reservedPorts });
908
+
909
+ const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
910
+ const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
911
+
912
+ next.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
913
+ next.HAPPY_STACKS_PG_PORT = String(pgPort);
914
+ next.HAPPY_STACKS_REDIS_PORT = String(redisPort);
915
+ next.HAPPY_STACKS_MINIO_PORT = String(minioPort);
916
+ next.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
917
+
918
+ next.DATABASE_URL = databaseUrl;
919
+ next.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
920
+ next.S3_HOST = '127.0.0.1';
921
+ next.S3_PORT = String(minioPort);
922
+ next.S3_USE_SSL = 'false';
923
+ next.S3_PUBLIC_URL = s3PublicUrl;
924
+ }
783
925
  }
784
926
 
785
927
  // Apply selections (create worktrees if needed)
@@ -810,7 +952,230 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
810
952
  await withStackEnv({
811
953
  stackName,
812
954
  extraEnv,
813
- fn: async ({ env }) => {
955
+ fn: async ({ env, envPath, stackEnv, runtimeStatePath, runtimeState }) => {
956
+ const isStartLike = scriptPath === 'dev.mjs' || scriptPath === 'run.mjs';
957
+ if (!isStartLike) {
958
+ await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
959
+ return;
960
+ }
961
+
962
+ const wantsRestart = args.includes('--restart');
963
+ const wantsJson = args.includes('--json');
964
+ const pinnedServerPort = Boolean((stackEnv.HAPPY_STACKS_SERVER_PORT ?? '').trim() || (stackEnv.HAPPY_LOCAL_SERVER_PORT ?? '').trim());
965
+ const serverComponent =
966
+ (stackEnv.HAPPY_STACKS_SERVER_COMPONENT ?? stackEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '').toString().trim() || 'happy-server-light';
967
+ const managedInfra =
968
+ serverComponent === 'happy-server'
969
+ ? ((stackEnv.HAPPY_STACKS_MANAGED_INFRA ?? stackEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0')
970
+ : false;
971
+
972
+ // If this is an ephemeral-port stack and it's already running, avoid spawning a second copy.
973
+ const existingOwnerPid = Number(runtimeState?.ownerPid);
974
+ const existingPort = Number(runtimeState?.ports?.server);
975
+ const existingUiPort = Number(runtimeState?.expo?.webPort);
976
+ const existingPorts =
977
+ runtimeState?.ports && typeof runtimeState.ports === 'object' ? runtimeState.ports : null;
978
+ const wasRunning = isPidAlive(existingOwnerPid);
979
+ // True restart = there was an active runner for this stack. If the stack is not running,
980
+ // `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
981
+ const isTrueRestart = wantsRestart && wasRunning;
982
+ if (wasRunning) {
983
+ if (!wantsRestart) {
984
+ const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
985
+ const uiPart =
986
+ scriptPath === 'dev.mjs' && Number.isFinite(existingUiPort) && existingUiPort > 0 ? ` ui=${existingUiPort}` : '';
987
+ console.log(`[stack] ${stackName}: already running (pid=${existingOwnerPid}${serverPart}${uiPart})`);
988
+
989
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
990
+ const noBrowser =
991
+ args.includes('--no-browser') ||
992
+ (env.HAPPY_STACKS_NO_BROWSER ?? env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
993
+ const openBrowser = isInteractive && !wantsJson && !noBrowser;
994
+
995
+ const host = resolveLocalhostHost({ stackMode: true, stackName });
996
+ const uiUrl =
997
+ scriptPath === 'dev.mjs'
998
+ ? Number.isFinite(existingUiPort) && existingUiPort > 0
999
+ ? `http://${host}:${existingUiPort}`
1000
+ : null
1001
+ : Number.isFinite(existingPort) && existingPort > 0
1002
+ ? `http://${host}:${existingPort}`
1003
+ : null;
1004
+
1005
+ if (uiUrl) {
1006
+ console.log(`[stack] ${stackName}: ui: ${uiUrl}`);
1007
+ if (openBrowser) {
1008
+ await openUrlInBrowser(uiUrl);
1009
+ }
1010
+ } else if (scriptPath === 'dev.mjs') {
1011
+ console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
1012
+ }
1013
+ return;
1014
+ }
1015
+ // Restart: stop the existing runner first.
1016
+ await killPidOwnedByStack(existingOwnerPid, { stackName, envPath, cliHomeDir: (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').toString(), label: 'runner', json: false });
1017
+ // Clear runtime state so we don't keep stale process PIDs; we'll re-create it for the new run below.
1018
+ await deleteStackRuntimeStateFile(runtimeStatePath);
1019
+ }
1020
+
1021
+ // Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
1022
+ if (!pinnedServerPort) {
1023
+ const reserved = await collectReservedStackPorts({ excludeStackName: stackName });
1024
+
1025
+ // Also avoid ports held by other *running* ephemeral stacks.
1026
+ const names = await listAllStackNames();
1027
+ for (const n of names) {
1028
+ if (n === stackName) continue;
1029
+ const p = getStackRuntimeStatePath(n);
1030
+ // eslint-disable-next-line no-await-in-loop
1031
+ const st = await readStackRuntimeStateFile(p);
1032
+ const pid = Number(st?.ownerPid);
1033
+ if (!isPidAlive(pid)) continue;
1034
+ const ports = st?.ports && typeof st.ports === 'object' ? st.ports : {};
1035
+ for (const v of Object.values(ports)) {
1036
+ const num = Number(v);
1037
+ if (Number.isFinite(num) && num > 0) reserved.add(num);
1038
+ }
1039
+ }
1040
+
1041
+ const startPort = getDefaultPortStart();
1042
+ const ports = {};
1043
+
1044
+ const parsePortOrNull = (v) => {
1045
+ const n = Number(v);
1046
+ return Number.isFinite(n) && n > 0 ? n : null;
1047
+ };
1048
+ const candidatePorts =
1049
+ isTrueRestart && existingPorts
1050
+ ? {
1051
+ server: parsePortOrNull(existingPorts.server),
1052
+ backend: parsePortOrNull(existingPorts.backend),
1053
+ pg: parsePortOrNull(existingPorts.pg),
1054
+ redis: parsePortOrNull(existingPorts.redis),
1055
+ minio: parsePortOrNull(existingPorts.minio),
1056
+ minioConsole: parsePortOrNull(existingPorts.minioConsole),
1057
+ }
1058
+ : null;
1059
+
1060
+ const canReuse =
1061
+ candidatePorts &&
1062
+ candidatePorts.server &&
1063
+ (serverComponent !== 'happy-server' || candidatePorts.backend) &&
1064
+ (!managedInfra ||
1065
+ (candidatePorts.pg && candidatePorts.redis && candidatePorts.minio && candidatePorts.minioConsole));
1066
+
1067
+ if (canReuse) {
1068
+ ports.server = candidatePorts.server;
1069
+ if (serverComponent === 'happy-server') {
1070
+ ports.backend = candidatePorts.backend;
1071
+ if (managedInfra) {
1072
+ ports.pg = candidatePorts.pg;
1073
+ ports.redis = candidatePorts.redis;
1074
+ ports.minio = candidatePorts.minio;
1075
+ ports.minioConsole = candidatePorts.minioConsole;
1076
+ }
1077
+ }
1078
+
1079
+ // Fail-closed if any of the reused ports are unexpectedly occupied (prevents cross-stack collisions).
1080
+ const toCheck = Object.values(ports)
1081
+ .map((n) => Number(n))
1082
+ .filter((n) => Number.isFinite(n) && n > 0);
1083
+ for (const p of toCheck) {
1084
+ // eslint-disable-next-line no-await-in-loop
1085
+ if (!(await isTcpPortFree(p))) {
1086
+ throw new Error(
1087
+ `[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
1088
+ `[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
1089
+ );
1090
+ }
1091
+ }
1092
+ } else {
1093
+ ports.server = await pickNextFreeTcpPort(startPort, { reservedPorts: reserved });
1094
+ reserved.add(ports.server);
1095
+
1096
+ if (serverComponent === 'happy-server') {
1097
+ ports.backend = await pickNextFreeTcpPort(ports.server + 10, { reservedPorts: reserved });
1098
+ reserved.add(ports.backend);
1099
+ if (managedInfra) {
1100
+ ports.pg = await pickNextFreeTcpPort(ports.server + 1000, { reservedPorts: reserved });
1101
+ reserved.add(ports.pg);
1102
+ ports.redis = await pickNextFreeTcpPort(ports.pg + 1, { reservedPorts: reserved });
1103
+ reserved.add(ports.redis);
1104
+ ports.minio = await pickNextFreeTcpPort(ports.redis + 1, { reservedPorts: reserved });
1105
+ reserved.add(ports.minio);
1106
+ ports.minioConsole = await pickNextFreeTcpPort(ports.minio + 1, { reservedPorts: reserved });
1107
+ reserved.add(ports.minioConsole);
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ // Sanity: if somehow the server port is now occupied, fail closed (avoids killPortListeners nuking random processes).
1113
+ if (!(await isTcpPortFree(Number(ports.server)))) {
1114
+ throw new Error(`[stack] ${stackName}: picked server port ${ports.server} but it is not free`);
1115
+ }
1116
+
1117
+ const childEnv = {
1118
+ ...env,
1119
+ HAPPY_STACKS_EPHEMERAL_PORTS: '1',
1120
+ HAPPY_LOCAL_EPHEMERAL_PORTS: '1',
1121
+ HAPPY_STACKS_SERVER_PORT: String(ports.server),
1122
+ HAPPY_LOCAL_SERVER_PORT: String(ports.server),
1123
+ ...(serverComponent === 'happy-server' && ports.backend
1124
+ ? {
1125
+ HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT: String(ports.backend),
1126
+ HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT: String(ports.backend),
1127
+ }
1128
+ : {}),
1129
+ ...(managedInfra && ports.pg
1130
+ ? {
1131
+ HAPPY_STACKS_PG_PORT: String(ports.pg),
1132
+ HAPPY_LOCAL_PG_PORT: String(ports.pg),
1133
+ HAPPY_STACKS_REDIS_PORT: String(ports.redis),
1134
+ HAPPY_LOCAL_REDIS_PORT: String(ports.redis),
1135
+ HAPPY_STACKS_MINIO_PORT: String(ports.minio),
1136
+ HAPPY_LOCAL_MINIO_PORT: String(ports.minio),
1137
+ HAPPY_STACKS_MINIO_CONSOLE_PORT: String(ports.minioConsole),
1138
+ HAPPY_LOCAL_MINIO_CONSOLE_PORT: String(ports.minioConsole),
1139
+ }
1140
+ : {}),
1141
+ };
1142
+
1143
+ // Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
1144
+ const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
1145
+ cwd: rootDir,
1146
+ env: childEnv,
1147
+ stdio: 'inherit',
1148
+ shell: false,
1149
+ });
1150
+
1151
+ // Record the chosen ports immediately (before the runner finishes booting), so other stack commands
1152
+ // can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
1153
+ await recordStackRuntimeStart(runtimeStatePath, {
1154
+ stackName,
1155
+ script: scriptPath,
1156
+ ephemeral: true,
1157
+ ownerPid: child.pid,
1158
+ ports,
1159
+ }).catch(() => {});
1160
+
1161
+ try {
1162
+ await new Promise((resolvePromise, rejectPromise) => {
1163
+ child.on('error', rejectPromise);
1164
+ child.on('exit', (code, sig) => {
1165
+ if (code === 0) return resolvePromise();
1166
+ return rejectPromise(new Error(`stack ${scriptPath} exited (code=${code ?? 'null'}, sig=${sig ?? 'null'})`));
1167
+ });
1168
+ });
1169
+ } finally {
1170
+ const cur = await readStackRuntimeStateFile(runtimeStatePath);
1171
+ if (Number(cur?.ownerPid) === Number(child.pid)) {
1172
+ await deleteStackRuntimeStateFile(runtimeStatePath);
1173
+ }
1174
+ }
1175
+ return;
1176
+ }
1177
+
1178
+ // Pinned port stack: run normally under the pinned env.
814
1179
  await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
815
1180
  },
816
1181
  });
@@ -836,6 +1201,12 @@ function resolveTransientComponentOverrides({ rootDir, kv }) {
836
1201
  }
837
1202
  }
838
1203
 
1204
+ if (Object.keys(overrides).length > 0) {
1205
+ // Mark these as transient so scripts/utils/env.mjs won't clobber them when it loads the stack env file.
1206
+ overrides.HAPPY_STACKS_TRANSIENT_COMPONENT_OVERRIDES = '1';
1207
+ overrides.HAPPY_LOCAL_TRANSIENT_COMPONENT_OVERRIDES = '1';
1208
+ }
1209
+
839
1210
  return overrides;
840
1211
  }
841
1212
 
@@ -964,26 +1335,8 @@ async function cmdMigrate({ argv }) {
964
1335
  }
965
1336
 
966
1337
  async function cmdListStacks() {
967
- const stacksDir = getStacksStorageRoot();
968
- const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
969
1338
  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();
1339
+ const names = (await listAllStackNames()).filter((n) => n !== 'main');
987
1340
  if (!names.length) {
988
1341
  console.log('[stack] no stacks found');
989
1342
  return;
@@ -997,53 +1350,103 @@ async function cmdListStacks() {
997
1350
  }
998
1351
  }
999
1352
 
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
1353
  async function cmdAudit({ rootDir, argv }) {
1030
- const { flags } = parseArgs(argv);
1354
+ const { flags, kv } = parseArgs(argv);
1031
1355
  const json = wantsJson(argv, { flags });
1032
1356
  const fix = flags.has('--fix');
1033
1357
  const fixMain = flags.has('--fix-main');
1358
+ const fixPorts = flags.has('--fix-ports');
1359
+ const fixWorkspace = flags.has('--fix-workspace');
1360
+ const fixPaths = flags.has('--fix-paths');
1361
+ const unpinPorts = flags.has('--unpin-ports');
1362
+ const unpinPortsExceptRaw = (kv.get('--unpin-ports-except') ?? '').trim();
1363
+ const unpinPortsExcept = new Set(
1364
+ unpinPortsExceptRaw
1365
+ .split(',')
1366
+ .map((s) => s.trim())
1367
+ .filter(Boolean)
1368
+ );
1369
+ const wantsEnvRepair = Boolean(fix || fixWorkspace || fixPaths);
1034
1370
 
1035
1371
  const stacks = await listAllStackNames();
1036
1372
 
1037
1373
  const report = [];
1038
1374
  const ports = new Map(); // port -> [stackName]
1375
+ const otherWorkspaceRoot = join(getHappyStacksHomeDir(), 'workspace');
1039
1376
 
1040
1377
  for (const stackName of stacks) {
1041
1378
  const resolved = resolveStackEnvPath(stackName);
1042
1379
  const envPath = resolved.envPath;
1043
1380
  const baseDir = resolved.baseDir;
1044
1381
 
1045
- const raw = await readExistingEnv(envPath);
1046
- const env = parseEnvToObject(raw);
1382
+ let raw = await readExistingEnv(envPath);
1383
+ let env = parseEnvToObject(raw);
1384
+
1385
+ // If the env file is missing/empty, optionally reconstruct a safe baseline env.
1386
+ if (!raw.trim() && wantsEnvRepair && (stackName !== 'main' || fixMain)) {
1387
+ const serverComponent =
1388
+ getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') ||
1389
+ getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') ||
1390
+ 'happy-server-light';
1391
+ const expectedUi = join(baseDir, 'ui');
1392
+ const expectedCli = join(baseDir, 'cli');
1393
+ // Port strategy: main is pinned by convention; non-main stacks default to ephemeral ports.
1394
+ const reservedPorts = stackName === 'main' ? await collectReservedStackPorts({ excludeStackName: stackName }) : new Set();
1395
+ const port = stackName === 'main' ? await pickNextFreePort(getDefaultPortStart(), { reservedPorts }) : null;
1396
+
1397
+ const nextEnv = {
1398
+ HAPPY_STACKS_STACK: stackName,
1399
+ HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
1400
+ HAPPY_STACKS_UI_BUILD_DIR: expectedUi,
1401
+ HAPPY_STACKS_CLI_HOME_DIR: expectedCli,
1402
+ HAPPY_STACKS_STACK_REMOTE: 'upstream',
1403
+ ...resolveDefaultComponentDirs({ rootDir }),
1404
+ };
1405
+ if (port != null) {
1406
+ nextEnv.HAPPY_STACKS_SERVER_PORT = String(port);
1407
+ }
1408
+
1409
+ if (serverComponent === 'happy-server-light') {
1410
+ const dataDir = join(baseDir, 'server-light');
1411
+ nextEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
1412
+ nextEnv.HAPPY_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
1413
+ nextEnv.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
1414
+ }
1415
+
1416
+ await writeStackEnv({ stackName, env: nextEnv });
1417
+ raw = await readExistingEnv(envPath);
1418
+ env = parseEnvToObject(raw);
1419
+ }
1420
+
1421
+ // Optional: unpin ports for non-main stacks (ephemeral port model).
1422
+ if (unpinPorts && stackName !== 'main' && !unpinPortsExcept.has(stackName) && raw.trim()) {
1423
+ const serverComponentTmp =
1424
+ getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1425
+ const remove = [
1426
+ // Always remove pinned public server port.
1427
+ 'HAPPY_STACKS_SERVER_PORT',
1428
+ 'HAPPY_LOCAL_SERVER_PORT',
1429
+ // Happy-server gateway/backend ports.
1430
+ 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
1431
+ 'HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT',
1432
+ // Managed infra ports.
1433
+ 'HAPPY_STACKS_PG_PORT',
1434
+ 'HAPPY_LOCAL_PG_PORT',
1435
+ 'HAPPY_STACKS_REDIS_PORT',
1436
+ 'HAPPY_LOCAL_REDIS_PORT',
1437
+ 'HAPPY_STACKS_MINIO_PORT',
1438
+ 'HAPPY_LOCAL_MINIO_PORT',
1439
+ 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
1440
+ 'HAPPY_LOCAL_MINIO_CONSOLE_PORT',
1441
+ ];
1442
+ if (serverComponentTmp === 'happy-server') {
1443
+ // These are derived from the ports above; safe to re-compute at start time.
1444
+ remove.push('DATABASE_URL', 'REDIS_URL', 'S3_PORT', 'S3_PUBLIC_URL');
1445
+ }
1446
+ await ensureEnvFilePruned({ envPath, removeKeys: remove });
1447
+ raw = await readExistingEnv(envPath);
1448
+ env = parseEnvToObject(raw);
1449
+ }
1047
1450
 
1048
1451
  const serverComponent = getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1049
1452
  const portRaw = getEnvValue(env, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_PORT');
@@ -1066,6 +1469,8 @@ async function cmdAudit({ rootDir, argv }) {
1066
1469
  const expectedUi = join(baseDir, 'ui');
1067
1470
  if (!uiBuildDir) {
1068
1471
  issues.push({ code: 'missing_ui_build_dir', message: `missing UI build dir (expected ${expectedUi})` });
1472
+ } else if (uiBuildDir !== expectedUi) {
1473
+ issues.push({ code: 'ui_build_dir_mismatch', message: `UI build dir points to ${uiBuildDir} (expected ${expectedUi})` });
1069
1474
  }
1070
1475
 
1071
1476
  const stacksCli = getEnvValue(env, 'HAPPY_STACKS_CLI_HOME_DIR');
@@ -1074,6 +1479,8 @@ async function cmdAudit({ rootDir, argv }) {
1074
1479
  const expectedCli = join(baseDir, 'cli');
1075
1480
  if (!cliHomeDir) {
1076
1481
  issues.push({ code: 'missing_cli_home_dir', message: `missing CLI home dir (expected ${expectedCli})` });
1482
+ } else if (cliHomeDir !== expectedCli) {
1483
+ issues.push({ code: 'cli_home_dir_mismatch', message: `CLI home dir points to ${cliHomeDir} (expected ${expectedCli})` });
1077
1484
  }
1078
1485
 
1079
1486
  // Component dirs: require at least server component dir + happy-cli (otherwise stacks can accidentally fall back to some other workspace).
@@ -1090,6 +1497,36 @@ async function cmdAudit({ rootDir, argv }) {
1090
1497
  }
1091
1498
  }
1092
1499
 
1500
+ // Workspace/component dir hygiene checks (best-effort).
1501
+ const componentDirKeys = [
1502
+ { component: 'happy', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY' },
1503
+ { component: 'happy-cli', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI' },
1504
+ { component: 'happy-server-light', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT' },
1505
+ { component: 'happy-server', key: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER' },
1506
+ ];
1507
+ for (const { component, key } of componentDirKeys) {
1508
+ const legacyKey = key.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
1509
+ const v = getEnvValue(env, key) || getEnvValue(env, legacyKey);
1510
+ if (!v) continue;
1511
+ if (!isAbsolute(v)) {
1512
+ issues.push({ code: 'relative_component_dir', message: `${key} is relative (${v}); prefer absolute paths under this workspace` });
1513
+ continue;
1514
+ }
1515
+ const norm = v.replaceAll('\\', '/');
1516
+ if (norm.startsWith(otherWorkspaceRoot.replaceAll('\\', '/') + '/')) {
1517
+ issues.push({ code: 'foreign_workspace_component_dir', message: `${key} points to another workspace: ${v}` });
1518
+ continue;
1519
+ }
1520
+ const rootNorm = resolve(rootDir).replaceAll('\\', '/') + '/';
1521
+ if (norm.includes('/components/') && !norm.startsWith(rootNorm)) {
1522
+ issues.push({ code: 'external_component_dir', message: `${key} points outside current workspace: ${v}` });
1523
+ }
1524
+ // Optional: fail-closed existence check.
1525
+ if (!existsSync(v)) {
1526
+ issues.push({ code: 'missing_component_path', message: `${key} path does not exist: ${v}` });
1527
+ }
1528
+ }
1529
+
1093
1530
  // Server-light DB/files isolation.
1094
1531
  const isServerLight = serverComponent === 'happy-server-light';
1095
1532
  if (isServerLight) {
@@ -1103,16 +1540,23 @@ async function cmdAudit({ rootDir, argv }) {
1103
1540
  if (!dataDir) issues.push({ code: 'missing_server_light_data_dir', message: `missing HAPPY_SERVER_LIGHT_DATA_DIR (expected ${expectedDataDir})` });
1104
1541
  if (!filesDir) issues.push({ code: 'missing_server_light_files_dir', message: `missing HAPPY_SERVER_LIGHT_FILES_DIR (expected ${expectedFilesDir})` });
1105
1542
  if (!dbUrl) issues.push({ code: 'missing_database_url', message: `missing DATABASE_URL (expected ${expectedDbUrl})` });
1543
+ if (dataDir && dataDir !== expectedDataDir) issues.push({ code: 'server_light_data_dir_mismatch', message: `HAPPY_SERVER_LIGHT_DATA_DIR=${dataDir} (expected ${expectedDataDir})` });
1544
+ if (filesDir && filesDir !== expectedFilesDir) issues.push({ code: 'server_light_files_dir_mismatch', message: `HAPPY_SERVER_LIGHT_FILES_DIR=${filesDir} (expected ${expectedFilesDir})` });
1545
+ if (dbUrl && dbUrl !== expectedDbUrl) issues.push({ code: 'database_url_mismatch', message: `DATABASE_URL=${dbUrl} (expected ${expectedDbUrl})` });
1106
1546
 
1107
1547
  }
1108
1548
 
1109
- // Best-effort env repair (missing keys only).
1110
- if (fix && (stackName !== 'main' || fixMain) && raw.trim()) {
1549
+ // Best-effort env repair (opt-in; non-main stacks only by default).
1550
+ if ((fix || fixWorkspace || fixPaths) && (stackName !== 'main' || fixMain) && raw.trim()) {
1111
1551
  const updates = [];
1112
1552
 
1113
1553
  // Always ensure stack directories are explicitly pinned when missing.
1114
1554
  if (!stacksUi && !localUi) updates.push({ key: 'HAPPY_STACKS_UI_BUILD_DIR', value: expectedUi });
1115
1555
  if (!stacksCli && !localCli) updates.push({ key: 'HAPPY_STACKS_CLI_HOME_DIR', value: expectedCli });
1556
+ if (fixPaths) {
1557
+ if (uiBuildDir && uiBuildDir !== expectedUi) updates.push({ key: 'HAPPY_STACKS_UI_BUILD_DIR', value: expectedUi });
1558
+ if (cliHomeDir && cliHomeDir !== expectedCli) updates.push({ key: 'HAPPY_STACKS_CLI_HOME_DIR', value: expectedCli });
1559
+ }
1116
1560
 
1117
1561
  // Pin component dirs if missing (best-effort).
1118
1562
  if (missingComponentKeys.length) {
@@ -1132,9 +1576,59 @@ async function cmdAudit({ rootDir, argv }) {
1132
1576
  const expectedDataDir = join(baseDir, 'server-light');
1133
1577
  const expectedFilesDir = join(expectedDataDir, 'files');
1134
1578
  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 });
1579
+ if (!dataDir || (fixPaths && dataDir !== expectedDataDir)) updates.push({ key: 'HAPPY_SERVER_LIGHT_DATA_DIR', value: expectedDataDir });
1580
+ if (!filesDir || (fixPaths && filesDir !== expectedFilesDir)) updates.push({ key: 'HAPPY_SERVER_LIGHT_FILES_DIR', value: expectedFilesDir });
1581
+ if (!dbUrl || (fixPaths && dbUrl !== expectedDbUrl)) updates.push({ key: 'DATABASE_URL', value: expectedDbUrl });
1582
+ }
1583
+
1584
+ if (fixWorkspace) {
1585
+ const otherNorm = otherWorkspaceRoot.replaceAll('\\', '/') + '/';
1586
+ for (const { component, key } of componentDirKeys) {
1587
+ const legacyKey = key.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
1588
+ const current = getEnvValue(env, key) || getEnvValue(env, legacyKey);
1589
+ if (!current) continue;
1590
+
1591
+ let next = current;
1592
+ if (!isAbsolute(next) && next.startsWith('components/')) {
1593
+ next = resolve(rootDir, next);
1594
+ }
1595
+ const norm = next.replaceAll('\\', '/');
1596
+ if (norm.startsWith(otherNorm)) {
1597
+ // Map any path under ~/.happy-stacks/workspace/... back into this repo root.
1598
+ const rel = norm.slice(otherNorm.length);
1599
+ const candidate = resolve(rootDir, rel);
1600
+ if (existsSync(candidate)) {
1601
+ next = candidate;
1602
+ } else if (rel.includes('/components/.worktrees/')) {
1603
+ // Attempt to recreate the referenced worktree inside this workspace.
1604
+ const marker = '/components/.worktrees/';
1605
+ const idx = rel.indexOf(marker);
1606
+ const rest = rel.slice(idx + marker.length); // <component>/<owner>/<slug...>
1607
+ const parts = rest.split('/').filter(Boolean);
1608
+ if (parts.length >= 3) {
1609
+ const comp = parts[0];
1610
+ const owner = parts[1];
1611
+ const slug = parts.slice(2).join('/');
1612
+ const remoteName = owner === 'slopus' ? 'upstream' : 'origin';
1613
+ try {
1614
+ // eslint-disable-next-line no-await-in-loop
1615
+ next = await createWorktree({ rootDir, component: comp, slug, remoteName });
1616
+ } catch {
1617
+ // Fall back to candidate path (even if missing) and let other checks surface it.
1618
+ next = candidate;
1619
+ }
1620
+ } else {
1621
+ next = candidate;
1622
+ }
1623
+ } else {
1624
+ next = candidate;
1625
+ }
1626
+ }
1627
+
1628
+ if (next !== current) {
1629
+ updates.push({ key, value: next });
1630
+ }
1631
+ }
1138
1632
  }
1139
1633
 
1140
1634
  if (updates.length) {
@@ -1155,7 +1649,136 @@ async function cmdAudit({ rootDir, argv }) {
1155
1649
  }
1156
1650
 
1157
1651
  // Port collisions (post-pass)
1652
+ const collisions = [];
1158
1653
  for (const [port, names] of ports.entries()) {
1654
+ if (names.length <= 1) continue;
1655
+ collisions.push({ port, names: Array.from(names) });
1656
+ }
1657
+
1658
+ // Optional: fix collisions by reassigning ports (non-main stacks only by default).
1659
+ if (fixPorts) {
1660
+ const allowMain = Boolean(fixMain);
1661
+ const planned = await collectReservedStackPorts();
1662
+ const byName = new Map(report.map((r) => [r.stackName, r]));
1663
+
1664
+ const parsePg = (url) => {
1665
+ try {
1666
+ const u = new URL(url);
1667
+ const db = u.pathname?.replace(/^\//, '') || '';
1668
+ return {
1669
+ user: decodeURIComponent(u.username || ''),
1670
+ password: decodeURIComponent(u.password || ''),
1671
+ db,
1672
+ host: u.hostname || '127.0.0.1',
1673
+ };
1674
+ } catch {
1675
+ return null;
1676
+ }
1677
+ };
1678
+
1679
+ for (const c of collisions) {
1680
+ const names = c.names.slice().sort();
1681
+ // Keep the first stack stable; reassign others to reduce churn.
1682
+ const keep = names[0];
1683
+ for (const stackName of names.slice(1)) {
1684
+ if (stackName === 'main' && !allowMain) {
1685
+ continue;
1686
+ }
1687
+ const entry = byName.get(stackName);
1688
+ if (!entry) continue;
1689
+ if (!entry.envPath) continue;
1690
+ const raw = await readExistingEnv(entry.envPath);
1691
+ if (!raw.trim()) continue;
1692
+ const env = parseEnvToObject(raw);
1693
+
1694
+ const serverComponent =
1695
+ getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1696
+ const portRaw = getEnvValue(env, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_PORT');
1697
+ const currentPort = portRaw ? Number(portRaw) : NaN;
1698
+ if (Number.isFinite(currentPort) && currentPort > 0) {
1699
+ // Fail-safe: don't rewrite ports for a stack that appears to be actively running.
1700
+ // Otherwise we can strand a running server/daemon on a now-stale port.
1701
+ // eslint-disable-next-line no-await-in-loop
1702
+ const free = await isPortFree(currentPort);
1703
+ if (!free) {
1704
+ entry.issues.push({
1705
+ code: 'port_fix_skipped_running',
1706
+ message: `skipped port reassignment because port ${currentPort} is currently in use (stop the stack and re-run --fix-ports)`,
1707
+ });
1708
+ continue;
1709
+ }
1710
+ }
1711
+ const startFrom = Number.isFinite(currentPort) && currentPort > 0 ? currentPort + 1 : getDefaultPortStart();
1712
+
1713
+ const updates = [];
1714
+ const newServerPort = await pickNextFreePort(startFrom, { reservedPorts: planned });
1715
+ planned.add(newServerPort);
1716
+ updates.push({ key: 'HAPPY_STACKS_SERVER_PORT', value: String(newServerPort) });
1717
+
1718
+ if (serverComponent === 'happy-server') {
1719
+ planned.add(newServerPort);
1720
+ const backendPort = await pickNextFreePort(newServerPort + 10, { reservedPorts: planned });
1721
+ planned.add(backendPort);
1722
+ const pgPort = await pickNextFreePort(newServerPort + 1000, { reservedPorts: planned });
1723
+ planned.add(pgPort);
1724
+ const redisPort = await pickNextFreePort(pgPort + 1, { reservedPorts: planned });
1725
+ planned.add(redisPort);
1726
+ const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts: planned });
1727
+ planned.add(minioPort);
1728
+ const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts: planned });
1729
+ planned.add(minioConsolePort);
1730
+
1731
+ updates.push({ key: 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT', value: String(backendPort) });
1732
+ updates.push({ key: 'HAPPY_STACKS_PG_PORT', value: String(pgPort) });
1733
+ updates.push({ key: 'HAPPY_STACKS_REDIS_PORT', value: String(redisPort) });
1734
+ updates.push({ key: 'HAPPY_STACKS_MINIO_PORT', value: String(minioPort) });
1735
+ updates.push({ key: 'HAPPY_STACKS_MINIO_CONSOLE_PORT', value: String(minioConsolePort) });
1736
+
1737
+ // Update URLs while preserving existing credentials.
1738
+ const pgUser = getEnvValue(env, 'HAPPY_STACKS_PG_USER') || 'handy';
1739
+ const pgPassword = getEnvValue(env, 'HAPPY_STACKS_PG_PASSWORD') || '';
1740
+ const pgDb = getEnvValue(env, 'HAPPY_STACKS_PG_DATABASE') || 'handy';
1741
+ let user = pgUser;
1742
+ let pass = pgPassword;
1743
+ let db = pgDb;
1744
+ const parsed = parsePg(getEnvValue(env, 'DATABASE_URL'));
1745
+ if (parsed) {
1746
+ if (parsed.user) user = parsed.user;
1747
+ if (parsed.password) pass = parsed.password;
1748
+ if (parsed.db) db = parsed.db;
1749
+ }
1750
+ const databaseUrl = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@127.0.0.1:${pgPort}/${encodeURIComponent(db)}`;
1751
+ updates.push({ key: 'DATABASE_URL', value: databaseUrl });
1752
+ updates.push({ key: 'REDIS_URL', value: `redis://127.0.0.1:${redisPort}` });
1753
+ updates.push({ key: 'S3_PORT', value: String(minioPort) });
1754
+ const bucket = getEnvValue(env, 'S3_BUCKET') || sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
1755
+ updates.push({ key: 'S3_PUBLIC_URL', value: `http://127.0.0.1:${minioPort}/${bucket}` });
1756
+ }
1757
+
1758
+ await ensureEnvFileUpdated({ envPath: entry.envPath, updates });
1759
+
1760
+ // Update in-memory report for follow-up collision recomputation.
1761
+ entry.serverPort = newServerPort;
1762
+ entry.issues.push({ code: 'port_reassigned', message: `server port reassigned -> ${newServerPort} (was ${currentPort || 'unknown'})` });
1763
+ }
1764
+ // Ensure the "kept" one remains reserved in planned as well.
1765
+ const keptEntry = byName.get(keep);
1766
+ if (keptEntry?.serverPort) planned.add(keptEntry.serverPort);
1767
+ }
1768
+ }
1769
+
1770
+ // Recompute port collisions after optional fixes.
1771
+ for (const r of report) {
1772
+ r.issues = (r.issues ?? []).filter((i) => i.code !== 'port_collision');
1773
+ }
1774
+ const portsNow = new Map();
1775
+ for (const r of report) {
1776
+ if (!Number.isFinite(r.serverPort) || r.serverPort == null) continue;
1777
+ const existing = portsNow.get(r.serverPort) ?? [];
1778
+ existing.push(r.stackName);
1779
+ portsNow.set(r.serverPort, existing);
1780
+ }
1781
+ for (const [port, names] of portsNow.entries()) {
1159
1782
  if (names.length <= 1) continue;
1160
1783
  for (const r of report) {
1161
1784
  if (r.serverPort === port) {
@@ -1166,7 +1789,7 @@ async function cmdAudit({ rootDir, argv }) {
1166
1789
 
1167
1790
  const out = {
1168
1791
  ok: true,
1169
- fixed: fix,
1792
+ fixed: Boolean(fix || fixPorts || fixWorkspace || fixPaths || unpinPorts),
1170
1793
  stacks: report,
1171
1794
  summary: {
1172
1795
  total: report.length,
@@ -1198,6 +1821,844 @@ async function cmdAudit({ rootDir, argv }) {
1198
1821
  }
1199
1822
  }
1200
1823
 
1824
+ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1825
+ const { flags, kv } = parseArgs(argv);
1826
+ const json = wantsJson(argv, { flags });
1827
+
1828
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1829
+ const name = (positionals[1] ?? '').trim() || 'dev-auth';
1830
+ const serverComponent = (kv.get('--server') ?? '').trim() || 'happy-server-light';
1831
+ const interactive = !flags.has('--non-interactive') && (flags.has('--interactive') || isTty());
1832
+
1833
+ if (json) {
1834
+ // Keep JSON mode non-interactive and stable by using the existing stack command output.
1835
+ // (We intentionally don't run the guided login flow in JSON mode.)
1836
+ const createArgs = ['new', name, '--no-copy-auth', '--server', serverComponent, '--json'];
1837
+ const created = await runCapture(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), ...createArgs], { cwd: rootDir, env: process.env }).catch((e) => {
1838
+ throw new Error(
1839
+ `[stack] create-dev-auth-seed: failed to create auth seed stack "${name}": ${e instanceof Error ? e.message : String(e)}`
1840
+ );
1841
+ });
1842
+
1843
+ printResult({
1844
+ json,
1845
+ data: {
1846
+ ok: true,
1847
+ seedStack: name,
1848
+ serverComponent,
1849
+ created: created.trim() ? JSON.parse(created.trim()) : { ok: true },
1850
+ next: {
1851
+ login: `happys stack auth ${name} login`,
1852
+ setEnv: `# add to ${getHomeEnvLocalPath()}:\nHAPPY_STACKS_AUTH_SEED_FROM=${name}\nHAPPY_STACKS_AUTO_AUTH_SEED=1`,
1853
+ reseedAll: `happys auth copy-from ${name} --all --except=main,${name}`,
1854
+ },
1855
+ },
1856
+ });
1857
+ return;
1858
+ }
1859
+
1860
+ // Create the seed stack as fresh auth (no copy) so it doesn't share main identity.
1861
+ // IMPORTANT: do this in-process (no recursive spawn) so the env file is definitely written
1862
+ // before we run any guided steps (withStackEnv/login).
1863
+ if (!stackExistsSync(name)) {
1864
+ await cmdNew({
1865
+ rootDir,
1866
+ argv: [name, '--no-copy-auth', '--server', serverComponent],
1867
+ });
1868
+ } else {
1869
+ console.log(`[stack] auth seed stack already exists: ${name}`);
1870
+ }
1871
+
1872
+ if (!stackExistsSync(name)) {
1873
+ throw new Error(`[stack] create-dev-auth-seed: expected stack "${name}" to exist after creation, but it does not`);
1874
+ }
1875
+
1876
+ // Interactive convenience: guide login first, then configure env.local + store dev key.
1877
+ if (interactive) {
1878
+ await withRl(async (rl) => {
1879
+ let savedDevKey = false;
1880
+ const wantLoginRaw = (await prompt(
1881
+ rl,
1882
+ `Run guided login now? (starts the seed server temporarily for this stack) (Y/n): `,
1883
+ { defaultValue: 'y' }
1884
+ ))
1885
+ .trim()
1886
+ .toLowerCase();
1887
+ const wantLogin = wantLoginRaw === 'y' || wantLoginRaw === 'yes' || wantLoginRaw === '';
1888
+
1889
+ if (wantLogin) {
1890
+ console.log('');
1891
+ console.log(`[stack] starting ${serverComponent} temporarily so we can log in...`);
1892
+
1893
+ const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
1894
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
1895
+ const publicServerUrl = `http://localhost:${serverPort}`;
1896
+
1897
+ const autostart = { stackName: name, baseDir: getStackDir(name) };
1898
+ const children = [];
1899
+
1900
+ await withStackEnv({
1901
+ stackName: name,
1902
+ extraEnv: {
1903
+ // Make sure stack auth login uses the same port we just picked, and avoid inheriting
1904
+ // any global/public URL (e.g. main stack’s Tailscale URL) for this guided flow.
1905
+ HAPPY_STACKS_SERVER_PORT: String(serverPort),
1906
+ HAPPY_LOCAL_SERVER_PORT: String(serverPort),
1907
+ HAPPY_STACKS_SERVER_URL: '',
1908
+ HAPPY_LOCAL_SERVER_URL: '',
1909
+ },
1910
+ fn: async ({ env }) => {
1911
+ const serverDir =
1912
+ serverComponent === 'happy-server'
1913
+ ? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
1914
+ : env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT;
1915
+ const resolvedServerDir = serverDir || getComponentDir(rootDir, serverComponent);
1916
+ const resolvedCliDir = env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI || getComponentDir(rootDir, 'happy-cli');
1917
+ const resolvedUiDir = env.HAPPY_STACKS_COMPONENT_DIR_HAPPY || getComponentDir(rootDir, 'happy');
1918
+
1919
+ await requireDir(serverComponent, resolvedServerDir);
1920
+ await requireDir('happy-cli', resolvedCliDir);
1921
+ await requireDir('happy', resolvedUiDir);
1922
+
1923
+ let serverProc = null;
1924
+ let uiProc = null;
1925
+ try {
1926
+ const started = await startDevServer({
1927
+ serverComponentName: serverComponent,
1928
+ serverDir: resolvedServerDir,
1929
+ autostart,
1930
+ baseEnv: env,
1931
+ serverPort,
1932
+ internalServerUrl,
1933
+ publicServerUrl,
1934
+ envPath: env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '',
1935
+ stackMode: true,
1936
+ runtimeStatePath: null,
1937
+ serverAlreadyRunning: false,
1938
+ restart: true,
1939
+ children,
1940
+ spawnOptions: { stdio: 'ignore' },
1941
+ });
1942
+ serverProc = started.serverProc;
1943
+
1944
+ // Start Expo web UI so /terminal/connect exists for happy-cli web auth.
1945
+ const uiRes = await startDevExpoWebUi({
1946
+ startUi: true,
1947
+ uiDir: resolvedUiDir,
1948
+ autostart,
1949
+ baseEnv: env,
1950
+ // In the browser, prefer localhost for API calls.
1951
+ apiServerUrl: publicServerUrl,
1952
+ restart: false,
1953
+ stackMode: true,
1954
+ runtimeStatePath: null,
1955
+ stackName: name,
1956
+ envPath: env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '',
1957
+ children,
1958
+ spawnOptions: { stdio: 'ignore' },
1959
+ });
1960
+ if (uiRes?.skipped === false && uiRes.proc) {
1961
+ uiProc = uiRes.proc;
1962
+ }
1963
+
1964
+ console.log('');
1965
+ const uiHost = `happy-${sanitizeDnsLabel(name)}.localhost`;
1966
+ const uiPort = uiRes?.port;
1967
+ const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
1968
+ const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
1969
+ const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
1970
+
1971
+ console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
1972
+ if (uiRoot) {
1973
+ console.log(`[stack] waiting for UI to be ready...`);
1974
+ // Prefer localhost for readiness checks (faster/more reliable), even though we
1975
+ // instruct the user to use the stack-scoped *.localhost origin for storage isolation.
1976
+ await waitForHttpOk(uiRootLocalhost || uiRoot, { timeoutMs: 30_000 });
1977
+ console.log(`- open: ${uiRoot}`);
1978
+ console.log(`- click: "Create Account"`);
1979
+ console.log(`- then open: ${uiSettings}`);
1980
+ console.log(`- tap: "Secret Key" to reveal + copy it`);
1981
+ } else {
1982
+ console.log(`- UI is running but the port was not detected; rerun with DEBUG logs if needed`);
1983
+ }
1984
+ await prompt(rl, `Press Enter once you've created the account in the UI... `);
1985
+
1986
+ console.log('');
1987
+ console.log('[stack] step 2/3: save the dev key locally (for agents / Playwright)');
1988
+ const keyInput = (await prompt(
1989
+ rl,
1990
+ `Paste the Secret Key now (from Settings → Account → Secret Key). Leave empty to skip: `
1991
+ )).trim();
1992
+ if (keyInput) {
1993
+ const res = await writeDevAuthKey({ env: process.env, input: keyInput });
1994
+ savedDevKey = true;
1995
+ console.log(`[stack] dev key saved: ${res.path}`);
1996
+ } else {
1997
+ console.log(`[stack] dev key not saved; you can do it later with: happys auth dev-key --set="<key>"`);
1998
+ }
1999
+
2000
+ console.log('');
2001
+ console.log('[stack] step 3/3: authenticate the CLI against this stack (web auth)');
2002
+ console.log(`[stack] launching: happys stack auth ${name} login`);
2003
+ await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), 'login', '--no-force'], {
2004
+ cwd: rootDir,
2005
+ env,
2006
+ });
2007
+ } finally {
2008
+ if (uiProc) {
2009
+ console.log('');
2010
+ console.log(`[stack] stopping temporary UI (pid=${uiProc.pid})...`);
2011
+ killProcessTree(uiProc, 'SIGINT');
2012
+ await Promise.race([
2013
+ new Promise((resolve) => uiProc.on('exit', resolve)),
2014
+ new Promise((resolve) => setTimeout(resolve, 15_000)),
2015
+ ]);
2016
+ }
2017
+ if (serverProc) {
2018
+ console.log('');
2019
+ console.log(`[stack] stopping temporary server (pid=${serverProc.pid})...`);
2020
+ killProcessTree(serverProc, 'SIGINT');
2021
+ await Promise.race([
2022
+ new Promise((resolve) => serverProc.on('exit', resolve)),
2023
+ new Promise((resolve) => setTimeout(resolve, 15_000)),
2024
+ ]);
2025
+ }
2026
+ }
2027
+ },
2028
+ });
2029
+
2030
+ console.log('');
2031
+ console.log('[stack] login step complete.');
2032
+ } else {
2033
+ console.log(`[stack] skipping guided login. You can do it later with: happys stack auth ${name} login`);
2034
+ }
2035
+
2036
+ const wantEnvRaw = (await prompt(
2037
+ rl,
2038
+ `Set this as the default auth seed (writes ${getHomeEnvLocalPath()})? (Y/n): `,
2039
+ { defaultValue: 'y' }
2040
+ ))
2041
+ .trim()
2042
+ .toLowerCase();
2043
+ const wantEnv = wantEnvRaw === 'y' || wantEnvRaw === 'yes' || wantEnvRaw === '';
2044
+ if (wantEnv) {
2045
+ const envLocalPath = getHomeEnvLocalPath();
2046
+ await ensureEnvFileUpdated({
2047
+ envPath: envLocalPath,
2048
+ updates: [
2049
+ { key: 'HAPPY_STACKS_AUTH_SEED_FROM', value: name },
2050
+ { key: 'HAPPY_STACKS_AUTO_AUTH_SEED', value: '1' },
2051
+ ],
2052
+ });
2053
+ console.log(`[stack] updated: ${envLocalPath}`);
2054
+ } else {
2055
+ console.log(`[stack] tip: set in ${getHomeEnvLocalPath()}: HAPPY_STACKS_AUTH_SEED_FROM=${name} and HAPPY_STACKS_AUTO_AUTH_SEED=1`);
2056
+ }
2057
+
2058
+ if (!savedDevKey) {
2059
+ const wantKey = (await prompt(rl, `Save the dev auth key for Playwright/UI logins now? (y/N): `)).trim().toLowerCase();
2060
+ if (wantKey === 'y' || wantKey === 'yes') {
2061
+ console.log(`[stack] paste the secret key (base64url OR backup-format like XXXXX-XXXXX-...):`);
2062
+ const input = (await prompt(rl, `dev key: `)).trim();
2063
+ if (input) {
2064
+ try {
2065
+ const res = await writeDevAuthKey({ env: process.env, input });
2066
+ console.log(`[stack] dev key saved: ${res.path}`);
2067
+ } catch (e) {
2068
+ console.warn(`[stack] dev key not saved: ${e instanceof Error ? e.message : String(e)}`);
2069
+ }
2070
+ } else {
2071
+ console.log('[stack] dev key not provided; skipping');
2072
+ }
2073
+ } else {
2074
+ console.log(`[stack] tip: you can set it later with: happys auth dev-key --set="<key>"`);
2075
+ }
2076
+ }
2077
+ });
2078
+ } else {
2079
+ console.log(`- set as default seed (recommended) in ${getHomeEnvLocalPath()}:`);
2080
+ console.log(` HAPPY_STACKS_AUTH_SEED_FROM=${name}`);
2081
+ console.log(` HAPPY_STACKS_AUTO_AUTH_SEED=1`);
2082
+ console.log(`- (optional) seed existing stacks: happys auth copy-from ${name} --all --except=main,${name}`);
2083
+ console.log(`- (optional) store dev key for UI automation: happys auth dev-key --set="<key>"`);
2084
+ }
2085
+ }
2086
+
2087
+ function parseServerComponentFromEnv(env) {
2088
+ const v =
2089
+ (env.HAPPY_STACKS_SERVER_COMPONENT ?? env.HAPPY_LOCAL_SERVER_COMPONENT ?? '').toString().trim() ||
2090
+ 'happy-server-light';
2091
+ return v === 'happy-server' ? 'happy-server' : 'happy-server-light';
2092
+ }
2093
+
2094
+ async function readStackEnvObject(stackName) {
2095
+ const envPath = getStackEnvPath(stackName);
2096
+ const raw = await readExistingEnv(envPath);
2097
+ const env = raw ? parseEnvToObject(raw) : {};
2098
+ return { envPath, env };
2099
+ }
2100
+
2101
+ function envKeyForComponentDir({ serverComponent, component }) {
2102
+ if (component === 'happy') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY';
2103
+ if (component === 'happy-cli') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI';
2104
+ if (component === 'happy-server') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER';
2105
+ if (component === 'happy-server-light') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT';
2106
+ // Fallback; caller should not use.
2107
+ return `HAPPY_STACKS_COMPONENT_DIR_${component.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
2108
+ }
2109
+
2110
+ function sanitizeSlugPart(s) {
2111
+ return String(s ?? '')
2112
+ .trim()
2113
+ .toLowerCase()
2114
+ .replace(/[^a-z0-9._/-]+/g, '-')
2115
+ .replace(/-+/g, '-')
2116
+ .replace(/^-+/, '')
2117
+ .replace(/-+$/, '');
2118
+ }
2119
+
2120
+ async function cmdDuplicate({ rootDir, argv }) {
2121
+ const { flags, kv } = parseArgs(argv);
2122
+ const json = wantsJson(argv, { flags });
2123
+
2124
+ const positionals = argv.filter((a) => !a.startsWith('--'));
2125
+ const fromStack = (positionals[1] ?? '').trim();
2126
+ const toStack = (positionals[2] ?? '').trim();
2127
+ if (!fromStack || !toStack) {
2128
+ throw new Error('[stack] usage: happys stack duplicate <from> <to> [--duplicate-worktrees] [--deps=...] [--json]');
2129
+ }
2130
+ if (toStack === 'main') {
2131
+ throw new Error('[stack] refusing to duplicate into stack name "main"');
2132
+ }
2133
+ if (!stackExistsSync(fromStack)) {
2134
+ throw new Error(`[stack] duplicate: source stack does not exist: ${fromStack}`);
2135
+ }
2136
+ if (stackExistsSync(toStack)) {
2137
+ throw new Error(`[stack] duplicate: destination stack already exists: ${toStack}`);
2138
+ }
2139
+
2140
+ const duplicateWorktrees =
2141
+ flags.has('--duplicate-worktrees') ||
2142
+ flags.has('--with-worktrees') ||
2143
+ (kv.get('--duplicate-worktrees') ?? '').trim() === '1';
2144
+ const depsMode = (kv.get('--deps') ?? '').trim(); // forwarded to wt new when duplicating worktrees
2145
+
2146
+ const { env: fromEnv } = await readStackEnvObject(fromStack);
2147
+ const serverComponent = parseServerComponentFromEnv(fromEnv);
2148
+
2149
+ // Create the destination stack env with the correct baseDir and defaults (do not copy auth/data).
2150
+ await cmdNew({
2151
+ rootDir,
2152
+ argv: [toStack, '--no-copy-auth', '--server', serverComponent],
2153
+ });
2154
+
2155
+ // Build component dir updates (copy overrides; optionally duplicate worktrees).
2156
+ // Copy all component directory overrides, not just the currently-selected server flavor.
2157
+ // This keeps the duplicated stack fully self-contained even if you later switch server flavor.
2158
+ const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
2159
+
2160
+ const updates = [];
2161
+ for (const component of components) {
2162
+ const key = envKeyForComponentDir({ serverComponent, component });
2163
+ const legacyKey = key.replace('HAPPY_STACKS_', 'HAPPY_LOCAL_');
2164
+ const rawDir = (fromEnv[key] ?? fromEnv[legacyKey] ?? '').toString().trim();
2165
+ if (!rawDir) continue;
2166
+
2167
+ let nextDir = rawDir;
2168
+ if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
2169
+ const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir });
2170
+ if (spec) {
2171
+ const [owner, ...restParts] = spec.split('/').filter(Boolean);
2172
+ const rest = restParts.join('/');
2173
+ const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
2174
+
2175
+ const repoDir = join(getComponentsDir(rootDir), component);
2176
+ const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
2177
+ // Base on the existing worktree's HEAD/branch so we get the same commit.
2178
+ nextDir = await createWorktreeFromBaseWorktree({
2179
+ rootDir,
2180
+ component,
2181
+ slug,
2182
+ baseWorktreeSpec: spec,
2183
+ remoteName,
2184
+ depsMode,
2185
+ });
2186
+ }
2187
+ }
2188
+
2189
+ updates.push({ key, value: nextDir });
2190
+ }
2191
+
2192
+ // Apply component dir overrides to the destination stack env file.
2193
+ const toEnvPath = getStackEnvPath(toStack);
2194
+ if (updates.length) {
2195
+ await ensureEnvFileUpdated({ envPath: toEnvPath, updates });
2196
+ }
2197
+
2198
+ const out = {
2199
+ ok: true,
2200
+ from: fromStack,
2201
+ to: toStack,
2202
+ serverComponent,
2203
+ duplicatedWorktrees: duplicateWorktrees,
2204
+ updatedKeys: updates.map((u) => u.key),
2205
+ envPath: toEnvPath,
2206
+ };
2207
+
2208
+ if (json) {
2209
+ printResult({ json, data: out });
2210
+ return;
2211
+ }
2212
+
2213
+ console.log(`[stack] duplicated: ${fromStack} -> ${toStack}`);
2214
+ console.log(`[stack] env: ${toEnvPath}`);
2215
+ if (duplicateWorktrees) {
2216
+ console.log(`[stack] worktrees: duplicated (deps=${depsMode || 'none'})`);
2217
+ } else {
2218
+ console.log('[stack] worktrees: not duplicated (reusing existing component dirs)');
2219
+ }
2220
+ }
2221
+
2222
+ async function cmdInfo({ rootDir, argv }) {
2223
+ const { flags } = parseArgs(argv);
2224
+ const json = wantsJson(argv, { flags });
2225
+ const positionals = argv.filter((a) => !a.startsWith('--'));
2226
+ const stackName = (positionals[1] ?? '').trim();
2227
+ if (!stackName) {
2228
+ throw new Error('[stack] usage: happys stack info <name> [--json]');
2229
+ }
2230
+ if (!stackExistsSync(stackName)) {
2231
+ throw new Error(`[stack] info: stack does not exist: ${stackName}`);
2232
+ }
2233
+
2234
+ const out = await cmdInfoInternal({ rootDir, stackName });
2235
+ if (json) {
2236
+ printResult({ json, data: out });
2237
+ return;
2238
+ }
2239
+
2240
+ console.log(`[stack] info: ${stackName}`);
2241
+ console.log(`- env: ${out.envPath}`);
2242
+ console.log(`- runtime: ${out.runtimeStatePath}`);
2243
+ console.log(`- server: ${out.serverComponent}`);
2244
+ console.log(`- running: ${out.runtime.running ? 'yes' : 'no'}${out.runtime.ownerPid ? ` (pid=${out.runtime.ownerPid})` : ''}`);
2245
+ if (out.ports.server) console.log(`- port: server=${out.ports.server}${out.ports.backend ? ` backend=${out.ports.backend}` : ''}`);
2246
+ if (out.ports.ui) console.log(`- port: ui=${out.ports.ui}`);
2247
+ if (out.urls.uiUrl) console.log(`- ui: ${out.urls.uiUrl}`);
2248
+ if (out.urls.internalServerUrl) console.log(`- internal: ${out.urls.internalServerUrl}`);
2249
+ if (out.pinned.serverPort) console.log(`- pinned: serverPort=${out.pinned.serverPort}`);
2250
+ console.log('- components:');
2251
+ for (const c of out.components) {
2252
+ console.log(` - ${c.component}: ${c.dir}${c.worktreeSpec ? ` (${c.worktreeSpec})` : ''}`);
2253
+ }
2254
+ }
2255
+
2256
+ async function cmdPrStack({ rootDir, argv }) {
2257
+ // Supports passing args to the eventual `stack dev/start` via `-- ...`.
2258
+ const sep = argv.indexOf('--');
2259
+ const argv0 = sep >= 0 ? argv.slice(0, sep) : argv;
2260
+ const passthrough = sep >= 0 ? argv.slice(sep + 1) : [];
2261
+
2262
+ const { flags, kv } = parseArgs(argv0);
2263
+ const json = wantsJson(argv0, { flags });
2264
+
2265
+ if (wantsHelp(argv0, { flags })) {
2266
+ printResult({
2267
+ json,
2268
+ data: {
2269
+ usage:
2270
+ '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...>]',
2271
+ },
2272
+ text: [
2273
+ '[stack] usage:',
2274
+ ' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start]',
2275
+ ' [--seed-auth] [--copy-auth-from=<stack|legacy>] [--link-auth] [--with-infra] [--auth-force]',
2276
+ ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force]',
2277
+ ' [--json] [-- <stack dev/start args...>]',
2278
+ '',
2279
+ 'examples:',
2280
+ ' # Create stack + check out PRs + start dev UI',
2281
+ ' happys stack pr pr123 \\',
2282
+ ' --happy=https://github.com/slopus/happy/pull/123 \\',
2283
+ ' --happy-cli=https://github.com/slopus/happy-cli/pull/456 \\',
2284
+ ' --seed-auth --copy-auth-from=dev-auth \\',
2285
+ ' --dev',
2286
+ '',
2287
+ ' # Use numeric PR refs (remote defaults to upstream)',
2288
+ ' happys stack pr pr123 --happy=123 --happy-cli=456 --seed-auth --copy-auth-from=dev-auth --dev',
2289
+ '',
2290
+ ' # Reuse an existing non-stacks Happy install for auth seeding',
2291
+ ' happys stack pr pr123 --happy=123 --seed-auth --copy-auth-from=legacy --link-auth --dev',
2292
+ '',
2293
+ 'notes:',
2294
+ ' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
2295
+ ' - For auth seeding, pass `--seed-auth` and optionally `--copy-auth-from=dev-auth` (or legacy/main)',
2296
+ ' - `--link-auth` symlinks auth files instead of copying (keeps credentials in sync, but reduces isolation)',
2297
+ ].join('\n'),
2298
+ });
2299
+ return;
2300
+ }
2301
+
2302
+ const positionals = argv0.filter((a) => !a.startsWith('--'));
2303
+ const stackName = (positionals[1] ?? '').trim();
2304
+ if (!stackName) {
2305
+ throw new Error('[stack] pr: missing stack name. Usage: happys stack pr <name> --happy=<pr>');
2306
+ }
2307
+ if (stackName === 'main') {
2308
+ throw new Error('[stack] pr: stack name "main" is reserved; pick a unique name for this PR stack');
2309
+ }
2310
+ const reuseExisting = flags.has('--reuse') || flags.has('--update-existing') || (kv.get('--reuse') ?? '').trim() === '1';
2311
+ const stackExists = stackExistsSync(stackName);
2312
+ if (stackExists && !reuseExisting) {
2313
+ throw new Error(
2314
+ `[stack] pr: stack already exists: ${stackName}\n` +
2315
+ `[stack] tip: re-run with --reuse to update the existing PR worktrees and keep the stack wiring intact`
2316
+ );
2317
+ }
2318
+
2319
+ const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
2320
+ const depsMode = (kv.get('--deps') ?? '').trim();
2321
+
2322
+ const prHappy = (kv.get('--happy') ?? '').trim();
2323
+ const prCli = (kv.get('--happy-cli') ?? '').trim();
2324
+ const prServerLight = (kv.get('--happy-server-light') ?? '').trim();
2325
+ const prServer = (kv.get('--happy-server') ?? '').trim();
2326
+
2327
+ if (!prHappy && !prCli && !prServerLight && !prServer) {
2328
+ throw new Error(
2329
+ '[stack] pr: missing PR inputs. Provide at least one of: --happy, --happy-cli, --happy-server-light, --happy-server'
2330
+ );
2331
+ }
2332
+ if (prServerLight && prServer) {
2333
+ throw new Error('[stack] pr: cannot specify both --happy-server and --happy-server-light');
2334
+ }
2335
+
2336
+ const serverFromArg = (kv.get('--server') ?? '').trim();
2337
+ const inferredServer = prServer ? 'happy-server' : prServerLight ? 'happy-server-light' : '';
2338
+ const serverComponent = (serverFromArg || inferredServer || 'happy-server-light').trim();
2339
+ if (serverComponent !== 'happy-server' && serverComponent !== 'happy-server-light') {
2340
+ throw new Error(`[stack] pr: invalid --server: ${serverFromArg || serverComponent}`);
2341
+ }
2342
+
2343
+ const wantsDev = flags.has('--dev') || flags.has('--start-dev');
2344
+ const wantsStart = flags.has('--start') || flags.has('--prod');
2345
+ if (wantsDev && wantsStart) {
2346
+ throw new Error('[stack] pr: choose either --dev or --start (not both)');
2347
+ }
2348
+
2349
+ const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
2350
+ const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
2351
+ const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
2352
+ const authForce = flags.has('--auth-force') || flags.has('--force-auth');
2353
+ const authLinkFlag = flags.has('--link-auth') || flags.has('--link') || flags.has('--symlink-auth') ? true : null;
2354
+ const authLinkEnv =
2355
+ (process.env.HAPPY_STACKS_AUTH_LINK ?? process.env.HAPPY_LOCAL_AUTH_LINK ?? '').toString().trim() === '1' ||
2356
+ (process.env.HAPPY_STACKS_AUTH_MODE ?? process.env.HAPPY_LOCAL_AUTH_MODE ?? '').toString().trim() === 'link';
2357
+
2358
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !json;
2359
+
2360
+ const mainAccessKeyPath = join(resolveStackEnvPath('main').baseDir, 'cli', 'access.key');
2361
+ const legacyAccessKeyPath = join(getLegacyHappyBaseDir(), 'cli', 'access.key');
2362
+ const devAuthAccessKeyPath = join(resolveStackEnvPath('dev-auth').baseDir, 'cli', 'access.key');
2363
+
2364
+ const hasMainAccessKey = existsSync(mainAccessKeyPath);
2365
+ const allowGlobal = sandboxAllowsGlobalSideEffects();
2366
+ const hasLegacyAccessKey = (!isSandboxed() || allowGlobal) && existsSync(legacyAccessKeyPath);
2367
+ const hasDevAuthAccessKey = existsSync(devAuthAccessKeyPath) && existsSync(resolveStackEnvPath('dev-auth').envPath);
2368
+
2369
+ const inferredSeedFromEnv = resolveAuthSeedFromEnv(process.env);
2370
+ const inferredSeedFromAvailability = hasDevAuthAccessKey ? 'dev-auth' : hasMainAccessKey ? 'main' : hasLegacyAccessKey ? 'legacy' : 'main';
2371
+ const defaultAuthFrom = authFromFlag || inferredSeedFromEnv || inferredSeedFromAvailability;
2372
+
2373
+ // Default behavior for stack pr:
2374
+ // - if user explicitly flags --seed-auth/--no-seed-auth, obey
2375
+ // - otherwise in interactive mode: prompt when we have *some* plausible source, default yes
2376
+ // - in non-interactive mode: follow HAPPY_STACKS_AUTO_AUTH_SEED (if set), else default false
2377
+ const envAutoSeed =
2378
+ (process.env.HAPPY_STACKS_AUTO_AUTH_SEED ?? process.env.HAPPY_LOCAL_AUTO_AUTH_SEED ?? '').toString().trim();
2379
+ const autoSeedEnabled = envAutoSeed ? envAutoSeed !== '0' : false;
2380
+
2381
+ let seedAuth = seedAuthFlag != null ? seedAuthFlag : autoSeedEnabled;
2382
+ let authFrom = defaultAuthFrom;
2383
+ let authLink = authLinkFlag != null ? authLinkFlag : authLinkEnv;
2384
+
2385
+ if (seedAuthFlag == null && isInteractive) {
2386
+ const anySource = hasDevAuthAccessKey || hasMainAccessKey || hasLegacyAccessKey;
2387
+ if (anySource) {
2388
+ seedAuth = await withRl(async (rl) => {
2389
+ return await promptSelect(rl, {
2390
+ title: 'Seed authentication into this PR stack so it works without a re-login?',
2391
+ options: [
2392
+ { label: 'yes (recommended)', value: true },
2393
+ { label: 'no (I will login manually for this stack)', value: false },
2394
+ ],
2395
+ defaultIndex: 0,
2396
+ });
2397
+ });
2398
+ } else {
2399
+ seedAuth = false;
2400
+ }
2401
+ }
2402
+
2403
+ if (seedAuth && !authFromFlag && isInteractive) {
2404
+ const options = [];
2405
+ if (hasDevAuthAccessKey) {
2406
+ options.push({ label: 'dev-auth (recommended) — use your dedicated dev auth seed stack', value: 'dev-auth' });
2407
+ }
2408
+ if (hasMainAccessKey) {
2409
+ options.push({ label: 'main — use Happy Stacks main credentials', value: 'main' });
2410
+ }
2411
+ if (hasLegacyAccessKey) {
2412
+ options.push({ label: 'legacy — use ~/.happy credentials (best-effort)', value: 'legacy' });
2413
+ }
2414
+ options.push({ label: 'skip seeding (manual login)', value: 'skip' });
2415
+
2416
+ const defaultIdx = Math.max(
2417
+ 0,
2418
+ options.findIndex((o) => o.value === (hasDevAuthAccessKey ? 'dev-auth' : hasMainAccessKey ? 'main' : hasLegacyAccessKey ? 'legacy' : 'skip'))
2419
+ );
2420
+ const picked = await withRl(async (rl) => {
2421
+ return await promptSelect(rl, {
2422
+ title: 'Which auth source should this PR stack use?',
2423
+ options,
2424
+ defaultIndex: defaultIdx,
2425
+ });
2426
+ });
2427
+ if (picked === 'skip') {
2428
+ seedAuth = false;
2429
+ } else {
2430
+ authFrom = String(picked);
2431
+ }
2432
+ }
2433
+
2434
+ if (seedAuth && authLinkFlag == null && isInteractive) {
2435
+ authLink = await withRl(async (rl) => {
2436
+ return await promptSelect(rl, {
2437
+ title: 'When seeding, reuse credentials via symlink or copy?',
2438
+ options: [
2439
+ { label: 'symlink (recommended) — stays up to date', value: true },
2440
+ { label: 'copy — more isolated per stack', value: false },
2441
+ ],
2442
+ defaultIndex: authLink ? 0 : 1,
2443
+ });
2444
+ });
2445
+ }
2446
+
2447
+ const progress = (line) => {
2448
+ // In JSON mode, never pollute stdout (reserved for final JSON).
2449
+ // eslint-disable-next-line no-console
2450
+ (json ? console.error : console.log)(line);
2451
+ };
2452
+
2453
+ // 1) Create (or reuse) the stack.
2454
+ let created = null;
2455
+ if (!stackExists) {
2456
+ progress(`[stack] pr: creating stack "${stackName}" (server=${serverComponent})...`);
2457
+ created = await cmdNew({
2458
+ rootDir,
2459
+ argv: [stackName, '--no-copy-auth', `--server=${serverComponent}`, ...(json ? ['--json'] : [])],
2460
+ // Prevent cmdNew from printing in JSON mode (we’ll print the final combined object below).
2461
+ emit: !json,
2462
+ });
2463
+ } else {
2464
+ progress(`[stack] pr: reusing existing stack "${stackName}"...`);
2465
+ // Ensure requested server flavor is compatible with the existing stack.
2466
+ const existing = await cmdInfoInternal({ rootDir, stackName });
2467
+ if (existing.serverComponent !== serverComponent) {
2468
+ throw new Error(
2469
+ `[stack] pr: existing stack "${stackName}" uses server=${existing.serverComponent}, but command requested server=${serverComponent}.\n` +
2470
+ `Fix: create a new stack name, or switch the stack's server flavor first (happys stack srv ${stackName} -- use ...).`
2471
+ );
2472
+ }
2473
+ created = { ok: true, stackName, reused: true, serverComponent: existing.serverComponent };
2474
+ }
2475
+
2476
+ // 2) Checkout PR worktrees and pin them to the stack env file.
2477
+ const prSpecs = [
2478
+ { component: 'happy', pr: prHappy },
2479
+ { component: 'happy-cli', pr: prCli },
2480
+ ...(serverComponent === 'happy-server' ? [{ component: 'happy-server', pr: prServer }] : []),
2481
+ ...(serverComponent === 'happy-server-light' ? [{ component: 'happy-server-light', pr: prServerLight }] : []),
2482
+ ].filter((x) => x.pr);
2483
+
2484
+ const worktrees = [];
2485
+ for (const { component, pr } of prSpecs) {
2486
+ progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
2487
+ const out = await withStackEnv({
2488
+ stackName,
2489
+ fn: async ({ env }) => {
2490
+ const doUpdate = reuseExisting || flags.has('--update');
2491
+ const args = [
2492
+ 'pr',
2493
+ component,
2494
+ pr,
2495
+ `--remote=${remoteName}`,
2496
+ '--use',
2497
+ ...(depsMode ? [`--deps=${depsMode}`] : []),
2498
+ ...(doUpdate ? ['--update'] : []),
2499
+ ...(flags.has('--force') ? ['--force'] : []),
2500
+ '--json',
2501
+ ];
2502
+ const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
2503
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
2504
+ },
2505
+ });
2506
+ if (json) {
2507
+ worktrees.push(out);
2508
+ } else if (out) {
2509
+ const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
2510
+ const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
2511
+ if (changed) {
2512
+ // eslint-disable-next-line no-console
2513
+ console.log(`[stack] pr: ${stackName}: ${component}: updated ${short(out.oldHead)} -> ${short(out.newHead)}`);
2514
+ } else if (out.updated) {
2515
+ // eslint-disable-next-line no-console
2516
+ console.log(`[stack] pr: ${stackName}: ${component}: already up to date (${short(out.newHead)})`);
2517
+ } else {
2518
+ // eslint-disable-next-line no-console
2519
+ console.log(`[stack] pr: ${stackName}: ${component}: checked out (${short(out.newHead)})`);
2520
+ }
2521
+ }
2522
+ }
2523
+
2524
+ // 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
2525
+ let auth = null;
2526
+ if (seedAuth) {
2527
+ progress(`[stack] pr: ${stackName}: seeding auth from "${authFrom}"...`);
2528
+ const args = [
2529
+ 'copy-from',
2530
+ authFrom,
2531
+ ...(authForce ? ['--force'] : []),
2532
+ ...(withInfra ? ['--with-infra'] : []),
2533
+ ...(authLink ? ['--link'] : []),
2534
+ ];
2535
+ if (json) {
2536
+ auth = await withStackEnv({
2537
+ stackName,
2538
+ fn: async ({ env }) => {
2539
+ const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
2540
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
2541
+ },
2542
+ });
2543
+ } else {
2544
+ await cmdAuth({ rootDir, stackName, args });
2545
+ auth = { ok: true, from: authFrom };
2546
+ }
2547
+ }
2548
+
2549
+ // 4) Optional: start dev / start.
2550
+ if (wantsDev) {
2551
+ progress(`[stack] pr: ${stackName}: starting dev...`);
2552
+ const args = passthrough.length ? ['--', ...passthrough] : [];
2553
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args });
2554
+ } else if (wantsStart) {
2555
+ progress(`[stack] pr: ${stackName}: starting...`);
2556
+ const args = passthrough.length ? ['--', ...passthrough] : [];
2557
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args });
2558
+ }
2559
+
2560
+ const info = await cmdInfoInternal({ rootDir, stackName });
2561
+
2562
+ const out = {
2563
+ ok: true,
2564
+ stackName,
2565
+ created,
2566
+ worktrees: worktrees.length ? worktrees : null,
2567
+ auth,
2568
+ info,
2569
+ };
2570
+
2571
+ if (json) {
2572
+ printResult({ json, data: out });
2573
+ return;
2574
+ }
2575
+ // Non-JSON mode already streamed output.
2576
+ }
2577
+
2578
+ async function cmdInfoInternal({ rootDir, stackName }) {
2579
+ // Minimal extraction from cmdInfo to avoid re-parsing argv/printing. Used by cmdPrStack.
2580
+ const baseDir = getStackDir(stackName);
2581
+ const envPath = getStackEnvPath(stackName);
2582
+ const envRaw = await readExistingEnv(envPath);
2583
+ const stackEnv = envRaw ? parseEnvToObject(envRaw) : {};
2584
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
2585
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
2586
+
2587
+ const serverComponent =
2588
+ getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
2589
+
2590
+ const pinnedServerPortRaw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_PORT', 'HAPPY_LOCAL_SERVER_PORT']);
2591
+ const pinnedServerPort = pinnedServerPortRaw ? Number(pinnedServerPortRaw) : null;
2592
+
2593
+ const ownerPid = Number(runtimeState?.ownerPid);
2594
+ const running = isPidAlive(ownerPid);
2595
+ const runtimePorts = runtimeState?.ports && typeof runtimeState.ports === 'object' ? runtimeState.ports : {};
2596
+ const serverPort =
2597
+ Number.isFinite(pinnedServerPort) && pinnedServerPort > 0
2598
+ ? pinnedServerPort
2599
+ : Number(runtimePorts?.server) > 0
2600
+ ? Number(runtimePorts.server)
2601
+ : null;
2602
+ const backendPort = Number(runtimePorts?.backend) > 0 ? Number(runtimePorts.backend) : null;
2603
+ const uiPort =
2604
+ runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
2605
+ ? Number(runtimeState.expo.webPort)
2606
+ : null;
2607
+
2608
+ const host = resolveLocalhostHost({ stackMode: true, stackName });
2609
+ const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
2610
+ const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
2611
+
2612
+ const componentSpecs = [
2613
+ { component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
2614
+ { component: 'happy-cli', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI'] },
2615
+ {
2616
+ component: 'happy-server-light',
2617
+ keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT'],
2618
+ },
2619
+ { component: 'happy-server', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER'] },
2620
+ ];
2621
+
2622
+ const components = componentSpecs.map((c) => {
2623
+ const dir = getEnvValueAny(stackEnv, c.keys) || getComponentDir(rootDir, c.component);
2624
+ const spec = worktreeSpecFromDir({ rootDir, component: c.component, dir }) || null;
2625
+ return { component: c.component, dir, worktreeSpec: spec };
2626
+ });
2627
+
2628
+ return {
2629
+ ok: true,
2630
+ stackName,
2631
+ baseDir,
2632
+ envPath,
2633
+ runtimeStatePath,
2634
+ serverComponent,
2635
+ pinned: {
2636
+ serverPort: Number.isFinite(pinnedServerPort) && pinnedServerPort > 0 ? pinnedServerPort : null,
2637
+ },
2638
+ runtime: {
2639
+ script: typeof runtimeState?.script === 'string' ? runtimeState.script : null,
2640
+ ownerPid: Number.isFinite(ownerPid) && ownerPid > 1 ? ownerPid : null,
2641
+ running,
2642
+ ports: runtimePorts,
2643
+ expo: runtimeState?.expo ?? null,
2644
+ processes: runtimeState?.processes ?? null,
2645
+ startedAt: runtimeState?.startedAt ?? null,
2646
+ updatedAt: runtimeState?.updatedAt ?? null,
2647
+ },
2648
+ urls: {
2649
+ host,
2650
+ internalServerUrl,
2651
+ uiUrl,
2652
+ },
2653
+ ports: {
2654
+ server: serverPort,
2655
+ backend: backendPort,
2656
+ ui: uiPort,
2657
+ },
2658
+ components,
2659
+ };
2660
+ }
2661
+
1201
2662
  async function main() {
1202
2663
  const rootDir = getRootDir(import.meta.url);
1203
2664
  // pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
@@ -1210,7 +2671,13 @@ async function main() {
1210
2671
  const cmd = positionals[0] || 'help';
1211
2672
  const json = wantsJson(argv, { flags });
1212
2673
 
1213
- if (wantsHelp(argv, { flags }) || cmd === 'help') {
2674
+ const wantsHelpFlag = wantsHelp(argv, { flags });
2675
+ // Allow subcommand-specific help (so `happys stack pr --help` shows PR stack flags).
2676
+ if (wantsHelpFlag && cmd === 'pr') {
2677
+ await cmdPrStack({ rootDir, argv });
2678
+ return;
2679
+ }
2680
+ if (wantsHelpFlag || cmd === 'help') {
1214
2681
  printResult({
1215
2682
  json,
1216
2683
  data: {
@@ -1220,13 +2687,20 @@ async function main() {
1220
2687
  'list',
1221
2688
  'migrate',
1222
2689
  'audit',
2690
+ 'duplicate',
2691
+ 'info',
2692
+ 'pr',
2693
+ 'create-dev-auth-seed',
1223
2694
  'auth',
1224
2695
  'dev',
1225
2696
  'start',
1226
2697
  'build',
1227
2698
  'typecheck',
2699
+ 'lint',
2700
+ 'test',
1228
2701
  'doctor',
1229
2702
  'mobile',
2703
+ 'resume',
1230
2704
  'stop',
1231
2705
  'srv',
1232
2706
  'wt',
@@ -1236,19 +2710,26 @@ async function main() {
1236
2710
  },
1237
2711
  text: [
1238
2712
  '[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]',
2713
+ ' 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
2714
  ' happys stack edit <name> --interactive [--json]',
1241
2715
  ' happys stack list [--json]',
1242
2716
  ' 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]',
2717
+ ' happys stack audit [--fix] [--fix-main] [--fix-ports] [--fix-workspace] [--fix-paths] [--unpin-ports] [--unpin-ports-except=stack1,stack2] [--json]',
2718
+ ' happys stack duplicate <from> <to> [--duplicate-worktrees] [--deps=none|link|install|link-or-install] [--json]',
2719
+ ' happys stack info <name> [--json]',
2720
+ ' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
2721
+ ' happys stack create-dev-auth-seed [name] [--server=happy-server|happy-server-light] [--non-interactive] [--json]',
2722
+ ' happys stack auth <name> status|login|copy-from [--json]',
1245
2723
  ' happys stack dev <name> [-- ...]',
1246
2724
  ' happys stack start <name> [-- ...]',
1247
2725
  ' happys stack build <name> [-- ...]',
1248
2726
  ' happys stack typecheck <name> [component...] [--json]',
2727
+ ' happys stack lint <name> [component...] [--json]',
2728
+ ' happys stack test <name> [component...] [--json]',
1249
2729
  ' happys stack doctor <name> [-- ...]',
1250
2730
  ' happys stack mobile <name> [-- ...]',
1251
- ' happys stack stop <name> [--aggressive] [--no-docker] [--json]',
2731
+ ' happys stack resume <name> <sessionId...> [--json]',
2732
+ ' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
1252
2733
  ' happys stack srv <name> -- status|use ...',
1253
2734
  ' happys stack wt <name> -- <wt args...>',
1254
2735
  ' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
@@ -1272,6 +2753,7 @@ async function main() {
1272
2753
  try {
1273
2754
  const stacksDir = getStacksStorageRoot();
1274
2755
  const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
2756
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
1275
2757
  const namesSet = new Set();
1276
2758
  const entries = await readdir(stacksDir, { withFileTypes: true });
1277
2759
  for (const e of entries) {
@@ -1280,10 +2762,12 @@ async function main() {
1280
2762
  namesSet.add(e.name);
1281
2763
  }
1282
2764
  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);
2765
+ if (allowLegacy) {
2766
+ const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
2767
+ for (const e of legacyEntries) {
2768
+ if (!e.isDirectory()) continue;
2769
+ namesSet.add(e.name);
2770
+ }
1287
2771
  }
1288
2772
  } catch {
1289
2773
  // ignore
@@ -1308,6 +2792,22 @@ async function main() {
1308
2792
  await cmdAudit({ rootDir, argv });
1309
2793
  return;
1310
2794
  }
2795
+ if (cmd === 'duplicate') {
2796
+ await cmdDuplicate({ rootDir, argv });
2797
+ return;
2798
+ }
2799
+ if (cmd === 'info') {
2800
+ await cmdInfo({ rootDir, argv });
2801
+ return;
2802
+ }
2803
+ if (cmd === 'pr') {
2804
+ await cmdPrStack({ rootDir, argv });
2805
+ return;
2806
+ }
2807
+ if (cmd === 'create-dev-auth-seed') {
2808
+ await cmdCreateDevAuthSeed({ rootDir, argv });
2809
+ return;
2810
+ }
1311
2811
 
1312
2812
  // Commands that need a stack name.
1313
2813
  const stackName = stackNameFromArg(positionals, 1);
@@ -1377,6 +2877,18 @@ async function main() {
1377
2877
  await cmdRunScript({ rootDir, stackName, scriptPath: 'typecheck.mjs', args: passthrough, extraEnv: overrides });
1378
2878
  return;
1379
2879
  }
2880
+ if (cmd === 'lint') {
2881
+ const { kv } = parseArgs(passthrough);
2882
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
2883
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'lint.mjs', args: passthrough, extraEnv: overrides });
2884
+ return;
2885
+ }
2886
+ if (cmd === 'test') {
2887
+ const { kv } = parseArgs(passthrough);
2888
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
2889
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
2890
+ return;
2891
+ }
1380
2892
  if (cmd === 'doctor') {
1381
2893
  await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
1382
2894
  return;
@@ -1385,16 +2897,42 @@ async function main() {
1385
2897
  await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
1386
2898
  return;
1387
2899
  }
2900
+ if (cmd === 'resume') {
2901
+ const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
2902
+ if (sessionIds.length === 0) {
2903
+ printResult({
2904
+ json,
2905
+ data: { ok: false, error: 'missing_session_ids' },
2906
+ text: [
2907
+ '[stack] usage:',
2908
+ ' happys stack resume <name> <sessionId...>',
2909
+ ].join('\n'),
2910
+ });
2911
+ process.exit(1);
2912
+ }
2913
+ const out = await withStackEnv({
2914
+ stackName,
2915
+ fn: async ({ env }) => {
2916
+ const cliDir = getComponentDir(rootDir, 'happy-cli');
2917
+ const happyBin = join(cliDir, 'bin', 'happy.mjs');
2918
+ // Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
2919
+ return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });
2920
+ },
2921
+ });
2922
+ if (json) printResult({ json, data: { ok: true, resumed: sessionIds, out } });
2923
+ return;
2924
+ }
1388
2925
 
1389
2926
  if (cmd === 'stop') {
1390
2927
  const { flags: stopFlags } = parseArgs(passthrough);
1391
2928
  const noDocker = stopFlags.has('--no-docker');
1392
2929
  const aggressive = stopFlags.has('--aggressive');
2930
+ const sweepOwned = stopFlags.has('--sweep-owned');
1393
2931
  const baseDir = getStackDir(stackName);
1394
2932
  const out = await withStackEnv({
1395
2933
  stackName,
1396
2934
  fn: async ({ env }) => {
1397
- return await stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker, aggressive });
2935
+ return await stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker, aggressive, sweepOwned });
1398
2936
  },
1399
2937
  });
1400
2938
  if (json) printResult({ json, data: { ok: true, stopped: out } });