@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/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
+ }
@@ -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
+ }