happy-stacks 0.2.0 → 0.4.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 +84 -25
- package/bin/happys.mjs +116 -17
- package/docs/happy-development.md +2 -2
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/package.json +5 -1
- package/scripts/auth.mjs +59 -208
- package/scripts/build.mjs +58 -12
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +5 -5
- package/scripts/daemon.mjs +168 -20
- package/scripts/dev.mjs +196 -70
- package/scripts/doctor.mjs +20 -36
- package/scripts/edison.mjs +105 -78
- package/scripts/happy.mjs +8 -19
- package/scripts/init.mjs +8 -14
- package/scripts/install.mjs +119 -23
- package/scripts/lint.mjs +31 -32
- package/scripts/menubar.mjs +6 -13
- package/scripts/migrate.mjs +11 -21
- package/scripts/mobile.mjs +93 -108
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +217 -0
- package/scripts/review_pr.mjs +368 -0
- package/scripts/run.mjs +95 -21
- package/scripts/self.mjs +11 -29
- package/scripts/server_flavor.mjs +4 -4
- package/scripts/service.mjs +19 -29
- package/scripts/setup.mjs +63 -160
- package/scripts/setup_pr.mjs +592 -52
- package/scripts/stack.mjs +608 -200
- package/scripts/stop.mjs +3 -3
- package/scripts/tailscale.mjs +44 -11
- package/scripts/test.mjs +52 -36
- package/scripts/tui.mjs +314 -74
- package/scripts/typecheck.mjs +31 -32
- package/scripts/ui_gateway.mjs +1 -1
- package/scripts/uninstall.mjs +6 -6
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
- package/scripts/utils/auth/sources.mjs +38 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +24 -0
- package/scripts/utils/cli/cwd_scope.mjs +82 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/smoke_help.mjs +2 -2
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
- package/scripts/utils/dev/expo_dev.mjs +246 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
- package/scripts/utils/dev_auth_key.mjs +1 -1
- package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
- package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
- package/scripts/utils/handy_master_secret.mjs +2 -2
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +56 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/runners/coderabbit.mjs +19 -0
- package/scripts/utils/review/runners/codex.mjs +51 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
- package/scripts/utils/server/urls.mjs +101 -0
- package/scripts/utils/server/validate.mjs +88 -0
- package/scripts/utils/service/autostart_darwin.mjs +182 -0
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +23 -0
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +88 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
- package/scripts/utils/stack_context.mjs +3 -3
- package/scripts/utils/stack_runtime_state.mjs +1 -1
- package/scripts/utils/stacks.mjs +2 -2
- package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/utils/validate.mjs +1 -1
- package/scripts/where.mjs +6 -6
- package/scripts/worktrees.mjs +171 -113
- package/scripts/utils/auth_sources.mjs +0 -12
- package/scripts/utils/dev_expo_web.mjs +0 -112
- package/scripts/utils/localhost_host.mjs +0 -17
- package/scripts/utils/server_port.mjs +0 -9
- package/scripts/utils/server_urls.mjs +0 -54
- /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
- /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
- /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
package/scripts/stack.mjs
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { chmod, copyFile, mkdir, open, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
|
-
|
|
6
|
+
// NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
|
+
import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs';
|
|
8
9
|
|
|
9
10
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
10
|
-
import { killProcessTree, run, runCapture } from './utils/proc.mjs';
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import { killProcessTree, run, runCapture } from './utils/proc/proc.mjs';
|
|
12
|
+
import {
|
|
13
|
+
componentDirEnvKey,
|
|
14
|
+
getComponentDir,
|
|
15
|
+
getComponentsDir,
|
|
16
|
+
getHappyStacksHomeDir,
|
|
17
|
+
getLegacyStorageRoot,
|
|
18
|
+
getRootDir,
|
|
19
|
+
getStacksStorageRoot,
|
|
20
|
+
resolveStackEnvPath,
|
|
21
|
+
} from './utils/paths/paths.mjs';
|
|
22
|
+
import { isTcpPortFree, listListenPids, pickNextFreeTcpPort } from './utils/net/ports.mjs';
|
|
13
23
|
import {
|
|
14
24
|
createWorktree,
|
|
15
25
|
createWorktreeFromBaseWorktree,
|
|
@@ -17,70 +27,64 @@ import {
|
|
|
17
27
|
isComponentWorktreePath,
|
|
18
28
|
resolveComponentSpecToDir,
|
|
19
29
|
worktreeSpecFromDir,
|
|
20
|
-
} from './utils/worktrees.mjs';
|
|
30
|
+
} from './utils/git/worktrees.mjs';
|
|
21
31
|
import { isTty, prompt, promptWorktreeSource, withRl } from './utils/cli/wizard.mjs';
|
|
22
|
-
import {
|
|
32
|
+
import { parseEnvToObject } from './utils/env/dotenv.mjs';
|
|
23
33
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
24
|
-
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env_file.mjs';
|
|
25
|
-
import { listAllStackNames } from './utils/stacks.mjs';
|
|
26
|
-
import { stopStackWithEnv } from './utils/
|
|
27
|
-
import { writeDevAuthKey } from './utils/
|
|
28
|
-
import { startDevServer } from './utils/
|
|
29
|
-
import {
|
|
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/
|
|
35
|
-
import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/
|
|
36
|
-
import { resolveAuthSeedFromEnv } from './utils/
|
|
37
|
-
import { getHomeEnvLocalPath } from './utils/config.mjs';
|
|
38
|
-
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
|
|
39
|
-
import { resolveHandyMasterSecretFromStack } from './utils/handy_master_secret.mjs';
|
|
34
|
+
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
35
|
+
import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
|
|
36
|
+
import { stopStackWithEnv } from './utils/stack/stop.mjs';
|
|
37
|
+
import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
|
|
38
|
+
import { startDevServer } from './utils/dev/server.mjs';
|
|
39
|
+
import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
|
|
40
|
+
import { requireDir } from './utils/proc/pm.mjs';
|
|
41
|
+
import { waitForHttpOk } from './utils/server/server.mjs';
|
|
42
|
+
import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
|
|
43
|
+
import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
44
|
+
import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth/files.mjs';
|
|
45
|
+
import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
|
|
46
|
+
import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
|
|
47
|
+
import { getHomeEnvLocalPath } from './utils/env/config.mjs';
|
|
48
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
|
|
49
|
+
import { resolveHandyMasterSecretFromStack } from './utils/auth/handy_master_secret.mjs';
|
|
50
|
+
import { readPinnedServerPortFromEnvFile } from './utils/server/port.mjs';
|
|
51
|
+
import { getEnvValue, getEnvValueAny } from './utils/env/values.mjs';
|
|
52
|
+
import { sanitizeDnsLabel } from './utils/net/dns.mjs';
|
|
53
|
+
import { coercePort, listPortsFromEnvObject, STACK_RESERVED_PORT_KEYS } from './utils/server/port.mjs';
|
|
40
54
|
import {
|
|
41
55
|
deleteStackRuntimeStateFile,
|
|
42
56
|
getStackRuntimeStatePath,
|
|
43
57
|
isPidAlive,
|
|
44
58
|
recordStackRuntimeStart,
|
|
45
59
|
readStackRuntimeStateFile,
|
|
46
|
-
} from './utils/
|
|
47
|
-
import { killPid } from './utils/expo.mjs';
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
}
|
|
60
|
+
} from './utils/stack/runtime_state.mjs';
|
|
61
|
+
import { killPid } from './utils/expo/expo.mjs';
|
|
62
|
+
import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
|
|
63
|
+
import { randomToken } from './utils/crypto/tokens.mjs';
|
|
64
|
+
import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
|
|
65
|
+
import { sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
66
|
+
import { isCursorInstalled, openWorkspaceInEditor, writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
|
|
67
|
+
import { readLastLines } from './utils/fs/tail.mjs';
|
|
68
|
+
import { defaultStackReleaseIdentity } from './utils/mobile/identifiers.mjs';
|
|
62
69
|
|
|
63
70
|
function stackNameFromArg(positionals, idx) {
|
|
64
71
|
const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
|
|
65
72
|
return name;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
function
|
|
69
|
-
return resolveStackEnvPath(stackName).baseDir;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function getStackEnvPath(stackName) {
|
|
73
|
-
return resolveStackEnvPath(stackName).envPath;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function getDefaultPortStart() {
|
|
75
|
+
function getDefaultPortStart(stackName = null) {
|
|
77
76
|
const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
|
|
78
77
|
? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
|
|
79
78
|
: process.env.HAPPY_LOCAL_STACK_PORT_START?.trim()
|
|
80
79
|
? process.env.HAPPY_LOCAL_STACK_PORT_START.trim()
|
|
81
80
|
: '';
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Default port strategy:
|
|
82
|
+
// - main historically lives at 3005
|
|
83
|
+
// - non-main stacks should avoid 3005 to reduce accidental collisions/confusion
|
|
84
|
+
const target = (stackName ?? '').toString().trim() || (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
85
|
+
const fallback = target === 'main' ? 3005 : 3009;
|
|
86
|
+
const n = raw ? Number(raw) : fallback;
|
|
87
|
+
return Number.isFinite(n) ? n : fallback;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
async function isPortFree(port) {
|
|
@@ -97,34 +101,14 @@ async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
async function readPortFromEnvFile(envPath) {
|
|
100
|
-
|
|
101
|
-
if (!raw.trim()) return null;
|
|
102
|
-
const parsed = parseEnvToObject(raw);
|
|
103
|
-
const portRaw = (parsed.HAPPY_STACKS_SERVER_PORT ?? parsed.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
|
|
104
|
-
const n = portRaw ? Number(portRaw) : NaN;
|
|
105
|
-
return Number.isFinite(n) && n > 0 ? n : null;
|
|
104
|
+
return await readPinnedServerPortFromEnvFile(envPath);
|
|
106
105
|
}
|
|
107
106
|
|
|
108
107
|
async function readPortsFromEnvFile(envPath) {
|
|
109
108
|
const raw = await readExistingEnv(envPath);
|
|
110
109
|
if (!raw.trim()) return [];
|
|
111
110
|
const parsed = parseEnvToObject(raw);
|
|
112
|
-
|
|
113
|
-
'HAPPY_STACKS_SERVER_PORT',
|
|
114
|
-
'HAPPY_LOCAL_SERVER_PORT',
|
|
115
|
-
'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
|
|
116
|
-
'HAPPY_STACKS_PG_PORT',
|
|
117
|
-
'HAPPY_STACKS_REDIS_PORT',
|
|
118
|
-
'HAPPY_STACKS_MINIO_PORT',
|
|
119
|
-
'HAPPY_STACKS_MINIO_CONSOLE_PORT',
|
|
120
|
-
];
|
|
121
|
-
const ports = [];
|
|
122
|
-
for (const k of keys) {
|
|
123
|
-
const rawV = (parsed[k] ?? '').toString().trim();
|
|
124
|
-
const n = rawV ? Number(rawV) : NaN;
|
|
125
|
-
if (Number.isFinite(n) && n > 0) ports.push(n);
|
|
126
|
-
}
|
|
127
|
-
return ports;
|
|
111
|
+
return listPortsFromEnvObject(parsed, STACK_RESERVED_PORT_KEYS);
|
|
128
112
|
}
|
|
129
113
|
|
|
130
114
|
async function collectReservedStackPorts({ excludeStackName = null } = {}) {
|
|
@@ -159,54 +143,7 @@ async function collectReservedStackPorts({ excludeStackName = null } = {}) {
|
|
|
159
143
|
return reserved;
|
|
160
144
|
}
|
|
161
145
|
|
|
162
|
-
|
|
163
|
-
return Buffer.from(buf)
|
|
164
|
-
.toString('base64')
|
|
165
|
-
.replaceAll('+', '-')
|
|
166
|
-
.replaceAll('/', '_')
|
|
167
|
-
.replaceAll('=', '');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function randomToken(lenBytes = 24) {
|
|
171
|
-
return base64Url(randomBytes(lenBytes));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
|
|
175
|
-
const s = String(raw ?? '')
|
|
176
|
-
.toLowerCase()
|
|
177
|
-
.replace(/[^a-z0-9-]+/g, '-')
|
|
178
|
-
.replace(/-+/g, '-')
|
|
179
|
-
.replace(/^-+/, '')
|
|
180
|
-
.replace(/-+$/, '');
|
|
181
|
-
return s || fallback;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function ensureDir(p) {
|
|
185
|
-
await mkdir(p, { recursive: true });
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function readTextIfExists(path) {
|
|
189
|
-
try {
|
|
190
|
-
if (!existsSync(path)) return null;
|
|
191
|
-
const raw = await readFile(path, 'utf-8');
|
|
192
|
-
const t = raw.trim();
|
|
193
|
-
return t ? t : null;
|
|
194
|
-
} catch {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// auth file copy/link helpers live in scripts/utils/auth_files.mjs
|
|
200
|
-
|
|
201
|
-
function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
202
|
-
const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
|
|
203
|
-
return fromEnv || join(stackBaseDir, 'cli');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
207
|
-
const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
|
|
208
|
-
return fromEnv || join(stackBaseDir, 'server-light');
|
|
209
|
-
}
|
|
146
|
+
// auth file copy/link helpers live in scripts/utils/auth/files.mjs
|
|
210
147
|
|
|
211
148
|
async function copyAuthFromStackIntoNewStack({
|
|
212
149
|
fromStackName,
|
|
@@ -255,8 +192,8 @@ async function copyAuthFromStackIntoNewStack({
|
|
|
255
192
|
'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
|
|
256
193
|
);
|
|
257
194
|
}
|
|
258
|
-
const sourceBaseDir = legacy ? getLegacyHappyBaseDir() :
|
|
259
|
-
const sourceEnvRaw = legacy ? '' : await readExistingEnv(
|
|
195
|
+
const sourceBaseDir = legacy ? getLegacyHappyBaseDir() : resolveStackEnvPath(fromStackName).baseDir;
|
|
196
|
+
const sourceEnvRaw = legacy ? '' : await readExistingEnv(resolveStackEnvPath(fromStackName).envPath);
|
|
260
197
|
const sourceEnv = parseEnvToObject(sourceEnvRaw);
|
|
261
198
|
const sourceCli = legacy ? join(sourceBaseDir, 'cli') : getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
|
|
262
199
|
const targetCli = stackEnv.HAPPY_STACKS_CLI_HOME_DIR;
|
|
@@ -302,25 +239,7 @@ function stringifyEnv(env) {
|
|
|
302
239
|
return lines.join('\n') + '\n';
|
|
303
240
|
}
|
|
304
241
|
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
const raw = await readFile(path, 'utf-8');
|
|
308
|
-
return raw;
|
|
309
|
-
} catch {
|
|
310
|
-
return '';
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function parseEnvToObject(raw) {
|
|
315
|
-
const parsed = parseDotenv(raw);
|
|
316
|
-
return Object.fromEntries(parsed.entries());
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function stackExistsSync(stackName) {
|
|
320
|
-
if (stackName === 'main') return true;
|
|
321
|
-
const envPath = getStackEnvPath(stackName);
|
|
322
|
-
return existsSync(envPath);
|
|
323
|
-
}
|
|
242
|
+
const readExistingEnv = readTextOrEmpty;
|
|
324
243
|
|
|
325
244
|
function resolveDefaultComponentDirs({ rootDir }) {
|
|
326
245
|
const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
@@ -328,16 +247,20 @@ function resolveDefaultComponentDirs({ rootDir }) {
|
|
|
328
247
|
for (const name of componentNames) {
|
|
329
248
|
const embedded = join(rootDir, 'components', name);
|
|
330
249
|
const workspace = join(getComponentsDir(rootDir), name);
|
|
331
|
-
|
|
250
|
+
// CRITICAL:
|
|
251
|
+
// In sandbox mode, never point stacks at the repo's embedded `components/*` checkouts.
|
|
252
|
+
// Sandboxes must use the sandbox workspace clones (HAPPY_STACKS_WORKSPACE_DIR/components/*),
|
|
253
|
+
// otherwise worktrees/branches collide with the user's real machine state.
|
|
254
|
+
const dir = !isSandboxed() && existsSync(embedded) ? embedded : workspace;
|
|
332
255
|
out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
|
|
333
256
|
}
|
|
334
257
|
return out;
|
|
335
258
|
}
|
|
336
259
|
|
|
337
260
|
async function writeStackEnv({ stackName, env }) {
|
|
338
|
-
const stackDir =
|
|
261
|
+
const stackDir = resolveStackEnvPath(stackName).baseDir;
|
|
339
262
|
await ensureDir(stackDir);
|
|
340
|
-
const envPath =
|
|
263
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
341
264
|
const next = stringifyEnv(env);
|
|
342
265
|
const existing = await readExistingEnv(envPath);
|
|
343
266
|
if (existing !== next) {
|
|
@@ -347,7 +270,7 @@ async function writeStackEnv({ stackName, env }) {
|
|
|
347
270
|
}
|
|
348
271
|
|
|
349
272
|
async function withStackEnv({ stackName, fn, extraEnv = {} }) {
|
|
350
|
-
const envPath =
|
|
273
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
351
274
|
if (!stackExistsSync(stackName)) {
|
|
352
275
|
throw new Error(
|
|
353
276
|
`[stack] stack "${stackName}" does not exist yet.\n` +
|
|
@@ -361,9 +284,46 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
|
|
|
361
284
|
// exported in their shell, it would otherwise "win" because utils/env.mjs only sets
|
|
362
285
|
// env vars if they are missing/empty.
|
|
363
286
|
const cleaned = { ...process.env };
|
|
287
|
+
const keepPrefixed = new Set([
|
|
288
|
+
// Stack/env pointers:
|
|
289
|
+
'HAPPY_LOCAL_ENV_FILE',
|
|
290
|
+
'HAPPY_STACKS_ENV_FILE',
|
|
291
|
+
'HAPPY_LOCAL_STACK',
|
|
292
|
+
'HAPPY_STACKS_STACK',
|
|
293
|
+
|
|
294
|
+
// Sandbox detection + policy (must propagate to child processes).
|
|
295
|
+
'HAPPY_STACKS_SANDBOX_DIR',
|
|
296
|
+
'HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL',
|
|
297
|
+
|
|
298
|
+
// Sandbox-enforced dirs (without these, sandbox isolation breaks).
|
|
299
|
+
'HAPPY_STACKS_CLI_ROOT_DISABLE',
|
|
300
|
+
'HAPPY_STACKS_CANONICAL_HOME_DIR',
|
|
301
|
+
'HAPPY_STACKS_HOME_DIR',
|
|
302
|
+
'HAPPY_STACKS_WORKSPACE_DIR',
|
|
303
|
+
'HAPPY_STACKS_RUNTIME_DIR',
|
|
304
|
+
'HAPPY_STACKS_STORAGE_DIR',
|
|
305
|
+
// Legacy prefix mirrors:
|
|
306
|
+
'HAPPY_LOCAL_CANONICAL_HOME_DIR',
|
|
307
|
+
'HAPPY_LOCAL_HOME_DIR',
|
|
308
|
+
'HAPPY_LOCAL_WORKSPACE_DIR',
|
|
309
|
+
'HAPPY_LOCAL_RUNTIME_DIR',
|
|
310
|
+
'HAPPY_LOCAL_STORAGE_DIR',
|
|
311
|
+
|
|
312
|
+
// Sandbox-safe UX knobs (keep consistent through stack wrappers).
|
|
313
|
+
'HAPPY_STACKS_VERBOSE',
|
|
314
|
+
'HAPPY_STACKS_UPDATE_CHECK',
|
|
315
|
+
'HAPPY_STACKS_UPDATE_CHECK_INTERVAL_MS',
|
|
316
|
+
'HAPPY_STACKS_UPDATE_NOTIFY_INTERVAL_MS',
|
|
317
|
+
|
|
318
|
+
// Guided auth flow coordination across wrappers.
|
|
319
|
+
// These are intentionally passed through even though most HAPPY_STACKS_* vars are scrubbed.
|
|
320
|
+
'HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH',
|
|
321
|
+
'HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH',
|
|
322
|
+
'HAPPY_STACKS_AUTH_FLOW',
|
|
323
|
+
'HAPPY_LOCAL_AUTH_FLOW',
|
|
324
|
+
]);
|
|
364
325
|
for (const k of Object.keys(cleaned)) {
|
|
365
|
-
if (k
|
|
366
|
-
if (k === 'HAPPY_LOCAL_STACK' || k === 'HAPPY_STACKS_STACK') continue;
|
|
326
|
+
if (keepPrefixed.has(k)) continue;
|
|
367
327
|
if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
|
|
368
328
|
delete cleaned[k];
|
|
369
329
|
}
|
|
@@ -604,7 +564,7 @@ async function cmdNew({ rootDir, argv, emit = true }) {
|
|
|
604
564
|
throw new Error(`[stack] invalid server component: ${serverComponent}`);
|
|
605
565
|
}
|
|
606
566
|
|
|
607
|
-
const baseDir =
|
|
567
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
608
568
|
const uiBuildDir = join(baseDir, 'ui');
|
|
609
569
|
const cliHomeDir = join(baseDir, 'cli');
|
|
610
570
|
|
|
@@ -804,7 +764,7 @@ async function cmdEdit({ rootDir, argv }) {
|
|
|
804
764
|
throw new Error('[stack] usage: happys stack edit <name> [--interactive]');
|
|
805
765
|
}
|
|
806
766
|
|
|
807
|
-
const envPath =
|
|
767
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
808
768
|
const raw = await readExistingEnv(envPath);
|
|
809
769
|
const existingEnv = parseEnvToObject(raw);
|
|
810
770
|
|
|
@@ -829,7 +789,7 @@ async function cmdEdit({ rootDir, argv }) {
|
|
|
829
789
|
const config = await withRl((rl) => interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }));
|
|
830
790
|
|
|
831
791
|
// Build next env, starting from existing env but enforcing stack-scoped invariants.
|
|
832
|
-
const baseDir =
|
|
792
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
833
793
|
const uiBuildDir = join(baseDir, 'ui');
|
|
834
794
|
const cliHomeDir = join(baseDir, 'cli');
|
|
835
795
|
|
|
@@ -948,7 +908,7 @@ async function cmdEdit({ rootDir, argv }) {
|
|
|
948
908
|
printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
|
|
949
909
|
}
|
|
950
910
|
|
|
951
|
-
async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
|
|
911
|
+
async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {}, background = false }) {
|
|
952
912
|
await withStackEnv({
|
|
953
913
|
stackName,
|
|
954
914
|
extraEnv,
|
|
@@ -979,6 +939,29 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
979
939
|
// True restart = there was an active runner for this stack. If the stack is not running,
|
|
980
940
|
// `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
|
|
981
941
|
const isTrueRestart = wantsRestart && wasRunning;
|
|
942
|
+
|
|
943
|
+
// Restart semantics (stack mode):
|
|
944
|
+
// - Stop stack-owned processes first (runner, daemon, Expo, etc.)
|
|
945
|
+
// - Never kill arbitrary port listeners
|
|
946
|
+
// - Preserve previous runtime ports in memory so a true restart can reuse them
|
|
947
|
+
if (wantsRestart && !wantsJson) {
|
|
948
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
949
|
+
try {
|
|
950
|
+
await stopStackWithEnv({
|
|
951
|
+
rootDir,
|
|
952
|
+
stackName,
|
|
953
|
+
baseDir,
|
|
954
|
+
env,
|
|
955
|
+
json: false,
|
|
956
|
+
noDocker: false,
|
|
957
|
+
aggressive: false,
|
|
958
|
+
sweepOwned: true,
|
|
959
|
+
});
|
|
960
|
+
} catch {
|
|
961
|
+
// ignore (fail-closed below on port checks)
|
|
962
|
+
}
|
|
963
|
+
await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
|
|
964
|
+
}
|
|
982
965
|
if (wasRunning) {
|
|
983
966
|
if (!wantsRestart) {
|
|
984
967
|
const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
|
|
@@ -1010,12 +993,16 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1010
993
|
} else if (scriptPath === 'dev.mjs') {
|
|
1011
994
|
console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
|
|
1012
995
|
}
|
|
996
|
+
|
|
997
|
+
// Opt-in: allow starting mobile Metro alongside an already-running stack without restarting the runner.
|
|
998
|
+
// This is important for workflows like re-running `setup-pr` with --mobile after the stack is already up.
|
|
999
|
+
const wantsMobile = args.includes('--mobile') || args.includes('--with-mobile');
|
|
1000
|
+
if (wantsMobile) {
|
|
1001
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'mobile.mjs'), '--metro'], { cwd: rootDir, env });
|
|
1002
|
+
}
|
|
1013
1003
|
return;
|
|
1014
1004
|
}
|
|
1015
|
-
// Restart:
|
|
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);
|
|
1005
|
+
// Restart: already handled above (stopStackWithEnv is ownership-gated).
|
|
1019
1006
|
}
|
|
1020
1007
|
|
|
1021
1008
|
// Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
|
|
@@ -1038,7 +1025,7 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1038
1025
|
}
|
|
1039
1026
|
}
|
|
1040
1027
|
|
|
1041
|
-
const startPort = getDefaultPortStart();
|
|
1028
|
+
const startPort = getDefaultPortStart(stackName);
|
|
1042
1029
|
const ports = {};
|
|
1043
1030
|
|
|
1044
1031
|
const parsePortOrNull = (v) => {
|
|
@@ -1083,6 +1070,42 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1083
1070
|
for (const p of toCheck) {
|
|
1084
1071
|
// eslint-disable-next-line no-await-in-loop
|
|
1085
1072
|
if (!(await isTcpPortFree(p))) {
|
|
1073
|
+
if (isTrueRestart && !wantsJson) {
|
|
1074
|
+
// Try one more safe cleanup of stack-owned processes and re-check.
|
|
1075
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1076
|
+
try {
|
|
1077
|
+
await stopStackWithEnv({
|
|
1078
|
+
rootDir,
|
|
1079
|
+
stackName,
|
|
1080
|
+
baseDir,
|
|
1081
|
+
env,
|
|
1082
|
+
json: false,
|
|
1083
|
+
noDocker: false,
|
|
1084
|
+
aggressive: false,
|
|
1085
|
+
sweepOwned: true,
|
|
1086
|
+
});
|
|
1087
|
+
} catch {
|
|
1088
|
+
// ignore
|
|
1089
|
+
}
|
|
1090
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1091
|
+
if (await isTcpPortFree(p)) {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Last resort: if we can prove the listener is stack-owned, kill it.
|
|
1096
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1097
|
+
const pids = await listListenPids(p);
|
|
1098
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1099
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1100
|
+
for (const pid of pids) {
|
|
1101
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1102
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${p}`, json: false });
|
|
1103
|
+
}
|
|
1104
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1105
|
+
if (await isTcpPortFree(p)) {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1086
1109
|
throw new Error(
|
|
1087
1110
|
`[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
|
|
1088
1111
|
`[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
|
|
@@ -1140,13 +1163,61 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1140
1163
|
: {}),
|
|
1141
1164
|
};
|
|
1142
1165
|
|
|
1166
|
+
// Background dev auth flow (automatic):
|
|
1167
|
+
// If we're starting `dev.mjs` in background and the stack is not authenticated yet,
|
|
1168
|
+
// keep the stack alive for guided login by marking this as an auth-flow so URL resolution
|
|
1169
|
+
// fails closed (never opens server port as "UI").
|
|
1170
|
+
//
|
|
1171
|
+
// IMPORTANT:
|
|
1172
|
+
// We must NOT start the daemon before credentials exist in orchestrated flows (setup-pr/review-pr),
|
|
1173
|
+
// because the daemon can enter its own auth flow and become stranded (lock held, no machine registration).
|
|
1174
|
+
if (background && scriptPath === 'dev.mjs') {
|
|
1175
|
+
const startUi = !args.includes('--no-ui') && (env.HAPPY_LOCAL_UI ?? '1').toString().trim() !== '0';
|
|
1176
|
+
const startDaemon = !args.includes('--no-daemon') && (env.HAPPY_LOCAL_DAEMON ?? '1').toString().trim() !== '0';
|
|
1177
|
+
if (startUi && startDaemon) {
|
|
1178
|
+
try {
|
|
1179
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1180
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1181
|
+
const hasCreds = existsSync(join(cliHomeDir, 'access.key'));
|
|
1182
|
+
if (!hasCreds) {
|
|
1183
|
+
childEnv.HAPPY_STACKS_AUTH_FLOW = '1';
|
|
1184
|
+
childEnv.HAPPY_LOCAL_AUTH_FLOW = '1';
|
|
1185
|
+
}
|
|
1186
|
+
} catch {
|
|
1187
|
+
// If we can't resolve CLI home dir, skip auto auth-flow markers (best-effort).
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Background mode: send runner output to a stack-scoped log file so quiet flows can
|
|
1193
|
+
// remain clean while still providing actionable error logs.
|
|
1194
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1195
|
+
const logsDir = join(stackBaseDir, 'logs');
|
|
1196
|
+
const logPath = join(logsDir, `${scriptPath.replace(/\.mjs$/, '')}.${Date.now()}.log`);
|
|
1197
|
+
if (background) {
|
|
1198
|
+
await ensureDir(logsDir);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
let logHandle = null;
|
|
1202
|
+
let outFd = null;
|
|
1203
|
+
if (background) {
|
|
1204
|
+
logHandle = await open(logPath, 'a');
|
|
1205
|
+
outFd = logHandle.fd;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1143
1208
|
// Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
|
|
1144
1209
|
const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
|
|
1145
1210
|
cwd: rootDir,
|
|
1146
1211
|
env: childEnv,
|
|
1147
|
-
stdio: 'inherit',
|
|
1212
|
+
stdio: background ? ['ignore', outFd ?? 'ignore', outFd ?? 'ignore'] : 'inherit',
|
|
1148
1213
|
shell: false,
|
|
1214
|
+
detached: background && process.platform !== 'win32',
|
|
1149
1215
|
});
|
|
1216
|
+
try {
|
|
1217
|
+
await logHandle?.close();
|
|
1218
|
+
} catch {
|
|
1219
|
+
// ignore
|
|
1220
|
+
}
|
|
1150
1221
|
|
|
1151
1222
|
// Record the chosen ports immediately (before the runner finishes booting), so other stack commands
|
|
1152
1223
|
// can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
|
|
@@ -1156,8 +1227,104 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1156
1227
|
ephemeral: true,
|
|
1157
1228
|
ownerPid: child.pid,
|
|
1158
1229
|
ports,
|
|
1230
|
+
...(background ? { logs: { runner: logPath } } : {}),
|
|
1159
1231
|
}).catch(() => {});
|
|
1160
1232
|
|
|
1233
|
+
if (background) {
|
|
1234
|
+
// Keep stack.runtime.json so stack-scoped stop/restart can manage this runner.
|
|
1235
|
+
// This mode is used by higher-level commands that want to run guided auth steps
|
|
1236
|
+
// without mixing them into server logs.
|
|
1237
|
+
const internalServerUrl = `http://127.0.0.1:${ports.server}`;
|
|
1238
|
+
|
|
1239
|
+
// Fail fast if the runner dies immediately or never exposes HTTP.
|
|
1240
|
+
// IMPORTANT: do not treat "some process answered /health" as success unless our runner
|
|
1241
|
+
// is still alive. Otherwise, if the chosen port is already in use, the runner can exit
|
|
1242
|
+
// and a different stack/process could satisfy the health check (leading to confusing
|
|
1243
|
+
// follow-on behavior like auth using the wrong port).
|
|
1244
|
+
try {
|
|
1245
|
+
let exited = null;
|
|
1246
|
+
const exitPromise = new Promise((resolvePromise) => {
|
|
1247
|
+
child.once('exit', (code, sig) => {
|
|
1248
|
+
exited = { kind: 'exit', code: code ?? 0, sig: sig ?? null };
|
|
1249
|
+
resolvePromise(exited);
|
|
1250
|
+
});
|
|
1251
|
+
child.once('error', (err) => {
|
|
1252
|
+
exited = { kind: 'error', error: err instanceof Error ? err.message : String(err) };
|
|
1253
|
+
resolvePromise(exited);
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
const readyPromise = (async () => {
|
|
1257
|
+
const timeoutMsRaw =
|
|
1258
|
+
(process.env.HAPPY_STACKS_STACK_BACKGROUND_READY_TIMEOUT_MS ??
|
|
1259
|
+
process.env.HAPPY_LOCAL_STACK_BACKGROUND_READY_TIMEOUT_MS ??
|
|
1260
|
+
'180000')
|
|
1261
|
+
.toString()
|
|
1262
|
+
.trim();
|
|
1263
|
+
const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 180_000;
|
|
1264
|
+
await waitForHttpOk(`${internalServerUrl}/health`, {
|
|
1265
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 180_000,
|
|
1266
|
+
intervalMs: 300,
|
|
1267
|
+
});
|
|
1268
|
+
return { kind: 'ready' };
|
|
1269
|
+
})();
|
|
1270
|
+
|
|
1271
|
+
const first = await Promise.race([exitPromise, readyPromise]);
|
|
1272
|
+
if (first.kind !== 'ready') {
|
|
1273
|
+
throw new Error(`[stack] ${stackName}: runner exited before becoming ready. log: ${logPath}`);
|
|
1274
|
+
}
|
|
1275
|
+
// Even if /health responded, ensure our runner is still alive.
|
|
1276
|
+
// (Prevents false positives when another process owns the port.)
|
|
1277
|
+
if (exited && exited.kind !== 'ready') {
|
|
1278
|
+
throw new Error(`[stack] ${stackName}: runner reported ready but exited immediately. log: ${logPath}`);
|
|
1279
|
+
}
|
|
1280
|
+
if (!isPidAlive(child.pid)) {
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`[stack] ${stackName}: runner health check passed, but runner is not running.\n` +
|
|
1283
|
+
`[stack] This usually means the chosen port (${ports.server}) is already in use by another process.\n` +
|
|
1284
|
+
`[stack] log: ${logPath}`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
} catch (e) {
|
|
1288
|
+
// Attach some log context so failures are debuggable even when a higher-level
|
|
1289
|
+
// command cleans up the sandbox directory afterwards.
|
|
1290
|
+
try {
|
|
1291
|
+
const tail = await readLastLines(logPath, 160);
|
|
1292
|
+
if (tail && e instanceof Error) {
|
|
1293
|
+
e.message = `${e.message}\n\n[stack] last runner log lines:\n${tail}`;
|
|
1294
|
+
}
|
|
1295
|
+
} catch {
|
|
1296
|
+
// ignore
|
|
1297
|
+
}
|
|
1298
|
+
// Best-effort cleanup on boot failure.
|
|
1299
|
+
try {
|
|
1300
|
+
// We spawned this runner process, so we can safely terminate it without relying
|
|
1301
|
+
// on ownership heuristics (which can be unreliable on some platforms due to `ps` truncation).
|
|
1302
|
+
if (background && process.platform !== 'win32') {
|
|
1303
|
+
try {
|
|
1304
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1305
|
+
} catch {
|
|
1306
|
+
// ignore
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
child.kill('SIGTERM');
|
|
1311
|
+
} catch {
|
|
1312
|
+
// ignore
|
|
1313
|
+
}
|
|
1314
|
+
} catch {
|
|
1315
|
+
// ignore
|
|
1316
|
+
}
|
|
1317
|
+
await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
|
|
1318
|
+
throw e;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (!wantsJson) {
|
|
1322
|
+
console.log(`[stack] ${stackName}: logs: ${logPath}`);
|
|
1323
|
+
}
|
|
1324
|
+
try { child.unref(); } catch { /* ignore */ }
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1161
1328
|
try {
|
|
1162
1329
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
1163
1330
|
child.on('error', rejectPromise);
|
|
@@ -1176,6 +1343,28 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
|
|
|
1176
1343
|
}
|
|
1177
1344
|
|
|
1178
1345
|
// Pinned port stack: run normally under the pinned env.
|
|
1346
|
+
if (background) {
|
|
1347
|
+
throw new Error('[stack] --background is only supported for ephemeral-port stacks');
|
|
1348
|
+
}
|
|
1349
|
+
if (wantsRestart && !wantsJson) {
|
|
1350
|
+
const pinnedPort = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
|
|
1351
|
+
if (pinnedPort && !(await isTcpPortFree(pinnedPort))) {
|
|
1352
|
+
// Last resort: kill listener only if it is stack-owned.
|
|
1353
|
+
const pids = await listListenPids(pinnedPort);
|
|
1354
|
+
const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
|
|
1355
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
|
|
1356
|
+
for (const pid of pids) {
|
|
1357
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1358
|
+
await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${pinnedPort}`, json: false });
|
|
1359
|
+
}
|
|
1360
|
+
if (!(await isTcpPortFree(pinnedPort))) {
|
|
1361
|
+
throw new Error(
|
|
1362
|
+
`[stack] ${stackName}: server port ${pinnedPort} is not free on restart.\n` +
|
|
1363
|
+
`[stack] Refusing to kill unknown listeners. Stop the process using it, or change the pinned port.`
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1179
1368
|
await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
|
|
1180
1369
|
},
|
|
1181
1370
|
});
|
|
@@ -1219,9 +1408,25 @@ async function cmdService({ rootDir, stackName, svcCmd }) {
|
|
|
1219
1408
|
});
|
|
1220
1409
|
}
|
|
1221
1410
|
|
|
1411
|
+
async function getRuntimePortExtraEnv(stackName) {
|
|
1412
|
+
const runtimeStatePath = getStackRuntimeStatePath(stackName);
|
|
1413
|
+
const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
|
|
1414
|
+
const runtimePort = Number(runtimeState?.ports?.server);
|
|
1415
|
+
return Number.isFinite(runtimePort) && runtimePort > 0
|
|
1416
|
+
? {
|
|
1417
|
+
// Ephemeral stacks (PR stacks) store their chosen ports in stack.runtime.json, not the env file.
|
|
1418
|
+
// Ensure stack-scoped commands that compute URLs don't fall back to 3005 (main default).
|
|
1419
|
+
HAPPY_STACKS_SERVER_PORT: String(runtimePort),
|
|
1420
|
+
HAPPY_LOCAL_SERVER_PORT: String(runtimePort),
|
|
1421
|
+
}
|
|
1422
|
+
: null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1222
1425
|
async function cmdTailscale({ rootDir, stackName, subcmd, args }) {
|
|
1426
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
1223
1427
|
await withStackEnv({
|
|
1224
1428
|
stackName,
|
|
1429
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
1225
1430
|
fn: async ({ env }) => {
|
|
1226
1431
|
await run(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), subcmd, ...args], { cwd: rootDir, env });
|
|
1227
1432
|
},
|
|
@@ -1256,8 +1461,10 @@ async function cmdAuth({ rootDir, stackName, args }) {
|
|
|
1256
1461
|
// Forward to scripts/auth.mjs under the stack env.
|
|
1257
1462
|
// This makes `happys stack auth <name> ...` resolve CLI home/urls for that stack.
|
|
1258
1463
|
const forwarded = args[0] === '--' ? args.slice(1) : args;
|
|
1464
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
1259
1465
|
await withStackEnv({
|
|
1260
1466
|
stackName,
|
|
1467
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
1261
1468
|
fn: async ({ env }) => {
|
|
1262
1469
|
await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...forwarded], { cwd: rootDir, env });
|
|
1263
1470
|
},
|
|
@@ -1892,9 +2099,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1892
2099
|
|
|
1893
2100
|
const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
|
|
1894
2101
|
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
1895
|
-
const publicServerUrl = `http://localhost:${serverPort}
|
|
2102
|
+
const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}`, { stackName: name });
|
|
1896
2103
|
|
|
1897
|
-
const autostart = { stackName: name, baseDir:
|
|
2104
|
+
const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
|
|
1898
2105
|
const children = [];
|
|
1899
2106
|
|
|
1900
2107
|
await withStackEnv({
|
|
@@ -1912,9 +2119,14 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1912
2119
|
serverComponent === 'happy-server'
|
|
1913
2120
|
? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
|
|
1914
2121
|
: env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT;
|
|
1915
|
-
const resolvedServerDir =
|
|
1916
|
-
|
|
1917
|
-
|
|
2122
|
+
const resolvedServerDir =
|
|
2123
|
+
(serverDir ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT ?? '').toString().trim() ||
|
|
2124
|
+
getComponentDir(rootDir, serverComponent);
|
|
2125
|
+
const resolvedCliDir =
|
|
2126
|
+
(env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() ||
|
|
2127
|
+
getComponentDir(rootDir, 'happy-cli');
|
|
2128
|
+
const resolvedUiDir =
|
|
2129
|
+
(env.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY ?? '').toString().trim() || getComponentDir(rootDir, 'happy');
|
|
1918
2130
|
|
|
1919
2131
|
await requireDir(serverComponent, resolvedServerDir);
|
|
1920
2132
|
await requireDir('happy-cli', resolvedCliDir);
|
|
@@ -1941,9 +2153,10 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1941
2153
|
});
|
|
1942
2154
|
serverProc = started.serverProc;
|
|
1943
2155
|
|
|
1944
|
-
// Start Expo web
|
|
1945
|
-
const uiRes = await
|
|
2156
|
+
// Start Expo (web) so /terminal/connect exists for happy-cli web auth.
|
|
2157
|
+
const uiRes = await ensureDevExpoServer({
|
|
1946
2158
|
startUi: true,
|
|
2159
|
+
startMobile: false,
|
|
1947
2160
|
uiDir: resolvedUiDir,
|
|
1948
2161
|
autostart,
|
|
1949
2162
|
baseEnv: env,
|
|
@@ -1962,10 +2175,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
|
1962
2175
|
}
|
|
1963
2176
|
|
|
1964
2177
|
console.log('');
|
|
1965
|
-
const uiHost = `happy-${sanitizeDnsLabel(name)}.localhost`;
|
|
1966
2178
|
const uiPort = uiRes?.port;
|
|
1967
|
-
const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
|
|
1968
2179
|
const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
|
|
2180
|
+
const uiRoot = uiRootLocalhost ? await preferStackLocalhostUrl(uiRootLocalhost, { stackName: name }) : null;
|
|
1969
2181
|
const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
|
|
1970
2182
|
|
|
1971
2183
|
console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
|
|
@@ -2092,7 +2304,7 @@ function parseServerComponentFromEnv(env) {
|
|
|
2092
2304
|
}
|
|
2093
2305
|
|
|
2094
2306
|
async function readStackEnvObject(stackName) {
|
|
2095
|
-
const envPath =
|
|
2307
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
2096
2308
|
const raw = await readExistingEnv(envPath);
|
|
2097
2309
|
const env = raw ? parseEnvToObject(raw) : {};
|
|
2098
2310
|
return { envPath, env };
|
|
@@ -2107,16 +2319,6 @@ function envKeyForComponentDir({ serverComponent, component }) {
|
|
|
2107
2319
|
return `HAPPY_STACKS_COMPONENT_DIR_${component.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
2108
2320
|
}
|
|
2109
2321
|
|
|
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
2322
|
async function cmdDuplicate({ rootDir, argv }) {
|
|
2121
2323
|
const { flags, kv } = parseArgs(argv);
|
|
2122
2324
|
const json = wantsJson(argv, { flags });
|
|
@@ -2165,14 +2367,14 @@ async function cmdDuplicate({ rootDir, argv }) {
|
|
|
2165
2367
|
if (!rawDir) continue;
|
|
2166
2368
|
|
|
2167
2369
|
let nextDir = rawDir;
|
|
2168
|
-
if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
|
|
2169
|
-
const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir });
|
|
2370
|
+
if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir, env: fromEnv })) {
|
|
2371
|
+
const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir, env: fromEnv });
|
|
2170
2372
|
if (spec) {
|
|
2171
2373
|
const [owner, ...restParts] = spec.split('/').filter(Boolean);
|
|
2172
2374
|
const rest = restParts.join('/');
|
|
2173
2375
|
const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
|
|
2174
2376
|
|
|
2175
|
-
const repoDir = join(getComponentsDir(rootDir), component);
|
|
2377
|
+
const repoDir = join(getComponentsDir(rootDir, fromEnv), component);
|
|
2176
2378
|
const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
|
|
2177
2379
|
// Base on the existing worktree's HEAD/branch so we get the same commit.
|
|
2178
2380
|
nextDir = await createWorktreeFromBaseWorktree({
|
|
@@ -2182,6 +2384,7 @@ async function cmdDuplicate({ rootDir, argv }) {
|
|
|
2182
2384
|
baseWorktreeSpec: spec,
|
|
2183
2385
|
remoteName,
|
|
2184
2386
|
depsMode,
|
|
2387
|
+
env: fromEnv,
|
|
2185
2388
|
});
|
|
2186
2389
|
}
|
|
2187
2390
|
}
|
|
@@ -2190,7 +2393,7 @@ async function cmdDuplicate({ rootDir, argv }) {
|
|
|
2190
2393
|
}
|
|
2191
2394
|
|
|
2192
2395
|
// Apply component dir overrides to the destination stack env file.
|
|
2193
|
-
const toEnvPath =
|
|
2396
|
+
const toEnvPath = resolveStackEnvPath(toStack).envPath;
|
|
2194
2397
|
if (updates.length) {
|
|
2195
2398
|
await ensureEnvFileUpdated({ envPath: toEnvPath, updates });
|
|
2196
2399
|
}
|
|
@@ -2267,13 +2470,14 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2267
2470
|
json,
|
|
2268
2471
|
data: {
|
|
2269
2472
|
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...>]',
|
|
2473
|
+
'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] [--background] [--mobile] [--json] [-- <stack dev/start args...>]',
|
|
2271
2474
|
},
|
|
2272
2475
|
text: [
|
|
2273
2476
|
'[stack] usage:',
|
|
2274
2477
|
' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start]',
|
|
2275
|
-
' [--seed-auth] [--copy-auth-from=<stack
|
|
2276
|
-
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force]',
|
|
2478
|
+
' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
|
|
2479
|
+
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
|
|
2480
|
+
' [--mobile] # also start Expo dev-client Metro for mobile',
|
|
2277
2481
|
' [--json] [-- <stack dev/start args...>]',
|
|
2278
2482
|
'',
|
|
2279
2483
|
'examples:',
|
|
@@ -2288,7 +2492,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2288
2492
|
' happys stack pr pr123 --happy=123 --happy-cli=456 --seed-auth --copy-auth-from=dev-auth --dev',
|
|
2289
2493
|
'',
|
|
2290
2494
|
' # Reuse an existing non-stacks Happy install for auth seeding',
|
|
2291
|
-
'
|
|
2495
|
+
' (deprecated) legacy ~/.happy is not supported for reliable seeding',
|
|
2292
2496
|
'',
|
|
2293
2497
|
'notes:',
|
|
2294
2498
|
' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
|
|
@@ -2316,7 +2520,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2316
2520
|
);
|
|
2317
2521
|
}
|
|
2318
2522
|
|
|
2319
|
-
const
|
|
2523
|
+
const remoteNameFromArg = (kv.get('--remote') ?? '').trim();
|
|
2320
2524
|
const depsMode = (kv.get('--deps') ?? '').trim();
|
|
2321
2525
|
|
|
2322
2526
|
const prHappy = (kv.get('--happy') ?? '').trim();
|
|
@@ -2346,6 +2550,9 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2346
2550
|
throw new Error('[stack] pr: choose either --dev or --start (not both)');
|
|
2347
2551
|
}
|
|
2348
2552
|
|
|
2553
|
+
const wantsMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
2554
|
+
const background = flags.has('--background') || flags.has('--bg') || (kv.get('--background') ?? '').trim() === '1';
|
|
2555
|
+
|
|
2349
2556
|
const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
|
|
2350
2557
|
const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
|
|
2351
2558
|
const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
|
|
@@ -2482,6 +2689,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2482
2689
|
].filter((x) => x.pr);
|
|
2483
2690
|
|
|
2484
2691
|
const worktrees = [];
|
|
2692
|
+
const stackEnvPath = resolveStackEnvPath(stackName).envPath;
|
|
2485
2693
|
for (const { component, pr } of prSpecs) {
|
|
2486
2694
|
progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
|
|
2487
2695
|
const out = await withStackEnv({
|
|
@@ -2492,7 +2700,7 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2492
2700
|
'pr',
|
|
2493
2701
|
component,
|
|
2494
2702
|
pr,
|
|
2495
|
-
`--remote=${
|
|
2703
|
+
...(remoteNameFromArg ? [`--remote=${remoteNameFromArg}`] : []),
|
|
2496
2704
|
'--use',
|
|
2497
2705
|
...(depsMode ? [`--deps=${depsMode}`] : []),
|
|
2498
2706
|
...(doUpdate ? ['--update'] : []),
|
|
@@ -2500,11 +2708,35 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2500
2708
|
'--json',
|
|
2501
2709
|
];
|
|
2502
2710
|
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
|
|
2503
|
-
|
|
2711
|
+
const parsed = stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
2712
|
+
|
|
2713
|
+
// Fail-closed invariant for PR stacks:
|
|
2714
|
+
// If you asked to pin a component to a PR checkout, it MUST be a worktree path under
|
|
2715
|
+
// the active workspace components dir (including sandbox workspace).
|
|
2716
|
+
if (parsed?.path && !isComponentWorktreePath({ rootDir, component, dir: parsed.path, env })) {
|
|
2717
|
+
throw new Error(
|
|
2718
|
+
`[stack] pr: refusing to pin ${component} because the checked out path is not a worktree.\n` +
|
|
2719
|
+
`- expected under: ${join(getComponentsDir(rootDir, env), '.worktrees', component)}/...\n` +
|
|
2720
|
+
`- actual: ${String(parsed.path ?? '').trim()}\n` +
|
|
2721
|
+
`Fix: this is a bug. Please re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
return parsed;
|
|
2504
2726
|
},
|
|
2505
2727
|
});
|
|
2506
|
-
if (
|
|
2728
|
+
if (out) {
|
|
2507
2729
|
worktrees.push(out);
|
|
2730
|
+
// Fail-closed invariant for PR stacks:
|
|
2731
|
+
// - if you asked to pin a component to a PR checkout, the stack env file MUST point at that exact worktree dir
|
|
2732
|
+
// before we start dev/start. Otherwise the stack can accidentally run the base checkout.
|
|
2733
|
+
//
|
|
2734
|
+
// We intentionally do NOT rely solely on `wt pr --use` for this; we make it explicit here.
|
|
2735
|
+
const key = componentDirEnvKey(component);
|
|
2736
|
+
await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key, value: out.path }] });
|
|
2737
|
+
}
|
|
2738
|
+
if (json) {
|
|
2739
|
+
// collected above
|
|
2508
2740
|
} else if (out) {
|
|
2509
2741
|
const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
|
|
2510
2742
|
const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
|
|
@@ -2521,6 +2753,36 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2521
2753
|
}
|
|
2522
2754
|
}
|
|
2523
2755
|
|
|
2756
|
+
// Validate that all PR components are pinned correctly before starting.
|
|
2757
|
+
// This prevents "wrong daemon" / "wrong UI" errors that are otherwise extremely confusing in review-pr.
|
|
2758
|
+
if (prSpecs.length) {
|
|
2759
|
+
const afterRaw = await readExistingEnv(stackEnvPath);
|
|
2760
|
+
const afterEnv = parseEnvToObject(afterRaw);
|
|
2761
|
+
for (const wt of worktrees) {
|
|
2762
|
+
const key = componentDirEnvKey(wt.component);
|
|
2763
|
+
const val = (afterEnv[key] ?? '').toString().trim();
|
|
2764
|
+
const expected = resolve(String(wt.path ?? '').trim());
|
|
2765
|
+
const actual = val ? resolve(val) : '';
|
|
2766
|
+
if (!actual) {
|
|
2767
|
+
throw new Error(
|
|
2768
|
+
`[stack] pr: failed to pin ${wt.component} to the PR checkout.\n` +
|
|
2769
|
+
`- missing env key: ${key}\n` +
|
|
2770
|
+
`- expected: ${expected}\n` +
|
|
2771
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2772
|
+
);
|
|
2773
|
+
}
|
|
2774
|
+
if (expected && actual !== expected) {
|
|
2775
|
+
throw new Error(
|
|
2776
|
+
`[stack] pr: stack is pinned to the wrong checkout for ${wt.component}.\n` +
|
|
2777
|
+
`- env key: ${key}\n` +
|
|
2778
|
+
`- expected: ${expected}\n` +
|
|
2779
|
+
`- actual: ${actual}\n` +
|
|
2780
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2524
2786
|
// 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
|
|
2525
2787
|
let auth = null;
|
|
2526
2788
|
if (seedAuth) {
|
|
@@ -2533,8 +2795,10 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2533
2795
|
...(authLink ? ['--link'] : []),
|
|
2534
2796
|
];
|
|
2535
2797
|
if (json) {
|
|
2798
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
2536
2799
|
auth = await withStackEnv({
|
|
2537
2800
|
stackName,
|
|
2801
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
2538
2802
|
fn: async ({ env }) => {
|
|
2539
2803
|
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
|
|
2540
2804
|
return stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
@@ -2549,12 +2813,18 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2549
2813
|
// 4) Optional: start dev / start.
|
|
2550
2814
|
if (wantsDev) {
|
|
2551
2815
|
progress(`[stack] pr: ${stackName}: starting dev...`);
|
|
2552
|
-
const args =
|
|
2553
|
-
|
|
2816
|
+
const args = [
|
|
2817
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
2818
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
2819
|
+
];
|
|
2820
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
|
|
2554
2821
|
} else if (wantsStart) {
|
|
2555
2822
|
progress(`[stack] pr: ${stackName}: starting...`);
|
|
2556
|
-
const args =
|
|
2557
|
-
|
|
2823
|
+
const args = [
|
|
2824
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
2825
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
2826
|
+
];
|
|
2827
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
|
|
2558
2828
|
}
|
|
2559
2829
|
|
|
2560
2830
|
const info = await cmdInfoInternal({ rootDir, stackName });
|
|
@@ -2577,8 +2847,8 @@ async function cmdPrStack({ rootDir, argv }) {
|
|
|
2577
2847
|
|
|
2578
2848
|
async function cmdInfoInternal({ rootDir, stackName }) {
|
|
2579
2849
|
// Minimal extraction from cmdInfo to avoid re-parsing argv/printing. Used by cmdPrStack.
|
|
2580
|
-
const baseDir =
|
|
2581
|
-
const envPath =
|
|
2850
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
2851
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
2582
2852
|
const envRaw = await readExistingEnv(envPath);
|
|
2583
2853
|
const stackEnv = envRaw ? parseEnvToObject(envRaw) : {};
|
|
2584
2854
|
const runtimeStatePath = getStackRuntimeStatePath(stackName);
|
|
@@ -2587,6 +2857,9 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2587
2857
|
const serverComponent =
|
|
2588
2858
|
getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
|
|
2589
2859
|
|
|
2860
|
+
const stackRemote =
|
|
2861
|
+
getEnvValueAny(stackEnv, ['HAPPY_STACKS_STACK_REMOTE', 'HAPPY_LOCAL_STACK_REMOTE']) || 'upstream';
|
|
2862
|
+
|
|
2590
2863
|
const pinnedServerPortRaw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_PORT', 'HAPPY_LOCAL_SERVER_PORT']);
|
|
2591
2864
|
const pinnedServerPort = pinnedServerPortRaw ? Number(pinnedServerPortRaw) : null;
|
|
2592
2865
|
|
|
@@ -2604,10 +2877,15 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2604
2877
|
runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
|
|
2605
2878
|
? Number(runtimeState.expo.webPort)
|
|
2606
2879
|
: null;
|
|
2880
|
+
const mobilePort =
|
|
2881
|
+
runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.mobilePort) > 0
|
|
2882
|
+
? Number(runtimeState.expo.mobilePort)
|
|
2883
|
+
: null;
|
|
2607
2884
|
|
|
2608
2885
|
const host = resolveLocalhostHost({ stackMode: true, stackName });
|
|
2609
2886
|
const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
2610
2887
|
const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
|
|
2888
|
+
const mobileUrl = mobilePort ? await preferStackLocalhostUrl(`http://localhost:${mobilePort}`, { stackName }) : null;
|
|
2611
2889
|
|
|
2612
2890
|
const componentSpecs = [
|
|
2613
2891
|
{ component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
|
|
@@ -2632,6 +2910,7 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2632
2910
|
envPath,
|
|
2633
2911
|
runtimeStatePath,
|
|
2634
2912
|
serverComponent,
|
|
2913
|
+
stackRemote,
|
|
2635
2914
|
pinned: {
|
|
2636
2915
|
serverPort: Number.isFinite(pinnedServerPort) && pinnedServerPort > 0 ? pinnedServerPort : null,
|
|
2637
2916
|
},
|
|
@@ -2649,16 +2928,43 @@ async function cmdInfoInternal({ rootDir, stackName }) {
|
|
|
2649
2928
|
host,
|
|
2650
2929
|
internalServerUrl,
|
|
2651
2930
|
uiUrl,
|
|
2931
|
+
mobileUrl,
|
|
2652
2932
|
},
|
|
2653
2933
|
ports: {
|
|
2654
2934
|
server: serverPort,
|
|
2655
2935
|
backend: backendPort,
|
|
2656
2936
|
ui: uiPort,
|
|
2937
|
+
mobile: mobilePort,
|
|
2657
2938
|
},
|
|
2658
2939
|
components,
|
|
2659
2940
|
};
|
|
2660
2941
|
}
|
|
2661
2942
|
|
|
2943
|
+
async function cmdStackCodeOrCursor({ rootDir, stackName, json, editor, includeStackDir, includeAllComponents, includeCliHome }) {
|
|
2944
|
+
const ws = await writeStackCodeWorkspace({ rootDir, stackName, includeStackDir, includeAllComponents, includeCliHome });
|
|
2945
|
+
|
|
2946
|
+
if (json) {
|
|
2947
|
+
printResult({
|
|
2948
|
+
json,
|
|
2949
|
+
data: {
|
|
2950
|
+
ok: true,
|
|
2951
|
+
stackName,
|
|
2952
|
+
editor,
|
|
2953
|
+
...ws,
|
|
2954
|
+
},
|
|
2955
|
+
});
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
await openWorkspaceInEditor({ rootDir, editor, workspacePath: ws.workspacePath });
|
|
2960
|
+
console.log(`[stack] opened ${editor === 'code' ? 'VS Code' : 'Cursor'} workspace for "${stackName}": ${ws.workspacePath}`);
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
async function cmdStackOpen({ rootDir, stackName, json, includeStackDir, includeAllComponents, includeCliHome }) {
|
|
2964
|
+
const editor = (await isCursorInstalled({ cwd: rootDir, env: process.env })) ? 'cursor' : 'code';
|
|
2965
|
+
await cmdStackCodeOrCursor({ rootDir, stackName, json, editor, includeStackDir, includeAllComponents, includeCliHome });
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2662
2968
|
async function main() {
|
|
2663
2969
|
const rootDir = getRootDir(import.meta.url);
|
|
2664
2970
|
// pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
|
|
@@ -2695,13 +3001,19 @@ async function main() {
|
|
|
2695
3001
|
'dev',
|
|
2696
3002
|
'start',
|
|
2697
3003
|
'build',
|
|
3004
|
+
'review',
|
|
2698
3005
|
'typecheck',
|
|
2699
3006
|
'lint',
|
|
2700
3007
|
'test',
|
|
2701
3008
|
'doctor',
|
|
2702
3009
|
'mobile',
|
|
3010
|
+
'mobile:install',
|
|
3011
|
+
'mobile-dev-client',
|
|
2703
3012
|
'resume',
|
|
2704
3013
|
'stop',
|
|
3014
|
+
'code',
|
|
3015
|
+
'cursor',
|
|
3016
|
+
'open',
|
|
2705
3017
|
'srv',
|
|
2706
3018
|
'wt',
|
|
2707
3019
|
'tailscale:*',
|
|
@@ -2723,13 +3035,19 @@ async function main() {
|
|
|
2723
3035
|
' happys stack dev <name> [-- ...]',
|
|
2724
3036
|
' happys stack start <name> [-- ...]',
|
|
2725
3037
|
' happys stack build <name> [-- ...]',
|
|
3038
|
+
' happys stack review <name> [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--json]',
|
|
2726
3039
|
' happys stack typecheck <name> [component...] [--json]',
|
|
2727
3040
|
' happys stack lint <name> [component...] [--json]',
|
|
2728
3041
|
' happys stack test <name> [component...] [--json]',
|
|
2729
3042
|
' happys stack doctor <name> [-- ...]',
|
|
2730
3043
|
' happys stack mobile <name> [-- ...]',
|
|
3044
|
+
' happys stack mobile:install <name> [--name="Happy (exp1)"] [--device=...] [--json]',
|
|
3045
|
+
' happys stack mobile-dev-client <name> --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
|
|
2731
3046
|
' happys stack resume <name> <sessionId...> [--json]',
|
|
2732
3047
|
' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
|
|
3048
|
+
' happys stack code <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
|
|
3049
|
+
' happys stack cursor <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
|
|
3050
|
+
' happys stack open <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json] # prefer Cursor, else VS Code',
|
|
2733
3051
|
' happys stack srv <name> -- status|use ...',
|
|
2734
3052
|
' happys stack wt <name> -- <wt args...>',
|
|
2735
3053
|
' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
|
|
@@ -2858,11 +3176,15 @@ async function main() {
|
|
|
2858
3176
|
const passthrough = argv.slice(2);
|
|
2859
3177
|
|
|
2860
3178
|
if (cmd === 'dev') {
|
|
2861
|
-
|
|
3179
|
+
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
3180
|
+
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|
|
3181
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
|
|
2862
3182
|
return;
|
|
2863
3183
|
}
|
|
2864
3184
|
if (cmd === 'start') {
|
|
2865
|
-
|
|
3185
|
+
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
3186
|
+
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|
|
3187
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
|
|
2866
3188
|
return;
|
|
2867
3189
|
}
|
|
2868
3190
|
if (cmd === 'build') {
|
|
@@ -2889,6 +3211,12 @@ async function main() {
|
|
|
2889
3211
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
|
|
2890
3212
|
return;
|
|
2891
3213
|
}
|
|
3214
|
+
if (cmd === 'review') {
|
|
3215
|
+
const { kv } = parseArgs(passthrough);
|
|
3216
|
+
const overrides = resolveTransientComponentOverrides({ rootDir, kv });
|
|
3217
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'review.mjs', args: passthrough, extraEnv: overrides });
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
2892
3220
|
if (cmd === 'doctor') {
|
|
2893
3221
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
|
|
2894
3222
|
return;
|
|
@@ -2897,6 +3225,62 @@ async function main() {
|
|
|
2897
3225
|
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
|
|
2898
3226
|
return;
|
|
2899
3227
|
}
|
|
3228
|
+
if (cmd === 'mobile-dev-client') {
|
|
3229
|
+
// Stack-scoped wrapper so the dev-client can be built from the stack's active happy checkout/worktree.
|
|
3230
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile_dev_client.mjs', args: passthrough });
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
if (cmd === 'mobile:install') {
|
|
3234
|
+
const { flags: mFlags, kv: mKv } = parseArgs(passthrough);
|
|
3235
|
+
const device = (mKv.get('--device') ?? '').toString();
|
|
3236
|
+
const name = (mKv.get('--name') ?? mKv.get('--app-name') ?? '').toString().trim();
|
|
3237
|
+
const jsonOut = wantsJson(passthrough, { flags: mFlags }) || json;
|
|
3238
|
+
|
|
3239
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
3240
|
+
const existingRaw = await readExistingEnv(envPath);
|
|
3241
|
+
const existing = parseEnvToObject(existingRaw);
|
|
3242
|
+
|
|
3243
|
+
const priorName =
|
|
3244
|
+
(existing.HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME ?? existing.HAPPY_LOCAL_MOBILE_RELEASE_IOS_APP_NAME ?? '').toString().trim();
|
|
3245
|
+
const identity = defaultStackReleaseIdentity({
|
|
3246
|
+
stackName,
|
|
3247
|
+
user: process.env.USER ?? process.env.USERNAME ?? 'user',
|
|
3248
|
+
appName: name || priorName || null,
|
|
3249
|
+
});
|
|
3250
|
+
|
|
3251
|
+
// Persist the chosen identity so re-installs are stable and user-friendly.
|
|
3252
|
+
await ensureEnvFileUpdated({
|
|
3253
|
+
envPath,
|
|
3254
|
+
updates: [
|
|
3255
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME', value: identity.iosAppName },
|
|
3256
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_BUNDLE_ID', value: identity.iosBundleId },
|
|
3257
|
+
{ key: 'HAPPY_STACKS_MOBILE_RELEASE_SCHEME', value: identity.scheme },
|
|
3258
|
+
],
|
|
3259
|
+
});
|
|
3260
|
+
|
|
3261
|
+
// Install a per-stack release-configured app (isolated container) without starting Metro.
|
|
3262
|
+
const args = [
|
|
3263
|
+
`--app-env=production`,
|
|
3264
|
+
`--ios-app-name=${identity.iosAppName}`,
|
|
3265
|
+
`--ios-bundle-id=${identity.iosBundleId}`,
|
|
3266
|
+
`--scheme=${identity.scheme}`,
|
|
3267
|
+
'--prebuild',
|
|
3268
|
+
'--run-ios',
|
|
3269
|
+
'--configuration=Release',
|
|
3270
|
+
'--no-metro',
|
|
3271
|
+
...(device ? [`--device=${device}`] : []),
|
|
3272
|
+
];
|
|
3273
|
+
|
|
3274
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args });
|
|
3275
|
+
|
|
3276
|
+
if (jsonOut) {
|
|
3277
|
+
printResult({
|
|
3278
|
+
json: true,
|
|
3279
|
+
data: { ok: true, stackName, installed: true, identity },
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
2900
3284
|
if (cmd === 'resume') {
|
|
2901
3285
|
const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
|
|
2902
3286
|
if (sessionIds.length === 0) {
|
|
@@ -2913,7 +3297,9 @@ async function main() {
|
|
|
2913
3297
|
const out = await withStackEnv({
|
|
2914
3298
|
stackName,
|
|
2915
3299
|
fn: async ({ env }) => {
|
|
2916
|
-
|
|
3300
|
+
// IMPORTANT: use the stack's pinned happy-cli checkout if set.
|
|
3301
|
+
// Do not read component dirs from this process's `process.env` (withStackEnv does not mutate it).
|
|
3302
|
+
const cliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() || getComponentDir(rootDir, 'happy-cli');
|
|
2917
3303
|
const happyBin = join(cliDir, 'bin', 'happy.mjs');
|
|
2918
3304
|
// Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
|
|
2919
3305
|
return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });
|
|
@@ -2928,7 +3314,7 @@ async function main() {
|
|
|
2928
3314
|
const noDocker = stopFlags.has('--no-docker');
|
|
2929
3315
|
const aggressive = stopFlags.has('--aggressive');
|
|
2930
3316
|
const sweepOwned = stopFlags.has('--sweep-owned');
|
|
2931
|
-
const baseDir =
|
|
3317
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
2932
3318
|
const out = await withStackEnv({
|
|
2933
3319
|
stackName,
|
|
2934
3320
|
fn: async ({ env }) => {
|
|
@@ -2939,6 +3325,28 @@ async function main() {
|
|
|
2939
3325
|
return;
|
|
2940
3326
|
}
|
|
2941
3327
|
|
|
3328
|
+
if (cmd === 'code') {
|
|
3329
|
+
const includeStackDir = !flags.has('--no-stack-dir');
|
|
3330
|
+
const includeAllComponents = flags.has('--include-all-components');
|
|
3331
|
+
const includeCliHome = flags.has('--include-cli-home');
|
|
3332
|
+
await cmdStackCodeOrCursor({ rootDir, stackName, json, editor: 'code', includeStackDir, includeAllComponents, includeCliHome });
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
if (cmd === 'cursor') {
|
|
3336
|
+
const includeStackDir = !flags.has('--no-stack-dir');
|
|
3337
|
+
const includeAllComponents = flags.has('--include-all-components');
|
|
3338
|
+
const includeCliHome = flags.has('--include-cli-home');
|
|
3339
|
+
await cmdStackCodeOrCursor({ rootDir, stackName, json, editor: 'cursor', includeStackDir, includeAllComponents, includeCliHome });
|
|
3340
|
+
return;
|
|
3341
|
+
}
|
|
3342
|
+
if (cmd === 'open') {
|
|
3343
|
+
const includeStackDir = !flags.has('--no-stack-dir');
|
|
3344
|
+
const includeAllComponents = flags.has('--include-all-components');
|
|
3345
|
+
const includeCliHome = flags.has('--include-cli-home');
|
|
3346
|
+
await cmdStackOpen({ rootDir, stackName, json, includeStackDir, includeAllComponents, includeCliHome });
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
|
|
2942
3350
|
if (cmd === 'srv') {
|
|
2943
3351
|
await cmdSrv({ rootDir, stackName, args: passthrough });
|
|
2944
3352
|
return;
|