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.
- package/README.md +22 -4
- package/bin/happys.mjs +76 -5
- package/docs/server-flavors.md +61 -2
- package/docs/stacks.md +16 -4
- package/extras/swiftbar/auth-login.sh +5 -5
- package/extras/swiftbar/happy-stacks.5s.sh +83 -41
- package/extras/swiftbar/happys-term.sh +151 -0
- package/extras/swiftbar/happys.sh +52 -0
- package/extras/swiftbar/lib/render.sh +74 -56
- package/extras/swiftbar/lib/system.sh +37 -6
- package/extras/swiftbar/lib/utils.sh +180 -4
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +2 -13
- package/extras/swiftbar/set-server-flavor.sh +8 -8
- package/extras/swiftbar/wt-pr.sh +1 -1
- package/package.json +1 -1
- package/scripts/auth.mjs +374 -3
- package/scripts/daemon.mjs +78 -11
- package/scripts/dev.mjs +122 -17
- package/scripts/init.mjs +238 -32
- package/scripts/migrate.mjs +292 -0
- package/scripts/mobile.mjs +51 -19
- package/scripts/run.mjs +118 -26
- package/scripts/service.mjs +176 -37
- package/scripts/stack.mjs +665 -22
- package/scripts/stop.mjs +157 -0
- package/scripts/tailscale.mjs +147 -21
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/utils/cli_registry.mjs +23 -0
- package/scripts/utils/config.mjs +9 -1
- package/scripts/utils/env.mjs +37 -15
- package/scripts/utils/expo.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +430 -0
- package/scripts/utils/pm.mjs +11 -2
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +46 -5
- package/scripts/utils/server.mjs +37 -0
- package/scripts/utils/stack_stop.mjs +206 -0
- package/scripts/utils/validate.mjs +42 -1
- 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=...]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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;
|