happy-stacks 0.1.0 → 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 (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  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/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -0
  64. package/scripts/utils/dev_daemon.mjs +104 -0
  65. package/scripts/utils/dev_expo_web.mjs +112 -0
  66. package/scripts/utils/dev_server.mjs +183 -0
  67. package/scripts/utils/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/stack.mjs CHANGED
@@ -1,15 +1,64 @@
1
1
  import './utils/env.mjs';
2
- import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
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
-
6
- import { parseArgs } from './utils/args.mjs';
7
- import { run } from './utils/proc.mjs';
8
- import { getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
9
- import { createWorktree, resolveComponentSpecToDir } from './utils/worktrees.mjs';
10
- import { isTty, prompt, promptWorktreeSource, withRl } from './utils/wizard.mjs';
5
+ import { existsSync } from 'node:fs';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { homedir } from 'node:os';
8
+
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';
11
22
  import { parseDotenv } from './utils/dotenv.mjs';
12
- import { printResult, wantsHelp, wantsJson } from './utils/cli.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';
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
+ }
13
62
 
14
63
  function stackNameFromArg(positionals, idx) {
15
64
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
@@ -35,32 +84,212 @@ function getDefaultPortStart() {
35
84
  }
36
85
 
37
86
  async function isPortFree(port) {
38
- return await new Promise((resolvePromise) => {
39
- const srv = net.createServer();
40
- srv.unref();
41
- srv.on('error', () => resolvePromise(false));
42
- srv.listen({ port, host: '127.0.0.1' }, () => {
43
- srv.close(() => resolvePromise(true));
44
- });
45
- });
87
+ return await isTcpPortFree(port, { host: '127.0.0.1' });
46
88
  }
47
89
 
48
- async function pickNextFreePort(startPort) {
49
- let port = startPort;
50
- for (let i = 0; i < 200; i++) {
51
- // eslint-disable-next-line no-await-in-loop
52
- if (await isPortFree(port)) {
53
- return port;
90
+ async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
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]'));
96
+ }
97
+ }
98
+
99
+ async function readPortFromEnvFile(envPath) {
100
+ const raw = await readExistingEnv(envPath);
101
+ if (!raw.trim()) return null;
102
+ const parsed = parseEnvToObject(raw);
103
+ const portRaw = (parsed.HAPPY_STACKS_SERVER_PORT ?? parsed.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
104
+ const n = portRaw ? Number(portRaw) : NaN;
105
+ return Number.isFinite(n) && n > 0 ? n : null;
106
+ }
107
+
108
+ async function readPortsFromEnvFile(envPath) {
109
+ const raw = await readExistingEnv(envPath);
110
+ if (!raw.trim()) return [];
111
+ const parsed = parseEnvToObject(raw);
112
+ const keys = [
113
+ 'HAPPY_STACKS_SERVER_PORT',
114
+ 'HAPPY_LOCAL_SERVER_PORT',
115
+ 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
116
+ 'HAPPY_STACKS_PG_PORT',
117
+ 'HAPPY_STACKS_REDIS_PORT',
118
+ 'HAPPY_STACKS_MINIO_PORT',
119
+ 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
120
+ ];
121
+ const ports = [];
122
+ for (const k of keys) {
123
+ const rawV = (parsed[k] ?? '').toString().trim();
124
+ const n = rawV ? Number(rawV) : NaN;
125
+ if (Number.isFinite(n) && n > 0) ports.push(n);
126
+ }
127
+ return ports;
128
+ }
129
+
130
+ async function collectReservedStackPorts({ excludeStackName = null } = {}) {
131
+ const reserved = new Set();
132
+
133
+ const roots = [
134
+ // New layout: ~/.happy/stacks/<name>/env (or overridden via HAPPY_STACKS_STORAGE_DIR)
135
+ getStacksStorageRoot(),
136
+ // Legacy layout: ~/.happy/local/stacks/<name>/env
137
+ join(getLegacyStorageRoot(), 'stacks'),
138
+ ];
139
+
140
+ for (const root of roots) {
141
+ let entries = [];
142
+ try {
143
+ // eslint-disable-next-line no-await-in-loop
144
+ entries = await readdir(root, { withFileTypes: true });
145
+ } catch {
146
+ entries = [];
147
+ }
148
+ for (const e of entries) {
149
+ if (!e.isDirectory()) continue;
150
+ const name = e.name;
151
+ if (excludeStackName && name === excludeStackName) continue;
152
+ const envPath = join(root, name, 'env');
153
+ // eslint-disable-next-line no-await-in-loop
154
+ const ports = await readPortsFromEnvFile(envPath);
155
+ for (const p of ports) reserved.add(p);
54
156
  }
55
- port += 1;
56
157
  }
57
- throw new Error(`[stack] unable to find a free port starting at ${startPort}`);
158
+
159
+ return reserved;
160
+ }
161
+
162
+ function base64Url(buf) {
163
+ return Buffer.from(buf)
164
+ .toString('base64')
165
+ .replaceAll('+', '-')
166
+ .replaceAll('/', '_')
167
+ .replaceAll('=', '');
168
+ }
169
+
170
+ function randomToken(lenBytes = 24) {
171
+ return base64Url(randomBytes(lenBytes));
172
+ }
173
+
174
+ function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
175
+ const s = String(raw ?? '')
176
+ .toLowerCase()
177
+ .replace(/[^a-z0-9-]+/g, '-')
178
+ .replace(/-+/g, '-')
179
+ .replace(/^-+/, '')
180
+ .replace(/-+$/, '');
181
+ return s || fallback;
58
182
  }
59
183
 
60
184
  async function ensureDir(p) {
61
185
  await mkdir(p, { recursive: true });
62
186
  }
63
187
 
188
+ async function readTextIfExists(path) {
189
+ try {
190
+ if (!existsSync(path)) return null;
191
+ const raw = await readFile(path, 'utf-8');
192
+ const t = raw.trim();
193
+ return t ? t : null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // auth file copy/link helpers live in scripts/utils/auth_files.mjs
200
+
201
+ function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
202
+ const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
203
+ return fromEnv || join(stackBaseDir, 'cli');
204
+ }
205
+
206
+ function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
207
+ const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
208
+ return fromEnv || join(stackBaseDir, 'server-light');
209
+ }
210
+
211
+ async function copyAuthFromStackIntoNewStack({
212
+ fromStackName,
213
+ stackName,
214
+ stackEnv,
215
+ serverComponent,
216
+ json,
217
+ requireSourceStackExists,
218
+ linkMode = false,
219
+ }) {
220
+ const { secret, source } = await resolveHandyMasterSecretFromStack({
221
+ stackName: fromStackName,
222
+ requireStackExists: requireSourceStackExists,
223
+ allowLegacyAuthSource: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
224
+ allowLegacyMainFallback: !isSandboxed() || sandboxAllowsGlobalSideEffects(),
225
+ });
226
+
227
+ const copied = { secret: false, accessKey: false, settings: false, sourceStack: fromStackName };
228
+
229
+ if (secret) {
230
+ if (serverComponent === 'happy-server-light') {
231
+ const dataDir = stackEnv.HAPPY_SERVER_LIGHT_DATA_DIR;
232
+ const target = join(dataDir, 'handy-master-secret.txt');
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 });
238
+ } else if (serverComponent === 'happy-server') {
239
+ const target = stackEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE;
240
+ if (target) {
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
+ }
247
+ }
248
+ }
249
+
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));
260
+ const sourceEnv = parseEnvToObject(sourceEnvRaw);
261
+ const sourceCli = legacy ? join(sourceBaseDir, 'cli') : getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
262
+ const targetCli = stackEnv.HAPPY_STACKS_CLI_HOME_DIR;
263
+
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
+ }
279
+
280
+ if (!json) {
281
+ const any = copied.secret || copied.accessKey || copied.settings;
282
+ if (any) {
283
+ console.log(`[stack] copied auth from "${fromStackName}" into "${stackName}" (no re-login needed)`);
284
+ if (copied.secret) console.log(` - master secret: copied (${source || 'unknown source'})`);
285
+ if (copied.accessKey) console.log(` - cli: copied access.key`);
286
+ if (copied.settings) console.log(` - cli: copied settings.json`);
287
+ }
288
+ }
289
+
290
+ return copied;
291
+ }
292
+
64
293
  function stringifyEnv(env) {
65
294
  const lines = [];
66
295
  for (const [k, v] of Object.entries(env)) {
@@ -87,6 +316,24 @@ function parseEnvToObject(raw) {
87
316
  return Object.fromEntries(parsed.entries());
88
317
  }
89
318
 
319
+ function stackExistsSync(stackName) {
320
+ if (stackName === 'main') return true;
321
+ const envPath = getStackEnvPath(stackName);
322
+ return existsSync(envPath);
323
+ }
324
+
325
+ function resolveDefaultComponentDirs({ rootDir }) {
326
+ const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
327
+ const out = {};
328
+ for (const name of componentNames) {
329
+ const embedded = join(rootDir, 'components', name);
330
+ const workspace = join(getComponentsDir(rootDir), name);
331
+ const dir = existsSync(embedded) ? embedded : workspace;
332
+ out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
333
+ }
334
+ return out;
335
+ }
336
+
90
337
  async function writeStackEnv({ stackName, env }) {
91
338
  const stackDir = getStackDir(stackName);
92
339
  await ensureDir(stackDir);
@@ -101,6 +348,15 @@ async function writeStackEnv({ stackName, env }) {
101
348
 
102
349
  async function withStackEnv({ stackName, fn, extraEnv = {} }) {
103
350
  const envPath = getStackEnvPath(stackName);
351
+ if (!stackExistsSync(stackName)) {
352
+ throw new Error(
353
+ `[stack] stack "${stackName}" does not exist yet.\n` +
354
+ `[stack] Create it first:\n` +
355
+ ` happys stack new ${stackName}\n` +
356
+ ` # or:\n` +
357
+ ` happys stack new ${stackName} --interactive\n`
358
+ );
359
+ }
104
360
  // IMPORTANT: stack env file should be authoritative. If the user has HAPPY_STACKS_* / HAPPY_LOCAL_*
105
361
  // exported in their shell, it would otherwise "win" because utils/env.mjs only sets
106
362
  // env vars if they are missing/empty.
@@ -112,17 +368,77 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
112
368
  delete cleaned[k];
113
369
  }
114
370
  }
115
- return await fn({
116
- env: {
117
- ...cleaned,
118
- HAPPY_STACKS_STACK: stackName,
119
- HAPPY_STACKS_ENV_FILE: envPath,
120
- HAPPY_LOCAL_STACK: stackName,
121
- HAPPY_LOCAL_ENV_FILE: envPath,
122
- ...extraEnv,
123
- },
124
- envPath,
125
- });
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 });
126
442
  }
