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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
5
|
+
|
|
6
|
+
function sanitizeXcodeProductName(name) {
|
|
7
|
+
const raw = (name ?? '').toString().trim();
|
|
8
|
+
const out = raw
|
|
9
|
+
.replace(/[^A-Za-z0-9_-]+/g, '-')
|
|
10
|
+
.replace(/-+/g, '-')
|
|
11
|
+
.replace(/^[-_]+|[-_]+$/g, '');
|
|
12
|
+
return out || 'Happy';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function listIosAppXcodeprojNames({ iosDir }) {
|
|
16
|
+
let entries = [];
|
|
17
|
+
try {
|
|
18
|
+
entries = await readdir(iosDir, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const names = entries
|
|
24
|
+
.filter((e) => e.isDirectory() && e.name.endsWith('.xcodeproj') && e.name.startsWith('Happy'))
|
|
25
|
+
.map((e) => e.name);
|
|
26
|
+
|
|
27
|
+
// Prefer the common names first to keep behavior stable if multiple projects exist.
|
|
28
|
+
const score = (name) => {
|
|
29
|
+
if (name === 'Happydev.xcodeproj') return 0;
|
|
30
|
+
if (name === 'Happy.xcodeproj') return 1;
|
|
31
|
+
return 2;
|
|
32
|
+
};
|
|
33
|
+
names.sort((a, b) => score(a) - score(b) || a.localeCompare(b));
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolveIosAppXcodeProjects({ uiDir }) {
|
|
38
|
+
const iosDir = join(uiDir, 'ios');
|
|
39
|
+
const projectNames = await listIosAppXcodeprojNames({ iosDir });
|
|
40
|
+
|
|
41
|
+
const projects = [];
|
|
42
|
+
for (const projectName of projectNames) {
|
|
43
|
+
const pbxprojPath = join(iosDir, projectName, 'project.pbxproj');
|
|
44
|
+
if (!(await pathExists(pbxprojPath))) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const appDirName = projectName.replace(/\.xcodeproj$/, '');
|
|
49
|
+
const infoPlistPath = join(iosDir, appDirName, 'Info.plist');
|
|
50
|
+
|
|
51
|
+
projects.push({
|
|
52
|
+
name: appDirName,
|
|
53
|
+
pbxprojPath,
|
|
54
|
+
infoPlistPath: (await pathExists(infoPlistPath)) ? infoPlistPath : null,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return projects;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function patchIosXcodeProjectsForSigningAndIdentity({
|
|
62
|
+
uiDir,
|
|
63
|
+
iosBundleId,
|
|
64
|
+
iosAppName = '',
|
|
65
|
+
} = {}) {
|
|
66
|
+
const bundleId = (iosBundleId ?? '').toString().trim();
|
|
67
|
+
const appName = (iosAppName ?? '').toString().trim();
|
|
68
|
+
const productName = sanitizeXcodeProductName(appName);
|
|
69
|
+
|
|
70
|
+
if (!uiDir || !bundleId) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const projects = await resolveIosAppXcodeProjects({ uiDir });
|
|
75
|
+
if (projects.length === 0) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const project of projects) {
|
|
80
|
+
// Patch pbxproj: clear pinned signing fields so Expo can reconfigure and include provisioning update flags,
|
|
81
|
+
// and force a per-stack bundle id + optional PRODUCT_NAME.
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readFile(project.pbxprojPath, 'utf-8');
|
|
84
|
+
let next = raw;
|
|
85
|
+
|
|
86
|
+
// Clear team identifiers (both TargetAttributes and build settings variants).
|
|
87
|
+
next = next.replaceAll(/^\s*DevelopmentTeam\s*=\s*[^;]+;\s*$/gm, '');
|
|
88
|
+
next = next.replaceAll(/^\s*DEVELOPMENT_TEAM\s*=\s*[^;]+;\s*$/gm, '');
|
|
89
|
+
// Clear any pinned provisioning profiles/specifiers (manual signing).
|
|
90
|
+
next = next.replaceAll(/^\s*PROVISIONING_PROFILE\s*=\s*[^;]+;\s*$/gm, '');
|
|
91
|
+
next = next.replaceAll(/^\s*PROVISIONING_PROFILE_SPECIFIER\s*=\s*[^;]+;\s*$/gm, '');
|
|
92
|
+
// Some projects pin code signing identity; remove to let Xcode resolve based on the selected team.
|
|
93
|
+
next = next.replaceAll(/^\s*CODE_SIGN_IDENTITY\s*=\s*[^;]+;\s*$/gm, '');
|
|
94
|
+
next = next.replaceAll(/^\s*"CODE_SIGN_IDENTITY\\[sdk=iphoneos\\*\\]"\s*=\s*[^;]+;\s*$/gm, '');
|
|
95
|
+
|
|
96
|
+
next = next.replaceAll(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${bundleId};`);
|
|
97
|
+
|
|
98
|
+
if (appName) {
|
|
99
|
+
// Expo CLI appears to treat some escaped build paths as literal (e.g. "Happy\\ (stack).app"),
|
|
100
|
+
// so keep PRODUCT_NAME free of spaces to avoid breaking post-build Info.plist parsing.
|
|
101
|
+
next = next.replaceAll(/PRODUCT_NAME = [^;]+;/g, `PRODUCT_NAME = ${productName};`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (next !== raw) {
|
|
105
|
+
await writeFile(project.pbxprojPath, next, 'utf-8');
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore project patch errors; Expo will surface actionable failures if needed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Patch Info.plist display name when possible (home screen label).
|
|
112
|
+
if (appName && project.infoPlistPath) {
|
|
113
|
+
try {
|
|
114
|
+
const plistRaw = await readFile(project.infoPlistPath, 'utf-8');
|
|
115
|
+
const escaped = appName.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
116
|
+
const replaced = plistRaw.replace(
|
|
117
|
+
/(<key>CFBundleDisplayName<\/key>\s*<string>)([\s\S]*?)(<\/string>)/m,
|
|
118
|
+
`$1${escaped}$3`
|
|
119
|
+
);
|
|
120
|
+
if (replaced !== plistRaw) {
|
|
121
|
+
await writeFile(project.infoPlistPath, replaced, 'utf-8');
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { patchIosXcodeProjectsForSigningAndIdentity } from './ios_xcodeproj_patch.mjs';
|
|
8
|
+
|
|
9
|
+
async function makeTempUiDir() {
|
|
10
|
+
return await mkdtemp(join(tmpdir(), 'happy-stacks-mobile-'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('patchIosXcodeProjectsForSigningAndIdentity patches legacy ios/Happy.xcodeproj + ios/Happy/Info.plist', async () => {
|
|
14
|
+
const uiDir = await makeTempUiDir();
|
|
15
|
+
try {
|
|
16
|
+
const iosDir = join(uiDir, 'ios');
|
|
17
|
+
await mkdir(join(iosDir, 'Happy.xcodeproj'), { recursive: true });
|
|
18
|
+
await mkdir(join(iosDir, 'Happy'), { recursive: true });
|
|
19
|
+
|
|
20
|
+
const pbxprojPath = join(iosDir, 'Happy.xcodeproj', 'project.pbxproj');
|
|
21
|
+
await writeFile(
|
|
22
|
+
pbxprojPath,
|
|
23
|
+
[
|
|
24
|
+
'ProvisioningStyle = Automatic;',
|
|
25
|
+
'DEVELOPMENT_TEAM = 3RSYVV66F6;',
|
|
26
|
+
'CODE_SIGN_IDENTITY = "Apple Development";',
|
|
27
|
+
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";',
|
|
28
|
+
'PROVISIONING_PROFILE_SPECIFIER = some-profile;',
|
|
29
|
+
'PRODUCT_BUNDLE_IDENTIFIER = com.ex3ndr.happy;',
|
|
30
|
+
'PRODUCT_NAME = Happy;',
|
|
31
|
+
'',
|
|
32
|
+
].join('\n'),
|
|
33
|
+
'utf-8'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const infoPlistPath = join(iosDir, 'Happy', 'Info.plist');
|
|
37
|
+
await writeFile(
|
|
38
|
+
infoPlistPath,
|
|
39
|
+
[
|
|
40
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
41
|
+
'<plist version="1.0"><dict>',
|
|
42
|
+
'<key>CFBundleDisplayName</key><string>Happy</string>',
|
|
43
|
+
'</dict></plist>',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n'),
|
|
46
|
+
'utf-8'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await patchIosXcodeProjectsForSigningAndIdentity({
|
|
50
|
+
uiDir,
|
|
51
|
+
iosBundleId: 'com.happystacks.stack.user.pre-pr272',
|
|
52
|
+
iosAppName: 'HAPPY LEGACY',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const pbxproj = await readFile(pbxprojPath, 'utf-8');
|
|
56
|
+
assert.match(pbxproj, /PRODUCT_BUNDLE_IDENTIFIER = com\.happystacks\.stack\.user\.pre-pr272;/);
|
|
57
|
+
assert.ok(!pbxproj.includes('DEVELOPMENT_TEAM ='));
|
|
58
|
+
assert.ok(!pbxproj.includes('PROVISIONING_PROFILE_SPECIFIER ='));
|
|
59
|
+
assert.ok(!pbxproj.includes('CODE_SIGN_IDENTITY ='));
|
|
60
|
+
assert.match(pbxproj, /PRODUCT_NAME = HAPPY-LEGACY;/);
|
|
61
|
+
|
|
62
|
+
const plist = await readFile(infoPlistPath, 'utf-8');
|
|
63
|
+
assert.match(plist, /<key>CFBundleDisplayName<\/key><string>HAPPY LEGACY<\/string>/);
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(uiDir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('patchIosXcodeProjectsForSigningAndIdentity patches both Happydev + Happy projects when present', async () => {
|
|
70
|
+
const uiDir = await makeTempUiDir();
|
|
71
|
+
try {
|
|
72
|
+
const iosDir = join(uiDir, 'ios');
|
|
73
|
+
|
|
74
|
+
await mkdir(join(iosDir, 'Happy.xcodeproj'), { recursive: true });
|
|
75
|
+
await mkdir(join(iosDir, 'Happy'), { recursive: true });
|
|
76
|
+
await writeFile(join(iosDir, 'Happy.xcodeproj', 'project.pbxproj'), 'PRODUCT_BUNDLE_IDENTIFIER = com.ex3ndr.happy;\n', 'utf-8');
|
|
77
|
+
await writeFile(join(iosDir, 'Happy', 'Info.plist'), '<key>CFBundleDisplayName</key><string>Happy</string>\n', 'utf-8');
|
|
78
|
+
|
|
79
|
+
await mkdir(join(iosDir, 'Happydev.xcodeproj'), { recursive: true });
|
|
80
|
+
await mkdir(join(iosDir, 'Happydev'), { recursive: true });
|
|
81
|
+
await writeFile(join(iosDir, 'Happydev.xcodeproj', 'project.pbxproj'), 'PRODUCT_BUNDLE_IDENTIFIER = com.slopus.happy.dev;\n', 'utf-8');
|
|
82
|
+
await writeFile(join(iosDir, 'Happydev', 'Info.plist'), '<key>CFBundleDisplayName</key><string>Happy (dev)</string>\n', 'utf-8');
|
|
83
|
+
|
|
84
|
+
await patchIosXcodeProjectsForSigningAndIdentity({
|
|
85
|
+
uiDir,
|
|
86
|
+
iosBundleId: 'com.happystacks.stack.user.pre-pr272',
|
|
87
|
+
iosAppName: 'HAPPY LEGACY',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const pbxprojRelease = await readFile(join(iosDir, 'Happy.xcodeproj', 'project.pbxproj'), 'utf-8');
|
|
91
|
+
assert.match(pbxprojRelease, /PRODUCT_BUNDLE_IDENTIFIER = com\.happystacks\.stack\.user\.pre-pr272;/);
|
|
92
|
+
|
|
93
|
+
const pbxprojDev = await readFile(join(iosDir, 'Happydev.xcodeproj', 'project.pbxproj'), 'utf-8');
|
|
94
|
+
assert.match(pbxprojDev, /PRODUCT_BUNDLE_IDENTIFIER = com\.happystacks\.stack\.user\.pre-pr272;/);
|
|
95
|
+
} finally {
|
|
96
|
+
await rm(uiDir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
2
|
+
|
|
3
|
+
export function pickLanIpv4() {
|
|
4
|
+
try {
|
|
5
|
+
const ifaces = networkInterfaces();
|
|
6
|
+
// Prefer en0 (typical Wi-Fi on macOS), then any non-internal IPv4.
|
|
7
|
+
const preferred = ['en0', 'en1', 'eth0', 'wlan0'];
|
|
8
|
+
for (const name of preferred) {
|
|
9
|
+
const list = ifaces[name] ?? [];
|
|
10
|
+
for (const i of list) {
|
|
11
|
+
if (i && i.family === 'IPv4' && !i.internal && i.address) return i.address;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
for (const list of Object.values(ifaces)) {
|
|
15
|
+
for (const i of list ?? []) {
|
|
16
|
+
if (i && i.family === 'IPv4' && !i.internal && i.address) return i.address;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
2
|
import net from 'node:net';
|
|
3
|
-
import {
|
|
3
|
+
import { runCaptureIfCommandExists } from '../proc/commands.mjs';
|
|
4
4
|
|
|
5
|
-
async function listListenPids(port) {
|
|
5
|
+
export async function listListenPids(port) {
|
|
6
6
|
if (!Number.isFinite(port) || port <= 0) return [];
|
|
7
7
|
if (process.platform === 'win32') return [];
|
|
8
8
|
|
|
9
9
|
let raw = '';
|
|
10
10
|
try {
|
|
11
11
|
// `lsof` exits non-zero if no matches; normalize to empty output.
|
|
12
|
-
raw = await
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
raw = await runCaptureIfCommandExists('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
|
13
|
+
if (!raw && process.platform === 'darwin') {
|
|
14
|
+
// Some non-interactive shells (launchd/GUI apps) have a PATH that omits /usr/sbin,
|
|
15
|
+
// which makes `command -v lsof` fail even though lsof exists. Fall back to absolute paths.
|
|
16
|
+
raw =
|
|
17
|
+
(await runCaptureIfCommandExists('/usr/sbin/lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'])) ||
|
|
18
|
+
(await runCaptureIfCommandExists('/usr/bin/lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'])) ||
|
|
19
|
+
'';
|
|
20
|
+
}
|
|
16
21
|
} catch {
|
|
17
22
|
raw = '';
|
|
18
23
|
}
|
|
@@ -102,3 +107,4 @@ export async function pickNextFreeTcpPort(startPort, { reservedPorts = new Set()
|
|
|
102
107
|
}
|
|
103
108
|
throw new Error(`[local] unable to find a free TCP port starting at ${startPort}`);
|
|
104
109
|
}
|
|
110
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function normalizeUrlNoTrailingSlash(raw) {
|
|
2
|
+
const s = String(raw ?? '').trim();
|
|
3
|
+
if (!s) return '';
|
|
4
|
+
|
|
5
|
+
let u;
|
|
6
|
+
try {
|
|
7
|
+
u = new URL(s);
|
|
8
|
+
} catch {
|
|
9
|
+
// Best-effort: if it's a plain string with trailing slash, trim it.
|
|
10
|
+
return s.endsWith('/') ? s.replace(/\/+$/, '') : s;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Only normalize "base" URLs without search/hash.
|
|
14
|
+
// If search/hash is present, removing slashes can change semantics.
|
|
15
|
+
if (u.search || u.hash) {
|
|
16
|
+
return u.toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Normalize multiple trailing slashes down to none (root) or one-less (non-root).
|
|
20
|
+
const path = u.pathname || '/';
|
|
21
|
+
if (path === '/' || path === '') {
|
|
22
|
+
return u.origin;
|
|
23
|
+
}
|
|
24
|
+
if (path.endsWith('/')) {
|
|
25
|
+
const nextPath = path.replace(/\/+$/, '');
|
|
26
|
+
return `${u.origin}${nextPath}`;
|
|
27
|
+
}
|
|
28
|
+
return u.toString();
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { normalizeUrlNoTrailingSlash } from './url.mjs';
|
|
5
|
+
|
|
6
|
+
test('normalizeUrlNoTrailingSlash removes trailing slash from origins', () => {
|
|
7
|
+
assert.equal(normalizeUrlNoTrailingSlash('https://example.com/'), 'https://example.com');
|
|
8
|
+
assert.equal(normalizeUrlNoTrailingSlash('http://localhost:3005/'), 'http://localhost:3005');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('normalizeUrlNoTrailingSlash removes trailing slash from path-only base URLs', () => {
|
|
12
|
+
assert.equal(normalizeUrlNoTrailingSlash('https://example.com/api/'), 'https://example.com/api');
|
|
13
|
+
assert.equal(normalizeUrlNoTrailingSlash('https://example.com/api///'), 'https://example.com/api');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('normalizeUrlNoTrailingSlash preserves query/hash URLs', () => {
|
|
17
|
+
assert.equal(normalizeUrlNoTrailingSlash('https://example.com/?q=1'), 'https://example.com/?q=1');
|
|
18
|
+
assert.equal(normalizeUrlNoTrailingSlash('https://example.com/api/?q=1'), 'https://example.com/api/?q=1');
|
|
19
|
+
});
|
|
20
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getStackName } from './paths.mjs';
|
|
2
|
+
import { sanitizeDnsLabel } from '../net/dns.mjs';
|
|
3
|
+
|
|
4
|
+
export function resolveLocalhostHost({ stackMode, stackName = null, env = process.env } = {}) {
|
|
5
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
6
|
+
if (!stackMode) return 'localhost';
|
|
7
|
+
if (!name || name === 'main') return 'localhost';
|
|
8
|
+
return `happy-${sanitizeDnsLabel(name)}.localhost`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function preferStackLocalhostHost({ stackName = null, env = process.env } = {}) {
|
|
12
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
13
|
+
if (!name || name === 'main') return 'localhost';
|
|
14
|
+
// IMPORTANT:
|
|
15
|
+
// We intentionally do NOT gate on `dns.lookup()` here.
|
|
16
|
+
//
|
|
17
|
+
// On some systems (notably macOS), Node's DNS resolver may return ENOTFOUND for `*.localhost`
|
|
18
|
+
// even though browsers treat `*.localhost` as loopback and will load it fine.
|
|
19
|
+
//
|
|
20
|
+
// Since this hostname is primarily used for browser-facing URLs and origin isolation, we
|
|
21
|
+
// prefer the stable `happy-<stack>.localhost` form by default and allow opting out via env.
|
|
22
|
+
const modeRaw = (env.HAPPY_STACKS_LOCALHOST_SUBDOMAINS ?? env.HAPPY_LOCAL_LOCALHOST_SUBDOMAINS ?? '')
|
|
23
|
+
.toString()
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase();
|
|
26
|
+
const disabled = modeRaw === '0' || modeRaw === 'false' || modeRaw === 'no' || modeRaw === 'off';
|
|
27
|
+
if (disabled) return 'localhost';
|
|
28
|
+
|
|
29
|
+
const preferredHost = resolveLocalhostHost({ stackMode: true, stackName: name, env });
|
|
30
|
+
return preferredHost || 'localhost';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Best-effort: for stacks, prefer `happy-<stack>.localhost` over `localhost` when it's reachable.
|
|
34
|
+
// This keeps URLs stable and stack-scoped while still failing closed to plain localhost.
|
|
35
|
+
export async function preferStackLocalhostUrl(url, { stackName = null, env = process.env } = {}) {
|
|
36
|
+
const raw = String(url ?? '').trim();
|
|
37
|
+
if (!raw) return '';
|
|
38
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
39
|
+
if (!name || name === 'main') return raw;
|
|
40
|
+
|
|
41
|
+
let u = null;
|
|
42
|
+
try {
|
|
43
|
+
u = new URL(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return raw;
|
|
48
|
+
|
|
49
|
+
const isLoopbackHost = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
|
|
50
|
+
if (!isLoopbackHost) return raw;
|
|
51
|
+
|
|
52
|
+
const preferredHost = await preferStackLocalhostHost({ stackName: name, env });
|
|
53
|
+
if (!preferredHost || preferredHost === 'localhost') return raw;
|
|
54
|
+
return raw.replace(`://${u.hostname}`, `://${preferredHost}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -2,7 +2,9 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
import { expandHome } from './canonical_home.mjs';
|
|
7
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from '../env/sandbox.mjs';
|
|
6
8
|
|
|
7
9
|
const PRIMARY_APP_SLUG = 'happy-stacks';
|
|
8
10
|
const LEGACY_APP_SLUG = 'happy-local';
|
|
@@ -16,18 +18,18 @@ export function getRootDir(importMetaUrl) {
|
|
|
16
18
|
return dirname(dirname(fileURLToPath(importMetaUrl)));
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export function getHappyStacksHomeDir() {
|
|
20
|
-
const fromEnv = (
|
|
21
|
+
export function getHappyStacksHomeDir(env = process.env) {
|
|
22
|
+
const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').trim();
|
|
21
23
|
if (fromEnv) {
|
|
22
|
-
return fromEnv
|
|
24
|
+
return expandHome(fromEnv);
|
|
23
25
|
}
|
|
24
26
|
return PRIMARY_HOME_DIR;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export function getWorkspaceDir(cliRootDir = null) {
|
|
28
|
-
const fromEnv = (
|
|
29
|
+
export function getWorkspaceDir(cliRootDir = null, env = process.env) {
|
|
30
|
+
const fromEnv = (env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
|
|
29
31
|
if (fromEnv) {
|
|
30
|
-
return fromEnv
|
|
32
|
+
return expandHome(fromEnv);
|
|
31
33
|
}
|
|
32
34
|
const homeDir = getHappyStacksHomeDir();
|
|
33
35
|
const defaultWorkspace = join(homeDir, 'workspace');
|
|
@@ -39,8 +41,8 @@ export function getWorkspaceDir(cliRootDir = null) {
|
|
|
39
41
|
return cliRootDir ? cliRootDir : defaultWorkspace;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
export function getComponentsDir(rootDir) {
|
|
43
|
-
const workspaceDir = getWorkspaceDir(rootDir);
|
|
44
|
+
export function getComponentsDir(rootDir, env = process.env) {
|
|
45
|
+
const workspaceDir = getWorkspaceDir(rootDir, env);
|
|
44
46
|
return join(workspaceDir, 'components');
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -48,48 +50,50 @@ export function componentDirEnvKey(name) {
|
|
|
48
50
|
return `HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
function normalizePathForEnv(rootDir, raw) {
|
|
53
|
+
function normalizePathForEnv(rootDir, raw, env = process.env) {
|
|
52
54
|
const trimmed = (raw ?? '').trim();
|
|
53
55
|
if (!trimmed) {
|
|
54
56
|
return '';
|
|
55
57
|
}
|
|
56
|
-
const expanded = trimmed
|
|
58
|
+
const expanded = expandHome(trimmed);
|
|
57
59
|
// If the path is relative, treat it as relative to the workspace root (default: repo root).
|
|
58
|
-
const workspaceDir = getWorkspaceDir(rootDir);
|
|
60
|
+
const workspaceDir = getWorkspaceDir(rootDir, env);
|
|
59
61
|
return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
export function getComponentDir(rootDir, name) {
|
|
64
|
+
export function getComponentDir(rootDir, name, env = process.env) {
|
|
63
65
|
const stacksKey = componentDirEnvKey(name);
|
|
64
66
|
const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
|
|
65
|
-
const fromEnv = normalizePathForEnv(rootDir,
|
|
67
|
+
const fromEnv = normalizePathForEnv(rootDir, env[stacksKey] ?? env[legacyKey], env);
|
|
66
68
|
if (fromEnv) {
|
|
67
69
|
return fromEnv;
|
|
68
70
|
}
|
|
69
|
-
return join(getComponentsDir(rootDir), name);
|
|
71
|
+
return join(getComponentsDir(rootDir, env), name);
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
export function getStackName() {
|
|
73
|
-
const raw =
|
|
74
|
-
?
|
|
75
|
-
:
|
|
76
|
-
?
|
|
74
|
+
export function getStackName(env = process.env) {
|
|
75
|
+
const raw = env.HAPPY_STACKS_STACK?.trim()
|
|
76
|
+
? env.HAPPY_STACKS_STACK.trim()
|
|
77
|
+
: env.HAPPY_LOCAL_STACK?.trim()
|
|
78
|
+
? env.HAPPY_LOCAL_STACK.trim()
|
|
77
79
|
: '';
|
|
78
80
|
return raw || 'main';
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
export function getStackLabel(stackName =
|
|
82
|
-
|
|
83
|
+
export function getStackLabel(stackName = null, env = process.env) {
|
|
84
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
85
|
+
return name === 'main' ? PRIMARY_LABEL_BASE : `${PRIMARY_LABEL_BASE}.${name}`;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
export function getLegacyStackLabel(stackName =
|
|
86
|
-
|
|
88
|
+
export function getLegacyStackLabel(stackName = null, env = process.env) {
|
|
89
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
90
|
+
return name === 'main' ? LEGACY_LABEL_BASE : `${LEGACY_LABEL_BASE}.${name}`;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
export function getStacksStorageRoot() {
|
|
90
|
-
const fromEnv = (
|
|
93
|
+
export function getStacksStorageRoot(env = process.env) {
|
|
94
|
+
const fromEnv = (env.HAPPY_STACKS_STORAGE_DIR ?? env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
|
|
91
95
|
if (fromEnv) {
|
|
92
|
-
return fromEnv
|
|
96
|
+
return expandHome(fromEnv);
|
|
93
97
|
}
|
|
94
98
|
return PRIMARY_STORAGE_ROOT;
|
|
95
99
|
}
|
|
@@ -98,19 +102,20 @@ export function getLegacyStorageRoot() {
|
|
|
98
102
|
return LEGACY_STORAGE_ROOT;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
export function resolveStackBaseDir(stackName =
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
+
export function resolveStackBaseDir(stackName = null, env = process.env) {
|
|
106
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
107
|
+
const preferredRoot = getStacksStorageRoot(env);
|
|
108
|
+
const newBase = join(preferredRoot, name);
|
|
109
|
+
const legacyBase = name === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', name);
|
|
105
110
|
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
106
111
|
|
|
107
112
|
// Prefer the new layout by default.
|
|
108
113
|
//
|
|
109
114
|
// For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
|
|
110
115
|
// This avoids breaking existing stacks until `happys stack migrate` is run.
|
|
111
|
-
if (allowLegacy &&
|
|
112
|
-
const newEnv = join(preferredRoot,
|
|
113
|
-
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks',
|
|
116
|
+
if (allowLegacy && name !== 'main') {
|
|
117
|
+
const newEnv = join(preferredRoot, name, 'env');
|
|
118
|
+
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
|
|
114
119
|
if (!existsSync(newEnv) && existsSync(legacyEnv)) {
|
|
115
120
|
return { baseDir: legacyBase, isLegacy: true };
|
|
116
121
|
}
|
|
@@ -119,30 +124,31 @@ export function resolveStackBaseDir(stackName = getStackName()) {
|
|
|
119
124
|
return { baseDir: newBase, isLegacy: false };
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
export function resolveStackEnvPath(stackName =
|
|
123
|
-
const
|
|
127
|
+
export function resolveStackEnvPath(stackName = null, env = process.env) {
|
|
128
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
129
|
+
const { baseDir: activeBase, isLegacy } = resolveStackBaseDir(name, env);
|
|
124
130
|
// New layout: ~/.happy/stacks/<name>/env
|
|
125
|
-
const newEnv = join(getStacksStorageRoot(),
|
|
131
|
+
const newEnv = join(getStacksStorageRoot(env), name, 'env');
|
|
126
132
|
// Legacy layout: ~/.happy/local/stacks/<name>/env
|
|
127
|
-
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks',
|
|
133
|
+
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
|
|
128
134
|
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
129
135
|
|
|
130
136
|
if (existsSync(newEnv)) {
|
|
131
|
-
return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(),
|
|
137
|
+
return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(env), name) };
|
|
132
138
|
}
|
|
133
139
|
if (allowLegacy && existsSync(legacyEnv)) {
|
|
134
|
-
return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks',
|
|
140
|
+
return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', name) };
|
|
135
141
|
}
|
|
136
142
|
return { envPath: newEnv, isLegacy, baseDir: activeBase };
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
export function getDefaultAutostartPaths() {
|
|
140
|
-
const stackName = getStackName();
|
|
141
|
-
const { baseDir, isLegacy } = resolveStackBaseDir(stackName);
|
|
145
|
+
export function getDefaultAutostartPaths(env = process.env) {
|
|
146
|
+
const stackName = getStackName(env);
|
|
147
|
+
const { baseDir, isLegacy } = resolveStackBaseDir(stackName, env);
|
|
142
148
|
const logsDir = join(baseDir, 'logs');
|
|
143
149
|
|
|
144
|
-
const primaryLabel = getStackLabel(stackName);
|
|
145
|
-
const legacyLabel = getLegacyStackLabel(stackName);
|
|
150
|
+
const primaryLabel = getStackLabel(stackName, env);
|
|
151
|
+
const legacyLabel = getLegacyStackLabel(stackName, env);
|
|
146
152
|
const primaryPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${primaryLabel}.plist`);
|
|
147
153
|
const legacyPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${legacyLabel}.plist`);
|
|
148
154
|
|
|
@@ -185,3 +191,4 @@ export function getDefaultAutostartPaths() {
|
|
|
185
191
|
legacyStderrPath,
|
|
186
192
|
};
|
|
187
193
|
}
|
|
194
|
+
|
|
@@ -9,7 +9,9 @@ export function getRuntimeDir() {
|
|
|
9
9
|
if (fromEnv) {
|
|
10
10
|
return expandHome(fromEnv);
|
|
11
11
|
}
|
|
12
|
-
const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim()
|
|
12
|
+
const homeDir = (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim()
|
|
13
|
+
? expandHome(process.env.HAPPY_STACKS_HOME_DIR.trim())
|
|
14
|
+
: join(homedir(), '.happy-stacks');
|
|
13
15
|
return join(homeDir, 'runtime');
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { runCapture } from './proc.mjs';
|
|
2
|
+
|
|
3
|
+
export async function resolveCommandPath(cmd, { cwd, env, timeoutMs } = {}) {
|
|
4
|
+
const c = String(cmd ?? '').trim();
|
|
5
|
+
if (!c) return '';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform === 'win32') {
|
|
9
|
+
const out = (await runCapture('where', [c], { cwd, env, timeoutMs })).trim();
|
|
10
|
+
const first = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
|
|
11
|
+
return first;
|
|
12
|
+
}
|
|
13
|
+
return (
|
|
14
|
+
await runCapture('sh', ['-lc', `command -v "${c}" 2>/dev/null || true`], { cwd, env, timeoutMs })
|
|
15
|
+
).trim();
|
|
16
|
+
} catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runCaptureIfCommandExists(cmd, args, { cwd, env, timeoutMs } = {}) {
|
|
22
|
+
const resolved = await resolveCommandPath(cmd, { cwd, env, timeoutMs });
|
|
23
|
+
if (!resolved) return '';
|
|
24
|
+
try {
|
|
25
|
+
return await runCapture(resolved, args, { cwd, env, timeoutMs });
|
|
26
|
+
} catch {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function commandExists(cmd, { cwd } = {}) {
|
|
32
|
+
return Boolean(await resolveCommandPath(cmd, { cwd }));
|
|
33
|
+
}
|
|
34
|
+
|