@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/LICENSE +21 -0
- package/README.md +319 -2
- package/package.json +48 -5
- package/src/args.ts +67 -0
- package/src/bin.ts +5 -0
- package/src/builtins/agents.ts +73 -0
- package/src/builtins/bootstrap.ts +39 -0
- package/src/builtins/browsers.ts +79 -0
- package/src/builtins/cloudflare.ts +32 -0
- package/src/builtins/database.ts +41 -0
- package/src/builtins/index.ts +41 -0
- package/src/builtins/languages.ts +218 -0
- package/src/builtins/recipes.ts +145 -0
- package/src/builtins/release.ts +133 -0
- package/src/builtins/resolve.ts +80 -0
- package/src/builtins/runtimes.ts +54 -0
- package/src/builtins/types.ts +38 -0
- package/src/commands/check.ts +101 -0
- package/src/commands/env.ts +99 -0
- package/src/commands/install.ts +134 -0
- package/src/commands/list.ts +29 -0
- package/src/commands/sync-pm.ts +53 -0
- package/src/config.ts +123 -0
- package/src/env.ts +103 -0
- package/src/errors.ts +4 -0
- package/src/exec.ts +64 -0
- package/src/graph.ts +33 -0
- package/src/index.ts +21 -0
- package/src/load.ts +43 -0
- package/src/main.ts +99 -0
- package/src/path.ts +172 -0
- package/src/platform.ts +73 -0
- package/src/probe.ts +44 -0
- package/src/runtime.ts +99 -0
- package/src/validate.ts +154 -0
- package/src/version.ts +33 -0
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
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|