happy-stacks 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -22
- package/bin/happys.mjs +2 -2
- package/package.json +1 -1
- package/scripts/auth.mjs +49 -202
- package/scripts/build.mjs +5 -6
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +5 -5
- package/scripts/daemon.mjs +9 -17
- package/scripts/dev.mjs +18 -27
- package/scripts/doctor.mjs +20 -36
- package/scripts/edison.mjs +102 -77
- package/scripts/happy.mjs +8 -19
- package/scripts/init.mjs +5 -13
- package/scripts/install.mjs +8 -8
- package/scripts/lint.mjs +8 -29
- package/scripts/menubar.mjs +6 -13
- package/scripts/migrate.mjs +11 -21
- package/scripts/mobile.mjs +13 -12
- package/scripts/run.mjs +15 -15
- package/scripts/self.mjs +11 -29
- package/scripts/server_flavor.mjs +4 -4
- package/scripts/service.mjs +18 -28
- package/scripts/setup.mjs +26 -122
- package/scripts/setup_pr.mjs +11 -28
- package/scripts/stack.mjs +111 -161
- package/scripts/stop.mjs +3 -3
- package/scripts/tailscale.mjs +7 -10
- package/scripts/test.mjs +8 -29
- package/scripts/tui.mjs +8 -38
- package/scripts/typecheck.mjs +8 -29
- package/scripts/ui_gateway.mjs +1 -1
- package/scripts/uninstall.mjs +6 -6
- package/scripts/utils/{dev_auth_key.mjs → auth/dev_key.mjs} +2 -8
- package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
- package/scripts/utils/{handy_master_secret.mjs → auth/handy_master_secret.mjs} +6 -32
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/smoke_help.mjs +2 -2
- 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} +4 -4
- package/scripts/utils/{dev_expo_web.mjs → dev/expo_web.mjs} +5 -5
- package/scripts/utils/{dev_server.mjs → dev/server.mjs} +7 -7
- 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.mjs → expo/expo.mjs} +3 -9
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +3 -3
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
- package/scripts/utils/{localhost_host.mjs → paths/localhost_host.mjs} +2 -10
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +10 -7
- 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/pids.mjs +11 -0
- package/scripts/utils/{pm.mjs → proc/pm.mjs} +65 -152
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +1 -0
- package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
- package/scripts/utils/server/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
- package/scripts/utils/server/urls.mjs +91 -0
- package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
- package/scripts/utils/service/autostart_darwin.mjs +142 -0
- package/scripts/utils/{stack_context.mjs → stack/context.mjs} +2 -2
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/{stack_runtime_state.mjs → stack/runtime_state.mjs} +10 -27
- package/scripts/utils/{stacks.mjs → stack/stacks.mjs} +9 -2
- package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +2 -2
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +9 -15
- package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/where.mjs +6 -6
- package/scripts/worktrees.mjs +30 -58
- package/scripts/utils/server_port.mjs +0 -9
- package/scripts/utils/server_urls.mjs +0 -54
- /package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +0 -0
- /package/scripts/utils/{auth_sources.mjs → auth/sources.mjs} +0 -0
- /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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
4
|
+
import { resolvePublicServerUrl } from '../../tailscale.mjs';
|
|
5
|
+
import { resolveServerPortFromEnv } from './port.mjs';
|
|
6
|
+
|
|
7
|
+
function stackEnvExplicitlySetsPublicUrl({ env, stackName }) {
|
|
8
|
+
try {
|
|
9
|
+
const envPath =
|
|
10
|
+
(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
|
|
11
|
+
resolveStackEnvPath(stackName).envPath;
|
|
12
|
+
if (!envPath || !existsSync(envPath)) return false;
|
|
13
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
14
|
+
return /^(HAPPY_STACKS_SERVER_URL|HAPPY_LOCAL_SERVER_URL)=/m.test(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stackEnvExplicitlySetsWebappUrl({ env, stackName }) {
|
|
21
|
+
try {
|
|
22
|
+
const envPath =
|
|
23
|
+
(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim() ||
|
|
24
|
+
resolveStackEnvPath(stackName).envPath;
|
|
25
|
+
if (!envPath || !existsSync(envPath)) return false;
|
|
26
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
27
|
+
return /^HAPPY_WEBAPP_URL=/m.test(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getPublicServerUrlEnvOverride({ env = process.env, serverPort, stackName = null } = {}) {
|
|
34
|
+
const defaultPublicUrl = `http://localhost:${serverPort}`;
|
|
35
|
+
const name =
|
|
36
|
+
(stackName ?? '').toString().trim() ||
|
|
37
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
38
|
+
getStackName();
|
|
39
|
+
|
|
40
|
+
let envPublicUrl =
|
|
41
|
+
(env.HAPPY_STACKS_SERVER_URL ?? env.HAPPY_LOCAL_SERVER_URL ?? '').toString().trim() || '';
|
|
42
|
+
|
|
43
|
+
// Safety: for non-main stacks, ignore a global SERVER_URL unless it was explicitly set in the stack env file.
|
|
44
|
+
if (name !== 'main' && envPublicUrl && !stackEnvExplicitlySetsPublicUrl({ env, stackName: name })) {
|
|
45
|
+
envPublicUrl = '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { defaultPublicUrl, envPublicUrl, publicServerUrl: envPublicUrl || defaultPublicUrl };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getWebappUrlEnvOverride({ env = process.env, stackName = null } = {}) {
|
|
52
|
+
const name =
|
|
53
|
+
(stackName ?? '').toString().trim() ||
|
|
54
|
+
(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim() ||
|
|
55
|
+
getStackName();
|
|
56
|
+
|
|
57
|
+
let envWebappUrl = (env.HAPPY_WEBAPP_URL ?? '').toString().trim() || '';
|
|
58
|
+
|
|
59
|
+
// Safety: for non-main stacks, ignore a global HAPPY_WEBAPP_URL unless it was explicitly set in the stack env file.
|
|
60
|
+
if (name !== 'main' && envWebappUrl && !stackEnvExplicitlySetsWebappUrl({ env, stackName: name })) {
|
|
61
|
+
envWebappUrl = '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { envWebappUrl };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolveServerUrls({ env = process.env, serverPort, allowEnable = true } = {}) {
|
|
68
|
+
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
69
|
+
const { defaultPublicUrl, envPublicUrl } = getPublicServerUrlEnvOverride({ env, serverPort });
|
|
70
|
+
const resolved = await resolvePublicServerUrl({
|
|
71
|
+
internalServerUrl,
|
|
72
|
+
defaultPublicUrl,
|
|
73
|
+
envPublicUrl,
|
|
74
|
+
allowEnable,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
internalServerUrl,
|
|
78
|
+
defaultPublicUrl,
|
|
79
|
+
envPublicUrl,
|
|
80
|
+
publicServerUrl: resolved.publicServerUrl,
|
|
81
|
+
publicServerUrlSource: resolved.source,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getInternalServerUrl({ env = process.env, defaultPort = 3005 } = {}) {
|
|
86
|
+
const port = resolveServerPortFromEnv({ env, defaultPort });
|
|
87
|
+
return { port, internalServerUrl: `http://127.0.0.1:${port}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export { resolveServerPortFromEnv };
|
|
91
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import { runCapture } from '../proc/proc.mjs';
|
|
6
|
+
import { getDefaultAutostartPaths } from '../paths/paths.mjs';
|
|
7
|
+
import { resolveInstalledCliRoot, resolveInstalledPath } from '../paths/runtime.mjs';
|
|
8
|
+
|
|
9
|
+
function plistPathForLabel(label) {
|
|
10
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function xmlEscape(s) {
|
|
14
|
+
return String(s ?? '')
|
|
15
|
+
.replaceAll('&', '&')
|
|
16
|
+
.replaceAll('<', '<')
|
|
17
|
+
.replaceAll('>', '>')
|
|
18
|
+
.replaceAll('"', '"')
|
|
19
|
+
.replaceAll("'", ''');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function plistXml({ label, programArgs, env = {}, stdoutPath, stderrPath, workingDirectory }) {
|
|
23
|
+
const envEntries = Object.entries(env ?? {}).filter(([k, v]) => String(k).trim() && String(v ?? '').trim());
|
|
24
|
+
const programArgsXml = programArgs.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
25
|
+
const envXml = envEntries
|
|
26
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
|
27
|
+
.join('\n');
|
|
28
|
+
const workingDirXml = workingDirectory
|
|
29
|
+
? `\n <key>WorkingDirectory</key>\n <string>${xmlEscape(workingDirectory)}</string>\n`
|
|
30
|
+
: '\n';
|
|
31
|
+
|
|
32
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
34
|
+
<plist version="1.0">
|
|
35
|
+
<dict>
|
|
36
|
+
<key>Label</key>
|
|
37
|
+
<string>${xmlEscape(label)}</string>
|
|
38
|
+
|
|
39
|
+
<key>ProgramArguments</key>
|
|
40
|
+
<array>
|
|
41
|
+
${programArgsXml}
|
|
42
|
+
</array>
|
|
43
|
+
|
|
44
|
+
<key>RunAtLoad</key>
|
|
45
|
+
<true/>
|
|
46
|
+
<key>KeepAlive</key>
|
|
47
|
+
<true/>
|
|
48
|
+
${workingDirXml} <key>StandardOutPath</key>
|
|
49
|
+
<string>${xmlEscape(stdoutPath)}</string>
|
|
50
|
+
<key>StandardErrorPath</key>
|
|
51
|
+
<string>${xmlEscape(stderrPath)}</string>
|
|
52
|
+
|
|
53
|
+
<key>EnvironmentVariables</key>
|
|
54
|
+
<dict>
|
|
55
|
+
${envXml}
|
|
56
|
+
</dict>
|
|
57
|
+
</dict>
|
|
58
|
+
</plist>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function ensureMacAutostartEnabled({ rootDir, label, env }) {
|
|
63
|
+
if (process.platform !== 'darwin') {
|
|
64
|
+
throw new Error('[local] macOS autostart is only supported on Darwin');
|
|
65
|
+
}
|
|
66
|
+
const l = String(label ?? '').trim();
|
|
67
|
+
if (!l) throw new Error('[local] missing launchd label');
|
|
68
|
+
|
|
69
|
+
const plistPath = plistPathForLabel(l);
|
|
70
|
+
const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
|
|
71
|
+
await mkdir(dirname(plistPath), { recursive: true }).catch(() => {});
|
|
72
|
+
await mkdir(dirname(stdoutPath), { recursive: true }).catch(() => {});
|
|
73
|
+
await mkdir(dirname(stderrPath), { recursive: true }).catch(() => {});
|
|
74
|
+
|
|
75
|
+
const programArgs = [process.execPath, resolveInstalledPath(rootDir, 'bin/happys.mjs'), 'start'];
|
|
76
|
+
const mergedEnv = {
|
|
77
|
+
...(env ?? {}),
|
|
78
|
+
// Ensure a reasonable PATH for subprocesses (git/docker/etc) in launchd’s minimal environment.
|
|
79
|
+
PATH: (process.env.PATH ?? '').trim() || '/usr/bin:/bin:/usr/sbin:/sbin',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const xml = plistXml({
|
|
83
|
+
label: l,
|
|
84
|
+
programArgs,
|
|
85
|
+
env: mergedEnv,
|
|
86
|
+
stdoutPath,
|
|
87
|
+
stderrPath,
|
|
88
|
+
workingDirectory: resolveInstalledCliRoot(rootDir),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const tmp = join(dirname(plistPath), `.tmp.${l}.${Date.now()}.plist`);
|
|
92
|
+
await writeFile(tmp, xml, 'utf-8');
|
|
93
|
+
await rename(tmp, plistPath);
|
|
94
|
+
|
|
95
|
+
// Best-effort load/enable; `scripts/service.mjs` has a more robust bootstrap fallback.
|
|
96
|
+
try {
|
|
97
|
+
await runCapture('launchctl', ['load', '-w', plistPath]);
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function ensureMacAutostartDisabled({ label }) {
|
|
104
|
+
if (process.platform !== 'darwin') {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const l = String(label ?? '').trim();
|
|
108
|
+
if (!l) return;
|
|
109
|
+
const plistPath = plistPathForLabel(l);
|
|
110
|
+
|
|
111
|
+
const uidRaw = Number(process.env.UID);
|
|
112
|
+
const uid = Number.isFinite(uidRaw) ? uidRaw : null;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await runCapture('launchctl', ['unload', '-w', plistPath]);
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await runCapture('launchctl', ['unload', plistPath]);
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
if (uid != null) {
|
|
125
|
+
try {
|
|
126
|
+
await runCapture('launchctl', ['disable', `gui/${uid}/${l}`]);
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await runCapture('launchctl', ['bootout', `gui/${uid}`, plistPath]);
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await runCapture('launchctl', ['remove', l]);
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { getStackName, resolveStackEnvPath } from '
|
|
2
|
-
import { getStackRuntimeStatePath } from './
|
|
1
|
+
import { getStackName, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
2
|
+
import { getStackRuntimeStatePath } from './runtime_state.mjs';
|
|
3
3
|
|
|
4
4
|
export function resolveStackContext({ env = process.env, autostart = null } = {}) {
|
|
5
5
|
const explicitStack = (env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').toString().trim();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { expandHome } from '../paths/canonical_home.mjs';
|
|
4
|
+
import { getDefaultAutostartPaths } from '../paths/paths.mjs';
|
|
5
|
+
|
|
6
|
+
export function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
7
|
+
const fromEnv = (env?.HAPPY_STACKS_CLI_HOME_DIR ?? env?.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
|
|
8
|
+
return fromEnv || join(stackBaseDir, 'cli');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
|
|
12
|
+
const fromEnv = (env?.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
|
|
13
|
+
return fromEnv || join(stackBaseDir, 'server-light');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveCliHomeDir(env = process.env) {
|
|
17
|
+
const fromExplicit = (env.HAPPY_HOME_DIR ?? '').trim();
|
|
18
|
+
if (fromExplicit) {
|
|
19
|
+
return expandHome(fromExplicit);
|
|
20
|
+
}
|
|
21
|
+
const fromStacks = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
|
|
22
|
+
if (fromStacks) {
|
|
23
|
+
return expandHome(fromStacks);
|
|
24
|
+
}
|
|
25
|
+
return join(getDefaultAutostartPaths().baseDir, 'cli');
|
|
26
|
+
}
|
|
27
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { expandHome } from '../paths/canonical_home.mjs';
|
|
5
|
+
import { getComponentDir, getWorkspaceDir, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
6
|
+
import { ensureDir } from '../fs/ops.mjs';
|
|
7
|
+
import { getEnvValueAny } from '../env/values.mjs';
|
|
8
|
+
import { readEnvObjectFromFile } from '../env/read.mjs';
|
|
9
|
+
import { resolveCommandPath } from '../proc/commands.mjs';
|
|
10
|
+
import { run, runCapture } from '../proc/proc.mjs';
|
|
11
|
+
import { getCliHomeDirFromEnvOrDefault } from './dirs.mjs';
|
|
12
|
+
|
|
13
|
+
function resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv }) {
|
|
14
|
+
const raw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_WORKSPACE_DIR', 'HAPPY_LOCAL_WORKSPACE_DIR']);
|
|
15
|
+
if (!raw) {
|
|
16
|
+
return getWorkspaceDir(rootDir);
|
|
17
|
+
}
|
|
18
|
+
const expanded = expandHome(raw);
|
|
19
|
+
return expanded.startsWith('/') ? expanded : resolve(rootDir, expanded);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component }) {
|
|
23
|
+
const raw = getEnvValueAny(stackEnv, keys);
|
|
24
|
+
if (!raw) return getComponentDir(rootDir, component);
|
|
25
|
+
const expanded = expandHome(raw);
|
|
26
|
+
if (expanded.startsWith('/')) return expanded;
|
|
27
|
+
const workspaceDir = resolveWorkspaceDirFromStackEnv({ rootDir, stackEnv });
|
|
28
|
+
return resolve(workspaceDir, expanded);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function isCursorInstalled({ cwd, env } = {}) {
|
|
32
|
+
if (await resolveCommandPath('cursor', { cwd, env })) return true;
|
|
33
|
+
if (process.platform !== 'darwin') return false;
|
|
34
|
+
try {
|
|
35
|
+
await runCapture('open', ['-Ra', 'Cursor'], { cwd, env });
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function openWorkspaceInEditor({ rootDir, editor, workspacePath }) {
|
|
43
|
+
if (editor === 'code') {
|
|
44
|
+
const codePath = await resolveCommandPath('code', { cwd: rootDir, env: process.env });
|
|
45
|
+
if (!codePath) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"[stack] VS Code CLI 'code' not found on PATH. In VS Code: Cmd+Shift+P → 'Shell Command: Install code command in PATH'."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
await run(codePath, ['-n', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cursorPath = await resolveCommandPath('cursor', { cwd: rootDir, env: process.env });
|
|
55
|
+
if (cursorPath) {
|
|
56
|
+
try {
|
|
57
|
+
await run(cursorPath, ['-n', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
|
|
58
|
+
} catch {
|
|
59
|
+
await run(cursorPath, [workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (process.platform === 'darwin') {
|
|
65
|
+
// Cursor installed but CLI missing is common on macOS.
|
|
66
|
+
await run('open', ['-na', 'Cursor', workspacePath], { cwd: rootDir, env: process.env, stdio: 'inherit' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error("[stack] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeStackCodeWorkspace({
|
|
74
|
+
rootDir,
|
|
75
|
+
stackName,
|
|
76
|
+
includeStackDir,
|
|
77
|
+
includeAllComponents,
|
|
78
|
+
includeCliHome,
|
|
79
|
+
}) {
|
|
80
|
+
const { baseDir, envPath } = resolveStackEnvPath(stackName);
|
|
81
|
+
const stackEnv = await readEnvObjectFromFile(envPath);
|
|
82
|
+
|
|
83
|
+
const serverComponent =
|
|
84
|
+
getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
|
|
85
|
+
|
|
86
|
+
const selectedComponents = includeAllComponents
|
|
87
|
+
? ['happy', 'happy-cli', 'happy-server-light', 'happy-server']
|
|
88
|
+
: ['happy', 'happy-cli', serverComponent];
|
|
89
|
+
|
|
90
|
+
const componentSpecs = [
|
|
91
|
+
{ component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
|
|
92
|
+
{ component: 'happy-cli', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI'] },
|
|
93
|
+
{
|
|
94
|
+
component: 'happy-server-light',
|
|
95
|
+
keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT'],
|
|
96
|
+
},
|
|
97
|
+
{ component: 'happy-server', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER'] },
|
|
98
|
+
];
|
|
99
|
+
const byName = new Map(componentSpecs.map((c) => [c.component, c.keys]));
|
|
100
|
+
|
|
101
|
+
const folders = [];
|
|
102
|
+
if (includeStackDir) {
|
|
103
|
+
folders.push({ name: `stack:${stackName}`, path: baseDir });
|
|
104
|
+
}
|
|
105
|
+
if (includeCliHome) {
|
|
106
|
+
const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir: baseDir, env: stackEnv });
|
|
107
|
+
folders.push({ name: `cli:${stackName}`, path: expandHome(cliHomeDir) });
|
|
108
|
+
}
|
|
109
|
+
for (const component of selectedComponents) {
|
|
110
|
+
const keys = byName.get(component) ?? [];
|
|
111
|
+
const dir = resolveComponentDirFromStackEnv({ rootDir, stackEnv, keys, component });
|
|
112
|
+
folders.push({ name: component, path: dir });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Deduplicate by path (can happen if multiple components are pointed at the same dir).
|
|
116
|
+
const uniqFolders = folders.filter((f, i, arr) => arr.findIndex((x) => x.path === f.path) === i);
|
|
117
|
+
|
|
118
|
+
await ensureDir(baseDir);
|
|
119
|
+
const workspacePath = join(baseDir, `stack.${stackName}.code-workspace`);
|
|
120
|
+
const payload = {
|
|
121
|
+
folders: uniqFolders,
|
|
122
|
+
settings: {
|
|
123
|
+
'search.exclude': {
|
|
124
|
+
'**/node_modules/**': true,
|
|
125
|
+
'**/.git/**': true,
|
|
126
|
+
'**/logs/**': true,
|
|
127
|
+
'**/cli/logs/**': true,
|
|
128
|
+
},
|
|
129
|
+
'files.watcherExclude': {
|
|
130
|
+
'**/node_modules/**': true,
|
|
131
|
+
'**/.git/**': true,
|
|
132
|
+
'**/logs/**': true,
|
|
133
|
+
'**/cli/logs/**': true,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
await writeFile(workspacePath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
workspacePath,
|
|
141
|
+
baseDir,
|
|
142
|
+
envPath,
|
|
143
|
+
serverComponent,
|
|
144
|
+
folders: uniqFolders,
|
|
145
|
+
flags: {
|
|
146
|
+
includeStackDir: Boolean(includeStackDir),
|
|
147
|
+
includeCliHome: Boolean(includeCliHome),
|
|
148
|
+
includeAllComponents: Boolean(includeAllComponents),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function sanitizeStackName(raw, { fallback = 'stack', maxLen = 64 } = {}) {
|
|
2
|
+
const s = String(raw ?? '')
|
|
3
|
+
.trim()
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
6
|
+
.replace(/-+/g, '-')
|
|
7
|
+
.replace(/^-+/, '')
|
|
8
|
+
.replace(/-+$/, '');
|
|
9
|
+
const out = s || String(fallback ?? 'stack');
|
|
10
|
+
return Number.isFinite(maxLen) && maxLen > 0 ? out.slice(0, maxLen) : out;
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -1,45 +1,28 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
-
import { resolveStackEnvPath } from '
|
|
5
|
+
import { resolveStackEnvPath } from '../paths/paths.mjs';
|
|
6
|
+
import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
|
|
7
|
+
import { isPidAlive } from '../proc/pids.mjs';
|
|
8
|
+
|
|
9
|
+
export { isPidAlive };
|
|
6
10
|
|
|
7
11
|
export function getStackRuntimeStatePath(stackName) {
|
|
8
12
|
const { baseDir } = resolveStackEnvPath(stackName);
|
|
9
13
|
return join(baseDir, 'stack.runtime.json');
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
export function isPidAlive(pid) {
|
|
13
|
-
const n = Number(pid);
|
|
14
|
-
if (!Number.isFinite(n) || n <= 1) return false;
|
|
15
|
-
try {
|
|
16
|
-
process.kill(n, 0);
|
|
17
|
-
return true;
|
|
18
|
-
} catch {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
16
|
export async function readStackRuntimeStateFile(statePath) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const raw = await readFile(statePath, 'utf-8');
|
|
27
|
-
const parsed = JSON.parse(raw);
|
|
28
|
-
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
29
|
-
} catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
17
|
+
const parsed = await readJsonIfExists(statePath, { defaultValue: null });
|
|
18
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
32
19
|
}
|
|
33
20
|
|
|
34
21
|
export async function writeStackRuntimeStateFile(statePath, state) {
|
|
35
22
|
if (!statePath) {
|
|
36
23
|
throw new Error('[stack] missing runtime state path');
|
|
37
24
|
}
|
|
38
|
-
|
|
39
|
-
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
40
|
-
const tmp = join(dir, `.stack.runtime.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
41
|
-
await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
42
|
-
await rename(tmp, statePath);
|
|
25
|
+
await writeJsonAtomic(statePath, state);
|
|
43
26
|
}
|
|
44
27
|
|
|
45
28
|
function isPlainObject(v) {
|
|
@@ -2,8 +2,9 @@ import { readdir } from 'node:fs/promises';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
-
import { getLegacyStorageRoot, getStacksStorageRoot } from '
|
|
6
|
-
import { isSandboxed, sandboxAllowsGlobalSideEffects } from '
|
|
5
|
+
import { getLegacyStorageRoot, getStacksStorageRoot } from '../paths/paths.mjs';
|
|
6
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
|
|
7
|
+
import { resolveStackEnvPath } from '../paths/paths.mjs';
|
|
7
8
|
|
|
8
9
|
export async function listAllStackNames() {
|
|
9
10
|
const names = new Set(['main']);
|
|
@@ -36,3 +37,9 @@ export async function listAllStackNames() {
|
|
|
36
37
|
|
|
37
38
|
return Array.from(names).sort();
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
export function stackExistsSync(stackName) {
|
|
42
|
+
const name = String(stackName ?? '').trim() || 'main';
|
|
43
|
+
if (name === 'main') return true;
|
|
44
|
+
return existsSync(resolveStackEnvPath(name).envPath);
|
|
45
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { runCapture } from '
|
|
2
|
-
import { ensureDepsInstalled, pmExecBin } from '
|
|
1
|
+
import { runCapture } from '../proc/proc.mjs';
|
|
2
|
+
import { ensureDepsInstalled, pmExecBin } from '../proc/pm.mjs';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
@@ -2,19 +2,13 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
-
import { getComponentDir } from '
|
|
6
|
-
import { isPidAlive, readPidState } from '
|
|
7
|
-
import { stopLocalDaemon } from '
|
|
8
|
-
import { stopHappyServerManagedInfra } from '
|
|
9
|
-
import { deleteStackRuntimeStateFile, getStackRuntimeStatePath, readStackRuntimeStateFile } from './
|
|
10
|
-
import { killPidOwnedByStack, killProcessGroupOwnedByStack, listPidsWithEnvNeedle } from '
|
|
11
|
-
|
|
12
|
-
function parseIntOrNull(raw) {
|
|
13
|
-
const s = String(raw ?? '').trim();
|
|
14
|
-
if (!s) return null;
|
|
15
|
-
const n = Number(s);
|
|
16
|
-
return Number.isFinite(n) && n > 0 ? n : null;
|
|
17
|
-
}
|
|
5
|
+
import { getComponentDir } from '../paths/paths.mjs';
|
|
6
|
+
import { isPidAlive, readPidState } from '../expo/expo.mjs';
|
|
7
|
+
import { stopLocalDaemon } from '../../daemon.mjs';
|
|
8
|
+
import { stopHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
|
|
9
|
+
import { deleteStackRuntimeStateFile, getStackRuntimeStatePath, readStackRuntimeStateFile } from './runtime_state.mjs';
|
|
10
|
+
import { killPidOwnedByStack, killProcessGroupOwnedByStack, listPidsWithEnvNeedle } from '../proc/ownership.mjs';
|
|
11
|
+
import { coercePort } from '../server/port.mjs';
|
|
18
12
|
|
|
19
13
|
function resolveServerComponentFromStackEnv(env) {
|
|
20
14
|
const v =
|
|
@@ -139,8 +133,8 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
|
|
|
139
133
|
};
|
|
140
134
|
|
|
141
135
|
const serverComponent = resolveServerComponentFromStackEnv(env);
|
|
142
|
-
const port =
|
|
143
|
-
const backendPort =
|
|
136
|
+
const port = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
|
|
137
|
+
const backendPort = coercePort(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
|
|
144
138
|
const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
|
|
145
139
|
const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
|
|
146
140
|
const envPath = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function stripAnsi(s) {
|
|
2
|
+
// eslint-disable-next-line no-control-regex
|
|
3
|
+
return String(s ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function padRight(s, n) {
|
|
7
|
+
const str = String(s ?? '');
|
|
8
|
+
if (str.length >= n) return str.slice(0, n);
|
|
9
|
+
return str + ' '.repeat(n - str.length);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parsePrefixedLabel(line) {
|
|
13
|
+
const m = String(line ?? '').match(/^\[([^\]]+)\]\s*/);
|
|
14
|
+
return m ? m[1] : null;
|
|
15
|
+
}
|
|
16
|
+
|
package/scripts/where.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import './utils/env.mjs';
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
2
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { parseArgs } from './utils/cli/args.mjs';
|
|
7
|
-
import { expandHome } from './utils/canonical_home.mjs';
|
|
8
|
-
import { getComponentsDir, getComponentDir, getHappyStacksHomeDir, getRootDir, getStackLabel, getStackName, getWorkspaceDir, resolveStackEnvPath } from './utils/paths.mjs';
|
|
7
|
+
import { expandHome } from './utils/paths/canonical_home.mjs';
|
|
8
|
+
import { getComponentsDir, getComponentDir, getHappyStacksHomeDir, getRootDir, getStackLabel, getStackName, getWorkspaceDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
|
|
9
9
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
10
|
-
import { getRuntimeDir } from './utils/runtime.mjs';
|
|
11
|
-
import { getCanonicalHomeDir, getCanonicalHomeEnvPath } from './utils/config.mjs';
|
|
12
|
-
import { getSandboxDir } from './utils/sandbox.mjs';
|
|
10
|
+
import { getRuntimeDir } from './utils/paths/runtime.mjs';
|
|
11
|
+
import { getCanonicalHomeDir, getCanonicalHomeEnvPath } from './utils/env/config.mjs';
|
|
12
|
+
import { getSandboxDir } from './utils/env/sandbox.mjs';
|
|
13
13
|
|
|
14
14
|
function getHomeEnvPaths() {
|
|
15
15
|
const homeDir = getHappyStacksHomeDir();
|