@workerstack/system 0.0.0-stage → 0.2.3
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 +1155 -2
- package/package.json +46 -5
- package/src/cli.ts +593 -0
- package/src/config.ts +120 -0
- package/src/env.ts +85 -0
- package/src/exec.ts +43 -0
- package/src/graph.ts +45 -0
- package/src/index.ts +15 -0
- package/src/path.ts +134 -0
- package/src/platform.ts +78 -0
package/README.md
CHANGED
|
@@ -1,3 +1,1156 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @workerstack/system
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Formerly [`@lovrozagar/crossdeps`](https://www.npmjs.com/package/@lovrozagar/crossdeps). Same code and history, new name.
|
|
4
|
+
|
|
5
|
+
Define system dependencies in a TypeScript (or JS) config file. Install, check, and wire their environment on macOS, Linux (apt / dnf / pacman), and Windows with one CLI.
|
|
6
|
+
|
|
7
|
+
The published package ships TypeScript source. The CLI shebang is `#!/usr/bin/env bun`, so **Bun is required to run the CLI**.
|
|
8
|
+
|
|
9
|
+
This README is the full usage contract. If you are an agent, read it end to end before writing a config or invoking the CLI. Every public command, flag, config field, export, and runtime rule is here with an example.
|
|
10
|
+
|
|
11
|
+
Source and the 24-dep catalog: [github.com/workerstack/system](https://github.com/workerstack/system).
|
|
12
|
+
|
|
13
|
+
## Table of contents
|
|
14
|
+
|
|
15
|
+
- [What this is](#what-this-is)
|
|
16
|
+
- [Requirements](#requirements)
|
|
17
|
+
- [Install](#install)
|
|
18
|
+
- [Quick start](#quick-start)
|
|
19
|
+
- [Agent contract](#agent-contract)
|
|
20
|
+
- [Config file](#config-file)
|
|
21
|
+
- [Discovery](#discovery)
|
|
22
|
+
- [Export shape](#export-shape)
|
|
23
|
+
- [`defineConfig`](#defineconfig)
|
|
24
|
+
- [`SystemDepConfig`](#systemdepconfig)
|
|
25
|
+
- [`OsCommands`](#oscommands)
|
|
26
|
+
- [Template variables](#template-variables)
|
|
27
|
+
- [`EnvVar`](#envvar)
|
|
28
|
+
- [CLI](#cli)
|
|
29
|
+
- [Invocation and flags](#invocation-and-flags)
|
|
30
|
+
- [`install`](#install-1)
|
|
31
|
+
- [`check`](#check)
|
|
32
|
+
- [`env`](#env)
|
|
33
|
+
- [`sync-pm`](#sync-pm)
|
|
34
|
+
- [Version detection](#version-detection)
|
|
35
|
+
- [Exit codes](#exit-codes)
|
|
36
|
+
- [OS detection](#os-detection)
|
|
37
|
+
- [How install commands are executed](#how-install-commands-are-executed)
|
|
38
|
+
- [Library API](#library-api)
|
|
39
|
+
- [Worked examples](#worked-examples)
|
|
40
|
+
- [package.json integration](#packagejson-integration)
|
|
41
|
+
- [Gotchas](#gotchas)
|
|
42
|
+
- [Changelog](#changelog)
|
|
43
|
+
- [License](#license)
|
|
44
|
+
|
|
45
|
+
## What this is
|
|
46
|
+
|
|
47
|
+
`@workerstack/system` is a **system-binary** installer, not an npm/bun package installer.
|
|
48
|
+
|
|
49
|
+
You write `system.config.ts` listing tools such as `node`, `bun`, `docker`, `adb`. Each entry has:
|
|
50
|
+
|
|
51
|
+
- a version string
|
|
52
|
+
- per-OS install shell commands
|
|
53
|
+
- an optional version-check command
|
|
54
|
+
- optional `dependsOn` install order
|
|
55
|
+
- optional env-var blocks written into the user shell profile
|
|
56
|
+
|
|
57
|
+
Then:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
bunx system install # install missing deps for this OS
|
|
61
|
+
bunx system install --upgrade # also re-run installers on version mismatch
|
|
62
|
+
bunx system check # report installed vs expected
|
|
63
|
+
bunx system env # write env blocks for deps that define env
|
|
64
|
+
bunx system sync-pm # write package.json "packageManager": "bun@<version>"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
It does **not**:
|
|
68
|
+
|
|
69
|
+
- install npm/bun workspace packages
|
|
70
|
+
- rewrite PATH so a newly installed binary wins over brew/nvm/fnm (it warns)
|
|
71
|
+
- install `dependsOn` targets when you install a single name
|
|
72
|
+
- support JSON/YAML/TOML config (modules only: `.ts` / `.js` / `.mjs`)
|
|
73
|
+
- expose `env.ts` / `exec.ts` helpers as public API
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
| Need | Detail |
|
|
78
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
79
|
+
| Bun | CLI is `src/cli.ts` with `#!/usr/bin/env bun`. `npx system` / `bunx system` only work if `bun` is on `PATH`. |
|
|
80
|
+
| Config module | Loaded with dynamic `import()`. Must be valid ESM that Bun can import. |
|
|
81
|
+
| Privileges | Install commands run as-is. If a command uses `sudo` / `choco` / `brew`, the machine must allow that. |
|
|
82
|
+
| Network | Most catalog commands download installers. Offline machines will fail those commands. |
|
|
83
|
+
|
|
84
|
+
The library API (`defineConfig`, `detectOs`, …) can be imported from TypeScript that resolves `.ts` exports (Bun, or a bundler). There is no compiled `dist/`.
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm install -D @workerstack/system
|
|
90
|
+
# or
|
|
91
|
+
bun add -D @workerstack/system
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The bin name is `system`. After install:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
bunx system
|
|
98
|
+
# prints usage and exits 0 (no command)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Quick start
|
|
102
|
+
|
|
103
|
+
Create `system.config.ts` in the project root (the directory you will run the CLI from):
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { defineConfig } from "@workerstack/system"
|
|
107
|
+
|
|
108
|
+
export default defineConfig({
|
|
109
|
+
packageJsonPath: "package.json",
|
|
110
|
+
deps: {
|
|
111
|
+
node: {
|
|
112
|
+
description: "JavaScript runtime",
|
|
113
|
+
required: true,
|
|
114
|
+
version: "22.12.0",
|
|
115
|
+
os: {
|
|
116
|
+
macos:
|
|
117
|
+
"curl -fsSL https://nodejs.org/dist/v{{version}}/node-v{{version}}-darwin-{{arch}}.tar.gz | sudo tar -xz -C /usr/local --strip-components=1",
|
|
118
|
+
"linux-apt":
|
|
119
|
+
"curl -fsSL https://nodejs.org/dist/v{{version}}/node-v{{version}}-linux-x64.tar.gz | sudo tar -xz -C /usr/local --strip-components=1",
|
|
120
|
+
windows: "choco install nodejs --version={{version}}",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
bun: {
|
|
124
|
+
description: "JavaScript runtime and package manager",
|
|
125
|
+
required: true,
|
|
126
|
+
version: "1.3.11",
|
|
127
|
+
os: {
|
|
128
|
+
all: 'curl -fsSL https://bun.sh/install | bash -s "bun-v{{version}}"',
|
|
129
|
+
windows: 'powershell -c "irm bun.sh/install.ps1|iex" && bun upgrade --to {{version}}',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
bunx system install
|
|
138
|
+
bunx system install --upgrade
|
|
139
|
+
bunx system check
|
|
140
|
+
bunx system install --dry-run
|
|
141
|
+
bunx system sync-pm
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Agent contract
|
|
145
|
+
|
|
146
|
+
Follow these rules. They are the actual runtime, not suggestions.
|
|
147
|
+
|
|
148
|
+
1. Config files must be named `system.config.ts`, `system.config.js`, or `system.config.mjs` in `cwd`, or passed with `--config <path>`.
|
|
149
|
+
2. The file must export an object with a `deps` field. Default export is preferred. A named export also works if it has `deps`.
|
|
150
|
+
3. `defineConfig` is an identity helper that types the object and defaults `packageJsonPath` to `"package.json"`. You can export a plain object instead.
|
|
151
|
+
4. OS targets are exactly: `macos`, `linux-apt`, `linux-dnf`, `linux-pacman`, `windows`.
|
|
152
|
+
5. Install command resolution: per-OS string wins; else `os.all`; `false` means unavailable (overrides `all`); omitted key with no `all` means unavailable.
|
|
153
|
+
6. `{{name}}`, `{{version}}`, `{{major}}`, `{{arch}}` are interpolated in `os` commands and `check.command`. `{{arch}}` is `arm64` only when `process.arch === "arm64"`; every other arch is `amd64`.
|
|
154
|
+
7. Default check command is `{{name}} --version`. A dep is "installed" only if the check binary exists **and** stdout/stderr of the check command matches `(\d+\.\d+[\w.-]*)`.
|
|
155
|
+
8. `install` (no name) topologically sorts by `dependsOn`. `install <name>` installs **only** that name and does **not** walk `dependsOn`.
|
|
156
|
+
9. `install` skips when a detected version **matches** the pin (`latest` or substring either way). Mismatch without `--upgrade` skips, prints the resolved binary path, and hints `system install <name> --upgrade`. `--upgrade` re-runs the install command on mismatch only. After that command succeeds, if PATH still reports a non-matching version, print a shadow warning (do not fail). `--dry-run` never skips.
|
|
157
|
+
10. Failed **required** deps fail `install` (exit 1). Failed **optional** deps are counted as skipped.
|
|
158
|
+
11. Unavailable on this OS is not a failure.
|
|
159
|
+
12. `check` / `check <name>` snapshot PATH once from a new interactive TTY (bash: `$SHELL -ic` with stdin `/dev/null`, not login-only `-lc`; zsh: `$SHELL -lic`; Windows: `powershell.exe` **with** profile, `$env:Path`). The spawn env is a keep-list (HOME/SHELL/locale/TERM) plus a stock PATH — not the caller's PATH or leftover toolchain exports. `--here` uses this process PATH. Spawn failure or empty PATH → this process PATH and one warning (do not crash). `install` / `--upgrade` always use this process PATH.
|
|
160
|
+
13. `check` (all): missing **required** → exit 1. Version mismatch → warning, exit 0. Missing optional → warning, exit 0.
|
|
161
|
+
14. `check <name>`: missing → exit 1 even if the dep is optional. Unavailable → exit 0. Any detected version → exit 0 (no match check).
|
|
162
|
+
15. `sync-pm` only reads `deps.bun`. Skips if `bun` is absent or `version` is `"latest"`. Rewrites `package.json` by string replace, relative to the **config file directory**.
|
|
163
|
+
16. `env` writes `~/.zshrc` or `~/.bashrc` on Unix, and `Documents/PowerShell/Microsoft.PowerShell_profile.ps1` on Windows. Detected paths are written **unexpanded** (`$HOME/...`, not the resolved path).
|
|
164
|
+
17. On Windows, commands matching PowerShell markers run in `powershell.exe`. Everything else runs in `cmd.exe`. On Unix, commands run in `/bin/bash`.
|
|
165
|
+
18. `--os <target>` sets `WORKERSTACK_SYSTEM_OS` for the process. Invalid targets exit 1.
|
|
166
|
+
19. Unknown CLI command → print usage, exit 1. No command → print usage, exit 0.
|
|
167
|
+
20. Public library surface is only what `@workerstack/system` re-exports from `index.ts`. Do not import `./env.ts`, `./exec.ts`, or `./path.ts` from the package.
|
|
168
|
+
21. Circular `dependsOn` logs a warning and still installs every node once.
|
|
169
|
+
|
|
170
|
+
## Config file
|
|
171
|
+
|
|
172
|
+
### Discovery
|
|
173
|
+
|
|
174
|
+
The CLI looks at `process.cwd()`, not the config file's parent, to find a convention name.
|
|
175
|
+
|
|
176
|
+
Search order:
|
|
177
|
+
|
|
178
|
+
1. `--config <path>` (resolved with `path.resolve(cwd, path)`). Missing flag value → exit 1. File does not exist → exit 1.
|
|
179
|
+
2. First existing of:
|
|
180
|
+
- `system.config.ts`
|
|
181
|
+
- `system.config.js`
|
|
182
|
+
- `system.config.mjs`
|
|
183
|
+
3. None found → exit 1:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
No system config found. Create one of: system.config.ts, system.config.js, system.config.mjs
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
There is no `system.config.json`. There is no recursive walk up parent directories.
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# convention name in cwd
|
|
193
|
+
bunx system install
|
|
194
|
+
|
|
195
|
+
# explicit path (any filename, must exist)
|
|
196
|
+
bunx system install --config ./tooling/deps.ts
|
|
197
|
+
bunx system check --config /abs/path/system.config.ts
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Export shape
|
|
201
|
+
|
|
202
|
+
The loader `import()`s the file and accepts:
|
|
203
|
+
|
|
204
|
+
1. `export default defineConfig({ deps, packageJsonPath? })`
|
|
205
|
+
2. `export default { deps, packageJsonPath? }`
|
|
206
|
+
3. Any named export whose value is an object with a `deps` field (first such value in `Object.values` order)
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// preferred
|
|
210
|
+
export default defineConfig({ deps: {/* ... */} })
|
|
211
|
+
|
|
212
|
+
// also valid
|
|
213
|
+
export const config = defineConfig({ deps: {/* ... */} })
|
|
214
|
+
|
|
215
|
+
// also valid (no defineConfig)
|
|
216
|
+
export default {
|
|
217
|
+
deps: {
|
|
218
|
+
jq: {
|
|
219
|
+
description: "JSON processor",
|
|
220
|
+
required: false,
|
|
221
|
+
version: "1.8.1",
|
|
222
|
+
os: { all: "echo install jq" },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
If nothing exported has `deps`:
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
Config file must export an object with a `deps` field (use defineConfig)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
and the process exits 1.
|
|
235
|
+
|
|
236
|
+
### `defineConfig`
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
function defineConfig(options: { packageJsonPath?: string; deps: Record<string, SystemDepConfig> }): SystemConfig
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
| Field | Type | Default | Meaning |
|
|
243
|
+
| ----------------- | --------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
244
|
+
| `deps` | `Record<string, SystemDepConfig>` | required | Map of dep key → config. The key is `{{name}}`. |
|
|
245
|
+
| `packageJsonPath` | `string` | `"package.json"` | Path used by `sync-pm` and the `check` packageManager probe. **Relative to the config file's directory**, not `cwd`. |
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { defineConfig } from "@workerstack/system"
|
|
249
|
+
|
|
250
|
+
export default defineConfig({
|
|
251
|
+
packageJsonPath: "./apps/web/package.json",
|
|
252
|
+
deps: {
|
|
253
|
+
bun: {
|
|
254
|
+
description: "JavaScript runtime and package manager",
|
|
255
|
+
required: true,
|
|
256
|
+
version: "1.3.11",
|
|
257
|
+
os: { all: 'curl -fsSL https://bun.sh/install | bash -s "bun-v{{version}}"' },
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Calling `defineConfig` does not validate commands or OS keys. It returns `{ deps, packageJsonPath }` with the default filled in.
|
|
264
|
+
|
|
265
|
+
### `SystemDepConfig`
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
interface SystemDepConfig {
|
|
269
|
+
description: string
|
|
270
|
+
required: boolean
|
|
271
|
+
version: string
|
|
272
|
+
os: OsCommands
|
|
273
|
+
check?: { command: string }
|
|
274
|
+
dependsOn?: string[]
|
|
275
|
+
env?: EnvVar[]
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
| Field | Required | Example | Meaning |
|
|
280
|
+
| ------------- | -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
281
|
+
| `description` | yes | `"JSON processor"` | Printed by `install` / `check`. Not used for logic. |
|
|
282
|
+
| `required` | yes | `true` | If `true`, a failed `install` of this dep increments Failed and exits 1. If `false`, a failed install is counted as Skipped. Unused by single-target `check` (missing always exits 1). |
|
|
283
|
+
| `version` | yes | `"1.8.1"` or `"latest"` | Interpolated as `{{version}}`. `"latest"` makes `check` treat any detected version as OK, and makes `sync-pm` skip. |
|
|
284
|
+
| `os` | yes | `{ all: "brew install jq" }` | Per-OS install commands. See [OsCommands](#oscommands). |
|
|
285
|
+
| `check` | no | `{ command: "atlas version" }` | Version-check command. Default: `"{{name}} --version"`. Interpolates the same templates as `os`. |
|
|
286
|
+
| `dependsOn` | no | `["node", "npm"]` | Keys that must be installed **before** this one when running `install` with no name. Unknown keys are ignored. |
|
|
287
|
+
| `env` | no | `[{ key: "FOO", value: "bar" }]` | Written by `install` (after a successful install) and by `system env`. |
|
|
288
|
+
|
|
289
|
+
Every field together:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
stripe: {
|
|
293
|
+
description: "Stripe CLI",
|
|
294
|
+
required: true,
|
|
295
|
+
version: "1.21.0",
|
|
296
|
+
dependsOn: ["brew"],
|
|
297
|
+
check: { command: "stripe version" },
|
|
298
|
+
os: {
|
|
299
|
+
macos: "brew install stripe/stripe-cli/stripe",
|
|
300
|
+
"linux-apt": "curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg && sudo apt-get install -y stripe",
|
|
301
|
+
windows: "choco install stripe-cli --version={{version}}",
|
|
302
|
+
"linux-dnf": false,
|
|
303
|
+
"linux-pacman": false,
|
|
304
|
+
},
|
|
305
|
+
env: [{ key: "STRIPE_CLI_TELEMETRY_OPTOUT", value: "1" }],
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### `OsCommands`
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
type OsTarget = "linux-apt" | "linux-dnf" | "linux-pacman" | "macos" | "windows"
|
|
313
|
+
type OsCommands = Partial<Record<OsTarget, string | false>> & { all?: string }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`OS_TARGETS` (exported) is `["linux-apt", "linux-dnf", "linux-pacman", "macos", "windows"]`.
|
|
317
|
+
|
|
318
|
+
Resolution for a given target:
|
|
319
|
+
|
|
320
|
+
| `os[target]` | `os.all` | Result |
|
|
321
|
+
| ------------ | -------- | ------------------------- |
|
|
322
|
+
| string | anything | that string, interpolated |
|
|
323
|
+
| `false` | anything | unavailable (`null`) |
|
|
324
|
+
| omitted | string | `os.all`, interpolated |
|
|
325
|
+
| omitted | omitted | unavailable (`null`) |
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
// same command everywhere
|
|
329
|
+
os: { all: "npm install -g {{name}}@{{version}}" }
|
|
330
|
+
|
|
331
|
+
// same everywhere except Windows
|
|
332
|
+
os: {
|
|
333
|
+
all: 'curl -fsSL https://bun.sh/install | bash -s "bun-v{{version}}"',
|
|
334
|
+
windows: 'powershell -c "irm bun.sh/install.ps1|iex" && bun upgrade --to {{version}}',
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Linux only; macOS/Windows unavailable
|
|
338
|
+
os: {
|
|
339
|
+
"linux-apt": "sudo apt-get install -y {{name}}",
|
|
340
|
+
"linux-dnf": "sudo dnf install -y {{name}}",
|
|
341
|
+
"linux-pacman": "sudo pacman -S --noconfirm {{name}}",
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// available on Unix, explicitly not on Windows (overrides all)
|
|
345
|
+
os: {
|
|
346
|
+
all: "cargo install {{name}} --version {{version}} --locked",
|
|
347
|
+
windows: false,
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Unavailable is printed as:
|
|
352
|
+
|
|
353
|
+
```
|
|
354
|
+
name@version — not available on linux-apt
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
It is not a failure.
|
|
358
|
+
|
|
359
|
+
### Template variables
|
|
360
|
+
|
|
361
|
+
Replaced globally (`replace(/\{\{name\}\}/g, …)`) in every `os` command and in `check.command`.
|
|
362
|
+
|
|
363
|
+
| Token | Source | Example input | Example output |
|
|
364
|
+
| ------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- | -------------- |
|
|
365
|
+
| `{{name}}` | dep key | key `stripe-cli` | `stripe-cli` |
|
|
366
|
+
| `{{version}}` | `config.version` as-is | `"22.12.0"` | `22.12.0` |
|
|
367
|
+
| `{{major}}` | first `.` segment of `version`, or the whole string if there is no `.` | `"22.12.0"` → `22`; `"stable"` → `stable` |
|
|
368
|
+
| `{{arch}}` | `process.arch === "arm64" ? "arm64" : "amd64"` | Apple Silicon → `arm64`; `x64` / `ia32` / `arm` → `amd64` |
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
os: {
|
|
372
|
+
macos:
|
|
373
|
+
"curl -fsSL https://nodejs.org/dist/v{{version}}/node-v{{version}}-darwin-{{arch}}.tar.gz -o /tmp/node.tgz",
|
|
374
|
+
}
|
|
375
|
+
check: { command: "{{name}}-{{major}} --version" }
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
There are no other tokens. `{{os}}`, `{{home}}`, and env vars are not interpolated here. Put `$HOME` / `%USERPROFILE%` in the shell command itself if you need them at install time.
|
|
379
|
+
|
|
380
|
+
### `EnvVar`
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
interface EnvVar {
|
|
384
|
+
key: string
|
|
385
|
+
value?: string
|
|
386
|
+
appendToPath?: boolean
|
|
387
|
+
detect?: string[]
|
|
388
|
+
fallback?: string
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
| Field | Meaning |
|
|
393
|
+
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
394
|
+
| `key` | Variable name (`ANDROID_HOME`, `PATH`, …). For `appendToPath: true` the written line still uses PATH/`$env:Path`; `key` is only for logging. |
|
|
395
|
+
| `value` | Used when `detect` is absent or empty. Written as-is (not expanded by `system`). |
|
|
396
|
+
| `detect` | Candidate paths. First path that exists **after expansion** wins. The **original unexpanded** string is what gets written. |
|
|
397
|
+
| `fallback` | Used when every `detect` path is missing. Written as-is. |
|
|
398
|
+
| `appendToPath` | If true, append to PATH instead of `export KEY=value`. |
|
|
399
|
+
|
|
400
|
+
Path expansion for `detect` existence checks only substitutes:
|
|
401
|
+
|
|
402
|
+
| Token | Becomes |
|
|
403
|
+
| ---------------- | ---------------------------------- |
|
|
404
|
+
| `$HOME` | `os.homedir()` |
|
|
405
|
+
| `%USERPROFILE%` | `os.homedir()` (case-insensitive) |
|
|
406
|
+
| `%HOME%` | `os.homedir()` (case-insensitive) |
|
|
407
|
+
| `$ANDROID_HOME` | `process.env.ANDROID_HOME` or `""` |
|
|
408
|
+
| `%ANDROID_HOME%` | `process.env.ANDROID_HOME` or `""` |
|
|
409
|
+
|
|
410
|
+
No other `$VAR` / `%VAR%` tokens are expanded.
|
|
411
|
+
|
|
412
|
+
Resolution order per `EnvVar`:
|
|
413
|
+
|
|
414
|
+
1. If `detect` is a non-empty array: first existing expanded path → return the **unexpanded** detect string. Else if `fallback` is set → return `fallback`. Else skip this var (log, do not write).
|
|
415
|
+
2. Else return `value` or `null`.
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
env: [
|
|
419
|
+
{
|
|
420
|
+
key: "ANDROID_HOME",
|
|
421
|
+
detect: ["$HOME/Android/sdk", "$HOME/Library/Android/sdk", "/usr/lib/android-sdk"],
|
|
422
|
+
fallback: "$HOME/Android/sdk",
|
|
423
|
+
},
|
|
424
|
+
{ key: "PATH", appendToPath: true, value: "$ANDROID_HOME/platform-tools" },
|
|
425
|
+
{ key: "PATH", appendToPath: true, value: "$ANDROID_HOME/emulator" },
|
|
426
|
+
]
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
If `$HOME/Library/Android/sdk` exists, the profile gets `export ANDROID_HOME="$HOME/Library/Android/sdk"` (the detect string), not the resolved `/Users/you/Library/Android/sdk`.
|
|
430
|
+
|
|
431
|
+
Written blocks are wrapped in markers and replaced on the next run:
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
# android-sdk environment (managed by @workerstack/system)
|
|
435
|
+
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
|
436
|
+
export PATH="$PATH:$ANDROID_HOME/platform-tools"
|
|
437
|
+
# end android-sdk environment
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
PowerShell 7:
|
|
441
|
+
|
|
442
|
+
```powershell
|
|
443
|
+
# android-sdk environment (managed by @workerstack/system)
|
|
444
|
+
$env:ANDROID_HOME = "$HOME/Library/Android/sdk"
|
|
445
|
+
$env:Path += ";$ANDROID_HOME/platform-tools"
|
|
446
|
+
# end android-sdk environment
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Target files:
|
|
450
|
+
|
|
451
|
+
| Platform | File |
|
|
452
|
+
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
453
|
+
| Windows | `{homedir}/Documents/PowerShell/Microsoft.PowerShell_profile.ps1` (PowerShell 7, not Windows PowerShell 5 `WindowsPowerShell`) |
|
|
454
|
+
| Unix and `process.env.SHELL` contains `zsh` | `{homedir}/.zshrc` |
|
|
455
|
+
| Other Unix | `{homedir}/.bashrc` |
|
|
456
|
+
|
|
457
|
+
After writing, the CLI prints:
|
|
458
|
+
|
|
459
|
+
- Unix: `Run: source ~/.bashrc` (or `.zshrc`)
|
|
460
|
+
- Windows: `Restart PowerShell or run: . $PROFILE`
|
|
461
|
+
|
|
462
|
+
`install` writes env only after a **successful** install of that dep (not on skip, not on dry-run). `system env` writes every dep that has `env`, regardless of install state.
|
|
463
|
+
|
|
464
|
+
## CLI
|
|
465
|
+
|
|
466
|
+
```
|
|
467
|
+
Usage: system <command> [args]
|
|
468
|
+
|
|
469
|
+
Commands:
|
|
470
|
+
install Install all deps (auto-detect OS)
|
|
471
|
+
install <name> Install single dep
|
|
472
|
+
check Check all deps
|
|
473
|
+
check <name> Check single dep
|
|
474
|
+
env Setup environment variables
|
|
475
|
+
sync-pm Sync packageManager field in package.json
|
|
476
|
+
|
|
477
|
+
Flags:
|
|
478
|
+
--config <path> Config file (default: system.config.ts in cwd)
|
|
479
|
+
--os <target> Force OS target (or set WORKERSTACK_SYSTEM_OS)
|
|
480
|
+
--dry-run Print install commands without running them
|
|
481
|
+
--upgrade Re-run install when the detected version does not match
|
|
482
|
+
--here check: use this process PATH instead of an interactive-shell snapshot
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Invocation and flags
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
bunx system <command> [name] [--config <path>] [--os <target>] [--dry-run] [--upgrade] [--here]
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Flags may appear before or after the command. Each flag is stripped once (first occurrence).
|
|
492
|
+
|
|
493
|
+
| Flag / env | Applies to | Behavior |
|
|
494
|
+
| ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
495
|
+
| `--config <path>` | all commands | Required path argument. Must exist. |
|
|
496
|
+
| `--os <target>` | all commands | Must be one of `OS_TARGETS`. Sets `process.env.WORKERSTACK_SYSTEM_OS`. Missing value or unknown target → exit 1. |
|
|
497
|
+
| `WORKERSTACK_SYSTEM_OS` | all commands | Same as `--os` when `--os` is not passed. |
|
|
498
|
+
| `--dry-run` | `install` only | Prints `dry-run: <command>` and counts the dep as installed. Silently ignored by `check` / `env` / `sync-pm`. |
|
|
499
|
+
| `--upgrade` | `install` only | Re-run the install command when the detected version does **not** match the pin. Matching versions still skip. Ignored by `check` / `env` / `sync-pm`. |
|
|
500
|
+
| `--here` | `check` only | Use this process PATH instead of an interactive-shell snapshot. Ignored by `install` / `env` / `sync-pm`. |
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
bunx system install
|
|
504
|
+
bunx system install node
|
|
505
|
+
bunx system install --upgrade
|
|
506
|
+
bunx system install node --upgrade
|
|
507
|
+
bunx system install --dry-run
|
|
508
|
+
bunx system install node --os windows --dry-run
|
|
509
|
+
bunx system --config ./deps.ts --os linux-dnf check bun
|
|
510
|
+
bunx system check --here
|
|
511
|
+
bunx system check node --here
|
|
512
|
+
WORKERSTACK_SYSTEM_OS=macos bunx system install --dry-run
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Unknown command:
|
|
516
|
+
|
|
517
|
+
```bash
|
|
518
|
+
bunx system foo
|
|
519
|
+
# prints USAGE
|
|
520
|
+
# Unknown command: foo
|
|
521
|
+
# exit 1
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
No command:
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
bunx system
|
|
528
|
+
# prints USAGE
|
|
529
|
+
# exit 0
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
`--os` without a value:
|
|
533
|
+
|
|
534
|
+
```bash
|
|
535
|
+
bunx system install --os
|
|
536
|
+
# --os requires a target argument
|
|
537
|
+
# exit 1
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
`--os freebsd`:
|
|
541
|
+
|
|
542
|
+
```
|
|
543
|
+
Unknown OS target: freebsd. Expected one of: linux-apt, linux-dnf, linux-pacman, macos, windows
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
exit 1.
|
|
547
|
+
|
|
548
|
+
### `install`
|
|
549
|
+
|
|
550
|
+
**All deps** (`system install`):
|
|
551
|
+
|
|
552
|
+
1. Detect OS.
|
|
553
|
+
2. Print every dep with `[required]` / `[optional]` and mark those with no command as `(not available on <os>)`.
|
|
554
|
+
3. Sort with `sortByDependencies` (`dependsOn` first).
|
|
555
|
+
4. For each dep, run the [single-dep steps](#single-dep-steps) below.
|
|
556
|
+
5. Print summary: Installed / Skipped / Unavailable / Failed.
|
|
557
|
+
6. Exit 1 if Failed > 0.
|
|
558
|
+
|
|
559
|
+
**One dep** (`system install node`):
|
|
560
|
+
|
|
561
|
+
1. Unknown name → `Unknown dependency: node` plus `Available: …`, exit 1.
|
|
562
|
+
2. Run the single-dep steps. Do **not** install `dependsOn` first.
|
|
563
|
+
3. Exit 1 only if that dep **failed**. Unavailable and skipped exit 0.
|
|
564
|
+
|
|
565
|
+
#### Single-dep steps
|
|
566
|
+
|
|
567
|
+
1. Resolve the install command for the detected OS. None → log unavailable, return.
|
|
568
|
+
2. If `--dry-run` → print `dry-run: <command>`, return **installed** (no check, no exec, no env write).
|
|
569
|
+
3. If [version detection](#version-detection) returns a string that **matches** the pin → print `Already installed (<ver>), skipping`, return skipped.
|
|
570
|
+
4. If a version is detected but does **not** match, and `--upgrade` is off → print `Already installed (<ver>), skipping (expected <pin>)` plus the resolved binary path and `run: system install <name> --upgrade`, return skipped.
|
|
571
|
+
5. Run the command. Success → print `Installed successfully`, then if `env` is non-empty run the env writer. Re-check the version; if PATH still does not match, print a shadow warning. Return installed.
|
|
572
|
+
6. Failure → print `Installation failed`. Required → failed. Optional → skipped.
|
|
573
|
+
|
|
574
|
+
Matching uses the same table as `check`. `--upgrade` does not re-run a dep whose detected version already matches.
|
|
575
|
+
|
|
576
|
+
### `check`
|
|
577
|
+
|
|
578
|
+
PATH for version probes is snapshotted **once** per `check` invocation:
|
|
579
|
+
|
|
580
|
+
- Default: interactive TTY PATH (bash: `$SHELL -ic` with stdin `/dev/null`; zsh: `$SHELL -lic`; Windows: `powershell.exe` with profile, `$env:Path`). Spawn env is a keep-list (HOME/SHELL/locale/TERM) plus a stock PATH, not the caller's PATH or leftover toolchain exports.
|
|
581
|
+
- `--here`: this process PATH.
|
|
582
|
+
- Spawn failure or empty PATH: this process PATH, one warning, do not crash.
|
|
583
|
+
|
|
584
|
+
`install` does not use this snapshot.
|
|
585
|
+
|
|
586
|
+
**All deps** (`system check`):
|
|
587
|
+
|
|
588
|
+
For each dep in `Object.entries` order (not topo-sorted):
|
|
589
|
+
|
|
590
|
+
| Situation | Line | Counter |
|
|
591
|
+
| -------------------------------- | ------------------------------------------------------- | -------- |
|
|
592
|
+
| No install command on this OS | `- [required] name — not available on <os>` | none |
|
|
593
|
+
| Check found no version, required | `x [required] name — not installed (expected <ver>)` | Missing |
|
|
594
|
+
| Check found no version, optional | `x [optional] name — not installed (expected <ver>)` | Mismatch |
|
|
595
|
+
| Version matches (see below) | `v [required] name@<installed>` | OK |
|
|
596
|
+
| Version does not match | `~ [required] name@<installed> (expected <ver>) <path>` | Mismatch |
|
|
597
|
+
|
|
598
|
+
A version **matches** if any of these is true:
|
|
599
|
+
|
|
600
|
+
- `config.version === "latest"`
|
|
601
|
+
- `installed === config.version`
|
|
602
|
+
- `config.version.includes(installed)`
|
|
603
|
+
- `installed.includes(config.version)`
|
|
604
|
+
|
|
605
|
+
Substring either way is intentional so `1.0.1` matches `v1.0.1-1c2aa24-canary` and the reverse.
|
|
606
|
+
|
|
607
|
+
Then, if `deps.bun` exists, `check` also probes `package.json` at `resolve(configDir, packageJsonPath)`:
|
|
608
|
+
|
|
609
|
+
| `deps.bun.version` | `packageManager` | Result |
|
|
610
|
+
| ------------------ | ----------------------- | -------- |
|
|
611
|
+
| `"latest"` | starts with `bun@` | OK line |
|
|
612
|
+
| `"latest"` | anything else / missing | Mismatch |
|
|
613
|
+
| other | exactly `bun@<version>` | OK line |
|
|
614
|
+
| other | anything else / missing | Mismatch |
|
|
615
|
+
|
|
616
|
+
Missing `package.json` throws (fatal, exit 1).
|
|
617
|
+
|
|
618
|
+
Summary:
|
|
619
|
+
|
|
620
|
+
```
|
|
621
|
+
OK: N Mismatch: N Missing: N
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
- Missing > 0 → `Required dependencies missing — run: system install`, exit 1.
|
|
625
|
+
- Mismatch > 0 → `Version mismatches found — run: system install --upgrade`, exit 0.
|
|
626
|
+
- Else → `All system dependencies OK`, exit 0.
|
|
627
|
+
|
|
628
|
+
**One dep** (`system check bun`):
|
|
629
|
+
|
|
630
|
+
| Situation | Output | Exit |
|
|
631
|
+
| ---------------------- | ----------------------------------------- | ---- |
|
|
632
|
+
| Unknown name | `Unknown dependency: …` | 1 |
|
|
633
|
+
| Unavailable on this OS | `bun — not available on <os>` | 0 |
|
|
634
|
+
| No version detected | `bun — not installed (expected <ver>)` | 1 |
|
|
635
|
+
| Any version detected | `bun@<installed> (expected <ver>) <path>` | 0 |
|
|
636
|
+
|
|
637
|
+
Single-target check does **not** apply the match table. Any parsed version is success, even if it disagrees with `config.version`. Single-target check does **not** honor `required: false` for the missing case.
|
|
638
|
+
|
|
639
|
+
### `env`
|
|
640
|
+
|
|
641
|
+
```bash
|
|
642
|
+
bunx system env
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Walks every dep that has a non-empty `env` array and writes/replaces that tool's managed block. Does not install anything.
|
|
646
|
+
|
|
647
|
+
If no dep has `env`:
|
|
648
|
+
|
|
649
|
+
```
|
|
650
|
+
No dependencies with environment variables configured.
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
exit 0.
|
|
654
|
+
|
|
655
|
+
### `sync-pm`
|
|
656
|
+
|
|
657
|
+
```bash
|
|
658
|
+
bunx system sync-pm
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Only uses `deps.bun`.
|
|
662
|
+
|
|
663
|
+
| Condition | Behavior | Exit |
|
|
664
|
+
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
665
|
+
| No `deps.bun` | `Skipping packageManager sync — bun not in config` | 0 |
|
|
666
|
+
| `deps.bun.version === "latest"` | `Skipping packageManager sync — bun version is "latest"` | 0 |
|
|
667
|
+
| File already has `"packageManager": "bun@<version>"` | `packageManager already correct: bun@<version>` | 0 |
|
|
668
|
+
| File has a different `packageManager` string | String-replaces `"packageManager": "<current>"` with `"packageManager": "bun@<version>"` | 0 |
|
|
669
|
+
| File has no `packageManager` | Injects `,\n\t"packageManager": "bun@<version>"` immediately after `"name": "<pkg.name>"` | 0 |
|
|
670
|
+
| Read/parse/write throws | `Failed to sync packageManager:` + error | 0 from `cmdSyncPm` (the function returns false; `main` does not exit 1) |
|
|
671
|
+
|
|
672
|
+
`packageJsonPath` is resolved from the **directory that contains the config file**.
|
|
673
|
+
|
|
674
|
+
This is a string edit, not a JSON rewrite. It expects the current value to appear exactly as `"packageManager": "<current>"`. Unusual formatting (single quotes, extra spaces) will not match; the inject-after-name path only runs when `packageManager` is missing from the parsed object.
|
|
675
|
+
|
|
676
|
+
`sync-pm` never writes a `node@` packageManager. Only `bun@<version>`.
|
|
677
|
+
|
|
678
|
+
### Version detection
|
|
679
|
+
|
|
680
|
+
Used by `install` (skip if present, **this process PATH**) and `check` (interactive TTY PATH snapshot, or this process PATH with `--here`).
|
|
681
|
+
|
|
682
|
+
```
|
|
683
|
+
checkCmd = resolveCheckCommand(name, config)
|
|
684
|
+
= interpolate(config.check?.command ?? "{{name}} --version", name, version)
|
|
685
|
+
|
|
686
|
+
binary = first space-separated token of checkCmd
|
|
687
|
+
if binary is set and commandExists(binary) is false → not installed (null)
|
|
688
|
+
|
|
689
|
+
run checkCmd via execSync
|
|
690
|
+
stdout+stderr must match /(\d+\.\d+[\w.-]*)/
|
|
691
|
+
first match → that string
|
|
692
|
+
no match, non-zero exit, or throw → null
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
`commandExists`:
|
|
696
|
+
|
|
697
|
+
1. Strip one pair of surrounding quotes from the token.
|
|
698
|
+
2. If that path `existsSync`, true.
|
|
699
|
+
3. Else run `command -v <token>` (Unix) or `where <token>` (Windows).
|
|
700
|
+
|
|
701
|
+
Implications:
|
|
702
|
+
|
|
703
|
+
- Check command `echo android-studio` finds `echo` on PATH, prints `android-studio`, regex misses, result is **not installed**. The check output must contain something like `1.2` / `22.12.0` / `1.0.0-beta`.
|
|
704
|
+
- Check command `"/usr/local/bin/node" --version` works because `commandExists` accepts an existing path.
|
|
705
|
+
- A tool that prints only `stable` or `ok` is treated as missing.
|
|
706
|
+
|
|
707
|
+
### Exit codes
|
|
708
|
+
|
|
709
|
+
| Situation | Exit |
|
|
710
|
+
| ----------------------------------------------------- | ---------------------------------------------------------- |
|
|
711
|
+
| `system` with no command | 0 |
|
|
712
|
+
| Unknown command | 1 |
|
|
713
|
+
| `--config` / `--os` missing value or bad `--os` | 1 |
|
|
714
|
+
| Config file missing or no `deps` export | 1 |
|
|
715
|
+
| `install` / `check` unknown dep name | 1 |
|
|
716
|
+
| `install` required dep command failed | 1 |
|
|
717
|
+
| `install` optional dep command failed | 0 (counted skipped) |
|
|
718
|
+
| `install` dep unavailable | 0 |
|
|
719
|
+
| `install --dry-run` | 0 |
|
|
720
|
+
| `check` all, required missing | 1 |
|
|
721
|
+
| `check` all, only version / packageManager mismatches | 0 |
|
|
722
|
+
| `check <name>` missing (even optional) | 1 |
|
|
723
|
+
| `check <name>` unavailable | 0 |
|
|
724
|
+
| `env` / `sync-pm` | 0 (sync-pm file errors are logged, not turned into exit 1) |
|
|
725
|
+
| Uncaught exception | 1 (`Fatal error:`) |
|
|
726
|
+
|
|
727
|
+
## OS detection
|
|
728
|
+
|
|
729
|
+
```ts
|
|
730
|
+
detectOs((override = process.env.WORKERSTACK_SYSTEM_OS))
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
| Input | Result |
|
|
734
|
+
| ------------------------------------------------- | ------------------------------- |
|
|
735
|
+
| `override` / `WORKERSTACK_SYSTEM_OS` / `--os` set | `parseOsTarget(value)` or throw |
|
|
736
|
+
| `process.platform === "darwin"` | `macos` |
|
|
737
|
+
| `process.platform === "win32"` | `windows` |
|
|
738
|
+
| Linux and `apt-get` on PATH | `linux-apt` |
|
|
739
|
+
| Linux and `dnf` on PATH | `linux-dnf` |
|
|
740
|
+
| Linux and `pacman` on PATH | `linux-pacman` |
|
|
741
|
+
| Linux and none of those | `linux-apt` (default) |
|
|
742
|
+
|
|
743
|
+
`--os` is implemented by assigning `process.env.WORKERSTACK_SYSTEM_OS` before any detect call.
|
|
744
|
+
|
|
745
|
+
```bash
|
|
746
|
+
# force the Windows command set while sitting on Linux (does not boot Windows)
|
|
747
|
+
bunx system install --os windows --dry-run
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
`--os` / `WORKERSTACK_SYSTEM_OS` do **not** change `process.platform`. Env file paths and the PowerShell-vs-cmd router still follow the real kernel. Use `--os` to select which `os.*` command string is resolved, not to emulate another OS's shell.
|
|
751
|
+
|
|
752
|
+
## How install commands are executed
|
|
753
|
+
|
|
754
|
+
Unix (`linux-*`, and also when you force `--os windows` from Linux):
|
|
755
|
+
|
|
756
|
+
```
|
|
757
|
+
execSync(command, { shell: "/bin/bash", stdio: "inherit" })
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
Multi-line scripts and `&&` / pipes work because the shell is bash.
|
|
761
|
+
|
|
762
|
+
Windows, **if** the command matches any PowerShell marker:
|
|
763
|
+
|
|
764
|
+
```
|
|
765
|
+
powershell.exe -NoProfile -NonInteractive -Command <command>
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
Markers (any one is enough):
|
|
769
|
+
|
|
770
|
+
| Marker | Example that trips it |
|
|
771
|
+
| ------------------- | ---------------------------------------------------------------- |
|
|
772
|
+
| `\birm\b` | `irm bun.sh/install.ps1 \| iex` |
|
|
773
|
+
| `\biex\b` | same |
|
|
774
|
+
| `$env:` | `Invoke-WebRequest … -OutFile "$env:LOCALAPPDATA\bin\atlas.exe"` |
|
|
775
|
+
| `Invoke-WebRequest` | official Windows binary downloads |
|
|
776
|
+
| `Invoke-Expression` | |
|
|
777
|
+
| `New-Item` | `New-Item -ItemType Directory -Force …` |
|
|
778
|
+
| `Test-Path` | |
|
|
779
|
+
| `Out-Null` | `… \| Out-Null` |
|
|
780
|
+
| `Get-Command` | |
|
|
781
|
+
| `$LASTEXITCODE` | |
|
|
782
|
+
|
|
783
|
+
Windows, otherwise:
|
|
784
|
+
|
|
785
|
+
```
|
|
786
|
+
execSync(command, { shell: process.env.ComSpec || "cmd.exe", stdio: "inherit" })
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
so `choco install nginx || choco install nginx` and `bun -e "…"` keep working. `||` is **invalid** in PowerShell; do not add `$env:` / `Out-Null` to a cmd-oriented command or it will be routed to PowerShell and break.
|
|
790
|
+
|
|
791
|
+
`stdio: "inherit"` means the user sees the installer output live.
|
|
792
|
+
|
|
793
|
+
## Library API
|
|
794
|
+
|
|
795
|
+
This is the entire public surface (`src/index.ts`):
|
|
796
|
+
|
|
797
|
+
```ts
|
|
798
|
+
export type { SystemConfig, EnvVar, OsCommands, OsTarget, SystemDepConfig }
|
|
799
|
+
export { defineConfig, interpolate, OS_TARGETS, resolveCheckCommand, resolveOsCommand, versionsMatch }
|
|
800
|
+
export { sortByDependencies }
|
|
801
|
+
export { commandExists, commandLookup, detectOs, detectOsFromPlatform, parseOsTarget, whichBinary }
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
### `OS_TARGETS`
|
|
805
|
+
|
|
806
|
+
```ts
|
|
807
|
+
const OS_TARGETS = ["linux-apt", "linux-dnf", "linux-pacman", "macos", "windows"] as const
|
|
808
|
+
type OsTarget = (typeof OS_TARGETS)[number]
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### `defineConfig(options)`
|
|
812
|
+
|
|
813
|
+
See [defineConfig](#defineconfig). Returns the same object with `packageJsonPath` defaulted.
|
|
814
|
+
|
|
815
|
+
### `interpolate(template, name, version)`
|
|
816
|
+
|
|
817
|
+
```ts
|
|
818
|
+
interpolate("{{name}}@{{version}} ({{major}}) {{arch}}", "node", "22.12.0")
|
|
819
|
+
// "node@22.12.0 (22) arm64" or "… amd64"
|
|
820
|
+
interpolate("{{major}}", "rust", "stable")
|
|
821
|
+
// "stable"
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
### `resolveOsCommand(name, config, target)`
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
resolveOsCommand(
|
|
828
|
+
"git",
|
|
829
|
+
{
|
|
830
|
+
description: "git",
|
|
831
|
+
required: true,
|
|
832
|
+
version: "2.39.5",
|
|
833
|
+
os: { all: "echo all", "linux-apt": "apt install git={{version}}" },
|
|
834
|
+
},
|
|
835
|
+
"linux-apt",
|
|
836
|
+
)
|
|
837
|
+
// "apt install git=2.39.5"
|
|
838
|
+
|
|
839
|
+
resolveOsCommand("flux", {/* os: { all: "…", windows: false } */}, "windows")
|
|
840
|
+
// null
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
### `resolveCheckCommand(name, config)`
|
|
844
|
+
|
|
845
|
+
```ts
|
|
846
|
+
resolveCheckCommand("node", {/* no check */})
|
|
847
|
+
// "node --version"
|
|
848
|
+
|
|
849
|
+
resolveCheckCommand("atlas", { check: { command: "{{name}} version {{version}}" }, version: "1.2.3" /* … */ })
|
|
850
|
+
// "atlas version 1.2.3"
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
### `sortByDependencies(entries)`
|
|
854
|
+
|
|
855
|
+
```ts
|
|
856
|
+
sortByDependencies([
|
|
857
|
+
["npm", { dependsOn: ["node"] /* … */ }],
|
|
858
|
+
["node", {/* … */}],
|
|
859
|
+
])
|
|
860
|
+
// [["node", …], ["npm", …]]
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
- Walks `dependsOn` depth-first.
|
|
864
|
+
- Names not present in the input set are ignored.
|
|
865
|
+
- Cycles: `console.warn("Circular dependency detected involving: <name>")`, then both nodes still appear once.
|
|
866
|
+
|
|
867
|
+
### `parseOsTarget(value)`
|
|
868
|
+
|
|
869
|
+
```ts
|
|
870
|
+
parseOsTarget("macos") // "macos"
|
|
871
|
+
parseOsTarget("freebsd") // throws Error("Unknown OS target: freebsd. Expected one of: …")
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
### `detectOsFromPlatform(platform, override?)`
|
|
875
|
+
|
|
876
|
+
```ts
|
|
877
|
+
detectOsFromPlatform("darwin") // "macos"
|
|
878
|
+
detectOsFromPlatform("win32") // "windows"
|
|
879
|
+
detectOsFromPlatform("linux", "linux-dnf") // "linux-dnf"
|
|
880
|
+
detectOsFromPlatform("linux") // linux-apt / linux-dnf / linux-pacman / linux-apt default
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
### `detectOs(override?)`
|
|
884
|
+
|
|
885
|
+
`detectOsFromPlatform(process.platform, override ?? process.env.WORKERSTACK_SYSTEM_OS)`.
|
|
886
|
+
|
|
887
|
+
### `commandLookup(command, platform = process.platform)`
|
|
888
|
+
|
|
889
|
+
```ts
|
|
890
|
+
commandLookup("bun", "win32") // "where bun >nul 2>&1"
|
|
891
|
+
commandLookup("bun", "linux") // "command -v bun >/dev/null 2>&1"
|
|
892
|
+
commandLookup("bun", "darwin") // "command -v bun >/dev/null 2>&1"
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
### `commandExists(command)`
|
|
896
|
+
|
|
897
|
+
`true` if the (optionally quoted) path exists on disk, or if `commandLookup` succeeds.
|
|
898
|
+
|
|
899
|
+
```ts
|
|
900
|
+
commandExists("sh") // true on Unix
|
|
901
|
+
commandExists("/usr/local/bin/node")
|
|
902
|
+
commandExists('"/usr/local/bin/node"')
|
|
903
|
+
commandExists("system-not-real") // false
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
### `versionsMatch(installed, expected)`
|
|
907
|
+
|
|
908
|
+
```ts
|
|
909
|
+
versionsMatch("24.19.0", "24.19.0") // true
|
|
910
|
+
versionsMatch("1.0.1", "v1.0.1-canary") // true
|
|
911
|
+
versionsMatch("25.5.0", "24.19.0") // false
|
|
912
|
+
versionsMatch("1.3.11", "latest") // true
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
### `whichBinary(command, platform?)`
|
|
916
|
+
|
|
917
|
+
```ts
|
|
918
|
+
whichBinary("sh") // "/usr/bin/sh"
|
|
919
|
+
whichBinary("system-not-real") // null
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
## Worked examples
|
|
923
|
+
|
|
924
|
+
### Optional dep
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
jq: {
|
|
928
|
+
description: "JSON processor",
|
|
929
|
+
required: false,
|
|
930
|
+
version: "1.8.1",
|
|
931
|
+
os: {
|
|
932
|
+
macos: "brew install jq",
|
|
933
|
+
"linux-apt": "sudo apt-get install -y jq",
|
|
934
|
+
windows: "choco install jq --version={{version}}",
|
|
935
|
+
},
|
|
936
|
+
}
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
If the command fails, `install` continues. `check` reports a mismatch, exit 0. `check jq` with jq missing still exits 1.
|
|
940
|
+
|
|
941
|
+
### Custom check command
|
|
942
|
+
|
|
943
|
+
```ts
|
|
944
|
+
atlas: {
|
|
945
|
+
description: "Database schema management tool",
|
|
946
|
+
required: true,
|
|
947
|
+
version: "v1.0.1-1c2aa24-canary",
|
|
948
|
+
check: { command: "atlas version" },
|
|
949
|
+
os: {
|
|
950
|
+
all: "curl -sSf https://atlasgo.sh | sh -s -- --version {{version}}",
|
|
951
|
+
windows:
|
|
952
|
+
'New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\\bin" | Out-Null; Invoke-WebRequest -UseBasicParsing https://release.ariga.io/atlas/atlas-windows-amd64-latest.exe -OutFile "$env:LOCALAPPDATA\\bin\\atlas.exe"',
|
|
953
|
+
},
|
|
954
|
+
}
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
Default `atlas --version` would be wrong. `check.command` must still print a `digits.digits` token or install will never skip.
|
|
958
|
+
|
|
959
|
+
### `dependsOn`
|
|
960
|
+
|
|
961
|
+
```ts
|
|
962
|
+
node: { /* … */ version: "22.12.0", os: { all: "…" } },
|
|
963
|
+
npm: {
|
|
964
|
+
description: "npm CLI",
|
|
965
|
+
required: true,
|
|
966
|
+
version: "10.9.0",
|
|
967
|
+
dependsOn: ["node"],
|
|
968
|
+
os: { all: "npm install -g npm@{{version}}" },
|
|
969
|
+
},
|
|
970
|
+
claude: {
|
|
971
|
+
description: "Claude Code CLI",
|
|
972
|
+
required: true,
|
|
973
|
+
version: "latest",
|
|
974
|
+
dependsOn: ["npm"],
|
|
975
|
+
os: { all: "npm install -g @anthropic-ai/claude-code@{{version}}" },
|
|
976
|
+
},
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
`system install` order: `node` → `npm` → `claude`.
|
|
980
|
+
|
|
981
|
+
`system install claude` runs only the claude command. Install `node` and `npm` first, or run the full `install`.
|
|
982
|
+
|
|
983
|
+
### `version: "latest"`
|
|
984
|
+
|
|
985
|
+
```ts
|
|
986
|
+
claude: {
|
|
987
|
+
description: "Claude Code CLI",
|
|
988
|
+
required: true,
|
|
989
|
+
version: "latest",
|
|
990
|
+
os: { all: "npm install -g @anthropic-ai/claude-code@{{version}}" },
|
|
991
|
+
}
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
- Install command becomes `…@latest`.
|
|
995
|
+
- If any version is already detected, install skips (will not refresh to a newer latest).
|
|
996
|
+
- `check` counts any detected version as OK.
|
|
997
|
+
- If this were `bun`, `sync-pm` would skip.
|
|
998
|
+
|
|
999
|
+
### Env detect + PATH append
|
|
1000
|
+
|
|
1001
|
+
```ts
|
|
1002
|
+
"android-sdk": {
|
|
1003
|
+
description: "Android SDK",
|
|
1004
|
+
required: true,
|
|
1005
|
+
version: "35.0.0",
|
|
1006
|
+
check: { command: "adb --version" },
|
|
1007
|
+
os: { macos: "brew install --cask android-commandlinetools" },
|
|
1008
|
+
env: [
|
|
1009
|
+
{
|
|
1010
|
+
key: "ANDROID_HOME",
|
|
1011
|
+
detect: ["$HOME/Library/Android/sdk", "$HOME/Android/sdk"],
|
|
1012
|
+
fallback: "$HOME/Library/Android/sdk",
|
|
1013
|
+
},
|
|
1014
|
+
{ key: "PATH", appendToPath: true, value: "$ANDROID_HOME/platform-tools" },
|
|
1015
|
+
],
|
|
1016
|
+
}
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
```bash
|
|
1020
|
+
bunx system env
|
|
1021
|
+
# writes ~/.zshrc or ~/.bashrc
|
|
1022
|
+
source ~/.zshrc
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
### Unavailable on one OS
|
|
1026
|
+
|
|
1027
|
+
```ts
|
|
1028
|
+
flux: {
|
|
1029
|
+
description: "InfluxDB CLI",
|
|
1030
|
+
required: false,
|
|
1031
|
+
version: "2.7.11",
|
|
1032
|
+
os: {
|
|
1033
|
+
macos: "brew install influxdb",
|
|
1034
|
+
windows: 'Invoke-WebRequest … -OutFile "$env:LOCALAPPDATA\\bin\\flux.exe"',
|
|
1035
|
+
"linux-apt": false,
|
|
1036
|
+
"linux-dnf": false,
|
|
1037
|
+
"linux-pacman": false,
|
|
1038
|
+
},
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
On Ubuntu: `flux@2.7.11 — not available on linux-apt`. Install and check succeed.
|
|
1043
|
+
|
|
1044
|
+
### Dry-run on another OS command set
|
|
1045
|
+
|
|
1046
|
+
```bash
|
|
1047
|
+
bunx system install --os windows --dry-run
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
Prints the Windows command strings. Does not run them. Does not write env. Does not skip already-installed deps.
|
|
1051
|
+
|
|
1052
|
+
### Named export and custom config path
|
|
1053
|
+
|
|
1054
|
+
```ts
|
|
1055
|
+
// tooling/system-deps.ts
|
|
1056
|
+
import { defineConfig } from "@workerstack/system"
|
|
1057
|
+
export const systemDeps = defineConfig({
|
|
1058
|
+
packageJsonPath: "../package.json",
|
|
1059
|
+
deps: { bun: {/* … */} },
|
|
1060
|
+
})
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
```bash
|
|
1064
|
+
bunx system install --config ./tooling/system-deps.ts
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
`sync-pm` will edit `tooling/../package.json`.
|
|
1068
|
+
|
|
1069
|
+
### Library-only use (no CLI)
|
|
1070
|
+
|
|
1071
|
+
```ts
|
|
1072
|
+
import {
|
|
1073
|
+
defineConfig,
|
|
1074
|
+
detectOs,
|
|
1075
|
+
interpolate,
|
|
1076
|
+
resolveCheckCommand,
|
|
1077
|
+
resolveOsCommand,
|
|
1078
|
+
sortByDependencies,
|
|
1079
|
+
} from "@workerstack/system"
|
|
1080
|
+
|
|
1081
|
+
const config = defineConfig({
|
|
1082
|
+
deps: {
|
|
1083
|
+
jq: {
|
|
1084
|
+
description: "JSON processor",
|
|
1085
|
+
required: false,
|
|
1086
|
+
version: "1.8.1",
|
|
1087
|
+
os: { all: "brew install jq" },
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
const os = detectOs()
|
|
1093
|
+
const command = resolveOsCommand("jq", config.deps.jq, os)
|
|
1094
|
+
const check = resolveCheckCommand("jq", config.deps.jq)
|
|
1095
|
+
const order = sortByDependencies(Object.entries(config.deps))
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
## package.json integration
|
|
1099
|
+
|
|
1100
|
+
```json
|
|
1101
|
+
{
|
|
1102
|
+
"scripts": {
|
|
1103
|
+
"setup:deps": "system install",
|
|
1104
|
+
"setup:deps:upgrade": "system install --upgrade",
|
|
1105
|
+
"setup:deps:check": "system check",
|
|
1106
|
+
"setup:deps:env": "system env",
|
|
1107
|
+
"setup:deps:sync-pm": "system sync-pm"
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
Onboarding:
|
|
1113
|
+
|
|
1114
|
+
```bash
|
|
1115
|
+
npm install # or bun install
|
|
1116
|
+
npm run setup:deps
|
|
1117
|
+
npm run setup:deps:env
|
|
1118
|
+
npm run setup:deps:sync-pm
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
`sync-pm` keeps Corepack / package-manager pinning aligned with `deps.bun.version`.
|
|
1122
|
+
|
|
1123
|
+
## Gotchas
|
|
1124
|
+
|
|
1125
|
+
| Trap | What actually happens |
|
|
1126
|
+
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1127
|
+
| `install <name>` of a dep with `dependsOn` | Dependents are **not** installed. |
|
|
1128
|
+
| Tool already installed at the wrong version | `install` skips. `install --upgrade` re-runs the command. If PATH still shows the old binary (brew/nvm/fnm), that is a shadow warning, not a failed install. |
|
|
1129
|
+
| Agent/IDE PATH vs a new terminal | `check` uses an interactive TTY PATH snapshot with a keep-list spawn env (stock PATH, no leftover toolchain exports). `check --here` uses this process PATH. `install` always uses this process PATH. |
|
|
1130
|
+
| `--dry-run` to "see what would skip" | Dry-run never checks installed versions. Everything with a command is "installed". |
|
|
1131
|
+
| Check command that prints no `N.N` | Treated as not installed. Install will run every time. |
|
|
1132
|
+
| `check <optional-dep>` when missing | Exit 1. `required: false` only changes all-deps `install` / `check`. |
|
|
1133
|
+
| `{{arch}}` on `x64` | `amd64`, not `x64`. |
|
|
1134
|
+
| Linux without apt/dnf/pacman | Detected as `linux-apt`. |
|
|
1135
|
+
| `os.windows` uses `\|\|` plus `$env:` | Routed to PowerShell; `\|\|` is wrong. Keep cmd syntax and PowerShell syntax in separate commands. |
|
|
1136
|
+
| `env.detect` writes the resolved path | No. It writes the template (`$HOME/...`). |
|
|
1137
|
+
| Extra env tokens in `detect` (`$XDG_DATA_HOME`) | Not expanded. Existence check looks for a literal `$XDG_DATA_HOME/…` path. |
|
|
1138
|
+
| `sync-pm` for node | Not implemented. Only `deps.bun`. |
|
|
1139
|
+
| `packageJsonPath` relative to cwd | No. Relative to the config file directory. |
|
|
1140
|
+
| Import `@workerstack/system/env` | Not exported. Use the CLI or copy the idea. |
|
|
1141
|
+
| JSON config | Not supported. |
|
|
1142
|
+
| Walking parent dirs for config | Not supported. Run from the directory that contains the file, or pass `--config`. |
|
|
1143
|
+
|
|
1144
|
+
## Releases
|
|
1145
|
+
|
|
1146
|
+
GitHub Releases match npm versions. Pushing a tag `vX.Y.Z` (same as this `package.json` `version`) runs the repo [`.github/workflows/release.yml`](https://github.com/workerstack/system/blob/main/.github/workflows/release.yml): test, `npm publish` via trusted publishing, GitHub Packages, then a GitHub Release.
|
|
1147
|
+
|
|
1148
|
+
Configure the trusted publisher once on this package (Settings → Trusted Publisher → GitHub Actions): repository `workerstack/system`, workflow `release.yml`, no environment, allow npm publish. Do not put an npm token in GitHub secrets.
|
|
1149
|
+
|
|
1150
|
+
## Changelog
|
|
1151
|
+
|
|
1152
|
+
See [CHANGELOG.md](./CHANGELOG.md).
|
|
1153
|
+
|
|
1154
|
+
## License
|
|
1155
|
+
|
|
1156
|
+
MIT
|