happy-stacks 0.0.0 → 0.1.2

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 (42) hide show
  1. package/README.md +22 -4
  2. package/bin/happys.mjs +76 -5
  3. package/docs/server-flavors.md +61 -2
  4. package/docs/stacks.md +16 -4
  5. package/extras/swiftbar/auth-login.sh +5 -5
  6. package/extras/swiftbar/happy-stacks.5s.sh +83 -41
  7. package/extras/swiftbar/happys-term.sh +151 -0
  8. package/extras/swiftbar/happys.sh +52 -0
  9. package/extras/swiftbar/lib/render.sh +74 -56
  10. package/extras/swiftbar/lib/system.sh +37 -6
  11. package/extras/swiftbar/lib/utils.sh +180 -4
  12. package/extras/swiftbar/pnpm-term.sh +2 -122
  13. package/extras/swiftbar/pnpm.sh +2 -13
  14. package/extras/swiftbar/set-server-flavor.sh +8 -8
  15. package/extras/swiftbar/wt-pr.sh +1 -1
  16. package/package.json +1 -1
  17. package/scripts/auth.mjs +374 -3
  18. package/scripts/daemon.mjs +78 -11
  19. package/scripts/dev.mjs +122 -17
  20. package/scripts/init.mjs +238 -32
  21. package/scripts/migrate.mjs +292 -0
  22. package/scripts/mobile.mjs +51 -19
  23. package/scripts/run.mjs +118 -26
  24. package/scripts/service.mjs +176 -37
  25. package/scripts/stack.mjs +665 -22
  26. package/scripts/stop.mjs +157 -0
  27. package/scripts/tailscale.mjs +147 -21
  28. package/scripts/typecheck.mjs +145 -0
  29. package/scripts/ui_gateway.mjs +248 -0
  30. package/scripts/uninstall.mjs +3 -3
  31. package/scripts/utils/cli_registry.mjs +23 -0
  32. package/scripts/utils/config.mjs +9 -1
  33. package/scripts/utils/env.mjs +37 -15
  34. package/scripts/utils/expo.mjs +94 -0
  35. package/scripts/utils/happy_server_infra.mjs +430 -0
  36. package/scripts/utils/pm.mjs +11 -2
  37. package/scripts/utils/ports.mjs +51 -13
  38. package/scripts/utils/proc.mjs +46 -5
  39. package/scripts/utils/server.mjs +37 -0
  40. package/scripts/utils/stack_stop.mjs +206 -0
  41. package/scripts/utils/validate.mjs +42 -1
  42. package/scripts/worktrees.mjs +53 -7
package/scripts/stack.mjs CHANGED
@@ -1,15 +1,20 @@
1
1
  import './utils/env.mjs';
2
- import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import net from 'node:net';
5
+ import { existsSync } from 'node:fs';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { homedir } from 'node:os';
5
8
 
6
9
  import { parseArgs } from './utils/args.mjs';