127
443
 
128
444
  async function interactiveNew({ rootDir, rl, defaults }) {
@@ -146,7 +462,7 @@ async function interactiveNew({ rootDir, rl, defaults }) {
146
462
 
147
463
  // Port
148
464
  if (!out.port) {
149
- const want = (await rl.question('Port (empty = auto-pick): ')).trim();
465
+ const want = (await rl.question('Port (empty = ephemeral): ')).trim();
150
466
  out.port = want ? Number(want) : null;
151
467
  }
152
468
 
@@ -196,8 +512,9 @@ async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }
196
512
 
197
513
  // Port
198
514
  const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
199
- const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'auto'}): `, { defaultValue: '' });
200
- 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);
201
518
 
202
519
  // Remote for creating new worktrees
203
520
  const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
@@ -228,10 +545,25 @@ async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }
228
545
  return out;
229
546
  }
230
547
 
231
- async function cmdNew({ rootDir, argv }) {
548
+ async function cmdNew({ rootDir, argv, emit = true }) {
232
549
  const { flags, kv } = parseArgs(argv);
233
550
  const positionals = argv.filter((a) => !a.startsWith('--'));
234
551
  const json = wantsJson(argv, { flags });
552
+ const copyAuth = !(flags.has('--no-copy-auth') || flags.has('--fresh-auth'));
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');
235
567
 
236
568
  // argv here is already "args after 'new'", so the first positional is the stack name.
237
569
  let stackName = stackNameFromArg(positionals, 0);
@@ -259,7 +591,8 @@ async function cmdNew({ rootDir, argv }) {
259
591
  if (!stackName) {
260
592
  throw new Error(
261
593
  '[stack] usage: happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] ' +
262
- '[--happy=default|<owner/...>|<path>] [--happy-cli=...] [--happy-server=...] [--happy-server-light=...] [--interactive]'
594
+ '[--happy=default|<owner/...>|<path>] [--happy-cli=...] [--happy-server=...] [--happy-server-light=...] ' +
595
+ '[--copy-auth-from=<stack|legacy>] [--link-auth] [--no-copy-auth] [--interactive] [--force-port]'
263
596
  );
264
597
  }
265
598
  if (stackName === 'main') {
@@ -275,29 +608,109 @@ async function cmdNew({ rootDir, argv }) {
275
608
  const uiBuildDir = join(baseDir, 'ui');
276
609
  const cliHomeDir = join(baseDir, 'cli');
277
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).
278
614
  let port = config.port;
279
- if (!port || !Number.isFinite(port)) {
280
- port = await pickNextFreePort(getDefaultPortStart());
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.
620
+ const reservedPorts = await collectReservedStackPorts();
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
+ }
281
638
  }
282
639
 
283
640
  // Always pin component dirs explicitly (so stack env is stable even if repo env changes).
284
- const defaultComponentDirs = {
285
- HAPPY_STACKS_COMPONENT_DIR_HAPPY: 'components/happy',
286
- HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI: 'components/happy-cli',
287
- HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT: 'components/happy-server-light',
288
- HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER: 'components/happy-server',
289
- };
641
+ const defaultComponentDirs = resolveDefaultComponentDirs({ rootDir });
290
642
 
291
643
  // Prepare component dirs (may create worktrees).
292
644
  const stackEnv = {
293
645
  HAPPY_STACKS_STACK: stackName,
294
- HAPPY_STACKS_SERVER_PORT: String(port),
295
646
  HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
296
647
  HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
297
648
  HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
298
649
  HAPPY_STACKS_STACK_REMOTE: config.createRemote?.trim() ? config.createRemote.trim() : 'upstream',
299
650
  ...defaultComponentDirs,
300
651
  };
652
+ if (port != null) {
653
+ stackEnv.HAPPY_STACKS_SERVER_PORT = String(port);
654
+ }
655
+
656
+ // Server-light storage isolation: ensure non-main stacks have their own sqlite + local files dir by default.
657
+ // (This prevents a dev stack from mutating main stack's DB when schema changes.)
658
+ if (serverComponent === 'happy-server-light') {
659
+ const dataDir = join(baseDir, 'server-light');
660
+ stackEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
661
+ stackEnv.HAPPY_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
662
+ stackEnv.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
663
+ }
664
+ if (serverComponent === 'happy-server') {
665
+ // Persist stable infra credentials in the stack env (ports are ephemeral unless explicitly pinned).
666
+ const pgUser = 'handy';
667
+ const pgPassword = randomToken(24);
668
+ const pgDb = 'handy';
669
+ const s3Bucket = sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
670
+ const s3AccessKey = randomToken(12);
671
+ const s3SecretKey = randomToken(24);
672
+
673
+ stackEnv.HAPPY_STACKS_MANAGED_INFRA = stackEnv.HAPPY_STACKS_MANAGED_INFRA ?? '1';
674
+ stackEnv.HAPPY_STACKS_PG_USER = pgUser;
675
+ stackEnv.HAPPY_STACKS_PG_PASSWORD = pgPassword;
676
+ stackEnv.HAPPY_STACKS_PG_DATABASE = pgDb;
677
+ stackEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE = join(baseDir, 'happy-server', 'handy-master-secret.txt');
678
+ stackEnv.S3_ACCESS_KEY = s3AccessKey;
679
+ stackEnv.S3_SECRET_KEY = s3SecretKey;
680
+ stackEnv.S3_BUCKET = s3Bucket;
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
+ }
713
+ }
301
714
 
302
715
  // happy
303
716
  const happySpec = config.components.happy;
@@ -345,12 +758,41 @@ async function cmdNew({ rootDir, argv }) {
345
758
  }
346
759
  }
347
760
 
761
+ if (copyAuth) {
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).
764
+ // Users can opt out with --no-copy-auth to force a fresh auth / machine identity.
765
+ await copyAuthFromStackIntoNewStack({
766
+ fromStackName: copyAuthFrom,
767
+ stackName,
768
+ stackEnv,
769
+ serverComponent,
770
+ json,
771
+ requireSourceStackExists: kv.has('--copy-auth-from'),
772
+ linkMode: linkAuth,
773
+ }).catch((err) => {
774
+ if (!json && emit) {
775
+ console.warn(`[stack] auth copy skipped: ${err instanceof Error ? err.message : String(err)}`);
776
+ console.warn(`[stack] tip: you can always run: happys stack auth ${stackName} login`);
777
+ }
778
+ });
779
+ }
780
+
348
781
  const envPath = await writeStackEnv({ stackName, env: stackEnv });
349
- printResult({
350
- json,
351
- data: { stackName, envPath, port, serverComponent },
352
- text: [`[stack] created ${stackName}`, `[stack] env: ${envPath}`, `[stack] port: ${port}`, `[stack] server: ${serverComponent}`].join('\n'),
353
- });
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;
354
796
  }
355
797
 
356
798
  async function cmdEdit({ rootDir, argv }) {
@@ -392,15 +834,14 @@ async function cmdEdit({ rootDir, argv }) {
392
834
  const cliHomeDir = join(baseDir, 'cli');
393
835
 
394
836
  let port = config.port;
395
- if (!port || !Number.isFinite(port)) {
396
- port = await pickNextFreePort(getDefaultPortStart());
837
+ if (!Number.isFinite(port) || port <= 0) {
838
+ port = null;
397
839
  }
398
840
 
399
841
  const serverComponent = (config.serverComponent || existingEnv.HAPPY_STACKS_SERVER_COMPONENT || existingEnv.HAPPY_LOCAL_SERVER_COMPONENT || 'happy-server-light').trim();
400
842
 
401
843
  const next = {
402
844
  HAPPY_STACKS_STACK: stackName,
403
- HAPPY_STACKS_SERVER_PORT: String(port),
404
845
  HAPPY_STACKS_SERVER_COMPONENT: serverComponent,
405
846
  HAPPY_STACKS_UI_BUILD_DIR: uiBuildDir,
406
847
  HAPPY_STACKS_CLI_HOME_DIR: cliHomeDir,
@@ -408,11 +849,80 @@ async function cmdEdit({ rootDir, argv }) {
408
849
  ? config.createRemote.trim()
409
850
  : (existingEnv.HAPPY_STACKS_STACK_REMOTE || existingEnv.HAPPY_LOCAL_STACK_REMOTE || 'upstream'),
410
851
  // Always pin defaults; overrides below can replace.
411
- HAPPY_STACKS_COMPONENT_DIR_HAPPY: 'components/happy',
412
- HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI: 'components/happy-cli',
413
- HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT: 'components/happy-server-light',
414
- HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER: 'components/happy-server',
852
+ ...resolveDefaultComponentDirs({ rootDir }),
415
853
  };
854
+ if (port != null) {
855
+ next.HAPPY_STACKS_SERVER_PORT = String(port);
856
+ }
857
+
858
+ if (serverComponent === 'happy-server-light') {
859
+ const dataDir = join(baseDir, 'server-light');
860
+ next.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
861
+ next.HAPPY_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
862
+ next.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
863
+ }
864
+ if (serverComponent === 'happy-server') {
865
+ // Persist stable infra credentials. Ports are ephemeral unless explicitly pinned.
866
+ const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
867
+ const pgPassword = (existingEnv.HAPPY_STACKS_PG_PASSWORD ?? '').trim() || randomToken(24);
868
+ const pgDb = (existingEnv.HAPPY_STACKS_PG_DATABASE ?? 'handy').trim() || 'handy';
869
+ const s3Bucket =
870
+ (existingEnv.S3_BUCKET ?? sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' })).trim() ||
871
+ sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
872
+ const s3AccessKey = (existingEnv.S3_ACCESS_KEY ?? '').trim() || randomToken(12);
873
+ const s3SecretKey = (existingEnv.S3_SECRET_KEY ?? '').trim() || randomToken(24);
874
+
875
+ next.HAPPY_STACKS_MANAGED_INFRA = (existingEnv.HAPPY_STACKS_MANAGED_INFRA ?? '1').trim() || '1';
876
+ next.HAPPY_STACKS_PG_USER = pgUser;
877
+ next.HAPPY_STACKS_PG_PASSWORD = pgPassword;
878
+ next.HAPPY_STACKS_PG_DATABASE = pgDb;
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');
881
+ next.S3_ACCESS_KEY = s3AccessKey;
882
+ next.S3_SECRET_KEY = s3SecretKey;
883
+ next.S3_BUCKET = s3Bucket;
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
+ }
925
+ }
416
926
 
417
927
  // Apply selections (create worktrees if needed)
418
928
  const applyComponent = async (component, key, spec) => {
@@ -438,15 +948,268 @@ async function cmdEdit({ rootDir, argv }) {
438
948
  printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
439
949
  }
440
950
 
441
- async function cmdRunScript({ rootDir, stackName, scriptPath, args }) {
951
+ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
442
952
  await withStackEnv({
443
953
  stackName,
444
- fn: async ({ env }) => {
954
+ extraEnv,
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.
445
1179
  await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
446
1180
  },
447
1181
  });
448
1182
  }
449
1183
 
1184
+ function resolveTransientComponentOverrides({ rootDir, kv }) {
1185
+ const overrides = {};
1186
+ const specs = [
1187
+ { flag: '--happy', component: 'happy', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY' },
1188
+ { flag: '--happy-cli', component: 'happy-cli', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI' },
1189
+ { flag: '--happy-server-light', component: 'happy-server-light', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT' },
1190
+ { flag: '--happy-server', component: 'happy-server', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER' },
1191
+ ];
1192
+
1193
+ for (const { flag, component, envKey } of specs) {
1194
+ const spec = (kv.get(flag) ?? '').trim();
1195
+ if (!spec) {
1196
+ continue;
1197
+ }
1198
+ const dir = resolveComponentSpecToDir({ rootDir, component, spec });
1199
+ if (dir) {
1200
+ overrides[envKey] = dir;
1201
+ }
1202
+ }
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
+
1210
+ return overrides;
1211
+ }
1212
+
450
1213
  async function cmdService({ rootDir, stackName, svcCmd }) {
451
1214
  await withStackEnv({
452
1215
  stackName,
@@ -572,26 +1335,8 @@ async function cmdMigrate({ argv }) {
572
1335
  }
573
1336
 
574
1337
  async function cmdListStacks() {
575
- const stacksDir = getStacksStorageRoot();
576
- const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
577
1338
  try {
578
- const namesSet = new Set();
579
- const entries = await readdir(stacksDir, { withFileTypes: true });
580
- for (const e of entries) {
581
- if (!e.isDirectory()) continue;
582
- if (e.name === 'main') continue;
583
- namesSet.add(e.name);
584
- }
585
- try {
586
- const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
587
- for (const e of legacyEntries) {
588
- if (!e.isDirectory()) continue;
589
- namesSet.add(e.name);
590
- }
591
- } catch {
592
- // ignore
593
- }
594
- const names = Array.from(namesSet).sort();
1339
+ const names = (await listAllStackNames()).filter((n) => n !== 'main');
595
1340
  if (!names.length) {
596
1341
  console.log('[stack] no stacks found');
597
1342
  return;
@@ -605,6 +1350,1315 @@ async function cmdListStacks() {
605
1350
  }
606
1351
  }
607
1352
 
1353
+ async function cmdAudit({ rootDir, argv }) {
1354
+ const { flags, kv } = parseArgs(argv);
1355
+ const json = wantsJson(argv, { flags });
1356
+ const fix = flags.has('--fix');
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);
1370
+
1371
+ const stacks = await listAllStackNames();
1372
+
1373
+ const report = [];
1374
+ const ports = new Map(); // port -> [stackName]
1375
+ const otherWorkspaceRoot = join(getHappyStacksHomeDir(), 'workspace');
1376
+
1377
+ for (const stackName of stacks) {
1378
+ const resolved = resolveStackEnvPath(stackName);
1379
+ const envPath = resolved.envPath;
1380
+ const baseDir = resolved.baseDir;
1381
+
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
+ }
1450
+
1451
+ const serverComponent = getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1452
+ const portRaw = getEnvValue(env, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_PORT');
1453
+ const port = portRaw ? Number(portRaw) : null;
1454
+ if (Number.isFinite(port) && port > 0) {
1455
+ const existing = ports.get(port) ?? [];
1456
+ existing.push(stackName);
1457
+ ports.set(port, existing);
1458
+ }
1459
+
1460
+ const issues = [];
1461
+
1462
+ if (!raw.trim()) {
1463
+ issues.push({ code: 'missing_env', message: `env file missing/empty (${envPath})` });
1464
+ }
1465
+
1466
+ const stacksUi = getEnvValue(env, 'HAPPY_STACKS_UI_BUILD_DIR');
1467
+ const localUi = getEnvValue(env, 'HAPPY_LOCAL_UI_BUILD_DIR');
1468
+ const uiBuildDir = stacksUi || localUi;
1469
+ const expectedUi = join(baseDir, 'ui');
1470
+ if (!uiBuildDir) {
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})` });
1474
+ }
1475
+
1476
+ const stacksCli = getEnvValue(env, 'HAPPY_STACKS_CLI_HOME_DIR');
1477
+ const localCli = getEnvValue(env, 'HAPPY_LOCAL_CLI_HOME_DIR');
1478
+ const cliHomeDir = stacksCli || localCli;
1479
+ const expectedCli = join(baseDir, 'cli');
1480
+ if (!cliHomeDir) {
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})` });
1484
+ }
1485
+
1486
+ // Component dirs: require at least server component dir + happy-cli (otherwise stacks can accidentally fall back to some other workspace).
1487
+ const requiredComponents = [
1488
+ 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI',
1489
+ serverComponent === 'happy-server' ? 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER' : 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT',
1490
+ ];
1491
+ const missingComponentKeys = [];
1492
+ for (const k of requiredComponents) {
1493
+ const legacyKey = k.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
1494
+ if (!getEnvValue(env, k) && !getEnvValue(env, legacyKey)) {
1495
+ missingComponentKeys.push(k);
1496
+ issues.push({ code: 'missing_component_dir', message: `missing ${k} (or ${legacyKey})` });
1497
+ }
1498
+ }
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
+
1530
+ // Server-light DB/files isolation.
1531
+ const isServerLight = serverComponent === 'happy-server-light';
1532
+ if (isServerLight) {
1533
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR');
1534
+ const filesDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_FILES_DIR');
1535
+ const dbUrl = getEnvValue(env, 'DATABASE_URL');
1536
+ const expectedDataDir = join(baseDir, 'server-light');
1537
+ const expectedFilesDir = join(expectedDataDir, 'files');
1538
+ const expectedDbUrl = `file:${join(expectedDataDir, 'happy-server-light.sqlite')}`;
1539
+
1540
+ if (!dataDir) issues.push({ code: 'missing_server_light_data_dir', message: `missing HAPPY_SERVER_LIGHT_DATA_DIR (expected ${expectedDataDir})` });
1541
+ if (!filesDir) issues.push({ code: 'missing_server_light_files_dir', message: `missing HAPPY_SERVER_LIGHT_FILES_DIR (expected ${expectedFilesDir})` });
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})` });
1546
+
1547
+ }
1548
+
1549
+ // Best-effort env repair (opt-in; non-main stacks only by default).
1550
+ if ((fix || fixWorkspace || fixPaths) && (stackName !== 'main' || fixMain) && raw.trim()) {
1551
+ const updates = [];
1552
+
1553
+ // Always ensure stack directories are explicitly pinned when missing.
1554
+ if (!stacksUi && !localUi) updates.push({ key: 'HAPPY_STACKS_UI_BUILD_DIR', value: expectedUi });
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
+ }
1560
+
1561
+ // Pin component dirs if missing (best-effort).
1562
+ if (missingComponentKeys.length) {
1563
+ const defaults = resolveDefaultComponentDirs({ rootDir });
1564
+ for (const k of missingComponentKeys) {
1565
+ if (defaults[k]) {
1566
+ updates.push({ key: k, value: defaults[k] });
1567
+ }
1568
+ }
1569
+ }
1570
+
1571
+ // Server-light storage isolation.
1572
+ if (isServerLight) {
1573
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR');
1574
+ const filesDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_FILES_DIR');
1575
+ const dbUrl = getEnvValue(env, 'DATABASE_URL');
1576
+ const expectedDataDir = join(baseDir, 'server-light');
1577
+ const expectedFilesDir = join(expectedDataDir, 'files');
1578
+ const expectedDbUrl = `file:${join(expectedDataDir, 'happy-server-light.sqlite')}`;
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
+ }
1632
+ }
1633
+
1634
+ if (updates.length) {
1635
+ await ensureEnvFileUpdated({ envPath, updates });
1636
+ }
1637
+ }
1638
+
1639
+ report.push({
1640
+ stackName,
1641
+ envPath,
1642
+ baseDir,
1643
+ serverComponent,
1644
+ serverPort: Number.isFinite(port) ? port : null,
1645
+ uiBuildDir: uiBuildDir || null,
1646
+ cliHomeDir: cliHomeDir || null,
1647
+ issues,
1648
+ });
1649
+ }
1650
+
1651
+ // Port collisions (post-pass)
1652
+ const collisions = [];
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()) {
1782
+ if (names.length <= 1) continue;
1783
+ for (const r of report) {
1784
+ if (r.serverPort === port) {
1785
+ r.issues.push({ code: 'port_collision', message: `server port ${port} is also used by: ${names.filter((n) => n !== r.stackName).join(', ')}` });
1786
+ }
1787
+ }
1788
+ }
1789
+
1790
+ const out = {
1791
+ ok: true,
1792
+ fixed: Boolean(fix || fixPorts || fixWorkspace || fixPaths || unpinPorts),
1793
+ stacks: report,
1794
+ summary: {
1795
+ total: report.length,
1796
+ withIssues: report.filter((r) => (r.issues ?? []).length > 0).length,
1797
+ },
1798
+ };
1799
+
1800
+ if (json) {
1801
+ printResult({ json, data: out });
1802
+ return;
1803
+ }
1804
+
1805
+ console.log('[stack] audit');
1806
+ for (const r of report) {
1807
+ const issueCount = (r.issues ?? []).length;
1808
+ const status = issueCount ? `issues=${issueCount}` : 'ok';
1809
+ console.log(`- ${r.stackName} (${status})`);
1810
+ if (issueCount) {
1811
+ for (const i of r.issues) console.log(` - ${i.code}: ${i.message}`);
1812
+ }
1813
+ }
1814
+ if (fix) {
1815
+ console.log('');
1816
+ console.log('[stack] audit: applied best-effort fixes (missing keys only).');
1817
+ } else {
1818
+ console.log('');
1819
+ console.log('[stack] tip: run with --fix to add missing safe defaults (non-main stacks only).');
1820
+ console.log('[stack] tip: include --fix-main if you also want to modify main stack env defaults.');
1821
+ }
1822
+ }
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
+
608
2662
  async function main() {
609
2663
  const rootDir = getRootDir(import.meta.url);
610
2664
  // pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
@@ -617,22 +2671,65 @@ async function main() {
617
2671
  const cmd = positionals[0] || 'help';
618
2672
  const json = wantsJson(argv, { flags });
619
2673
 
620
- 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') {
621
2681
  printResult({
622
2682
  json,
623
- data: { commands: ['new', 'edit', 'list', 'migrate', 'auth', 'dev', 'start', 'build', 'doctor', 'mobile', 'srv', 'wt', 'tailscale:*', 'service:*'] },
2683
+ data: {
2684
+ commands: [
2685
+ 'new',
2686
+ 'edit',
2687
+ 'list',
2688
+ 'migrate',
2689
+ 'audit',
2690
+ 'duplicate',
2691
+ 'info',
2692
+ 'pr',
2693
+ 'create-dev-auth-seed',
2694
+ 'auth',
2695
+ 'dev',
2696
+ 'start',
2697
+ 'build',
2698
+ 'typecheck',
2699
+ 'lint',
2700
+ 'test',
2701
+ 'doctor',
2702
+ 'mobile',
2703
+ 'resume',
2704
+ 'stop',
2705
+ 'srv',
2706
+ 'wt',
2707
+ 'tailscale:*',
2708
+ 'service:*',
2709
+ ],
2710
+ },
624
2711
  text: [
625
2712
  '[stack] usage:',
626
- ' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--happy-cli=...] [--interactive] [--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]',
627
2714
  ' happys stack edit <name> --interactive [--json]',
628
2715
  ' happys stack list [--json]',
629
2716
  ' happys stack migrate [--json] # copy legacy env files from ~/.happy/local/stacks/* -> ~/.happy/stacks/*',
630
- ' 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]',
631
2723
  ' happys stack dev <name> [-- ...]',
632
2724
  ' happys stack start <name> [-- ...]',
633
2725
  ' happys stack build <name> [-- ...]',
2726
+ ' happys stack typecheck <name> [component...] [--json]',
2727
+ ' happys stack lint <name> [component...] [--json]',
2728
+ ' happys stack test <name> [component...] [--json]',
634
2729
  ' happys stack doctor <name> [-- ...]',
635
2730
  ' happys stack mobile <name> [-- ...]',
2731
+ ' happys stack resume <name> <sessionId...> [--json]',
2732
+ ' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
636
2733
  ' happys stack srv <name> -- status|use ...',
637
2734
  ' happys stack wt <name> -- <wt args...>',
638
2735
  ' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
@@ -656,6 +2753,7 @@ async function main() {
656
2753
  try {
657
2754
  const stacksDir = getStacksStorageRoot();
658
2755
  const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
2756
+ const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
659
2757
  const namesSet = new Set();
660
2758
  const entries = await readdir(stacksDir, { withFileTypes: true });
661
2759
  for (const e of entries) {
@@ -664,10 +2762,12 @@ async function main() {
664
2762
  namesSet.add(e.name);
665
2763
  }
666
2764
  try {
667
- const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
668
- for (const e of legacyEntries) {
669
- if (!e.isDirectory()) continue;
670
- 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
+ }
671
2771
  }
672
2772
  } catch {
673
2773
  // ignore
@@ -688,6 +2788,26 @@ async function main() {
688
2788
  await cmdMigrate({ argv });
689
2789
  return;
690
2790
  }
2791
+ if (cmd === 'audit') {
2792
+ await cmdAudit({ rootDir, argv });
2793
+ return;
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
+ }
691
2811
 
692
2812
  // Commands that need a stack name.
693
2813
  const stackName = stackNameFromArg(positionals, 1);
@@ -746,7 +2866,27 @@ async function main() {
746
2866
  return;
747
2867
  }
748
2868
  if (cmd === 'build') {
749
- await cmdRunScript({ rootDir, stackName, scriptPath: 'build.mjs', args: passthrough });
2869
+ const { kv } = parseArgs(passthrough);
2870
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
2871
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'build.mjs', args: passthrough, extraEnv: overrides });
2872
+ return;
2873
+ }
2874
+ if (cmd === 'typecheck') {
2875
+ const { kv } = parseArgs(passthrough);
2876
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
2877
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'typecheck.mjs', args: passthrough, extraEnv: overrides });
2878
+ return;
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 });
750
2890
  return;
751
2891
  }
752
2892
  if (cmd === 'doctor') {
@@ -757,6 +2897,47 @@ async function main() {
757
2897
  await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
758
2898
  return;
759
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
+ }
2925
+
2926
+ if (cmd === 'stop') {
2927
+ const { flags: stopFlags } = parseArgs(passthrough);
2928
+ const noDocker = stopFlags.has('--no-docker');
2929
+ const aggressive = stopFlags.has('--aggressive');
2930
+ const sweepOwned = stopFlags.has('--sweep-owned');
2931
+ const baseDir = getStackDir(stackName);
2932
+ const out = await withStackEnv({
2933
+ stackName,
2934
+ fn: async ({ env }) => {
2935
+ return await stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker, aggressive, sweepOwned });
2936
+ },
2937
+ });
2938
+ if (json) printResult({ json, data: { ok: true, stopped: out } });
2939
+ return;
2940
+ }
760
2941
 
761
2942
  if (cmd === 'srv') {
762
2943
  await cmdSrv({ rootDir, stackName, args: passthrough });