@workerstack/system 0.0.0-stage → 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/src/path.ts ADDED
@@ -0,0 +1,172 @@
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
+ /** why the interactive shell could not be read, when `source` is `process` */
15
+ reason?: string
16
+ source: PathSource
17
+ }
18
+
19
+ export type ExecFile = (file: string, args: readonly string[], env: NodeJS.ProcessEnv) => string
20
+
21
+ export const TTY_PATH_FALLBACK_WARN = "could not read interactive-shell PATH; using this process PATH"
22
+
23
+ export const PATH_PRINTF = 'printf %s "$PATH"'
24
+ export const UNIX_BASH_PATH_ARGS = ["-ic", PATH_PRINTF] as const
25
+ export const UNIX_ZSH_PATH_ARGS = ["-lic", PATH_PRINTF] as const
26
+ /**
27
+ * What a new Windows terminal does: PATH from the registry (Machine, then User, where installers and
28
+ * choco write), then each profile in PowerShell's startup order, then print PATH. Profiles are dot-sourced
29
+ * explicitly (-NoProfile) because they must run after PATH is rebuilt.
30
+ */
31
+ const WINDOWS_TTY_PATH_SCRIPT = [
32
+ "$env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User')",
33
+ "foreach ($p in @($PROFILE.AllUsersAllHosts, $PROFILE.AllUsersCurrentHost, $PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) { if (Test-Path $p) { . $p } }",
34
+ "$env:Path",
35
+ ].join("; ")
36
+
37
+ export const WINDOWS_TTY_PATH_ARGS = [
38
+ "-NoLogo",
39
+ "-NoProfile",
40
+ "-NonInteractive",
41
+ "-Command",
42
+ WINDOWS_TTY_PATH_SCRIPT,
43
+ ] as const
44
+
45
+ const defaultExecFile: ExecFile = (file, args, env) =>
46
+ execFileSync(file, [...args], {
47
+ encoding: "utf-8",
48
+ env,
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ timeout: 30_000,
51
+ })
52
+
53
+ export function processPath(env: NodeJS.ProcessEnv = process.env): string {
54
+ return env.PATH ?? env.Path ?? ""
55
+ }
56
+
57
+ export function pathEnv(searchPath: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
58
+ return { ...base, PATH: searchPath, Path: searchPath }
59
+ }
60
+
61
+ /**
62
+ * HOME/USER/SHELL, locale, TERM, and the Windows profile and user-dir roots a profile expands
63
+ * (`$env:LOCALAPPDATA\bin`). Not PATH. Not leftover toolchain exports. Not PSModulePath.
64
+ */
65
+ export const TTY_ENV_KEEP = [
66
+ "APPDATA",
67
+ "ComSpec",
68
+ "COMSPEC",
69
+ "HOME",
70
+ "HOMEDRIVE",
71
+ "HOMEPATH",
72
+ "HOMESHARE",
73
+ "LANG",
74
+ "LANGUAGE",
75
+ "LOCALAPPDATA",
76
+ "LOGNAME",
77
+ "PATHEXT",
78
+ "ProgramData",
79
+ "PROGRAMDATA",
80
+ "ProgramFiles",
81
+ "PROGRAMFILES",
82
+ "ProgramFiles(x86)",
83
+ "SHELL",
84
+ "SYSTEMROOT",
85
+ "SystemRoot",
86
+ "TEMP",
87
+ "TERM",
88
+ "TMP",
89
+ "USER",
90
+ "USERPROFILE",
91
+ "WINDIR",
92
+ "windir",
93
+ ] as const
94
+
95
+ export function isTtyEnvKeep(key: string): boolean {
96
+ if (key.startsWith("LC_")) return true
97
+ return (TTY_ENV_KEEP as readonly string[]).includes(key)
98
+ }
99
+
100
+ export function unixStockPath(): string {
101
+ return "/usr/local/bin:/usr/bin:/bin"
102
+ }
103
+
104
+ export function windowsStockPath(env: NodeJS.ProcessEnv): string {
105
+ const root = env.SYSTEMROOT || env.SystemRoot || "C:\\Windows"
106
+ /* Windows PowerShell lives outside System32 proper, and the snapshot spawns it by name from this PATH */
107
+ return `${root}\\System32;${root};${root}\\System32\\WindowsPowerShell\\v1.0`
108
+ }
109
+
110
+ /** Env for the TTY PATH spawn. Interactive rc applies the default toolchain on a stock PATH. */
111
+ /**
112
+ * Unix: the keep-list, so leftover toolchain exports cannot pin a stale binary. Windows: everything but
113
+ * PATH and PSModulePath, because the snapshot rebuilds PATH from the registry anyway and Windows
114
+ * PowerShell needs its system variables to start promptly.
115
+ */
116
+ export function ttySnapshotEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): NodeJS.ProcessEnv {
117
+ const out: NodeJS.ProcessEnv = {}
118
+ for (const [key, value] of Object.entries(env)) {
119
+ if (value === undefined) continue
120
+ const keep = platform === "win32" ? !/^(path|psmodulepath)$/i.test(key) : isTtyEnvKeep(key)
121
+ if (keep) out[key] = value
122
+ }
123
+ const stock = platform === "win32" ? windowsStockPath(env) : unixStockPath()
124
+ out.PATH = stock
125
+ out.Path = stock
126
+ return out
127
+ }
128
+
129
+ /** bash: interactive (`-ic`). zsh: login+interactive (`-lic`). Other Unix shells: `-ic`. */
130
+ export function unixPathSnapshotArgs(shell: string): readonly [string, string] {
131
+ const name = basename(shell).replace(/\.exe$/i, "")
132
+ if (name === "zsh") return ["-lic", PATH_PRINTF]
133
+ return ["-ic", PATH_PRINTF]
134
+ }
135
+
136
+ /** Interactive rc may print MOTD on stdout; the PATH line is last. */
137
+ export function parsePathOutput(out: string): string {
138
+ const lines = out.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
139
+ for (let i = lines.length - 1; i >= 0; i--) {
140
+ const line = lines[i]?.trim()
141
+ if (line) return line
142
+ }
143
+ return ""
144
+ }
145
+
146
+ /**
147
+ * Snapshot PATH from a new interactive TTY once.
148
+ * Unix bash: `$SHELL -ic` with stdin `/dev/null` (not login-only `-lc`).
149
+ * Unix zsh: `$SHELL -lic` (`.zprofile` + `.zshrc`).
150
+ * Windows: `powershell.exe` rebuilding PATH from the registry, then its profiles (WINDOWS_TTY_PATH_ARGS).
151
+ * Spawn env is a keep-list (HOME/SHELL/locale/TERM) plus a stock PATH so leftover
152
+ * parent PATH and toolchain exports cannot pin a stale binary.
153
+ * Spawn failure or empty PATH → this process PATH (`source: "process"`).
154
+ */
155
+ export function snapshotTtyPath(
156
+ platform: NodeJS.Platform = process.platform,
157
+ env: NodeJS.ProcessEnv = process.env,
158
+ execFile: ExecFile = defaultExecFile,
159
+ ): PathSnapshot {
160
+ try {
161
+ const childEnv = ttySnapshotEnv(env, platform)
162
+ const raw =
163
+ platform === "win32"
164
+ ? execFile("powershell.exe", WINDOWS_TTY_PATH_ARGS, childEnv)
165
+ : execFile(env.SHELL || "/bin/sh", unixPathSnapshotArgs(env.SHELL || "/bin/sh"), childEnv)
166
+ const path = parsePathOutput(raw)
167
+ if (path) return { path, source: "tty" }
168
+ return { path: processPath(env), reason: "the shell printed no PATH", source: "process" }
169
+ } catch (error) {
170
+ return { path: processPath(env), reason: error instanceof Error ? error.message : String(error), source: "process" }
171
+ }
172
+ }
@@ -0,0 +1,73 @@
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
+ /** Run a shell command and return stdout; throws on a non-zero exit. Injected so lookups are testable. */
11
+ export type ShellExec = (command: string, env: NodeJS.ProcessEnv | undefined) => string
12
+
13
+ const shellExec: ShellExec = (command, env) =>
14
+ execSync(command, { encoding: "utf-8", env, stdio: ["ignore", "pipe", "ignore"] })
15
+
16
+ /** Resolved path of the first token (`command -v`, or `where` on Windows). Null if missing. */
17
+ export function whichBinary(
18
+ command: string,
19
+ platform: NodeJS.Platform = process.platform,
20
+ searchPath?: string,
21
+ exec: ShellExec = shellExec,
22
+ ): string | null {
23
+ const token = command.replace(/^["']|["']$/g, "")
24
+ if (!token) return null
25
+ if (existsSync(token)) return token
26
+ const lookup = platform === "win32" ? `where ${token}` : `command -v ${token}`
27
+ try {
28
+ const out = exec(lookup, searchPath === undefined ? undefined : pathEnv(searchPath))
29
+ /* `where` lists every match, one per line; the first is what the shell runs */
30
+ return out.trim().split(/\r?\n/)[0] || null
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ export function commandExists(command: string, searchPath?: string): boolean {
37
+ return whichBinary(command, process.platform, searchPath) !== null
38
+ }
39
+
40
+ export function parseOsTarget(value: string): OsTarget {
41
+ if ((OS_TARGETS as readonly string[]).includes(value)) return value as OsTarget
42
+ throw new Error(`Unknown OS target: ${value}. Expected one of: ${OS_TARGETS.join(", ")}`)
43
+ }
44
+
45
+ /** True when `command` resolves on PATH. Injected so detection is testable without the host's tools. */
46
+ export type CommandProbe = (command: string) => boolean
47
+
48
+ export function detectLinuxDistro(has: CommandProbe = commandExists): OsTarget {
49
+ if (has("apt-get")) return "linux-apt"
50
+ if (has("dnf")) return "linux-dnf"
51
+ if (has("pacman")) return "linux-pacman"
52
+ /* default to apt if nothing detected */
53
+ return "linux-apt"
54
+ }
55
+
56
+ export function detectOsFromPlatform(
57
+ platform: NodeJS.Platform,
58
+ override: string | undefined = process.env.WORKERSTACK_SYSTEM_OS,
59
+ has: CommandProbe = commandExists,
60
+ ): OsTarget {
61
+ if (override) return parseOsTarget(override)
62
+ if (platform === "darwin") return "macos"
63
+ if (platform === "win32") return "windows"
64
+ return detectLinuxDistro(has)
65
+ }
66
+
67
+ /**
68
+ * Detect the current OS target.
69
+ * `override` (or `WORKERSTACK_SYSTEM_OS`) forces a target — used by tests and `--os`.
70
+ */
71
+ export function detectOs(override: string | undefined = process.env.WORKERSTACK_SYSTEM_OS): OsTarget {
72
+ return detectOsFromPlatform(process.platform, override)
73
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What is installed: the check command's binary, where it resolves, and the version it reports.
3
+ */
4
+
5
+ import { type OsTarget, resolveCheckCommand, type SystemDepConfig } from "./config.ts"
6
+ import type { Runtime } from "./runtime.ts"
7
+ import { parseVersion } from "./version.ts"
8
+
9
+ /** Everything a command needs: the effects, the resolved deps, and where the config lives. */
10
+ export interface Context {
11
+ configDir: string
12
+ deps: Record<string, SystemDepConfig>
13
+ os: OsTarget
14
+ packageJsonPath: string
15
+ rt: Runtime
16
+ }
17
+
18
+ /** First token of the check command: the binary whose presence and path are probed. */
19
+ export function checkBinary(name: string, config: SystemDepConfig): string {
20
+ return resolveCheckCommand(name, config).split(/\s+/)[0] as string
21
+ }
22
+
23
+ export interface Installed {
24
+ /** where the check binary resolves */
25
+ path: string
26
+ /** parsed from the check output (`check.pattern`, else the first `N.N…`) */
27
+ version: string
28
+ }
29
+
30
+ /** The installed binary and the version it reports, or null when it is missing or reports none. */
31
+ export function probeInstalled(
32
+ rt: Runtime,
33
+ name: string,
34
+ config: SystemDepConfig,
35
+ searchPath?: string,
36
+ ): Installed | null {
37
+ const path = rt.which(checkBinary(name, config), searchPath)
38
+ if (path === null) return null
39
+ const command = resolveCheckCommand(name, config)
40
+ const result = rt.capture(command, searchPath)
41
+ if (result.timedOut) rt.warn(` ${name}: check timed out: ${command}`)
42
+ const version = result.ok ? parseVersion(result.stdout, config.check?.pattern) : null
43
+ return version === null ? null : { path, version }
44
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Every side effect the CLI performs, behind one interface. Commands take a `Runtime` and return an exit
3
+ * code, so tests drive them with a fake and only `bin.ts` touches `process`.
4
+ */
5
+
6
+ import { execSync } from "node:child_process"
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
8
+ import { homedir } from "node:os"
9
+ import { delimiter, dirname } from "node:path"
10
+ import { pathToFileURL } from "node:url"
11
+ import { runShellCommand } from "./exec.ts"
12
+ import { type PathSnapshot, pathEnv, snapshotTtyPath } from "./path.ts"
13
+ import { whichBinary } from "./platform.ts"
14
+
15
+ export interface CaptureResult {
16
+ ok: boolean
17
+ stdout: string
18
+ /** the command was killed after the check timeout */
19
+ timedOut: boolean
20
+ }
21
+
22
+ export interface RuntimeOptions {
23
+ /** Kill a check command after this long. Default 30s, so a hanging `--version` cannot hang a run. */
24
+ checkTimeoutMs?: number
25
+ /** The environment commands run in and PATH changes go to. Default `process.env`. */
26
+ env?: NodeJS.ProcessEnv
27
+ }
28
+
29
+ export interface Runtime {
30
+ readonly arch: string
31
+ readonly cwd: string
32
+ readonly env: NodeJS.ProcessEnv
33
+ readonly home: string
34
+ readonly platform: NodeJS.Platform
35
+ /** Run a check command and capture stdout. `searchPath` replaces PATH for the child. */
36
+ capture(command: string, searchPath?: string): CaptureResult
37
+ error(line: string): void
38
+ exists(path: string): boolean
39
+ importModule(path: string): Promise<Record<string, unknown>>
40
+ log(line: string): void
41
+ /** Put `dir` first on this process's PATH (once), so later commands in the run find what it holds. */
42
+ prependPath(dir: string): void
43
+ readFile(path: string): string
44
+ /** Run an install command with inherited stdio. True on exit 0. */
45
+ run(command: string): boolean
46
+ ttyPath(): PathSnapshot
47
+ warn(line: string): void
48
+ /** Resolved path of the first token, or null. `searchPath` replaces PATH for the lookup. */
49
+ which(token: string, searchPath?: string): string | null
50
+ /** Write a file, creating parent directories. */
51
+ writeFile(path: string, content: string): void
52
+ }
53
+
54
+ export function nodeRuntime({ checkTimeoutMs = 30_000, env = process.env }: RuntimeOptions = {}): Runtime {
55
+ return {
56
+ arch: process.arch,
57
+ capture(command, searchPath) {
58
+ try {
59
+ const stdout = execSync(command, {
60
+ encoding: "utf-8",
61
+ env: searchPath === undefined ? env : pathEnv(searchPath, env),
62
+ stdio: ["pipe", "pipe", "pipe"],
63
+ timeout: checkTimeoutMs,
64
+ })
65
+ return { ok: true, stdout: stdout.trim(), timedOut: false }
66
+ } catch (error) {
67
+ return { ok: false, stdout: "", timedOut: (error as { code?: string }).code === "ETIMEDOUT" }
68
+ }
69
+ },
70
+ cwd: process.cwd(),
71
+ env,
72
+ error: (line) => console.error(line),
73
+ exists: (path) => existsSync(path),
74
+ home: homedir(),
75
+ importModule: async (path) => (await import(pathToFileURL(path).href)) as Record<string, unknown>,
76
+ log: (line) => console.log(line),
77
+ platform: process.platform,
78
+ prependPath(dir) {
79
+ const parts = (env.PATH ?? "").split(delimiter).filter(Boolean)
80
+ if (!parts.includes(dir)) env.PATH = [dir, ...parts].join(delimiter)
81
+ },
82
+ readFile: (path) => readFileSync(path, "utf-8"),
83
+ run(command) {
84
+ try {
85
+ runShellCommand(command, process.platform, env)
86
+ return true
87
+ } catch {
88
+ return false
89
+ }
90
+ },
91
+ ttyPath: () => snapshotTtyPath(),
92
+ warn: (line) => console.warn(line),
93
+ which: (token, searchPath) => whichBinary(token, process.platform, searchPath),
94
+ writeFile(path, content) {
95
+ mkdirSync(dirname(path), { recursive: true })
96
+ writeFileSync(path, content)
97
+ },
98
+ }
99
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shape check for a loaded config. Collects every problem with its path, then throws one `ConfigError`,
3
+ * so a typo fails before anything installs instead of mid-run.
4
+ */
5
+
6
+ import { OS_TARGETS, type SystemConfig } from "./config.ts"
7
+ import { ConfigError } from "./errors.ts"
8
+
9
+ type Record_ = Record<string, unknown>
10
+
11
+ const TOP_KEYS = ["deps", "packageJsonPath"]
12
+ const DEP_KEYS = ["check", "dependsOn", "description", "env", "os", "required", "version"]
13
+ const CHECK_KEYS = ["command", "pattern"]
14
+ const OS_KEYS = ["all", ...OS_TARGETS]
15
+ const ENV_SHAPE = "must be { path }, { key, value } or { key, detect, fallback? }"
16
+
17
+ function isObject(value: unknown): value is Record_ {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value)
19
+ }
20
+
21
+ function isText(value: unknown): value is string {
22
+ return typeof value === "string" && value.length > 0
23
+ }
24
+
25
+ function isTextList(value: unknown): value is string[] {
26
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
27
+ }
28
+
29
+ function compiles(source: string): boolean {
30
+ try {
31
+ return new RegExp(source) instanceof RegExp
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
37
+ function unknownKeys(value: Record_, allowed: readonly string[], at: string, problems: string[]): void {
38
+ for (const key of Object.keys(value)) {
39
+ if (!allowed.includes(key)) problems.push(`${at}${key}: unknown key (allowed: ${allowed.join(", ")})`)
40
+ }
41
+ }
42
+
43
+ function checkOs(os: unknown, at: string, problems: string[], allowEmpty: boolean): void {
44
+ if (!isObject(os)) {
45
+ problems.push(`${at}: must be an object of commands`)
46
+ return
47
+ }
48
+ if (!allowEmpty && Object.keys(os).length === 0) problems.push(`${at}: must name at least one OS key or all`)
49
+ for (const [key, command] of Object.entries(os)) {
50
+ if (key === "all") {
51
+ if (!isText(command)) problems.push(`${at}.all: must be a command string`)
52
+ } else if (!OS_KEYS.includes(key)) {
53
+ problems.push(`${at}.${key}: unknown OS (allowed: ${OS_KEYS.join(", ")})`)
54
+ } else if (command !== false && !isText(command)) {
55
+ problems.push(`${at}.${key}: must be a command string or false`)
56
+ }
57
+ }
58
+ }
59
+
60
+ function checkCheck(check: unknown, at: string, problems: string[]): void {
61
+ if (!isObject(check)) {
62
+ problems.push(`${at}: must be an object like { command }`)
63
+ return
64
+ }
65
+ if (!isText(check.command)) problems.push(`${at}.command: must be a non-empty string`)
66
+ if (check.pattern !== undefined) {
67
+ if (typeof check.pattern !== "string") problems.push(`${at}.pattern: must be a regex source string`)
68
+ else if (!compiles(check.pattern)) problems.push(`${at}.pattern: invalid regex`)
69
+ }
70
+ unknownKeys(check, CHECK_KEYS, `${at}.`, problems)
71
+ }
72
+
73
+ function checkEnvEntry(entry: unknown, at: string, problems: string[]): void {
74
+ if (!isObject(entry)) {
75
+ problems.push(`${at}: ${ENV_SHAPE}`)
76
+ return
77
+ }
78
+ if ("appendToPath" in entry) {
79
+ problems.push(`${at}: appendToPath is gone; use { path: ${JSON.stringify(entry.value)} } (it goes first on PATH)`)
80
+ return
81
+ }
82
+ if ("path" in entry) {
83
+ if (!isText(entry.path)) problems.push(`${at}.path: must be a non-empty string`)
84
+ return
85
+ }
86
+ if (!("value" in entry) && !("detect" in entry)) {
87
+ problems.push(`${at}: ${ENV_SHAPE}`)
88
+ return
89
+ }
90
+ if (!isText(entry.key)) problems.push(`${at}.key: must be a non-empty string`)
91
+ if ("value" in entry) {
92
+ if (typeof entry.value !== "string") problems.push(`${at}.value: must be a string`)
93
+ return
94
+ }
95
+ if (!isTextList(entry.detect)) problems.push(`${at}.detect: must be a list of paths`)
96
+ if (entry.fallback !== undefined && typeof entry.fallback !== "string") {
97
+ problems.push(`${at}.fallback: must be a string`)
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Problems with one dep entry, each prefixed with `at` (e.g. `deps.go`). A custom dep needs description,
103
+ * version, required and a non-empty os. A built-in takes a version string, or an object whose only
104
+ * required field is version; its os merges over the catalog's, so it may be empty.
105
+ */
106
+ export function checkDep(dep: unknown, at: string, problems: string[], builtin: boolean): void {
107
+ if (builtin && (typeof dep === "string" || !isObject(dep))) {
108
+ if (!isText(dep)) problems.push(`${at}: must be a version string or { version, ... }`)
109
+ return
110
+ }
111
+ if (!isObject(dep)) {
112
+ problems.push(`${at}: must be an object`)
113
+ return
114
+ }
115
+ if (!builtin || dep.description !== undefined) {
116
+ if (!isText(dep.description)) problems.push(`${at}.description: must be a non-empty string`)
117
+ }
118
+ if (!isText(dep.version)) problems.push(`${at}.version: must be a non-empty string`)
119
+ if (!builtin || dep.required !== undefined) {
120
+ if (typeof dep.required !== "boolean") problems.push(`${at}.required: must be true or false`)
121
+ }
122
+ if (!builtin || dep.os !== undefined) checkOs(dep.os, `${at}.os`, problems, builtin)
123
+ if (dep.check !== undefined) checkCheck(dep.check, `${at}.check`, problems)
124
+ if (dep.dependsOn !== undefined && !isTextList(dep.dependsOn)) {
125
+ problems.push(`${at}.dependsOn: must be a list of dep names`)
126
+ }
127
+ if (dep.env !== undefined) {
128
+ if (!Array.isArray(dep.env)) problems.push(`${at}.env: must be a list`)
129
+ else dep.env.forEach((entry, i) => checkEnvEntry(entry, `${at}.env[${i}]`, problems))
130
+ }
131
+ unknownKeys(dep, DEP_KEYS, `${at}.`, problems)
132
+ }
133
+
134
+ /**
135
+ * `config` typed as a `SystemConfig`, or a `ConfigError` listing every problem.
136
+ * Names in `builtins` take the built-in entry forms.
137
+ */
138
+ export function validateConfig(config: unknown, builtins: ReadonlySet<string> = new Set()): SystemConfig {
139
+ const problems: string[] = []
140
+ if (!isObject(config)) {
141
+ problems.push("config: must be an object like { deps: { ... } }")
142
+ } else {
143
+ if (!isObject(config.deps)) problems.push("deps: must be an object of dependencies")
144
+ else
145
+ for (const [name, dep] of Object.entries(config.deps)) checkDep(dep, `deps.${name}`, problems, builtins.has(name))
146
+ if (config.packageJsonPath !== undefined && typeof config.packageJsonPath !== "string") {
147
+ problems.push("packageJsonPath: must be a string")
148
+ }
149
+ unknownKeys(config, TOP_KEYS, "", problems)
150
+ }
151
+ if (problems.length > 0)
152
+ throw new ConfigError(`Invalid system config:\n${problems.map((p) => ` - ${p}`).join("\n")}`)
153
+ return config as SystemConfig
154
+ }
package/src/version.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Version parsing and matching for `check` and `install`.
3
+ */
4
+
5
+ const DEFAULT_PATTERN = /(\d+\.\d+[\w.-]*)/
6
+
7
+ /** The first capture of `pattern` (a regex source), else the first dotted version, else null. */
8
+ export function parseVersion(output: string, pattern?: string): string | null {
9
+ const match = output.match(pattern === undefined ? DEFAULT_PATTERN : new RegExp(pattern))
10
+ return match?.[1] ?? null
11
+ }
12
+
13
+ function strip(version: string): string {
14
+ return version.replace(/^v/, "")
15
+ }
16
+
17
+ /**
18
+ * Whether `installed` satisfies the pinned `expected`.
19
+ * - `latest` matches anything.
20
+ * - Plain versions match when expected's dot segments prefix installed's: `22` matches `22.20.0`,
21
+ * `1.27.1` matches `1.27.1` and `1.27.1.4`, but never `1.27.10`.
22
+ * - Build metadata (`+9`, and anything after it) on the installed side is ignored unless the pin has some,
23
+ * as in semver: `21` matches Temurin's `21.0.13+9`. A pin with `+` matches exactly.
24
+ * - A prerelease (`-beta.12`) on either side must match exactly.
25
+ */
26
+ export function versionsMatch(installed: string, expected: string): boolean {
27
+ if (expected === "latest") return true
28
+ const want = strip(expected)
29
+ const have = want.includes("+") ? strip(installed) : (strip(installed).split("+")[0] as string)
30
+ if (/[-+]/.test(have) || /[-+]/.test(want)) return have === want
31
+ const haveParts = have.split(".")
32
+ return want.split(".").every((part, i) => haveParts[i] === part)
33
+ }