@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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
2
|
+
import type { Context } from "../probe.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `json` with `packageManager` set to `value`, keeping the file's indent, EOL, trailing newline and key
|
|
6
|
+
* order. A new field goes right after `name`, or first when there is no `name`.
|
|
7
|
+
*/
|
|
8
|
+
export function withPackageManager(json: string, path: string, value: string): string {
|
|
9
|
+
const pkg: unknown = JSON.parse(json)
|
|
10
|
+
if (typeof pkg !== "object" || pkg === null || Array.isArray(pkg)) throw new Error(`${path} is not a JSON object`)
|
|
11
|
+
|
|
12
|
+
const entries = Object.entries(pkg).filter(([key]) => key !== "packageManager")
|
|
13
|
+
const existing = Object.keys(pkg).indexOf("packageManager")
|
|
14
|
+
const at = existing !== -1 ? existing : entries.findIndex(([key]) => key === "name") + 1
|
|
15
|
+
entries.splice(at, 0, ["packageManager", value])
|
|
16
|
+
|
|
17
|
+
const indent = json.match(/^[ \t]+(?=")/m)?.[0] ?? ""
|
|
18
|
+
const eol = json.includes("\r\n") ? "\r\n" : "\n"
|
|
19
|
+
const body = JSON.stringify(Object.fromEntries(entries), null, indent).replace(/\n/g, eol)
|
|
20
|
+
return json.endsWith("\n") ? body + eol : body
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Set package.json `packageManager` to `bun@<deps.bun.version>`. bun is the only package manager. */
|
|
24
|
+
export function syncPmCommand(ctx: Context): number {
|
|
25
|
+
const { rt } = ctx
|
|
26
|
+
const bun = ctx.deps.bun
|
|
27
|
+
if (!bun) {
|
|
28
|
+
rt.log("\nSkipping packageManager sync — bun not in config")
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
31
|
+
if (bun.version === "latest") {
|
|
32
|
+
rt.log('\nSkipping packageManager sync — bun version is "latest"')
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const path = resolve(ctx.configDir, ctx.packageJsonPath)
|
|
37
|
+
const expected = `bun@${bun.version}`
|
|
38
|
+
try {
|
|
39
|
+
const content = rt.readFile(path)
|
|
40
|
+
const current = (JSON.parse(content) as { packageManager?: unknown }).packageManager
|
|
41
|
+
if (current === expected) {
|
|
42
|
+
rt.log(`\npackageManager already correct: ${expected}`)
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
rt.log(`\nSyncing packageManager: ${typeof current === "string" ? current : "(missing)"} -> ${expected}`)
|
|
46
|
+
rt.writeFile(path, withPackageManager(content, path, expected))
|
|
47
|
+
rt.log(`Updated packageManager to ${expected}`)
|
|
48
|
+
return 0
|
|
49
|
+
} catch (error) {
|
|
50
|
+
rt.error(`Failed to sync packageManager: ${error instanceof Error ? error.message : String(error)}`)
|
|
51
|
+
return 1
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
import type { BuiltinName } from "./builtins/index.ts"
|
|
12
|
+
import type { BuiltinOverride } from "./builtins/types.ts"
|
|
13
|
+
|
|
14
|
+
export const OS_TARGETS = ["linux-apt", "linux-dnf", "linux-pacman", "macos", "windows"] as const
|
|
15
|
+
|
|
16
|
+
export type OsTarget = (typeof OS_TARGETS)[number]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-OS install commands.
|
|
20
|
+
* - Use specific OS keys for platform-specific commands
|
|
21
|
+
* - Use "all" for commands identical across all platforms
|
|
22
|
+
* - Per-OS keys override "all" when both are present
|
|
23
|
+
* - Omit an OS key (without "all") = unavailable on that OS
|
|
24
|
+
* - Set an OS key to `false` = explicitly unavailable on that OS (overrides "all")
|
|
25
|
+
*/
|
|
26
|
+
export type OsCommands = Partial<Record<OsTarget, string | false>> & { all?: string }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One shell environment entry, written verbatim into the managed rc block (the shell expands `$HOME`).
|
|
30
|
+
* - `{ path }` puts a directory first on PATH, so the pinned binary wins over one already installed.
|
|
31
|
+
* - `{ key, value }` sets a variable.
|
|
32
|
+
* - `{ key, detect, fallback? }` sets a variable to the first `detect` path that exists, else `fallback`.
|
|
33
|
+
*/
|
|
34
|
+
export type EnvVar =
|
|
35
|
+
| { path: string }
|
|
36
|
+
| { key: string; value: string }
|
|
37
|
+
| { detect: readonly string[]; fallback?: string; key: string }
|
|
38
|
+
|
|
39
|
+
export interface SystemDepConfig {
|
|
40
|
+
description: string
|
|
41
|
+
required: boolean
|
|
42
|
+
version: string
|
|
43
|
+
/** Per-OS install commands. Supports {{name}}, {{version}}, {{major}} templates. */
|
|
44
|
+
os: OsCommands
|
|
45
|
+
/**
|
|
46
|
+
* Version check command. Supports {{name}} template.
|
|
47
|
+
* Default: "{{name}} --version" — only declare when it differs.
|
|
48
|
+
*/
|
|
49
|
+
check?: {
|
|
50
|
+
command: string
|
|
51
|
+
/** Regex source whose first capture is the version. Default: the first `N.N…` in the output. */
|
|
52
|
+
pattern?: string
|
|
53
|
+
}
|
|
54
|
+
/** Deps that must be installed before this one (by dep key name) */
|
|
55
|
+
dependsOn?: readonly string[]
|
|
56
|
+
/** Env vars to configure after install */
|
|
57
|
+
env?: readonly EnvVar[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One `deps` entry. A built-in name takes its version (`go: "1.27.2"`) or an override object
|
|
62
|
+
* (`{ version, ...fields to replace, os merged per key }`); any other name is a full custom dep.
|
|
63
|
+
*/
|
|
64
|
+
export type DepEntry<K extends PropertyKey> = K extends BuiltinName ? BuiltinOverride | string : SystemDepConfig
|
|
65
|
+
|
|
66
|
+
export interface SystemConfig<
|
|
67
|
+
D extends Record<string, unknown> = Record<string, BuiltinOverride | SystemDepConfig | string>,
|
|
68
|
+
> {
|
|
69
|
+
deps: { [K in keyof D]: DepEntry<K> }
|
|
70
|
+
/** package.json whose `packageManager` follows the bun pin. Relative to the config file. */
|
|
71
|
+
packageJsonPath?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `{{arch}}` value: "arm64" on arm64, "amd64" everywhere else. */
|
|
75
|
+
export function archLabel(arch: string = process.arch): "amd64" | "arm64" {
|
|
76
|
+
return arch === "arm64" ? "arm64" : "amd64"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Replace {{name}}, {{version}}, {{major}} and {{arch}} in a template. */
|
|
80
|
+
export function interpolate(template: string, name: string, version: string, arch: string = process.arch): string {
|
|
81
|
+
const major = version.split(".")[0] as string
|
|
82
|
+
return template
|
|
83
|
+
.replace(/\{\{name\}\}/g, name)
|
|
84
|
+
.replace(/\{\{version\}\}/g, version)
|
|
85
|
+
.replace(/\{\{major\}\}/g, major)
|
|
86
|
+
.replace(/\{\{arch\}\}/g, archLabel(arch))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the install command for a dep on a given OS target.
|
|
91
|
+
* Returns the interpolated command, or null if unavailable.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveOsCommand(name: string, config: SystemDepConfig, target: OsTarget): string | null {
|
|
94
|
+
const specific = config.os[target]
|
|
95
|
+
|
|
96
|
+
/* explicitly unavailable */
|
|
97
|
+
if (specific === false) return null
|
|
98
|
+
|
|
99
|
+
/* prefer specific OS command, fall back to "all" */
|
|
100
|
+
const raw = specific ?? config.os.all
|
|
101
|
+
if (!raw) return null
|
|
102
|
+
|
|
103
|
+
return interpolate(raw, name, config.version)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the version check command for a dep.
|
|
108
|
+
* Default: "{name} --version"
|
|
109
|
+
*/
|
|
110
|
+
export function resolveCheckCommand(name: string, config: SystemDepConfig): string {
|
|
111
|
+
const raw = config.check?.command ?? "{{name}} --version"
|
|
112
|
+
return interpolate(raw, name, config.version)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Type a `system.config.ts` default export. Each key is checked on its own: a built-in name takes a
|
|
117
|
+
* version string or override object, any other name a full `SystemDepConfig`.
|
|
118
|
+
*/
|
|
119
|
+
export function defineConfig<const D extends Record<string, unknown>>(
|
|
120
|
+
config: SystemConfig<D> & { deps: D },
|
|
121
|
+
): { deps: D; packageJsonPath?: string } {
|
|
122
|
+
return config
|
|
123
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell rc blocks: which file each shell reads, and the managed block written into it.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { basename, resolve } from "node:path"
|
|
6
|
+
|
|
7
|
+
export type ShellKind = "fish" | "posix" | "powershell"
|
|
8
|
+
|
|
9
|
+
/** One rendered line: a directory put first on PATH, or a variable set to a value. */
|
|
10
|
+
export type EnvLine = { path: string } | { key: string; value: string }
|
|
11
|
+
|
|
12
|
+
/** PowerShell on Windows; fish when `SHELL` is fish; posix (bash/zsh) otherwise. */
|
|
13
|
+
export function shellKind(platform: NodeJS.Platform, shell = ""): ShellKind {
|
|
14
|
+
if (platform === "win32") return "powershell"
|
|
15
|
+
return basename(shell) === "fish" ? "fish" : "posix"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The rc files the managed block goes into. On Windows both profiles: PowerShell 7 (`pwsh`) reads
|
|
20
|
+
* `Documents\PowerShell`, Windows PowerShell 5.1 (`powershell.exe`, which `check` snapshots) reads
|
|
21
|
+
* `Documents\WindowsPowerShell`.
|
|
22
|
+
*/
|
|
23
|
+
export function shellConfigPaths(home: string, kind: ShellKind, shell = "", env: NodeJS.ProcessEnv = {}): string[] {
|
|
24
|
+
if (kind === "powershell") {
|
|
25
|
+
return ["PowerShell", "WindowsPowerShell"].map((dir) =>
|
|
26
|
+
resolve(home, "Documents", dir, "Microsoft.PowerShell_profile.ps1"),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
if (kind === "fish") {
|
|
30
|
+
return [resolve(env.XDG_CONFIG_HOME ?? resolve(home, ".config"), "fish", "conf.d", "workerstack-system.fish")]
|
|
31
|
+
}
|
|
32
|
+
return [resolve(home, shell.includes("zsh") ? ".zshrc" : ".bashrc")]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Expand `$env:VAR`, `$VAR`, `${VAR}` and `%VAR%`. `HOME` and `USERPROFILE` are `home`; every other name comes from
|
|
37
|
+
* `env`. Names `env` does not define stay literal, so a later shell can still expand them.
|
|
38
|
+
*/
|
|
39
|
+
export function expandPath(path: string, home: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
40
|
+
const lookup = (name: string): string | undefined => (/^(home|userprofile)$/i.test(name) ? home : env[name])
|
|
41
|
+
return path.replace(
|
|
42
|
+
/\$env:(\w+)|\$\{(\w+)\}|\$(\w+)|%(\w+)%/g,
|
|
43
|
+
(token, ps?: string, braced?: string, bare?: string, percent?: string) =>
|
|
44
|
+
lookup((ps ?? braced ?? bare ?? percent) as string) ?? token,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const MARKER_OWNER = "@workerstack/system"
|
|
49
|
+
|
|
50
|
+
/** Owner tag written before the rename; blocks carrying it are replaced, never duplicated. */
|
|
51
|
+
export const LEGACY_MARKER_OWNER = "crossdeps"
|
|
52
|
+
|
|
53
|
+
export function envMarkers(tool: string, owner: string = MARKER_OWNER): { end: string; start: string } {
|
|
54
|
+
return {
|
|
55
|
+
end: `# end ${tool} environment`,
|
|
56
|
+
start: `# ${tool} environment (managed by ${owner})`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeRegExp(text: string): string {
|
|
61
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stripBlock(existing: string, tool: string, owner: string): string {
|
|
65
|
+
const { end, start } = envMarkers(tool, owner)
|
|
66
|
+
if (!existing.includes(start)) return existing
|
|
67
|
+
return existing.replace(new RegExp(`\\r?\\n?${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\r?\\n?`, "g"), "")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderLine(line: EnvLine, kind: ShellKind): string {
|
|
71
|
+
if ("path" in line) {
|
|
72
|
+
if (kind === "powershell") return `$env:Path = "${line.path};" + $env:Path`
|
|
73
|
+
if (kind === "fish") return `set -gx PATH "${line.path}" $PATH`
|
|
74
|
+
return `export PATH="${line.path}:$PATH"`
|
|
75
|
+
}
|
|
76
|
+
if (kind === "powershell") return `$env:${line.key} = "${line.value}"`
|
|
77
|
+
if (kind === "fish") return `set -gx ${line.key} "${line.value}"`
|
|
78
|
+
return `export ${line.key}="${line.value}"`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The managed block for `tool`. PATH entries go first on PATH so a pinned toolchain wins. */
|
|
82
|
+
export function renderEnvBlock(tool: string, lines: readonly EnvLine[], kind: ShellKind): string {
|
|
83
|
+
const { end, start } = envMarkers(tool)
|
|
84
|
+
const nl = kind === "powershell" ? "\r\n" : "\n"
|
|
85
|
+
return ["", start, ...lines.map((line) => renderLine(line, kind)), end, ""].join(nl)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Replace `tool`'s block (current or legacy marker) with `block`; other content is kept. */
|
|
89
|
+
export function applyEnvBlock(existing: string, block: string, tool: string): string {
|
|
90
|
+
const withoutLegacy = stripBlock(existing, tool, LEGACY_MARKER_OWNER)
|
|
91
|
+
return stripBlock(withoutLegacy, tool, MARKER_OWNER) + block
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when the rc already holds a block for `tool`, current or legacy. */
|
|
95
|
+
export function hasEnvBlock(existing: string, tool: string): boolean {
|
|
96
|
+
return existing.includes(envMarkers(tool).start) || existing.includes(envMarkers(tool, LEGACY_MARKER_OWNER).start)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function reloadHint(kind: ShellKind, configPath: string): string {
|
|
100
|
+
if (kind === "powershell") return "Restart PowerShell or run: . $PROFILE"
|
|
101
|
+
if (kind === "fish") return `Run: source ${configPath} (or restart fish)`
|
|
102
|
+
return `Run: source ~/${basename(configPath)} (or restart terminal)`
|
|
103
|
+
}
|
package/src/errors.ts
ADDED
package/src/exec.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
/** One process spawn: `verbatim` passes args to cmd.exe unquoted, as Node does for `shell: true`. */
|
|
4
|
+
export interface ShellInvocation {
|
|
5
|
+
args: string[]
|
|
6
|
+
file: string
|
|
7
|
+
verbatim: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const POWERSHELL_MARKERS = [
|
|
11
|
+
/\birm\b/i,
|
|
12
|
+
/\biex\b/i,
|
|
13
|
+
/\$env:/,
|
|
14
|
+
/Invoke-WebRequest/i,
|
|
15
|
+
/Invoke-Expression/i,
|
|
16
|
+
/New-Item\b/i,
|
|
17
|
+
/Test-Path\b/i,
|
|
18
|
+
/Out-Null/i,
|
|
19
|
+
/Get-Command\b/i,
|
|
20
|
+
/\$LASTEXITCODE/,
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export function needsPowerShell(command: string): boolean {
|
|
24
|
+
return POWERSHELL_MARKERS.some((re) => re.test(command))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function shellInvocation(
|
|
28
|
+
command: string,
|
|
29
|
+
platform: NodeJS.Platform = process.platform,
|
|
30
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
31
|
+
): ShellInvocation {
|
|
32
|
+
if (platform !== "win32") return { args: ["-c", command], file: "/bin/bash", verbatim: false }
|
|
33
|
+
if (needsPowerShell(command)) {
|
|
34
|
+
return { args: ["-NoProfile", "-NonInteractive", "-Command", command], file: "powershell.exe", verbatim: false }
|
|
35
|
+
}
|
|
36
|
+
/* cmd.exe so bun -e / choco / || keep working */
|
|
37
|
+
return { args: ["/d", "/s", "/c", `"${command}"`], file: env.ComSpec || "cmd.exe", verbatim: true }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The env for spawning `file`. Windows PowerShell 5.1 gets no PSModulePath: one inherited from a pwsh 7
|
|
42
|
+
* parent points it at 7's modules and breaks autoload (`Get-FileHash` "not recognized"); 5.1 rebuilds
|
|
43
|
+
* its own default.
|
|
44
|
+
*/
|
|
45
|
+
export function shellEnv(file: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
46
|
+
if (file !== "powershell.exe") return env
|
|
47
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== "psmodulepath"))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run `command` with inherited stdio. Throws when the shell cannot start or exits non-zero. */
|
|
51
|
+
export function runShellCommand(
|
|
52
|
+
command: string,
|
|
53
|
+
platform: NodeJS.Platform = process.platform,
|
|
54
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
55
|
+
): void {
|
|
56
|
+
const { args, file, verbatim } = shellInvocation(command, platform, env)
|
|
57
|
+
const result = spawnSync(file, args, {
|
|
58
|
+
env: shellEnv(file, env),
|
|
59
|
+
stdio: "inherit",
|
|
60
|
+
windowsVerbatimArguments: verbatim,
|
|
61
|
+
})
|
|
62
|
+
if (result.error) throw result.error
|
|
63
|
+
if (result.status !== 0) throw new Error(`exit ${result.status ?? result.signal}: ${command}`)
|
|
64
|
+
}
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SystemDepConfig } from "./config.ts"
|
|
2
|
+
import { ConfigError } from "./errors.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Install order: every `dependsOn` target before its dependent, otherwise config order.
|
|
6
|
+
* Throws `ConfigError` on a `dependsOn` name missing from the config, or on a cycle.
|
|
7
|
+
*/
|
|
8
|
+
export function sortByDependencies<T extends Pick<SystemDepConfig, "dependsOn" | "version">>(
|
|
9
|
+
entries: Array<[string, T]>,
|
|
10
|
+
): Array<[string, T]> {
|
|
11
|
+
const byName = new Map(entries)
|
|
12
|
+
const sorted: Array<[string, T]> = []
|
|
13
|
+
const done = new Set<string>()
|
|
14
|
+
const path: string[] = []
|
|
15
|
+
|
|
16
|
+
function visit(name: string, config: T): void {
|
|
17
|
+
if (done.has(name)) return
|
|
18
|
+
const start = path.indexOf(name)
|
|
19
|
+
if (start !== -1) throw new ConfigError(`dependency cycle: ${[...path.slice(start), name].join(" → ")}`)
|
|
20
|
+
path.push(name)
|
|
21
|
+
for (const dep of config.dependsOn ?? []) {
|
|
22
|
+
const target = byName.get(dep)
|
|
23
|
+
if (target === undefined) throw new ConfigError(`deps.${name}.dependsOn: "${dep}" is not a dep in this config`)
|
|
24
|
+
visit(dep, target)
|
|
25
|
+
}
|
|
26
|
+
path.pop()
|
|
27
|
+
done.add(name)
|
|
28
|
+
sorted.push([name, config])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const [name, config] of entries) visit(name, config)
|
|
32
|
+
return sorted
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @workerstack/system — every OS dependency a workerstack repo needs, from one config.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { BUILTIN_GROUPS, BUILTIN_NAMES, BUILTINS, type BuiltinName } from "./builtins/index.ts"
|
|
6
|
+
export { type RawEntry, resolveDeps } from "./builtins/resolve.ts"
|
|
7
|
+
export type { Arch, BuiltinDep, BuiltinOverride, RecipeContext } from "./builtins/types.ts"
|
|
8
|
+
export type { DepEntry, EnvVar, OsCommands, OsTarget, SystemConfig, SystemDepConfig } from "./config.ts"
|
|
9
|
+
export { defineConfig, interpolate, OS_TARGETS, resolveCheckCommand, resolveOsCommand } from "./config.ts"
|
|
10
|
+
export { ConfigError } from "./errors.ts"
|
|
11
|
+
export { sortByDependencies } from "./graph.ts"
|
|
12
|
+
export {
|
|
13
|
+
commandExists,
|
|
14
|
+
detectLinuxDistro,
|
|
15
|
+
detectOs,
|
|
16
|
+
detectOsFromPlatform,
|
|
17
|
+
parseOsTarget,
|
|
18
|
+
whichBinary,
|
|
19
|
+
} from "./platform.ts"
|
|
20
|
+
export { validateConfig } from "./validate.ts"
|
|
21
|
+
export { parseVersion, versionsMatch } from "./version.ts"
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config discovery and import. Returns a result, never exits.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve } from "node:path"
|
|
6
|
+
import type { SystemConfig } from "./config.ts"
|
|
7
|
+
import type { Runtime } from "./runtime.ts"
|
|
8
|
+
import { validateConfig } from "./validate.ts"
|
|
9
|
+
import { BUILTIN_NAMES } from "./builtins/index.ts"
|
|
10
|
+
|
|
11
|
+
export const CONFIG_NAMES = ["system.config.ts", "system.config.js", "system.config.mjs"] as const
|
|
12
|
+
|
|
13
|
+
export type Result<T> = { ok: true; value: T } | { error: string; ok: false }
|
|
14
|
+
|
|
15
|
+
/** `--config <path>` relative to cwd, else the first convention name in cwd. */
|
|
16
|
+
export function findConfig(rt: Runtime, explicit: string | undefined): Result<string> {
|
|
17
|
+
if (explicit !== undefined) {
|
|
18
|
+
const path = resolve(rt.cwd, explicit)
|
|
19
|
+
return rt.exists(path) ? { ok: true, value: path } : { error: `Config file not found: ${path}`, ok: false }
|
|
20
|
+
}
|
|
21
|
+
for (const name of CONFIG_NAMES) {
|
|
22
|
+
const path = resolve(rt.cwd, name)
|
|
23
|
+
if (rt.exists(path)) return { ok: true, value: path }
|
|
24
|
+
}
|
|
25
|
+
return { error: `No system config found. Create one of: ${CONFIG_NAMES.join(", ")}`, ok: false }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasDeps(value: unknown): value is { deps: unknown } {
|
|
29
|
+
return typeof value === "object" && value !== null && "deps" in value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `export default defineConfig(...)`, else the first named export holding `deps`, validated.
|
|
34
|
+
* Throws `ConfigError` listing every problem in the config's shape.
|
|
35
|
+
*/
|
|
36
|
+
export async function importConfig(rt: Runtime, path: string): Promise<Result<SystemConfig>> {
|
|
37
|
+
const mod = await rt.importModule(path)
|
|
38
|
+
const config = hasDeps(mod.default) ? mod.default : Object.values(mod).find(hasDeps)
|
|
39
|
+
if (config === undefined) {
|
|
40
|
+
return { error: "Config file must export an object with a `deps` field (use defineConfig)", ok: false }
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, value: validateConfig(config, BUILTIN_NAMES) }
|
|
43
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI: parse, load the config, dispatch. Returns the exit code; only `bin.ts` exits.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { dirname } from "node:path"
|
|
6
|
+
import { parseArgs } from "./args.ts"
|
|
7
|
+
import { BUILTINS } from "./builtins/index.ts"
|
|
8
|
+
import { resolveDeps } from "./builtins/resolve.ts"
|
|
9
|
+
import { checkCommand } from "./commands/check.ts"
|
|
10
|
+
import { envCommand } from "./commands/env.ts"
|
|
11
|
+
import { installCommand } from "./commands/install.ts"
|
|
12
|
+
import { listCommand } from "./commands/list.ts"
|
|
13
|
+
import { syncPmCommand } from "./commands/sync-pm.ts"
|
|
14
|
+
import { ConfigError } from "./errors.ts"
|
|
15
|
+
import { findConfig, importConfig } from "./load.ts"
|
|
16
|
+
import { detectOsFromPlatform } from "./platform.ts"
|
|
17
|
+
import type { Context } from "./probe.ts"
|
|
18
|
+
import type { Runtime } from "./runtime.ts"
|
|
19
|
+
|
|
20
|
+
export const USAGE = `Usage: system <command> [args]
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
install Install all deps (auto-detect OS)
|
|
24
|
+
install <name> Install single dep
|
|
25
|
+
check Check all deps
|
|
26
|
+
check <name> Check single dep
|
|
27
|
+
env Setup environment variables
|
|
28
|
+
sync-pm Sync packageManager field in package.json
|
|
29
|
+
list List built-in deps (give a version: go: "1.27.2")
|
|
30
|
+
|
|
31
|
+
Flags:
|
|
32
|
+
--config <path> Config file (default: system.config.ts in cwd)
|
|
33
|
+
--os <target> Force OS target (or set WORKERSTACK_SYSTEM_OS)
|
|
34
|
+
--dry-run Print install commands without running them
|
|
35
|
+
--upgrade Re-run install when the detected version does not match
|
|
36
|
+
--here check: use this process PATH instead of an interactive-shell snapshot
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
const COMMANDS = ["check", "env", "install", "list", "sync-pm"] as const
|
|
40
|
+
|
|
41
|
+
async function run(argv: readonly string[], rt: Runtime): Promise<number> {
|
|
42
|
+
const parsed = parseArgs(argv)
|
|
43
|
+
if (!parsed.ok) {
|
|
44
|
+
rt.error(parsed.error)
|
|
45
|
+
return 1
|
|
46
|
+
}
|
|
47
|
+
const args = parsed.value
|
|
48
|
+
|
|
49
|
+
if (args.command === undefined || !(COMMANDS as readonly string[]).includes(args.command)) {
|
|
50
|
+
rt.log(USAGE)
|
|
51
|
+
if (args.command === undefined) return 0
|
|
52
|
+
rt.error(`Unknown command: ${args.command}`)
|
|
53
|
+
return 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (args.command === "list") return listCommand(rt, BUILTINS)
|
|
57
|
+
|
|
58
|
+
const found = findConfig(rt, args.config)
|
|
59
|
+
if (!found.ok) {
|
|
60
|
+
rt.error(found.error)
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
const loaded = await importConfig(rt, found.value)
|
|
64
|
+
if (!loaded.ok) {
|
|
65
|
+
rt.error(loaded.error)
|
|
66
|
+
return 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const override = args.os ?? rt.env.WORKERSTACK_SYSTEM_OS
|
|
70
|
+
const os = detectOsFromPlatform(rt.platform, override, (command) => rt.which(command) !== null)
|
|
71
|
+
const ctx: Context = {
|
|
72
|
+
configDir: dirname(found.value),
|
|
73
|
+
deps: resolveDeps(loaded.value.deps, { arch: rt.arch, os }, BUILTINS),
|
|
74
|
+
os,
|
|
75
|
+
packageJsonPath: loaded.value.packageJsonPath ?? "package.json",
|
|
76
|
+
rt,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
switch (args.command) {
|
|
80
|
+
case "check":
|
|
81
|
+
return checkCommand(ctx, args.target, args.here)
|
|
82
|
+
case "env":
|
|
83
|
+
return envCommand(ctx)
|
|
84
|
+
case "install":
|
|
85
|
+
return installCommand(ctx, args.target, { dryRun: args.dryRun, upgrade: args.upgrade })
|
|
86
|
+
default:
|
|
87
|
+
return syncPmCommand(ctx)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function main(argv: readonly string[], rt: Runtime): Promise<number> {
|
|
92
|
+
try {
|
|
93
|
+
return await run(argv, rt)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof ConfigError) rt.error(error.message)
|
|
96
|
+
else rt.error(`\nFatal error: ${error instanceof Error ? error.message : String(error)}`)
|
|
97
|
+
return 1
|
|
98
|
+
}
|
|
99
|
+
}
|