7
- import { run } from './utils/proc.mjs';
8
- import { getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
10
+ import { run, runCapture } from './utils/proc.mjs';
11
+ import { getComponentDir, getComponentsDir, getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
9
12
  import { createWorktree, resolveComponentSpecToDir } from './utils/worktrees.mjs';
10
13
  import { isTty, prompt, promptWorktreeSource, withRl } from './utils/wizard.mjs';
11
14
  import { parseDotenv } from './utils/dotenv.mjs';
12
15
  import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
16
+ import { ensureEnvFileUpdated } from './utils/env_file.mjs';
17
+ import { stopStackWithEnv } from './utils/stack_stop.mjs';
13
18
 
14
19
  function stackNameFromArg(positionals, idx) {
15
20
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
@@ -45,11 +50,11 @@ async function isPortFree(port) {
45
50
  });
46
51
  }
47
52
 
48
- async function pickNextFreePort(startPort) {
53
+ async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
49
54
  let port = startPort;
50
55
  for (let i = 0; i < 200; i++) {
51
56
  // eslint-disable-next-line no-await-in-loop
52
- if (await isPortFree(port)) {
57
+ if (!reservedPorts.has(port) && (await isPortFree(port))) {
53
58
  return port;
54
59
  }
55
60
  port += 1;
@@ -57,10 +62,221 @@ async function pickNextFreePort(startPort) {
57
62
  throw new Error(`[stack] unable to find a free port starting at ${startPort}`);
58
63
  }
59
64
 
65
+ async function readPortFromEnvFile(envPath) {
66
+ const raw = await readExistingEnv(envPath);
67
+ if (!raw.trim()) return null;
68
+ const parsed = parseEnvToObject(raw);
69
+ const portRaw = (parsed.HAPPY_STACKS_SERVER_PORT ?? parsed.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
70
+ const n = portRaw ? Number(portRaw) : NaN;
71
+ return Number.isFinite(n) && n > 0 ? n : null;
72
+ }
73
+
74
+ async function readPortsFromEnvFile(envPath) {
75
+ const raw = await readExistingEnv(envPath);
76
+ if (!raw.trim()) return [];
77
+ const parsed = parseEnvToObject(raw);
78
+ const keys = [
79
+ 'HAPPY_STACKS_SERVER_PORT',
80
+ 'HAPPY_LOCAL_SERVER_PORT',
81
+ 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
82
+ 'HAPPY_STACKS_PG_PORT',
83
+ 'HAPPY_STACKS_REDIS_PORT',
84
+ 'HAPPY_STACKS_MINIO_PORT',
85
+ 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
86
+ ];
87
+ const ports = [];
88
+ for (const k of keys) {
89
+ const rawV = (parsed[k] ?? '').toString().trim();
90
+ const n = rawV ? Number(rawV) : NaN;
91
+ if (Number.isFinite(n) && n > 0) ports.push(n);
92
+ }
93
+ return ports;
94
+ }
95
+
96
+ async function collectReservedStackPorts({ excludeStackName = null } = {}) {
97
+ const reserved = new Set();
98
+
99
+ const roots = [
100
+ // New layout: ~/.happy/stacks/<name>/env (or overridden via HAPPY_STACKS_STORAGE_DIR)
101
+ getStacksStorageRoot(),
102
+ // Legacy layout: ~/.happy/local/stacks/<name>/env
103
+ join(getLegacyStorageRoot(), 'stacks'),
104
+ ];
105
+
106
+ for (const root of roots) {
107
+ let entries = [];
108
+ try {
109
+ // eslint-disable-next-line no-await-in-loop
110
+ entries = await readdir(root, { withFileTypes: true });
111
+ } catch {
112
+ entries = [];
113
+ }
114
+ for (const e of entries) {
115
+ if (!e.isDirectory()) continue;
116
+ const name = e.name;
117
+ if (excludeStackName && name === excludeStackName) continue;
118
+ const envPath = join(root, name, 'env');
119
+ // eslint-disable-next-line no-await-in-loop
120
+ const ports = await readPortsFromEnvFile(envPath);
121
+ for (const p of ports) reserved.add(p);
122
+ }
123
+ }
124
+
125
+ return reserved;
126
+ }
127
+
128
+ function base64Url(buf) {
129
+ return Buffer.from(buf)
130
+ .toString('base64')
131
+ .replaceAll('+', '-')
132
+ .replaceAll('/', '_')
133
+ .replaceAll('=', '');
134
+ }
135
+
136
+ function randomToken(lenBytes = 24) {
137
+ return base64Url(randomBytes(lenBytes));
138
+ }
139
+
140
+ function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
141
+ const s = String(raw ?? '')
142
+ .toLowerCase()
143
+ .replace(/[^a-z0-9-]+/g, '-')
144
+ .replace(/-+/g, '-')
145
+ .replace(/^-+/, '')
146
+ .replace(/-+$/, '');
147
+ return s || fallback;
148
+ }
149
+
60
150
  async function ensureDir(p) {
61
151
  await mkdir(p, { recursive: true });
62
152
  }
63
153
 
154
+ async function readTextIfExists(path) {
155
+ try {
156
+ if (!existsSync(path)) return null;
157
+ const raw = await readFile(path, 'utf-8');
158
+ const t = raw.trim();
159
+ return t ? t : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ async function writeSecretFileIfMissing({ path, secret }) {
166
+ if (existsSync(path)) return false;
167
+ await ensureDir(dirname(path));
168
+ await writeFile(path, secret, { encoding: 'utf-8', mode: 0o600 });
169
+ return true;
170
+ }
171
+
172
+ async function copyFileIfMissing({ from, to, mode }) {
173
+ if (existsSync(to)) return false;
174
+ if (!existsSync(from)) return false;
175
+ await ensureDir(dirname(to));
176
+ await copyFile(from, to);
177
+ if (mode) {
178
+ await chmod(to, mode).catch(() => {});
179
+ }
180
+ return true;
181
+ }
182
+
183
+ function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
184
+ const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
185
+ return fromEnv || join(stackBaseDir, 'cli');
186
+ }
187
+
188
+ function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
189
+ const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
190
+ return fromEnv || join(stackBaseDir, 'server-light');
191
+ }
192
+
193
+ async function resolveHandyMasterSecretFromStack({ stackName, requireStackExists }) {
194
+ if (requireStackExists && !stackExistsSync(stackName)) {
195
+ throw new Error(`[stack] cannot copy auth: source stack "${stackName}" does not exist`);
196
+ }
197
+
198
+ const sourceBaseDir = getStackDir(stackName);
199
+ const sourceEnvPath = getStackEnvPath(stackName);
200
+ const raw = await readExistingEnv(sourceEnvPath);
201
+ const env = parseEnvToObject(raw);
202
+
203
+ const inline = (env.HANDY_MASTER_SECRET ?? '').trim();
204
+ if (inline) {
205
+ return { secret: inline, source: `${sourceEnvPath} (HANDY_MASTER_SECRET)` };
206
+ }
207
+
208
+ const secretFile = (env.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE ?? '').trim();
209
+ if (secretFile) {
210
+ const secret = await readTextIfExists(secretFile);
211
+ if (secret) return { secret, source: secretFile };
212
+ }
213
+
214
+ const dataDir = getServerLightDataDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env });
215
+ const secretPath = join(dataDir, 'handy-master-secret.txt');
216
+ const secret = await readTextIfExists(secretPath);
217
+ if (secret) return { secret, source: secretPath };
218
+
219
+ // Last-resort legacy: if main has never been migrated to stack dirs.
220
+ if (stackName === 'main') {
221
+ const legacy = join(homedir(), '.happy', 'server-light', 'handy-master-secret.txt');
222
+ const legacySecret = await readTextIfExists(legacy);
223
+ if (legacySecret) return { secret: legacySecret, source: legacy };
224
+ }
225
+
226
+ return { secret: null, source: null };
227
+ }
228
+
229
+ async function copyAuthFromStackIntoNewStack({ fromStackName, stackName, stackEnv, serverComponent, json, requireSourceStackExists }) {
230
+ const { secret, source } = await resolveHandyMasterSecretFromStack({
231
+ stackName: fromStackName,
232
+ requireStackExists: requireSourceStackExists,
233
+ });
234
+
235
+ const copied = { secret: false, accessKey: false, settings: false, sourceStack: fromStackName };
236
+
237
+ if (secret) {
238
+ if (serverComponent === 'happy-server-light') {
239
+ const dataDir = stackEnv.HAPPY_SERVER_LIGHT_DATA_DIR;
240
+ const target = join(dataDir, 'handy-master-secret.txt');
241
+ copied.secret = await writeSecretFileIfMissing({ path: target, secret });
242
+ } else if (serverComponent === 'happy-server') {
243
+ const target = stackEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE;
244
+ if (target) {
245
+ copied.secret = await writeSecretFileIfMissing({ path: target, secret });
246
+ }
247
+ }
248
+ }
249
+
250
+ const sourceBaseDir = getStackDir(fromStackName);
251
+ const sourceEnvRaw = await readExistingEnv(getStackEnvPath(fromStackName));
252
+ const sourceEnv = parseEnvToObject(sourceEnvRaw);
253
+ const sourceCli = getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
254
+ const targetCli = stackEnv.HAPPY_STACKS_CLI_HOME_DIR;
255
+
256
+ copied.accessKey = await copyFileIfMissing({
257
+ from: join(sourceCli, 'access.key'),
258
+ to: join(targetCli, 'access.key'),
259
+ mode: 0o600,
260
+ });
261
+ copied.settings = await copyFileIfMissing({
262
+ from: join(sourceCli, 'settings.json'),
263
+ to: join(targetCli, 'settings.json'),
264
+ mode: 0o600,
265
+ });
266
+
267
+ if (!json) {
268
+ const any = copied.secret || copied.accessKey || copied.settings;
269
+ if (any) {
270
+ console.log(`[stack] copied auth from "${fromStackName}" into "${stackName}" (no re-login needed)`);
271
+ if (copied.secret) console.log(` - master secret: copied (${source || 'unknown source'})`);
272
+ if (copied.accessKey) console.log(` - cli: copied access.key`);
273
+ if (copied.settings) console.log(` - cli: copied settings.json`);
274
+ }
275
+ }
276
+
277
+ return copied;
278
+ }
279
+
64
280
  function stringifyEnv(env) {
65
281
  const lines = [];
66
282
  for (const [k, v] of Object.entries(env)) {
@@ -87,6 +303,24 @@ function parseEnvToObject(raw) {
87
303
  return Object.fromEntries(parsed.entries());
88
304
  }
89
305
 
306
+ function stackExistsSync(stackName) {
307
+ if (stackName === 'main') return true;
308
+ const envPath = getStackEnvPath(stackName);
309
+ return existsSync(envPath);
310
+ }
311
+
312
+ function resolveDefaultComponentDirs({ rootDir }) {
313
+ const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
314
+ const out = {};
315
+ for (const name of componentNames) {
316
+ const embedded = join(rootDir, 'components', name);
317
+ const workspace = join(getComponentsDir(rootDir), name);
318
+ const dir = existsSync(embedded) ? embedded : workspace;
319
+ out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
320
+ }
321
+ return out;
322
+ }
323
+
90
324
  async function writeStackEnv({ stackName, env }) {
91
325
  const stackDir = getStackDir(stackName);
92
326
  await ensureDir(stackDir);
@@ -101,6 +335,15 @@ async function writeStackEnv({ stackName, env }) {
101
335
 
102
336
  async function withStackEnv({ stackName, fn, extraEnv = {} }) {
103
337
  const envPath = getStackEnvPath(stackName);
338
+ if (!stackExistsSync(stackName)) {
339
+ throw new Error(
340
+ `[stack] stack "${stackName}" does not exist yet.\n` +
341
+ `[stack] Create it first:\n` +
342
+ ` happys stack new ${stackName}\n` +
343
+ ` # or:\n` +
344
+ ` happys stack new ${stackName} --interactive\n`
345
+ );
346
+ }
104
347
  // IMPORTANT: stack env file should be authoritative. If the user has HAPPY_STACKS_* / HAPPY_LOCAL_*
105
348
  // exported in their shell, it would otherwise "win" because utils/env.mjs only sets
106
349
  // env vars if they are missing/empty.
@@ -232,6 +475,8 @@ async function cmdNew({ rootDir, argv }) {
232
475
  const { flags, kv } = parseArgs(argv);
233
476
  const positionals = argv.filter((a) => !a.startsWith('--'));
234
477
  const json = wantsJson(argv, { flags });
478
+ const copyAuth = !(flags.has('--no-copy-auth') || flags.has('--fresh-auth'));
479
+ const copyAuthFrom = (kv.get('--copy-auth-from') ?? '').trim() || 'main';
235
480
 
236
481
  // argv here is already "args after 'new'", so the first positional is the stack name.
237
482
  let stackName = stackNameFromArg(positionals, 0);
@@ -259,7 +504,8 @@ async function cmdNew({ rootDir, argv }) {
259
504
  if (!stackName) {
260
505
  throw new Error(
261
506
  '[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]'
507
+ '[--happy=default|<owner/...>|<path>] [--happy-cli=...] [--happy-server=...] [--happy-server-light=...] ' +
508
+ '[--copy-auth-from=main] [--no-copy-auth] [--interactive]'
263
509
  );
264
510
  }
265
511
  if (stackName === 'main') {
@@ -277,16 +523,12 @@ async function cmdNew({ rootDir, argv }) {
277
523
 
278
524
  let port = config.port;
279
525
  if (!port || !Number.isFinite(port)) {
280
- port = await pickNextFreePort(getDefaultPortStart());
526
+ const reservedPorts = await collectReservedStackPorts();
527
+ port = await pickNextFreePort(getDefaultPortStart(), { reservedPorts });
281
528
  }
282
529
 
283
530
  // 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
- };
531
+ const defaultComponentDirs = resolveDefaultComponentDirs({ rootDir });
290
532
 
291
533
  // Prepare component dirs (may create worktrees).
292
534
  const stackEnv = {
@@ -299,6 +541,61 @@ async function cmdNew({ rootDir, argv }) {
299
541
  ...defaultComponentDirs,
300
542
  };
301
543
 
544
+ // Server-light storage isolation: ensure non-main stacks have their own sqlite + local files dir by default.
545
+ // (This prevents a dev stack from mutating main stack's DB when schema changes.)
546
+ if (serverComponent === 'happy-server-light') {
547
+ const dataDir = join(baseDir, 'server-light');
548
+ stackEnv.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
549
+ stackEnv.HAPPY_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
550
+ stackEnv.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
551
+ }
552
+ if (serverComponent === 'happy-server') {
553
+ const reservedPorts = await collectReservedStackPorts();
554
+ reservedPorts.add(port);
555
+ const backendPort = await pickNextFreePort(port + 10, { reservedPorts });
556
+ reservedPorts.add(backendPort);
557
+ const pgPort = await pickNextFreePort(port + 1000, { reservedPorts });
558
+ reservedPorts.add(pgPort);
559
+ const redisPort = await pickNextFreePort(pgPort + 1, { reservedPorts });
560
+ reservedPorts.add(redisPort);
561
+ const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts });
562
+ reservedPorts.add(minioPort);
563
+ const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts });
564
+
565
+ const pgUser = 'handy';
566
+ const pgPassword = randomToken(24);
567
+ const pgDb = 'handy';
568
+ const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
569
+
570
+ const s3Bucket = sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
571
+ const s3AccessKey = randomToken(12);
572
+ const s3SecretKey = randomToken(24);
573
+ const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
574
+
575
+ // Persist infra config in the stack env so restarts are stable/reproducible.
576
+ stackEnv.HAPPY_STACKS_MANAGED_INFRA = stackEnv.HAPPY_STACKS_MANAGED_INFRA ?? '1';
577
+ stackEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
578
+ stackEnv.HAPPY_STACKS_PG_PORT = String(pgPort);
579
+ stackEnv.HAPPY_STACKS_REDIS_PORT = String(redisPort);
580
+ stackEnv.HAPPY_STACKS_MINIO_PORT = String(minioPort);
581
+ stackEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
582
+ stackEnv.HAPPY_STACKS_PG_USER = pgUser;
583
+ stackEnv.HAPPY_STACKS_PG_PASSWORD = pgPassword;
584
+ stackEnv.HAPPY_STACKS_PG_DATABASE = pgDb;
585
+ stackEnv.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE = join(baseDir, 'happy-server', 'handy-master-secret.txt');
586
+
587
+ // Vars consumed by happy-server:
588
+ stackEnv.DATABASE_URL = databaseUrl;
589
+ stackEnv.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
590
+ stackEnv.S3_HOST = '127.0.0.1';
591
+ stackEnv.S3_PORT = String(minioPort);
592
+ stackEnv.S3_USE_SSL = 'false';
593
+ stackEnv.S3_ACCESS_KEY = s3AccessKey;
594
+ stackEnv.S3_SECRET_KEY = s3SecretKey;
595
+ stackEnv.S3_BUCKET = s3Bucket;
596
+ stackEnv.S3_PUBLIC_URL = s3PublicUrl;
597
+ }
598
+
302
599
  // happy
303
600
  const happySpec = config.components.happy;
304
601
  if (happySpec && typeof happySpec === 'object' && happySpec.create) {
@@ -345,6 +642,24 @@ async function cmdNew({ rootDir, argv }) {
345
642
  }
346
643
  }
347
644
 
645
+ if (copyAuth) {
646
+ // Default: inherit main stack auth so creating a new stack doesn't require re-login.
647
+ // Users can opt out with --no-copy-auth to force a fresh auth / machine identity.
648
+ await copyAuthFromStackIntoNewStack({
649
+ fromStackName: copyAuthFrom,
650
+ stackName,
651
+ stackEnv,
652
+ serverComponent,
653
+ json,
654
+ requireSourceStackExists: kv.has('--copy-auth-from'),
655
+ }).catch((err) => {
656
+ if (!json) {
657
+ console.warn(`[stack] auth copy skipped: ${err instanceof Error ? err.message : String(err)}`);
658
+ console.warn(`[stack] tip: you can always run: happys stack auth ${stackName} login`);
659
+ }
660
+ });
661
+ }
662
+
348
663
  const envPath = await writeStackEnv({ stackName, env: stackEnv });
349
664
  printResult({
350
665
  json,
@@ -393,7 +708,8 @@ async function cmdEdit({ rootDir, argv }) {
393
708
 
394
709
  let port = config.port;
395
710
  if (!port || !Number.isFinite(port)) {
396
- port = await pickNextFreePort(getDefaultPortStart());
711
+ const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
712
+ port = await pickNextFreePort(getDefaultPortStart(), { reservedPorts });
397
713
  }
398
714
 
399
715
  const serverComponent = (config.serverComponent || existingEnv.HAPPY_STACKS_SERVER_COMPONENT || existingEnv.HAPPY_LOCAL_SERVER_COMPONENT || 'happy-server-light').trim();
@@ -408,12 +724,64 @@ async function cmdEdit({ rootDir, argv }) {
408
724
  ? config.createRemote.trim()
409
725
  : (existingEnv.HAPPY_STACKS_STACK_REMOTE || existingEnv.HAPPY_LOCAL_STACK_REMOTE || 'upstream'),
410
726
  // 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',
727
+ ...resolveDefaultComponentDirs({ rootDir }),
415
728
  };
416
729
 
730
+ if (serverComponent === 'happy-server-light') {
731
+ const dataDir = join(baseDir, 'server-light');
732
+ next.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir;
733
+ next.HAPPY_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
734
+ next.DATABASE_URL = `file:${join(dataDir, 'happy-server-light.sqlite')}`;
735
+ }
736
+ if (serverComponent === 'happy-server') {
737
+ const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
738
+ reservedPorts.add(port);
739
+ const backendPort = existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT?.trim()
740
+ ? Number(existingEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT.trim())
741
+ : await pickNextFreePort(port + 10, { reservedPorts });
742
+ reservedPorts.add(backendPort);
743
+ const pgPort = existingEnv.HAPPY_STACKS_PG_PORT?.trim() ? Number(existingEnv.HAPPY_STACKS_PG_PORT.trim()) : await pickNextFreePort(port + 1000, { reservedPorts });
744
+ reservedPorts.add(pgPort);
745
+ const redisPort = existingEnv.HAPPY_STACKS_REDIS_PORT?.trim() ? Number(existingEnv.HAPPY_STACKS_REDIS_PORT.trim()) : await pickNextFreePort(pgPort + 1, { reservedPorts });
746
+ reservedPorts.add(redisPort);
747
+ const minioPort = existingEnv.HAPPY_STACKS_MINIO_PORT?.trim() ? Number(existingEnv.HAPPY_STACKS_MINIO_PORT.trim()) : await pickNextFreePort(redisPort + 1, { reservedPorts });
748
+ reservedPorts.add(minioPort);
749
+ const minioConsolePort = existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT?.trim()
750
+ ? Number(existingEnv.HAPPY_STACKS_MINIO_CONSOLE_PORT.trim())
751
+ : await pickNextFreePort(minioPort + 1, { reservedPorts });
752
+
753
+ const pgUser = (existingEnv.HAPPY_STACKS_PG_USER ?? 'handy').trim() || 'handy';
754
+ const pgPassword = (existingEnv.HAPPY_STACKS_PG_PASSWORD ?? '').trim() || randomToken(24);
755
+ const pgDb = (existingEnv.HAPPY_STACKS_PG_DATABASE ?? 'handy').trim() || 'handy';
756
+ const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
757
+
758
+ const s3Bucket = (existingEnv.S3_BUCKET ?? sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' })).trim() || sanitizeDnsLabel(`happy-${stackName}`, { fallback: 'happy' });
759
+ const s3AccessKey = (existingEnv.S3_ACCESS_KEY ?? '').trim() || randomToken(12);
760
+ const s3SecretKey = (existingEnv.S3_SECRET_KEY ?? '').trim() || randomToken(24);
761
+ const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
762
+
763
+ next.HAPPY_STACKS_MANAGED_INFRA = (existingEnv.HAPPY_STACKS_MANAGED_INFRA ?? '1').trim() || '1';
764
+ next.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT = String(backendPort);
765
+ next.HAPPY_STACKS_PG_PORT = String(pgPort);
766
+ next.HAPPY_STACKS_REDIS_PORT = String(redisPort);
767
+ next.HAPPY_STACKS_MINIO_PORT = String(minioPort);
768
+ next.HAPPY_STACKS_MINIO_CONSOLE_PORT = String(minioConsolePort);
769
+ next.HAPPY_STACKS_PG_USER = pgUser;
770
+ next.HAPPY_STACKS_PG_PASSWORD = pgPassword;
771
+ next.HAPPY_STACKS_PG_DATABASE = pgDb;
772
+ next.HAPPY_STACKS_HANDY_MASTER_SECRET_FILE = join(baseDir, 'happy-server', 'handy-master-secret.txt');
773
+
774
+ next.DATABASE_URL = databaseUrl;
775
+ next.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
776
+ next.S3_HOST = '127.0.0.1';
777
+ next.S3_PORT = String(minioPort);
778
+ next.S3_USE_SSL = 'false';
779
+ next.S3_ACCESS_KEY = s3AccessKey;
780
+ next.S3_SECRET_KEY = s3SecretKey;
781
+ next.S3_BUCKET = s3Bucket;
782
+ next.S3_PUBLIC_URL = s3PublicUrl;
783
+ }
784
+
417
785
  // Apply selections (create worktrees if needed)
418
786
  const applyComponent = async (component, key, spec) => {
419
787
  if (spec && typeof spec === 'object' && spec.create) {
@@ -438,15 +806,39 @@ async function cmdEdit({ rootDir, argv }) {
438
806
  printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
439
807
  }
440
808
 
441
- async function cmdRunScript({ rootDir, stackName, scriptPath, args }) {
809
+ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
442
810
  await withStackEnv({
443
811
  stackName,
812
+ extraEnv,
444
813
  fn: async ({ env }) => {
445
814
  await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
446
815
  },
447
816
  });
448
817
  }
449
818
 
819
+ function resolveTransientComponentOverrides({ rootDir, kv }) {
820
+ const overrides = {};
821
+ const specs = [
822
+ { flag: '--happy', component: 'happy', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY' },
823
+ { flag: '--happy-cli', component: 'happy-cli', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI' },
824
+ { flag: '--happy-server-light', component: 'happy-server-light', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT' },
825
+ { flag: '--happy-server', component: 'happy-server', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER' },
826
+ ];
827
+
828
+ for (const { flag, component, envKey } of specs) {
829
+ const spec = (kv.get(flag) ?? '').trim();
830
+ if (!spec) {
831
+ continue;
832
+ }
833
+ const dir = resolveComponentSpecToDir({ rootDir, component, spec });
834
+ if (dir) {
835
+ overrides[envKey] = dir;
836
+ }
837
+ }
838
+
839
+ return overrides;
840
+ }
841
+
450
842
  async function cmdService({ rootDir, stackName, svcCmd }) {
451
843
  await withStackEnv({
452
844
  stackName,
@@ -605,6 +997,207 @@ async function cmdListStacks() {
605
997
  }
606
998
  }
607
999
 
1000
+ async function listAllStackNames() {
1001
+ const stacksDir = getStacksStorageRoot();
1002
+ const legacyStacksDir = join(getLegacyStorageRoot(), 'stacks');
1003
+ const namesSet = new Set(['main']);
1004
+ try {
1005
+ const entries = await readdir(stacksDir, { withFileTypes: true });
1006
+ for (const e of entries) {
1007
+ if (!e.isDirectory()) continue;
1008
+ namesSet.add(e.name);
1009
+ }
1010
+ } catch {
1011
+ // ignore
1012
+ }
1013
+ try {
1014
+ const legacyEntries = await readdir(legacyStacksDir, { withFileTypes: true });
1015
+ for (const e of legacyEntries) {
1016
+ if (!e.isDirectory()) continue;
1017
+ namesSet.add(e.name);
1018
+ }
1019
+ } catch {
1020
+ // ignore
1021
+ }
1022
+ return Array.from(namesSet).sort();
1023
+ }
1024
+
1025
+ function getEnvValue(obj, key) {
1026
+ return (obj?.[key] ?? '').toString().trim();
1027
+ }
1028
+
1029
+ async function cmdAudit({ rootDir, argv }) {
1030
+ const { flags } = parseArgs(argv);
1031
+ const json = wantsJson(argv, { flags });
1032
+ const fix = flags.has('--fix');
1033
+ const fixMain = flags.has('--fix-main');
1034
+
1035
+ const stacks = await listAllStackNames();
1036
+
1037
+ const report = [];
1038
+ const ports = new Map(); // port -> [stackName]
1039
+
1040
+ for (const stackName of stacks) {
1041
+ const resolved = resolveStackEnvPath(stackName);
1042
+ const envPath = resolved.envPath;
1043
+ const baseDir = resolved.baseDir;
1044
+
1045
+ const raw = await readExistingEnv(envPath);
1046
+ const env = parseEnvToObject(raw);
1047
+
1048
+ const serverComponent = getEnvValue(env, 'HAPPY_STACKS_SERVER_COMPONENT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
1049
+ const portRaw = getEnvValue(env, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(env, 'HAPPY_LOCAL_SERVER_PORT');
1050
+ const port = portRaw ? Number(portRaw) : null;
1051
+ if (Number.isFinite(port) && port > 0) {
1052
+ const existing = ports.get(port) ?? [];
1053
+ existing.push(stackName);
1054
+ ports.set(port, existing);
1055
+ }
1056
+
1057
+ const issues = [];
1058
+
1059
+ if (!raw.trim()) {
1060
+ issues.push({ code: 'missing_env', message: `env file missing/empty (${envPath})` });
1061
+ }
1062
+
1063
+ const stacksUi = getEnvValue(env, 'HAPPY_STACKS_UI_BUILD_DIR');
1064
+ const localUi = getEnvValue(env, 'HAPPY_LOCAL_UI_BUILD_DIR');
1065
+ const uiBuildDir = stacksUi || localUi;
1066
+ const expectedUi = join(baseDir, 'ui');
1067
+ if (!uiBuildDir) {
1068
+ issues.push({ code: 'missing_ui_build_dir', message: `missing UI build dir (expected ${expectedUi})` });
1069
+ }
1070
+
1071
+ const stacksCli = getEnvValue(env, 'HAPPY_STACKS_CLI_HOME_DIR');
1072
+ const localCli = getEnvValue(env, 'HAPPY_LOCAL_CLI_HOME_DIR');
1073
+ const cliHomeDir = stacksCli || localCli;
1074
+ const expectedCli = join(baseDir, 'cli');
1075
+ if (!cliHomeDir) {
1076
+ issues.push({ code: 'missing_cli_home_dir', message: `missing CLI home dir (expected ${expectedCli})` });
1077
+ }
1078
+
1079
+ // Component dirs: require at least server component dir + happy-cli (otherwise stacks can accidentally fall back to some other workspace).
1080
+ const requiredComponents = [
1081
+ 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI',
1082
+ serverComponent === 'happy-server' ? 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER' : 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT',
1083
+ ];
1084
+ const missingComponentKeys = [];
1085
+ for (const k of requiredComponents) {
1086
+ const legacyKey = k.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
1087
+ if (!getEnvValue(env, k) && !getEnvValue(env, legacyKey)) {
1088
+ missingComponentKeys.push(k);
1089
+ issues.push({ code: 'missing_component_dir', message: `missing ${k} (or ${legacyKey})` });
1090
+ }
1091
+ }
1092
+
1093
+ // Server-light DB/files isolation.
1094
+ const isServerLight = serverComponent === 'happy-server-light';
1095
+ if (isServerLight) {
1096
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR');
1097
+ const filesDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_FILES_DIR');
1098
+ const dbUrl = getEnvValue(env, 'DATABASE_URL');
1099
+ const expectedDataDir = join(baseDir, 'server-light');
1100
+ const expectedFilesDir = join(expectedDataDir, 'files');
1101
+ const expectedDbUrl = `file:${join(expectedDataDir, 'happy-server-light.sqlite')}`;
1102
+
1103
+ if (!dataDir) issues.push({ code: 'missing_server_light_data_dir', message: `missing HAPPY_SERVER_LIGHT_DATA_DIR (expected ${expectedDataDir})` });
1104
+ if (!filesDir) issues.push({ code: 'missing_server_light_files_dir', message: `missing HAPPY_SERVER_LIGHT_FILES_DIR (expected ${expectedFilesDir})` });
1105
+ if (!dbUrl) issues.push({ code: 'missing_database_url', message: `missing DATABASE_URL (expected ${expectedDbUrl})` });
1106
+
1107
+ }
1108
+
1109
+ // Best-effort env repair (missing keys only).
1110
+ if (fix && (stackName !== 'main' || fixMain) && raw.trim()) {
1111
+ const updates = [];
1112
+
1113
+ // Always ensure stack directories are explicitly pinned when missing.
1114
+ if (!stacksUi && !localUi) updates.push({ key: 'HAPPY_STACKS_UI_BUILD_DIR', value: expectedUi });
1115
+ if (!stacksCli && !localCli) updates.push({ key: 'HAPPY_STACKS_CLI_HOME_DIR', value: expectedCli });
1116
+
1117
+ // Pin component dirs if missing (best-effort).
1118
+ if (missingComponentKeys.length) {
1119
+ const defaults = resolveDefaultComponentDirs({ rootDir });
1120
+ for (const k of missingComponentKeys) {
1121
+ if (defaults[k]) {
1122
+ updates.push({ key: k, value: defaults[k] });
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ // Server-light storage isolation.
1128
+ if (isServerLight) {
1129
+ const dataDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_DATA_DIR');
1130
+ const filesDir = getEnvValue(env, 'HAPPY_SERVER_LIGHT_FILES_DIR');
1131
+ const dbUrl = getEnvValue(env, 'DATABASE_URL');
1132
+ const expectedDataDir = join(baseDir, 'server-light');
1133
+ const expectedFilesDir = join(expectedDataDir, 'files');
1134
+ const expectedDbUrl = `file:${join(expectedDataDir, 'happy-server-light.sqlite')}`;
1135
+ if (!dataDir) updates.push({ key: 'HAPPY_SERVER_LIGHT_DATA_DIR', value: expectedDataDir });
1136
+ if (!filesDir) updates.push({ key: 'HAPPY_SERVER_LIGHT_FILES_DIR', value: expectedFilesDir });
1137
+ if (!dbUrl) updates.push({ key: 'DATABASE_URL', value: expectedDbUrl });
1138
+ }
1139
+
1140
+ if (updates.length) {
1141
+ await ensureEnvFileUpdated({ envPath, updates });
1142
+ }
1143
+ }
1144
+
1145
+ report.push({
1146
+ stackName,
1147
+ envPath,
1148
+ baseDir,
1149
+ serverComponent,
1150
+ serverPort: Number.isFinite(port) ? port : null,
1151
+ uiBuildDir: uiBuildDir || null,
1152
+ cliHomeDir: cliHomeDir || null,
1153
+ issues,
1154
+ });
1155
+ }
1156
+
1157
+ // Port collisions (post-pass)
1158
+ for (const [port, names] of ports.entries()) {
1159
+ if (names.length <= 1) continue;
1160
+ for (const r of report) {
1161
+ if (r.serverPort === port) {
1162
+ r.issues.push({ code: 'port_collision', message: `server port ${port} is also used by: ${names.filter((n) => n !== r.stackName).join(', ')}` });
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ const out = {
1168
+ ok: true,
1169
+ fixed: fix,
1170
+ stacks: report,
1171
+ summary: {
1172
+ total: report.length,
1173
+ withIssues: report.filter((r) => (r.issues ?? []).length > 0).length,
1174
+ },
1175
+ };
1176
+
1177
+ if (json) {
1178
+ printResult({ json, data: out });
1179
+ return;
1180
+ }
1181
+
1182
+ console.log('[stack] audit');
1183
+ for (const r of report) {
1184
+ const issueCount = (r.issues ?? []).length;
1185
+ const status = issueCount ? `issues=${issueCount}` : 'ok';
1186
+ console.log(`- ${r.stackName} (${status})`);
1187
+ if (issueCount) {
1188
+ for (const i of r.issues) console.log(` - ${i.code}: ${i.message}`);
1189
+ }
1190
+ }
1191
+ if (fix) {
1192
+ console.log('');
1193
+ console.log('[stack] audit: applied best-effort fixes (missing keys only).');
1194
+ } else {
1195
+ console.log('');
1196
+ console.log('[stack] tip: run with --fix to add missing safe defaults (non-main stacks only).');
1197
+ console.log('[stack] tip: include --fix-main if you also want to modify main stack env defaults.');
1198
+ }
1199
+ }
1200
+
608
1201
  async function main() {
609
1202
  const rootDir = getRootDir(import.meta.url);
610
1203
  // pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
@@ -620,19 +1213,42 @@ async function main() {
620
1213
  if (wantsHelp(argv, { flags }) || cmd === 'help') {
621
1214
  printResult({
622
1215
  json,
623
- data: { commands: ['new', 'edit', 'list', 'migrate', 'auth', 'dev', 'start', 'build', 'doctor', 'mobile', 'srv', 'wt', 'tailscale:*', 'service:*'] },
1216
+ data: {
1217
+ commands: [
1218
+ 'new',
1219
+ 'edit',
1220
+ 'list',
1221
+ 'migrate',
1222
+ 'audit',
1223
+ 'auth',
1224
+ 'dev',
1225
+ 'start',
1226
+ 'build',
1227
+ 'typecheck',
1228
+ 'doctor',
1229
+ 'mobile',
1230
+ 'stop',
1231
+ 'srv',
1232
+ 'wt',
1233
+ 'tailscale:*',
1234
+ 'service:*',
1235
+ ],
1236
+ },
624
1237
  text: [
625
1238
  '[stack] usage:',
626
- ' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--happy-cli=...] [--interactive] [--json]',
1239
+ ' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--happy-cli=...] [--interactive] [--copy-auth-from=main] [--no-copy-auth] [--json]',
627
1240
  ' happys stack edit <name> --interactive [--json]',
628
1241
  ' happys stack list [--json]',
629
1242
  ' happys stack migrate [--json] # copy legacy env files from ~/.happy/local/stacks/* -> ~/.happy/stacks/*',
1243
+ ' happys stack audit [--fix] [--fix-main] [--json]',
630
1244
  ' happys stack auth <name> status|login [--json]',
631
1245
  ' happys stack dev <name> [-- ...]',
632
1246
  ' happys stack start <name> [-- ...]',
633
1247
  ' happys stack build <name> [-- ...]',
1248
+ ' happys stack typecheck <name> [component...] [--json]',
634
1249
  ' happys stack doctor <name> [-- ...]',
635
1250
  ' happys stack mobile <name> [-- ...]',
1251
+ ' happys stack stop <name> [--aggressive] [--no-docker] [--json]',
636
1252
  ' happys stack srv <name> -- status|use ...',
637
1253
  ' happys stack wt <name> -- <wt args...>',
638
1254
  ' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
@@ -688,6 +1304,10 @@ async function main() {
688
1304
  await cmdMigrate({ argv });
689
1305
  return;
690
1306
  }
1307
+ if (cmd === 'audit') {
1308
+ await cmdAudit({ rootDir, argv });
1309
+ return;
1310
+ }
691
1311
 
692
1312
  // Commands that need a stack name.
693
1313
  const stackName = stackNameFromArg(positionals, 1);
@@ -746,7 +1366,15 @@ async function main() {
746
1366
  return;
747
1367
  }
748
1368
  if (cmd === 'build') {
749
- await cmdRunScript({ rootDir, stackName, scriptPath: 'build.mjs', args: passthrough });
1369
+ const { kv } = parseArgs(passthrough);
1370
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
1371
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'build.mjs', args: passthrough, extraEnv: overrides });
1372
+ return;
1373
+ }
1374
+ if (cmd === 'typecheck') {
1375
+ const { kv } = parseArgs(passthrough);
1376
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
1377
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'typecheck.mjs', args: passthrough, extraEnv: overrides });
750
1378
  return;
751
1379
  }
752
1380
  if (cmd === 'doctor') {
@@ -758,6 +1386,21 @@ async function main() {
758
1386
  return;
759
1387
  }
760
1388
 
1389
+ if (cmd === 'stop') {
1390
+ const { flags: stopFlags } = parseArgs(passthrough);
1391
+ const noDocker = stopFlags.has('--no-docker');
1392
+ const aggressive = stopFlags.has('--aggressive');
1393
+ const baseDir = getStackDir(stackName);
1394
+ const out = await withStackEnv({
1395
+ stackName,
1396
+ fn: async ({ env }) => {
1397
+ return await stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker, aggressive });
1398
+ },
1399
+ });
1400
+ if (json) printResult({ json, data: { ok: true, stopped: out } });
1401
+ return;
1402
+ }
1403
+
761
1404
  if (cmd === 'srv') {
762
1405
  await cmdSrv({ rootDir, stackName, args: passthrough });
763
1406
  return;