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