@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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Release and CI tooling: git, gh, jq, curl, goreleaser, cosign, shellcheck, pwsh.
3
+ */
4
+
5
+ import { archive, binary, localBinEnv, pick, tree, unix, winArchive, winBinary, winTree } from "./recipes.ts"
6
+ import type { BuiltinDep } from "./types.ts"
7
+
8
+ const GH = "https://github.com"
9
+
10
+ export const RELEASE = {
11
+ curl: {
12
+ dependsOn: ["brew", "choco"],
13
+ description: "HTTP client the install commands use (the OS package manager's current build)",
14
+ os: () => ({
15
+ "linux-apt": "sudo apt-get install -y curl",
16
+ "linux-dnf": "sudo dnf install -y curl",
17
+ "linux-pacman": "sudo pacman -S --noconfirm curl",
18
+ macos: "brew install curl",
19
+ windows: "choco install curl -y",
20
+ }),
21
+ versions: "latest",
22
+ },
23
+ git: {
24
+ dependsOn: ["brew", "choco"],
25
+ description: "Version control (the OS package manager's current build)",
26
+ os: () => ({
27
+ "linux-apt": "sudo apt-get install -y git",
28
+ "linux-dnf": "sudo dnf install -y git",
29
+ "linux-pacman": "sudo pacman -S --noconfirm git",
30
+ macos: "brew install git",
31
+ windows: "choco install git -y",
32
+ }),
33
+ versions: "latest",
34
+ },
35
+ gh: {
36
+ description: "GitHub CLI, for releases, PRs and the API",
37
+ env: localBinEnv,
38
+ os: ({ arch, version }) => {
39
+ const a = pick(arch, { arm64: "arm64", x64: "amd64" })
40
+ const base = `${GH}/cli/cli/releases/download/v${version}/gh_${version}`
41
+ return {
42
+ ...unix(
43
+ archive(`${base}_linux_${a}.tar.gz`, `gh_${version}_linux_${a}/bin/gh`, "gh"),
44
+ archive(`${base}_macOS_${a}.zip`, `gh_${version}_macOS_${a}/bin/gh`, "gh"),
45
+ ),
46
+ windows: winArchive(`${base}_windows_amd64.zip`, "bin\\gh.exe", "gh"),
47
+ }
48
+ },
49
+ versions: "pinned",
50
+ },
51
+ goreleaser: {
52
+ check: { command: "goreleaser --version", pattern: "GitVersion:\\s+v?(\\d+\\.\\d+\\.\\d+)" },
53
+ description: "Release automation for the generated Go CLI",
54
+ env: localBinEnv,
55
+ os: ({ arch, version }) => {
56
+ const base = `${GH}/goreleaser/goreleaser/releases/download/v${version}/goreleaser`
57
+ const a = pick(arch, { arm64: "arm64", x64: "x86_64" })
58
+ return {
59
+ ...unix(
60
+ archive(`${base}_Linux_${a}.tar.gz`, "goreleaser", "goreleaser"),
61
+ archive(`${base}_Darwin_${a}.tar.gz`, "goreleaser", "goreleaser"),
62
+ ),
63
+ windows: winArchive(`${base}_Windows_x86_64.zip`, "goreleaser.exe", "goreleaser"),
64
+ }
65
+ },
66
+ versions: "pinned",
67
+ },
68
+ cosign: {
69
+ check: { command: "cosign version", pattern: "GitVersion:\\s+v?(\\d+\\.\\d+\\.\\d+)" },
70
+ description: "Sigstore signing for release artifacts",
71
+ env: localBinEnv,
72
+ os: ({ arch, version }) => {
73
+ const base = `${GH}/sigstore/cosign/releases/download/v${version}/cosign`
74
+ const a = pick(arch, { arm64: "arm64", x64: "amd64" })
75
+ return {
76
+ ...unix(binary(`${base}-linux-${a}`, "cosign"), binary(`${base}-darwin-${a}`, "cosign")),
77
+ windows: winBinary(`${base}-windows-amd64.exe`, "cosign"),
78
+ }
79
+ },
80
+ versions: "pinned",
81
+ },
82
+ jq: {
83
+ description: "JSON processor",
84
+ env: localBinEnv,
85
+ os: ({ arch, version }) => {
86
+ const base = `${GH}/jqlang/jq/releases/download/jq-${version}/jq`
87
+ const a = pick(arch, { arm64: "arm64", x64: "amd64" })
88
+ return {
89
+ ...unix(binary(`${base}-linux-${a}`, "jq"), binary(`${base}-macos-${a}`, "jq")),
90
+ windows: winBinary(`${base}-windows-amd64.exe`, "jq"),
91
+ }
92
+ },
93
+ versions: "pinned",
94
+ },
95
+ pwsh: {
96
+ check: { command: "pwsh --version" },
97
+ description: "PowerShell 7, for the generated Windows install scripts",
98
+ env: (os) => (os === "windows" ? [{ path: "$env:LOCALAPPDATA\\Programs\\pwsh" }] : localBinEnv(os)),
99
+ os: ({ arch, version }) => {
100
+ const base = `${GH}/PowerShell/PowerShell/releases/download/v${version}`
101
+ const a = pick(arch, { arm64: "arm64", x64: "x64" })
102
+ const dir = "$HOME/.local/share/pwsh"
103
+ const linux = `${tree(`${base}/powershell-${version}-linux-${a}.tar.gz`, dir, { bins: ["pwsh"] })} && chmod +x "${dir}/pwsh"`
104
+ return {
105
+ ...unix(
106
+ linux,
107
+ `${tree(`${base}/powershell-${version}-osx-${a}.tar.gz`, dir, { bins: ["pwsh"] })} && chmod +x "${dir}/pwsh"`,
108
+ ),
109
+ /* Fedora images lack the ICU library PowerShell needs at startup */
110
+ "linux-dnf": `sudo dnf install -y libicu && ${linux}`,
111
+ windows: winTree(`${base}/PowerShell-${version}-win-x64.zip`, "$env:LOCALAPPDATA\\Programs\\pwsh"),
112
+ }
113
+ },
114
+ versions: "pinned",
115
+ },
116
+ shellcheck: {
117
+ description: "Shell script linter, for the generated install scripts",
118
+ env: localBinEnv,
119
+ os: ({ arch, version }) => {
120
+ const base = `${GH}/koalaman/shellcheck/releases/download/v${version}/shellcheck-v${version}`
121
+ const a = pick(arch, { arm64: "aarch64", x64: "x86_64" })
122
+ const member = `shellcheck-v${version}/shellcheck`
123
+ return {
124
+ ...unix(
125
+ archive(`${base}.linux.${a}.tar.xz`, member, "shellcheck"),
126
+ archive(`${base}.darwin.${a}.tar.xz`, member, "shellcheck"),
127
+ ),
128
+ windows: winArchive(`${base}.zip`, "shellcheck.exe", "shellcheck"),
129
+ }
130
+ },
131
+ versions: "pinned",
132
+ },
133
+ } satisfies Record<string, BuiltinDep>
@@ -0,0 +1,80 @@
1
+ import type { OsTarget, SystemDepConfig } from "../config.ts"
2
+ import { ConfigError } from "../errors.ts"
3
+ import type { Arch, BuiltinDep, BuiltinOverride } from "./types.ts"
4
+
5
+ /** A config entry before resolution: a built-in's version or override, or a full custom dep. */
6
+ export type RawEntry = BuiltinOverride | SystemDepConfig | string
7
+
8
+ function isArch(arch: string): arch is Arch {
9
+ return arch === "x64" || arch === "arm64"
10
+ }
11
+
12
+ function expand(builtin: BuiltinDep, entry: BuiltinOverride | string, arch: Arch, os: OsTarget): SystemDepConfig {
13
+ const o: BuiltinOverride = typeof entry === "string" ? { version: entry } : entry
14
+ const config: SystemDepConfig = {
15
+ description: o.description ?? builtin.description,
16
+ os: { ...builtin.os({ arch, version: o.version }), ...o.os },
17
+ required: o.required ?? true,
18
+ version: o.version,
19
+ }
20
+ const check = o.check ?? builtin.check
21
+ const dependsOn = o.dependsOn ?? builtin.dependsOn
22
+ const env = o.env ?? builtin.env?.(os)
23
+ if (check) config.check = check
24
+ if (dependsOn) config.dependsOn = [...dependsOn]
25
+ if (env) config.env = [...env]
26
+ return config
27
+ }
28
+
29
+ /**
30
+ * Every entry as a full `SystemDepConfig`. Built-in names expand from `catalog` for `arch` and `os`; other names
31
+ * pass through. A `latest`-only built-in that a dep needs is added when missing. Throws `ConfigError`
32
+ * listing every version-policy, prerequisite and CPU problem.
33
+ */
34
+ export function resolveDeps(
35
+ deps: Record<string, RawEntry>,
36
+ { arch, os }: { arch: string; os: OsTarget },
37
+ catalog: Readonly<Record<string, BuiltinDep>>,
38
+ ): Record<string, SystemDepConfig> {
39
+ const problems: string[] = []
40
+ const usesBuiltin = Object.keys(deps).some((name) => name in catalog)
41
+ if (usesBuiltin && !isArch(arch)) {
42
+ throw new ConfigError(`Invalid system config:\n - built-in deps support x64 and arm64; this machine is ${arch}`)
43
+ }
44
+
45
+ const resolved: Record<string, SystemDepConfig> = {}
46
+ for (const [name, entry] of Object.entries(deps)) {
47
+ const builtin = catalog[name]
48
+ if (builtin === undefined) {
49
+ resolved[name] = entry as SystemDepConfig
50
+ continue
51
+ }
52
+ const version = typeof entry === "string" ? entry : entry.version
53
+ if (builtin.versions === "latest" && version !== "latest")
54
+ problems.push(`deps.${name}: ${name} only installs "latest"`)
55
+ if (builtin.versions === "pinned" && version === "latest") {
56
+ problems.push(`deps.${name}: ${name} needs a pinned version, not "latest"`)
57
+ }
58
+ resolved[name] = expand(builtin, entry as BuiltinOverride | string, arch as Arch, os)
59
+ }
60
+
61
+ /* prerequisites: add latest-only built-ins, name the fix for the rest */
62
+ const queue = Object.keys(resolved)
63
+ for (let i = 0; i < queue.length; i++) {
64
+ const name = queue[i] as string
65
+ for (const need of resolved[name]?.dependsOn ?? []) {
66
+ if (need in resolved) continue
67
+ const builtin = catalog[need]
68
+ if (builtin?.versions === "latest") {
69
+ resolved[need] = expand(builtin, "latest", arch as Arch, os)
70
+ queue.push(need)
71
+ } else if (builtin) {
72
+ problems.push(`deps.${name}: needs ${need}; add ${need}: "<version>"`)
73
+ }
74
+ }
75
+ }
76
+
77
+ if (problems.length > 0)
78
+ throw new ConfigError(`Invalid system config:\n${problems.map((p) => ` - ${p}`).join("\n")}`)
79
+ return resolved
80
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * JavaScript runtimes. bun is the package manager; node and deno are runtimes only.
3
+ */
4
+
5
+ import { LOCAL_SHARE, pick, ps, tree, unix, winArchive, WIN_BIN, WIN_PROGRAMS, winTree } from "./recipes.ts"
6
+ import type { BuiltinDep } from "./types.ts"
7
+
8
+ export const RUNTIMES = {
9
+ bun: {
10
+ description: "Bun: runtime and the only package manager (its version also pins packageManager)",
11
+ env: (os) => [{ path: os === "windows" ? "$HOME\\.bun\\bin" : "$HOME/.bun/bin" }],
12
+ os: ({ version }) => ({
13
+ ...unix(
14
+ `curl -fsSL https://bun.sh/install | bash -s "bun-v${version}"`,
15
+ `curl -fsSL https://bun.sh/install | bash -s "bun-v${version}"`,
16
+ ),
17
+ windows: ps(`& ([scriptblock]::Create((irm https://bun.sh/install.ps1))) -Version ${version}`),
18
+ }),
19
+ versions: "pinned",
20
+ },
21
+ deno: {
22
+ description: "Deno, for the generated TypeScript SDK checks",
23
+ env: (os) => (os === "windows" ? [{ path: WIN_BIN }] : [{ path: "$HOME/.deno/bin" }]),
24
+ os: ({ version }) => {
25
+ const zip = `https://dl.deno.land/release/v${version}/deno-x86_64-pc-windows-msvc.zip`
26
+ return {
27
+ /* CI=1 keeps the installer from prompting for shell setup */
28
+ ...unix(
29
+ `curl -fsSL https://deno.com/install.sh | CI=1 sh -s v${version}`,
30
+ `curl -fsSL https://deno.com/install.sh | CI=1 sh -s v${version}`,
31
+ ),
32
+ windows: winArchive(zip, "deno.exe", "deno"),
33
+ }
34
+ },
35
+ versions: "pinned",
36
+ },
37
+ node: {
38
+ description: "Node.js runtime, for tools that need it (bun stays the package manager)",
39
+ env: (os) => [{ path: os === "windows" ? `${WIN_PROGRAMS}\\node` : `${LOCAL_SHARE}/node/bin` }],
40
+ os: ({ arch, version }) => {
41
+ const base = `https://nodejs.org/dist/v${version}/node-v${version}`
42
+ const a = pick(arch, { arm64: "arm64", x64: "x64" })
43
+ const dir = `${LOCAL_SHARE}/node`
44
+ return {
45
+ ...unix(
46
+ tree(`${base}-linux-${a}.tar.xz`, dir, { nested: true }),
47
+ tree(`${base}-darwin-${a}.tar.gz`, dir, { nested: true }),
48
+ ),
49
+ windows: winTree(`${base}-win-x64.zip`, `${WIN_PROGRAMS}\\node`, { nested: true }),
50
+ }
51
+ },
52
+ versions: "pinned",
53
+ },
54
+ } satisfies Record<string, BuiltinDep>
@@ -0,0 +1,38 @@
1
+ import type { EnvVar, OsCommands, OsTarget, SystemDepConfig } from "../config.ts"
2
+
3
+ /** CPU architectures built-ins ship recipes for. */
4
+ export type Arch = "arm64" | "x64"
5
+
6
+ export interface RecipeContext {
7
+ arch: Arch
8
+ version: string
9
+ }
10
+
11
+ /** A tool workerstack supports by name: the config gives a version, the catalog gives the rest. */
12
+ export interface BuiltinDep {
13
+ /** `check` runs this; default `<name> --version` */
14
+ check?: SystemDepConfig["check"]
15
+ /** Other deps installed first. A `latest`-only built-in missing from the config is added; any other is an error. */
16
+ dependsOn?: readonly string[]
17
+ description: string
18
+ /** Shell env entries for the OS the config resolves on (PATH dirs differ between unix and Windows). */
19
+ env?: (os: OsTarget) => readonly EnvVar[]
20
+ /** Per-OS install commands for this version and CPU. */
21
+ os: (ctx: RecipeContext) => OsCommands
22
+ /**
23
+ * `pinned`: needs an exact version, rejects `"latest"`. `latest`: only `"latest"` (the source cannot pin).
24
+ * `any`: a pin or `"latest"`.
25
+ */
26
+ versions: "any" | "latest" | "pinned"
27
+ }
28
+
29
+ /** The object form of a built-in entry: a version plus anything to override. `os` merges per key. */
30
+ export interface BuiltinOverride {
31
+ check?: SystemDepConfig["check"]
32
+ dependsOn?: readonly string[]
33
+ description?: string
34
+ env?: readonly EnvVar[]
35
+ os?: OsCommands
36
+ required?: boolean
37
+ version: string
38
+ }
@@ -0,0 +1,101 @@
1
+ import { resolve } from "node:path"
2
+ import { resolveOsCommand } from "../config.ts"
3
+ import { versionsMatch } from "../version.ts"
4
+ import { TTY_PATH_FALLBACK_WARN } from "../path.ts"
5
+ import { type Context, probeInstalled } from "../probe.ts"
6
+ import type { Runtime } from "../runtime.ts"
7
+
8
+ /** `--here`: this process PATH. Otherwise a new interactive TTY's PATH, falling back with one warning. */
9
+ function searchPath(rt: Runtime, here: boolean): string {
10
+ if (here) return rt.env.PATH ?? rt.env.Path ?? ""
11
+ const snapshot = rt.ttyPath()
12
+ if (snapshot.source === "process") rt.warn(`${TTY_PATH_FALLBACK_WARN} (${snapshot.reason})`)
13
+ return snapshot.path
14
+ }
15
+
16
+ /** Mismatch count from the packageManager field (0 or 1). bun is the only package manager. */
17
+ function checkPackageManager(ctx: Context): number {
18
+ const bun = ctx.deps.bun
19
+ if (!bun) return 0
20
+ const { rt } = ctx
21
+ const pkg = JSON.parse(rt.readFile(resolve(ctx.configDir, ctx.packageJsonPath))) as { packageManager?: unknown }
22
+ const current = typeof pkg.packageManager === "string" ? pkg.packageManager : undefined
23
+
24
+ if (bun.version === "latest") {
25
+ if (current?.startsWith("bun@")) {
26
+ rt.log(`\n v packageManager: ${current} (latest mode)`)
27
+ return 0
28
+ }
29
+ rt.log(`\n ~ packageManager: ${current ?? "(missing)"} (expected bun@*)`)
30
+ return 1
31
+ }
32
+ const expected = `bun@${bun.version}`
33
+ if (current === expected) {
34
+ rt.log(`\n v packageManager: ${current}`)
35
+ return 0
36
+ }
37
+ rt.log(`\n ~ packageManager: ${current ?? "(missing)"} (expected ${expected})`)
38
+ return 1
39
+ }
40
+
41
+ export function checkCommand(ctx: Context, target: string | undefined, here: boolean): number {
42
+ const { rt } = ctx
43
+ const path = searchPath(rt, here)
44
+
45
+ if (target !== undefined) {
46
+ const config = ctx.deps[target]
47
+ if (!config) {
48
+ rt.error(`Unknown dependency: ${target}`)
49
+ rt.log(`Available: ${Object.keys(ctx.deps).join(", ")}`)
50
+ return 1
51
+ }
52
+ if (resolveOsCommand(target, config, ctx.os) === null) {
53
+ rt.log(`${target} — not available on ${ctx.os}`)
54
+ return 0
55
+ }
56
+ const installed = probeInstalled(rt, target, config, path)
57
+ if (!installed) {
58
+ rt.log(`${target} — not installed (expected ${config.version})`)
59
+ return 1
60
+ }
61
+ rt.log(`${target}@${installed.version} (expected ${config.version}) ${installed.path}`)
62
+ return 0
63
+ }
64
+
65
+ rt.log("@workerstack/system — check")
66
+ rt.log(`Detected OS: ${ctx.os}`)
67
+ rt.log("=".repeat(50))
68
+
69
+ let ok = 0
70
+ let mismatch = 0
71
+ let missing = 0
72
+ for (const [name, config] of Object.entries(ctx.deps)) {
73
+ const badge = config.required ? "[required]" : "[optional]"
74
+ if (resolveOsCommand(name, config, ctx.os) === null) {
75
+ rt.log(` - ${badge} ${name} — not available on ${ctx.os}`)
76
+ continue
77
+ }
78
+ const installed = probeInstalled(rt, name, config, path)
79
+ if (!installed) {
80
+ rt.log(` x ${badge} ${name} — not installed (expected ${config.version})`)
81
+ if (config.required) missing++
82
+ else mismatch++
83
+ } else if (versionsMatch(installed.version, config.version)) {
84
+ rt.log(` v ${badge} ${name}@${installed.version}`)
85
+ ok++
86
+ } else {
87
+ rt.log(` ~ ${badge} ${name}@${installed.version} (expected ${config.version}) ${installed.path}`)
88
+ mismatch++
89
+ }
90
+ }
91
+ mismatch += checkPackageManager(ctx)
92
+
93
+ rt.log(`\n${"=".repeat(50)}`)
94
+ rt.log(`OK: ${ok} Mismatch: ${mismatch} Missing: ${missing}`)
95
+ if (missing > 0) {
96
+ rt.log("\nRequired dependencies missing — run: system install")
97
+ return 1
98
+ }
99
+ rt.log(mismatch > 0 ? "\nVersion mismatches found — run: system install --upgrade" : "\nAll system dependencies OK")
100
+ return 0
101
+ }
@@ -0,0 +1,99 @@
1
+ import type { EnvVar } from "../config.ts"
2
+ import {
3
+ applyEnvBlock,
4
+ type EnvLine,
5
+ expandPath,
6
+ hasEnvBlock,
7
+ reloadHint,
8
+ renderEnvBlock,
9
+ shellConfigPaths,
10
+ shellKind,
11
+ } from "../env.ts"
12
+ import type { Context } from "../probe.ts"
13
+ import type { Runtime } from "../runtime.ts"
14
+
15
+ /** The line `env` renders to, or null when a `detect` entry finds nothing and has no fallback. */
16
+ function resolveEnvLine(rt: Runtime, env: EnvVar): EnvLine | null {
17
+ if ("path" in env) return { path: env.path }
18
+ if ("value" in env) return { key: env.key, value: env.value }
19
+ for (const path of env.detect) {
20
+ const expanded = expandPath(path, rt.home, rt.env)
21
+ if (rt.exists(expanded)) {
22
+ rt.log(` Detected ${env.key} at: ${expanded}`)
23
+ return { key: env.key, value: path }
24
+ }
25
+ }
26
+ if (env.fallback !== undefined) {
27
+ rt.log(` Using fallback for ${env.key}: ${env.fallback}`)
28
+ return { key: env.key, value: env.fallback }
29
+ }
30
+ rt.log(` Could not detect ${env.key}, no fallback specified`)
31
+ return null
32
+ }
33
+
34
+ function rcFiles(rt: Runtime): { kind: ReturnType<typeof shellKind>; paths: string[] } {
35
+ const shell = rt.env.SHELL ?? ""
36
+ const kind = shellKind(rt.platform, shell)
37
+ return { kind, paths: shellConfigPaths(rt.home, kind, shell, rt.env) }
38
+ }
39
+
40
+ function fileName(path: string): string {
41
+ return path.split(/[\\/]/).pop() as string
42
+ }
43
+
44
+ /** Write `tool`'s managed block into the shell rc. True when a block was written. */
45
+ export function configureEnvVars(rt: Runtime, tool: string, envVars: readonly EnvVar[]): boolean {
46
+ if (envVars.length === 0) return true
47
+
48
+ const { kind, paths } = rcFiles(rt)
49
+ const names = paths.map(fileName).join(", ")
50
+ rt.log(`\n Configuring environment variables in ${names}...`)
51
+
52
+ const lines = envVars.map((env) => resolveEnvLine(rt, env)).filter((line) => line !== null)
53
+ if (lines.length === 0) {
54
+ rt.log(" No environment variables to configure")
55
+ return false
56
+ }
57
+
58
+ try {
59
+ for (const path of paths) {
60
+ const existing = rt.exists(path) ? rt.readFile(path) : ""
61
+ if (hasEnvBlock(existing, tool)) rt.log(` Updating existing ${tool} environment config in ${fileName(path)}...`)
62
+ rt.writeFile(path, applyEnvBlock(existing, renderEnvBlock(tool, lines, kind), tool))
63
+ }
64
+ } catch (error) {
65
+ rt.error(` Failed to configure env vars: ${error instanceof Error ? error.message : String(error)}`)
66
+ return false
67
+ }
68
+
69
+ rt.log(` Added to ${names}:`)
70
+ for (const line of lines) rt.log("path" in line ? ` PATH=${line.path}:$PATH` : ` ${line.key}=${line.value}`)
71
+ rt.log(`\n ${reloadHint(kind, paths[0] as string)}`)
72
+ return true
73
+ }
74
+
75
+ export function envCommand(ctx: Context): number {
76
+ const { rt } = ctx
77
+ rt.log("Setting up environment variables")
78
+ rt.log("=".repeat(50))
79
+
80
+ const envDeps = Object.entries(ctx.deps).flatMap(([tool, config]) =>
81
+ config.env && config.env.length > 0 ? [[tool, config.env] as const] : [],
82
+ )
83
+ if (envDeps.length === 0) {
84
+ rt.log("\nNo dependencies with environment variables configured.")
85
+ return 0
86
+ }
87
+
88
+ let failed = 0
89
+ for (const [tool, env] of envDeps) {
90
+ rt.log(`\n${tool}`)
91
+ if (!configureEnvVars(rt, tool, env)) failed++
92
+ }
93
+
94
+ const { kind, paths } = rcFiles(rt)
95
+ rt.log(`\n${"=".repeat(50)}`)
96
+ rt.log(`Environment variables configured in ${paths.join(", ")}`)
97
+ rt.log(`\n${reloadHint(kind, paths[0] as string)}`)
98
+ return failed > 0 ? 1 : 0
99
+ }
@@ -0,0 +1,134 @@
1
+ import { resolveOsCommand, type SystemDepConfig } from "../config.ts"
2
+ import { versionsMatch } from "../version.ts"
3
+ import { sortByDependencies } from "../graph.ts"
4
+ import { type Context, probeInstalled } from "../probe.ts"
5
+ import { expandPath } from "../env.ts"
6
+ import { configureEnvVars } from "./env.ts"
7
+
8
+ export interface InstallOptions {
9
+ dryRun: boolean
10
+ upgrade: boolean
11
+ }
12
+
13
+ type Outcome = "failed" | "installed" | "planned" | "skipped" | "unavailable"
14
+
15
+ /**
16
+ * Write the dep's env block whenever it is present, installed now or before: an already-installed tool
17
+ * (a CI image's rust, a setup action's bun) is otherwise missing from a new shell's PATH.
18
+ */
19
+ function writeEnv(ctx: Context, name: string, config: SystemDepConfig): void {
20
+ if (config.env && config.env.length > 0) configureEnvVars(ctx.rt, name, config.env)
21
+ }
22
+
23
+ function installOne(ctx: Context, name: string, config: SystemDepConfig, opts: InstallOptions): Outcome {
24
+ const { rt } = ctx
25
+ const command = resolveOsCommand(name, config, ctx.os)
26
+ if (!command) {
27
+ rt.log(`${name}@${config.version} — not available on ${ctx.os}`)
28
+ return "unavailable"
29
+ }
30
+
31
+ rt.log(`${name}@${config.version}`)
32
+ rt.log(` ${config.description}`)
33
+
34
+ if (opts.dryRun) {
35
+ rt.log(` dry-run: ${command}`)
36
+ return "planned"
37
+ }
38
+
39
+ const installed = probeInstalled(rt, name, config)
40
+ if (installed && versionsMatch(installed.version, config.version)) {
41
+ rt.log(` Already installed (${installed.version}), skipping`)
42
+ writeEnv(ctx, name, config)
43
+ return "skipped"
44
+ }
45
+ if (installed && !opts.upgrade) {
46
+ rt.log(` Already installed (${installed.version}), skipping (expected ${config.version}) ${installed.path}`)
47
+ rt.log(` run: system install ${name} --upgrade`)
48
+ writeEnv(ctx, name, config)
49
+ return "skipped"
50
+ }
51
+
52
+ if (!rt.run(command)) {
53
+ rt.error(`\nCommand failed: ${command}`)
54
+ rt.error(" Installation failed")
55
+ return "failed"
56
+ }
57
+
58
+ rt.log(" Installed successfully")
59
+ writeEnv(ctx, name, config)
60
+ /* Installer often does not win PATH — brew/nvm/fnm still first. */
61
+ const after = probeInstalled(rt, name, config)
62
+ if (after && !versionsMatch(after.version, config.version)) {
63
+ rt.log(` PATH still has ${after.version} (expected ${config.version}) ${after.path}`)
64
+ rt.log(" Unpin/unlink that binary or put the pinned one first on PATH")
65
+ }
66
+ return "installed"
67
+ }
68
+
69
+ /**
70
+ * Put every dep's `{ path }` dirs first on this run's PATH, so a dep finds a prerequisite installed earlier
71
+ * in the same run (ruff needs uv) and the skip check sees the pinned binary before a system one.
72
+ */
73
+ function prependDepPaths(ctx: Context): void {
74
+ for (const config of Object.values(ctx.deps)) {
75
+ for (const entry of config.env ?? []) {
76
+ if ("path" in entry) ctx.rt.prependPath(expandPath(entry.path, ctx.rt.home, ctx.rt.env))
77
+ }
78
+ }
79
+ }
80
+
81
+ export function installCommand(ctx: Context, target: string | undefined, opts: InstallOptions): number {
82
+ const { rt } = ctx
83
+ prependDepPaths(ctx)
84
+
85
+ if (target !== undefined) {
86
+ const config = ctx.deps[target]
87
+ if (!config) {
88
+ rt.error(`Unknown dependency: ${target}`)
89
+ rt.log(`Available: ${Object.keys(ctx.deps).join(", ")}`)
90
+ return 1
91
+ }
92
+ rt.log(`Detected OS: ${ctx.os}`)
93
+ return installOne(ctx, target, config, opts) === "failed" ? 1 : 0
94
+ }
95
+
96
+ rt.log("@workerstack/system — install")
97
+ rt.log(`Detected OS: ${ctx.os}`)
98
+ rt.log("=".repeat(50))
99
+
100
+ const entries = sortByDependencies(Object.entries(ctx.deps))
101
+ rt.log(`\nFound ${entries.length} dependencies to install:\n`)
102
+ for (const [name, config] of entries) {
103
+ const badge = config.required ? "[required]" : "[optional]"
104
+ const available = resolveOsCommand(name, config, ctx.os) !== null
105
+ rt.log(` ${badge} ${name}@${config.version}${available ? "" : ` (not available on ${ctx.os})`}`)
106
+ }
107
+
108
+ rt.log(`\n${"=".repeat(50)}`)
109
+ rt.log(opts.dryRun ? "Dry-run — commands only, nothing installed" : "Installing dependencies...")
110
+ rt.log("=".repeat(50))
111
+
112
+ const counts: Record<Outcome, number> = { failed: 0, installed: 0, planned: 0, skipped: 0, unavailable: 0 }
113
+ for (const [name, config] of entries) {
114
+ const outcome = installOne(ctx, name, config, opts)
115
+ /* an optional dep that fails does not fail the run */
116
+ counts[outcome === "failed" && !config.required ? "skipped" : outcome]++
117
+ }
118
+
119
+ rt.log(`\n${"=".repeat(50)}`)
120
+ rt.log("Installation Summary")
121
+ rt.log("=".repeat(50))
122
+ if (opts.dryRun) rt.log(`Planned: ${counts.planned}`)
123
+ else rt.log(`Installed: ${counts.installed}`)
124
+ rt.log(`Skipped: ${counts.skipped}`)
125
+ rt.log(`Unavailable on ${ctx.os}: ${counts.unavailable}`)
126
+ rt.log(`Failed: ${counts.failed}`)
127
+
128
+ if (counts.failed > 0) {
129
+ rt.log("\nSome required dependencies failed to install")
130
+ return 1
131
+ }
132
+ rt.log(opts.dryRun ? "\nDry-run complete" : "\nAll required system dependencies are ready!")
133
+ return 0
134
+ }
@@ -0,0 +1,29 @@
1
+ import type { BuiltinDep } from "../builtins/types.ts"
2
+ import { OS_TARGETS } from "../config.ts"
3
+ import type { Runtime } from "../runtime.ts"
4
+
5
+ /** The OS targets a built-in has a command for. */
6
+ function availability(builtin: BuiltinDep): string {
7
+ const os = builtin.os({ arch: "x64", version: builtin.versions === "latest" ? "latest" : "0.0.0" })
8
+ return OS_TARGETS.filter((target) => {
9
+ const command = os[target] ?? os.all
10
+ return typeof command === "string"
11
+ }).join(" ")
12
+ }
13
+
14
+ /** Print the built-in catalog: name, version policy, where it installs, what it is. */
15
+ export function listCommand(rt: Runtime, catalog: Readonly<Record<string, BuiltinDep>>): number {
16
+ const rows = Object.entries(catalog).map(([name, builtin]) => [
17
+ name,
18
+ builtin.versions,
19
+ availability(builtin),
20
+ builtin.description,
21
+ ])
22
+ const widths = [0, 1, 2].map((col) => Math.max(...rows.map((row) => (row[col] as string).length)))
23
+ rt.log('Built-in deps (give a version: go: "1.27.2")')
24
+ rt.log("")
25
+ for (const row of rows) {
26
+ rt.log(` ${row.map((cell, col) => (col < 3 ? cell.padEnd(widths[col] as number) : cell)).join(" ")}`)
27
+ }
28
+ return 0
29
+ }