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