@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,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
@@ -0,0 +1,4 @@
1
+ /** A config the CLI cannot act on. `main` prints the message and exits 1, without a stack. */
2
+ export class ConfigError extends Error {
3
+ override name = "ConfigError"
4
+ }
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
+ }