@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/README.md CHANGED
@@ -1,3 +1,1156 @@
1
- # Temporary Holding Version
1
+ # @workerstack/system
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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