@workerstack/system 0.0.0-stage → 0.2.3
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/LICENSE +21 -0
- package/README.md +1155 -2
- package/package.json +46 -5
- package/src/cli.ts +593 -0
- package/src/config.ts +120 -0
- package/src/env.ts +85 -0
- package/src/exec.ts +43 -0
- package/src/graph.ts +45 -0
- package/src/index.ts +15 -0
- package/src/path.ts +134 -0
- package/src/platform.ts +78 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System dependencies configuration helpers
|
|
3
|
+
*
|
|
4
|
+
* Template variables available in `os` commands and `check.command`:
|
|
5
|
+
* {{name}} — dep key (e.g. "node", "stripe-cli")
|
|
6
|
+
* {{version}} — full version string (e.g. "22.20.0")
|
|
7
|
+
* {{major}} — major version number (e.g. "22")
|
|
8
|
+
* {{arch}} — CPU architecture: "arm64" or "amd64" (auto-detected)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const OS_TARGETS = ["linux-apt", "linux-dnf", "linux-pacman", "macos", "windows"] as const
|
|
12
|
+
|
|
13
|
+
export type OsTarget = (typeof OS_TARGETS)[number]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-OS install commands.
|
|
17
|
+
* - Use specific OS keys for platform-specific commands
|
|
18
|
+
* - Use "all" for commands identical across all platforms
|
|
19
|
+
* - Per-OS keys override "all" when both are present
|
|
20
|
+
* - Omit an OS key (without "all") = unavailable on that OS
|
|
21
|
+
* - Set an OS key to `false` = explicitly unavailable on that OS (overrides "all")
|
|
22
|
+
*/
|
|
23
|
+
export type OsCommands = Partial<Record<OsTarget, string | false>> & { all?: string }
|
|
24
|
+
|
|
25
|
+
export interface EnvVar {
|
|
26
|
+
/** Environment variable name */
|
|
27
|
+
key: string
|
|
28
|
+
/** Value (supports $HOME and other env refs). Can be omitted if detect is provided. */
|
|
29
|
+
value?: string
|
|
30
|
+
/** If true, appends to PATH instead of setting directly */
|
|
31
|
+
appendToPath?: boolean
|
|
32
|
+
/** Auto-detect value from these paths (first existing path wins). Supports $HOME. */
|
|
33
|
+
detect?: string[]
|
|
34
|
+
/** Fallback value if detect finds nothing */
|
|
35
|
+
fallback?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SystemDepConfig {
|
|
39
|
+
description: string
|
|
40
|
+
required: boolean
|
|
41
|
+
version: string
|
|
42
|
+
/** Per-OS install commands. Supports {{name}}, {{version}}, {{major}} templates. */
|
|
43
|
+
os: OsCommands
|
|
44
|
+
/**
|
|
45
|
+
* Version check command. Supports {{name}} template.
|
|
46
|
+
* Default: "{{name}} --version" — only declare when it differs.
|
|
47
|
+
*/
|
|
48
|
+
check?: { command: string }
|
|
49
|
+
/** Deps that must be installed before this one (by dep key name) */
|
|
50
|
+
dependsOn?: string[]
|
|
51
|
+
/** Env vars to configure after install */
|
|
52
|
+
env?: EnvVar[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SystemConfig {
|
|
56
|
+
/** Path to package.json for packageManager sync. Relative to config file directory. */
|
|
57
|
+
packageJsonPath?: string
|
|
58
|
+
deps: Record<string, SystemDepConfig>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Interpolate {{name}}, {{version}}, {{major}} in a string
|
|
63
|
+
*/
|
|
64
|
+
function detectArch(): string {
|
|
65
|
+
if (process.arch === "arm64") return "arm64"
|
|
66
|
+
return "amd64"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Same table `check` uses. `"latest"` matches anything with a parsed version. */
|
|
70
|
+
export function versionsMatch(installed: string, expected: string): boolean {
|
|
71
|
+
return expected === "latest" || installed === expected || expected.includes(installed) || installed.includes(expected)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function interpolate(template: string, name: string, version: string): string {
|
|
75
|
+
const major = version.split(".")[0] ?? version
|
|
76
|
+
return template
|
|
77
|
+
.replace(/\{\{name\}\}/g, name)
|
|
78
|
+
.replace(/\{\{version\}\}/g, version)
|
|
79
|
+
.replace(/\{\{major\}\}/g, major)
|
|
80
|
+
.replace(/\{\{arch\}\}/g, detectArch())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the install command for a dep on a given OS target.
|
|
85
|
+
* Returns the interpolated command, or null if unavailable.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveOsCommand(name: string, config: SystemDepConfig, target: OsTarget): string | null {
|
|
88
|
+
const specific = config.os[target]
|
|
89
|
+
|
|
90
|
+
/* explicitly unavailable */
|
|
91
|
+
if (specific === false) return null
|
|
92
|
+
|
|
93
|
+
/* prefer specific OS command, fall back to "all" */
|
|
94
|
+
const raw = specific ?? config.os.all
|
|
95
|
+
if (!raw) return null
|
|
96
|
+
|
|
97
|
+
return interpolate(raw, name, config.version)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the version check command for a dep.
|
|
102
|
+
* Default: "{name} --version"
|
|
103
|
+
*/
|
|
104
|
+
export function resolveCheckCommand(name: string, config: SystemDepConfig): string {
|
|
105
|
+
const raw = config.check?.command ?? "{{name}} --version"
|
|
106
|
+
return interpolate(raw, name, config.version)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Helper to define system dependencies configuration with type safety
|
|
111
|
+
*/
|
|
112
|
+
export function defineConfig(options: {
|
|
113
|
+
packageJsonPath?: string
|
|
114
|
+
deps: Record<string, SystemDepConfig>
|
|
115
|
+
}): SystemConfig {
|
|
116
|
+
return {
|
|
117
|
+
deps: options.deps,
|
|
118
|
+
packageJsonPath: options.packageJsonPath ?? "package.json",
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs"
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
export type ShellKind = "posix" | "powershell"
|
|
5
|
+
|
|
6
|
+
export function shellKind(platform: NodeJS.Platform): ShellKind {
|
|
7
|
+
return platform === "win32" ? "powershell" : "posix"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function shellConfigPath(home: string, kind: ShellKind, shell = ""): string {
|
|
11
|
+
if (kind === "powershell") {
|
|
12
|
+
return resolve(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1")
|
|
13
|
+
}
|
|
14
|
+
if (shell.includes("zsh")) return resolve(home, ".zshrc")
|
|
15
|
+
return resolve(home, ".bashrc")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function expandPath(path: string, home: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
19
|
+
return path
|
|
20
|
+
.replace(/\$HOME/g, home)
|
|
21
|
+
.replace(/%USERPROFILE%/gi, home)
|
|
22
|
+
.replace(/%HOME%/gi, home)
|
|
23
|
+
.replace(/\$ANDROID_HOME/g, env.ANDROID_HOME || "")
|
|
24
|
+
.replace(/%ANDROID_HOME%/gi, env.ANDROID_HOME || "")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MARKER_OWNER = "@workerstack/system"
|
|
28
|
+
|
|
29
|
+
/** Owner tag written before the rename; blocks carrying it are replaced, never duplicated. */
|
|
30
|
+
export const LEGACY_MARKER_OWNER = "crossdeps"
|
|
31
|
+
|
|
32
|
+
export function envMarkers(tool: string, owner: string = MARKER_OWNER): { end: string; start: string } {
|
|
33
|
+
return {
|
|
34
|
+
end: `# end ${tool} environment`,
|
|
35
|
+
start: `# ${tool} environment (managed by ${owner})`,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function escapeRegExp(text: string): string {
|
|
40
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripBlock(existing: string, tool: string, owner: string): string {
|
|
44
|
+
const { end, start } = envMarkers(tool, owner)
|
|
45
|
+
if (!existing.includes(start)) return existing
|
|
46
|
+
return existing.replace(new RegExp(`\\r?\\n?${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\r?\\n?`, "g"), "")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderEnvBlock(
|
|
50
|
+
tool: string,
|
|
51
|
+
envs: Array<{ appendToPath?: boolean; key: string; value: string }>,
|
|
52
|
+
kind: ShellKind,
|
|
53
|
+
): string {
|
|
54
|
+
const { end, start } = envMarkers(tool)
|
|
55
|
+
const nl = kind === "powershell" ? "\r\n" : "\n"
|
|
56
|
+
const lines = ["", start]
|
|
57
|
+
for (const env of envs) {
|
|
58
|
+
if (env.appendToPath) {
|
|
59
|
+
lines.push(kind === "powershell" ? `$env:Path += ";${env.value}"` : `export PATH="$PATH:${env.value}"`)
|
|
60
|
+
} else {
|
|
61
|
+
lines.push(kind === "powershell" ? `$env:${env.key} = "${env.value}"` : `export ${env.key}="${env.value}"`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
lines.push(end, "")
|
|
65
|
+
return lines.join(nl)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function applyEnvBlock(existing: string, block: string, tool: string): string {
|
|
69
|
+
const withoutLegacy = stripBlock(existing, tool, LEGACY_MARKER_OWNER)
|
|
70
|
+
return stripBlock(withoutLegacy, tool, MARKER_OWNER) + block
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when the rc already holds a block for `tool`, current or legacy. */
|
|
74
|
+
export function hasEnvBlock(existing: string, tool: string): boolean {
|
|
75
|
+
return existing.includes(envMarkers(tool).start) || existing.includes(envMarkers(tool, LEGACY_MARKER_OWNER).start)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ensureParentDir(filePath: string): void {
|
|
79
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function reloadHint(kind: ShellKind, configName: string): string {
|
|
83
|
+
if (kind === "powershell") return "Restart PowerShell or run: . $PROFILE"
|
|
84
|
+
return `Run: source ~/${configName} (or restart terminal)`
|
|
85
|
+
}
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
export type ShellInvocation = { args: string[]; file: string } | { command: string; shell: string }
|
|
4
|
+
|
|
5
|
+
const POWERSHELL_MARKERS = [
|
|
6
|
+
/\birm\b/i,
|
|
7
|
+
/\biex\b/i,
|
|
8
|
+
/\$env:/,
|
|
9
|
+
/Invoke-WebRequest/i,
|
|
10
|
+
/Invoke-Expression/i,
|
|
11
|
+
/New-Item\b/i,
|
|
12
|
+
/Test-Path\b/i,
|
|
13
|
+
/Out-Null/i,
|
|
14
|
+
/Get-Command\b/i,
|
|
15
|
+
/\$LASTEXITCODE/,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export function needsPowerShell(command: string): boolean {
|
|
19
|
+
return POWERSHELL_MARKERS.some((re) => re.test(command))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shellInvocation(command: string, platform: NodeJS.Platform = process.platform): ShellInvocation {
|
|
23
|
+
if (platform === "win32") {
|
|
24
|
+
if (needsPowerShell(command)) {
|
|
25
|
+
return {
|
|
26
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", command],
|
|
27
|
+
file: "powershell.exe",
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/* cmd.exe so bun -e / choco / || keep working */
|
|
31
|
+
return { command, shell: process.env.ComSpec || "cmd.exe" }
|
|
32
|
+
}
|
|
33
|
+
return { command, shell: "/bin/bash" }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function runShellCommand(command: string, platform: NodeJS.Platform = process.platform): void {
|
|
37
|
+
const invocation = shellInvocation(command, platform)
|
|
38
|
+
if ("file" in invocation) {
|
|
39
|
+
execFileSync(invocation.file, invocation.args, { stdio: "inherit" })
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
execSync(invocation.command, { shell: invocation.shell, stdio: "inherit" })
|
|
43
|
+
}
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SystemDepConfig } from "./config.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Topological sort of deps respecting `dependsOn` field.
|
|
5
|
+
* Returns dep names in install order.
|
|
6
|
+
*/
|
|
7
|
+
export function sortByDependencies(depEntries: Array<[string, SystemDepConfig]>): Array<[string, SystemDepConfig]> {
|
|
8
|
+
const nameSet = new Set(depEntries.map(([name]) => name))
|
|
9
|
+
const sorted: Array<[string, SystemDepConfig]> = []
|
|
10
|
+
const visited = new Set<string>()
|
|
11
|
+
const visiting = new Set<string>()
|
|
12
|
+
|
|
13
|
+
const depMap = new Map(depEntries)
|
|
14
|
+
|
|
15
|
+
function visit(name: string): void {
|
|
16
|
+
if (visited.has(name)) return
|
|
17
|
+
if (visiting.has(name)) {
|
|
18
|
+
console.warn(`Circular dependency detected involving: ${name}`)
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
visiting.add(name)
|
|
23
|
+
|
|
24
|
+
const config = depMap.get(name)
|
|
25
|
+
if (config?.dependsOn) {
|
|
26
|
+
for (const dep of config.dependsOn) {
|
|
27
|
+
if (nameSet.has(dep)) {
|
|
28
|
+
visit(dep)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
visiting.delete(name)
|
|
34
|
+
visited.add(name)
|
|
35
|
+
if (config) {
|
|
36
|
+
sorted.push([name, config])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const [name] of depEntries) {
|
|
41
|
+
visit(name)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return sorted
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @workerstack/system — cross-platform system dependency manager
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type { SystemConfig, EnvVar, OsCommands, OsTarget, SystemDepConfig } from "./config.ts"
|
|
6
|
+
export {
|
|
7
|
+
defineConfig,
|
|
8
|
+
interpolate,
|
|
9
|
+
OS_TARGETS,
|
|
10
|
+
resolveCheckCommand,
|
|
11
|
+
resolveOsCommand,
|
|
12
|
+
versionsMatch,
|
|
13
|
+
} from "./config.ts"
|
|
14
|
+
export { sortByDependencies } from "./graph.ts"
|
|
15
|
+
export { commandExists, commandLookup, detectOs, detectOsFromPlatform, parseOsTarget, whichBinary } from "./platform.ts"
|
package/src/path.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive-TTY PATH snapshot for `check`.
|
|
3
|
+
*
|
|
4
|
+
* `install` keeps using this process PATH. `check --here` does too.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process"
|
|
8
|
+
import { basename } from "node:path"
|
|
9
|
+
|
|
10
|
+
export type PathSource = "tty" | "process"
|
|
11
|
+
|
|
12
|
+
export interface PathSnapshot {
|
|
13
|
+
path: string
|
|
14
|
+
source: PathSource
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ExecFile = (file: string, args: readonly string[], env: NodeJS.ProcessEnv) => string
|
|
18
|
+
|
|
19
|
+
export const TTY_PATH_FALLBACK_WARN = "could not read interactive-shell PATH; using this process PATH"
|
|
20
|
+
|
|
21
|
+
export const PATH_PRINTF = 'printf %s "$PATH"'
|
|
22
|
+
export const UNIX_BASH_PATH_ARGS = ["-ic", PATH_PRINTF] as const
|
|
23
|
+
export const UNIX_ZSH_PATH_ARGS = ["-lic", PATH_PRINTF] as const
|
|
24
|
+
export const WINDOWS_TTY_PATH_ARGS = ["-NoLogo", "-NonInteractive", "-Command", "$env:Path"] as const
|
|
25
|
+
|
|
26
|
+
const defaultExecFile: ExecFile = (file, args, env) =>
|
|
27
|
+
execFileSync(file, [...args], {
|
|
28
|
+
encoding: "utf-8",
|
|
29
|
+
env,
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
timeout: 30_000,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export function processPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
35
|
+
return env.PATH ?? env.Path ?? ""
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function pathEnv(searchPath: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
39
|
+
return { ...base, PATH: searchPath, Path: searchPath }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** HOME/USER/SHELL, locale, TERM, Windows profile roots. Not PATH. Not leftover toolchain exports. */
|
|
43
|
+
export const TTY_ENV_KEEP = [
|
|
44
|
+
"ComSpec",
|
|
45
|
+
"COMSPEC",
|
|
46
|
+
"HOME",
|
|
47
|
+
"HOMEDRIVE",
|
|
48
|
+
"HOMEPATH",
|
|
49
|
+
"HOMESHARE",
|
|
50
|
+
"LANG",
|
|
51
|
+
"LANGUAGE",
|
|
52
|
+
"LOGNAME",
|
|
53
|
+
"PATHEXT",
|
|
54
|
+
"SHELL",
|
|
55
|
+
"SYSTEMROOT",
|
|
56
|
+
"SystemRoot",
|
|
57
|
+
"TERM",
|
|
58
|
+
"USER",
|
|
59
|
+
"USERPROFILE",
|
|
60
|
+
"WINDIR",
|
|
61
|
+
"windir",
|
|
62
|
+
] as const
|
|
63
|
+
|
|
64
|
+
export function isTtyEnvKeep(key: string): boolean {
|
|
65
|
+
if (key.startsWith("LC_")) return true
|
|
66
|
+
return (TTY_ENV_KEEP as readonly string[]).includes(key)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function unixStockPath(): string {
|
|
70
|
+
return "/usr/local/bin:/usr/bin:/bin"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function windowsStockPath(env: NodeJS.ProcessEnv): string {
|
|
74
|
+
const root = env.SYSTEMROOT || env.SystemRoot || "C:\\Windows"
|
|
75
|
+
return `${root}\\System32;${root}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Env for the TTY PATH spawn. Interactive rc applies the default toolchain on a stock PATH. */
|
|
79
|
+
export function ttySnapshotEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): NodeJS.ProcessEnv {
|
|
80
|
+
const out: NodeJS.ProcessEnv = {}
|
|
81
|
+
for (const [key, value] of Object.entries(env)) {
|
|
82
|
+
if (value === undefined) continue
|
|
83
|
+
if (isTtyEnvKeep(key)) out[key] = value
|
|
84
|
+
}
|
|
85
|
+
const stock = platform === "win32" ? windowsStockPath(env) : unixStockPath()
|
|
86
|
+
out.PATH = stock
|
|
87
|
+
out.Path = stock
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** bash: interactive (`-ic`). zsh: login+interactive (`-lic`). Other Unix shells: `-ic`. */
|
|
92
|
+
export function unixPathSnapshotArgs(shell: string): readonly [string, string] {
|
|
93
|
+
const name = basename(shell).replace(/\.exe$/i, "")
|
|
94
|
+
if (name === "zsh") return ["-lic", PATH_PRINTF]
|
|
95
|
+
return ["-ic", PATH_PRINTF]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Interactive rc may print MOTD on stdout; the PATH line is last. */
|
|
99
|
+
export function parsePathOutput(out: string): string {
|
|
100
|
+
const lines = out.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
|
|
101
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
102
|
+
const line = lines[i]?.trim()
|
|
103
|
+
if (line) return line
|
|
104
|
+
}
|
|
105
|
+
return ""
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Snapshot PATH from a new interactive TTY once.
|
|
110
|
+
* Unix bash: `$SHELL -ic` with stdin `/dev/null` (not login-only `-lc`).
|
|
111
|
+
* Unix zsh: `$SHELL -lic` (`.zprofile` + `.zshrc`).
|
|
112
|
+
* Windows: `powershell.exe` with profile (`$env:Path`), not `-NoProfile`.
|
|
113
|
+
* Spawn env is a keep-list (HOME/SHELL/locale/TERM) plus a stock PATH so leftover
|
|
114
|
+
* parent PATH and toolchain exports cannot pin a stale binary.
|
|
115
|
+
* Spawn failure or empty PATH → this process PATH (`source: "process"`).
|
|
116
|
+
*/
|
|
117
|
+
export function snapshotTtyPath(
|
|
118
|
+
platform: NodeJS.Platform = process.platform,
|
|
119
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
120
|
+
execFile: ExecFile = defaultExecFile,
|
|
121
|
+
): PathSnapshot {
|
|
122
|
+
try {
|
|
123
|
+
const childEnv = ttySnapshotEnv(env, platform)
|
|
124
|
+
const raw =
|
|
125
|
+
platform === "win32"
|
|
126
|
+
? execFile("powershell.exe", WINDOWS_TTY_PATH_ARGS, childEnv)
|
|
127
|
+
: execFile(env.SHELL || "/bin/sh", unixPathSnapshotArgs(env.SHELL || "/bin/sh"), childEnv)
|
|
128
|
+
const path = parsePathOutput(raw)
|
|
129
|
+
if (path) return { path, source: "tty" }
|
|
130
|
+
} catch {
|
|
131
|
+
/* fall through */
|
|
132
|
+
}
|
|
133
|
+
return { path: processPath(env), source: "process" }
|
|
134
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS detection for system dependency installation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from "node:child_process"
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
7
|
+
import { OS_TARGETS, type OsTarget } from "./config.ts"
|
|
8
|
+
import { pathEnv } from "./path.ts"
|
|
9
|
+
|
|
10
|
+
export function commandLookup(command: string, platform: NodeJS.Platform = process.platform): string {
|
|
11
|
+
return platform === "win32" ? `where ${command} >nul 2>&1` : `command -v ${command} >/dev/null 2>&1`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function commandExists(command: string, searchPath?: string): boolean {
|
|
15
|
+
const unquoted = command.replace(/^["']|["']$/g, "")
|
|
16
|
+
if (existsSync(unquoted)) return true
|
|
17
|
+
const opts = searchPath !== undefined ? { env: pathEnv(searchPath) } : undefined
|
|
18
|
+
try {
|
|
19
|
+
execSync(commandLookup(command), opts)
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolved path of the first token (`command -v` / `where`). Null if missing. */
|
|
27
|
+
export function whichBinary(
|
|
28
|
+
command: string,
|
|
29
|
+
platform: NodeJS.Platform = process.platform,
|
|
30
|
+
searchPath?: string,
|
|
31
|
+
): string | null {
|
|
32
|
+
const token = command.replace(/^["']|["']$/g, "")
|
|
33
|
+
if (!token) return null
|
|
34
|
+
if (existsSync(token)) return token
|
|
35
|
+
const opts: { encoding: "utf-8"; env?: NodeJS.ProcessEnv } = { encoding: "utf-8" }
|
|
36
|
+
if (searchPath !== undefined) opts.env = pathEnv(searchPath)
|
|
37
|
+
try {
|
|
38
|
+
if (platform === "win32") {
|
|
39
|
+
const out = execSync(`where ${token}`, opts).trim()
|
|
40
|
+
return out.split(/\r?\n/).find(Boolean) ?? null
|
|
41
|
+
}
|
|
42
|
+
const out = execSync(`command -v ${token}`, opts).trim()
|
|
43
|
+
return out || null
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseOsTarget(value: string): OsTarget {
|
|
50
|
+
if ((OS_TARGETS as readonly string[]).includes(value)) return value as OsTarget
|
|
51
|
+
throw new Error(`Unknown OS target: ${value}. Expected one of: ${OS_TARGETS.join(", ")}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function detectLinuxDistro(): OsTarget {
|
|
55
|
+
if (commandExists("apt-get")) return "linux-apt"
|
|
56
|
+
if (commandExists("dnf")) return "linux-dnf"
|
|
57
|
+
if (commandExists("pacman")) return "linux-pacman"
|
|
58
|
+
/* default to apt if nothing detected */
|
|
59
|
+
return "linux-apt"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function detectOsFromPlatform(
|
|
63
|
+
platform: NodeJS.Platform,
|
|
64
|
+
override: string | undefined = process.env.WORKERSTACK_SYSTEM_OS,
|
|
65
|
+
): OsTarget {
|
|
66
|
+
if (override) return parseOsTarget(override)
|
|
67
|
+
if (platform === "darwin") return "macos"
|
|
68
|
+
if (platform === "win32") return "windows"
|
|
69
|
+
return detectLinuxDistro()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Detect the current OS target.
|
|
74
|
+
* `override` (or `WORKERSTACK_SYSTEM_OS`) forces a target — used by tests and `--os`.
|
|
75
|
+
*/
|
|
76
|
+
export function detectOs(override: string | undefined = process.env.WORKERSTACK_SYSTEM_OS): OsTarget {
|
|
77
|
+
return detectOsFromPlatform(process.platform, override)
|
|
78
|
+
}
|