@hades200082/envsync 0.0.2
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 +674 -0
- package/README.md +303 -0
- package/dist/src/cli.js +176 -0
- package/dist/src/config.js +61 -0
- package/dist/src/env.js +183 -0
- package/dist/src/github.js +62 -0
- package/dist/src/jsonc.js +74 -0
- package/dist/src/log.js +47 -0
- package/dist/src/platform.js +234 -0
- package/dist/src/resolve.js +35 -0
- package/dist/src/runner.js +213 -0
- package/dist/src/shell.js +101 -0
- package/dist/src/template.js +43 -0
- package/dist/src/types.js +1 -0
- package/dist/src/update-check.js +44 -0
- package/dist/src/validate.js +127 -0
- package/dist/src/which.js +53 -0
- package/package.json +52 -0
- package/schema.json +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# envsync
|
|
2
|
+
|
|
3
|
+
One JSON file that lists the CLI tools and agent skills you want. One command that installs or updates them on whatever machine you are sitting at: Ubuntu, Mint, Fedora, macOS, Windows, WSL, or an agent sandbox.
|
|
4
|
+
|
|
5
|
+
I wrote it because I work across an Ubuntu server, a Linux Mint desktop and a Windows 11 laptop, plus the odd Claude Code sandbox, and one of them was always missing `gh` or running stale skills.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
1. Make a config file. This writes a starter one into the current directory:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx -y envsync@latest --init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Add `--global` to write it to `~/.config/envsync/envsync.json` instead, so it is found from any directory.
|
|
16
|
+
|
|
17
|
+
2. Edit `envsync.json`. Each entry is a tool with a `check`, an `install` and an `update` command. Commands can differ per platform.
|
|
18
|
+
|
|
19
|
+
3. Run it. Same command on every machine:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx -y envsync@latest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Tools that are missing get installed. Tools that are present get updated. Anything that fails is reported and the rest carries on.
|
|
26
|
+
|
|
27
|
+
Useful variations:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx -y envsync@latest --status # just tell me what is missing
|
|
31
|
+
npx -y envsync@latest --dry-run # show what would run
|
|
32
|
+
npx -y envsync@latest --only gh # one tool
|
|
33
|
+
npx -y envsync@latest -f ./work.json # a specific file or URL
|
|
34
|
+
npx -y envsync@latest -g you/dotfiles # envsync.json from a GitHub repo
|
|
35
|
+
npx -y envsync@latest --info # what does this machine look like to envsync?
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Node 18 or later. No other dependencies.
|
|
39
|
+
|
|
40
|
+
## Where the config comes from
|
|
41
|
+
|
|
42
|
+
First hit wins:
|
|
43
|
+
|
|
44
|
+
1. `--file <path or URL>`. A local file, or an `http(s)` URL that returns the JSON.
|
|
45
|
+
2. `--github <owner/repo>`. Reads `envsync.json` from the repo root through `gh`, so private repos work when you are logged in. Falls back to `raw.githubusercontent.com` when `gh` is not installed (public repos only, or set `GITHUB_TOKEN`). `owner/repo@branch` and `owner/repo:path/to/file.json` also work.
|
|
46
|
+
3. `./envsync.json` in the current directory.
|
|
47
|
+
4. `$XDG_CONFIG_HOME/envsync/envsync.json`, which is `~/.config/envsync/envsync.json` unless you have changed it.
|
|
48
|
+
5. `%APPDATA%\envsync\envsync.json` on Windows.
|
|
49
|
+
|
|
50
|
+
## The config file
|
|
51
|
+
|
|
52
|
+
Comments and trailing commas are allowed. The `$schema` line gives you completion and validation in VS Code and most editors.
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
{
|
|
56
|
+
"$schema": "https://raw.githubusercontent.com/hades200082/env-sync/master/schema.json",
|
|
57
|
+
"tools": [
|
|
58
|
+
{
|
|
59
|
+
"name": "gh",
|
|
60
|
+
"check": "gh",
|
|
61
|
+
"install": {
|
|
62
|
+
"apt": "$ENVSYNC_SUDO apt-get update && $ENVSYNC_SUDO apt-get install -y gh",
|
|
63
|
+
"brew": "brew install gh",
|
|
64
|
+
"winget": "winget install --id GitHub.cli --exact"
|
|
65
|
+
},
|
|
66
|
+
"update": {
|
|
67
|
+
"apt": "$ENVSYNC_SUDO apt-get update && $ENVSYNC_SUDO apt-get install -y --only-upgrade gh",
|
|
68
|
+
"brew": "brew upgrade gh || true",
|
|
69
|
+
"winget": "winget upgrade --id GitHub.cli --exact"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "skills",
|
|
74
|
+
"install": "npx -y skills@latest add mattpocock/skills -a claude-code -g -y",
|
|
75
|
+
"update": "npx -y skills@latest update -g -y"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Tools run in the order listed. Put anything a later tool needs, like `gh`, first.
|
|
82
|
+
|
|
83
|
+
[examples/envsync.json](examples/envsync.json) is the starter file. [examples/installers.json](examples/installers.json) has recipes for the common installer shapes.
|
|
84
|
+
|
|
85
|
+
### Each tool
|
|
86
|
+
|
|
87
|
+
| Field | What it does |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `name` | Shown in the output. Used by `--only` and `--skip`. |
|
|
90
|
+
| `check` | Exit 0 means installed. A bare word such as `"gh"` is looked up on PATH instead of being run, which behaves the same on every OS. Optional. |
|
|
91
|
+
| `install` | Runs when the check fails. |
|
|
92
|
+
| `update` | Runs when the check passes. |
|
|
93
|
+
| `platforms` | Optional list of selectors. The tool is skipped on machines that match none of them. |
|
|
94
|
+
| `description` | Free text for your own benefit. |
|
|
95
|
+
|
|
96
|
+
A tool with no `check` runs `install` and then `update` every time. That is the right shape for the `skills` CLI, where `add` is idempotent and `update` pulls the latest.
|
|
97
|
+
|
|
98
|
+
After every install or update, envsync re-reads the environment the way a new terminal would (the registry on Windows, a login shell on Linux and macOS) and merges new PATH entries into itself. So `gh` installed by the first tool is available to the `skills` tool that follows without opening a new terminal. If the check still fails after an install, the tool is reported as `unverified` rather than failed.
|
|
99
|
+
|
|
100
|
+
### Commands
|
|
101
|
+
|
|
102
|
+
`check`, `install` and `update` all take the same shape. Pick whichever fits:
|
|
103
|
+
|
|
104
|
+
**A string.** Runs as-is in the default shell for the OS.
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
"install": "brew install gh"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**An array of strings.** One step per line. Stops at the first step that fails (`set -e` in bash, `$ErrorActionPreference = 'Stop'` in PowerShell, `&&` in cmd).
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
"install": ["curl -fsSL https://example.com/install.sh -o /tmp/i.sh", "sh /tmp/i.sh"]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**An object with `run` and `shell`,** when one command needs a specific shell.
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
"install": { "run": "irm get.scoop.sh | iex", "shell": "pwsh" }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**`null`,** to say "nothing to do here" for one platform without it being reported as unsupported.
|
|
123
|
+
|
|
124
|
+
**A platform map.** Keys are selectors, values are any of the above.
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
"install": {
|
|
128
|
+
"linuxmint": "sudo apt-get install -y foo-mint-build",
|
|
129
|
+
"apt": "sudo apt-get install -y foo",
|
|
130
|
+
"dnf": "sudo dnf install -y foo",
|
|
131
|
+
"brew": "brew install foo",
|
|
132
|
+
"winget": "winget install foo",
|
|
133
|
+
"default": null
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Selectors
|
|
138
|
+
|
|
139
|
+
This is how one file covers several distros. Each machine gets an ordered list of selectors, most specific first, and a platform map uses the first key it has that appears in that list. `npx -y envsync@latest --info` prints the list for the machine you are on. A Linux Mint 22.1 desktop with apt and snap looks like this:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
linuxmint-22.1, linuxmint-22, linuxmint, ubuntu, debian, apt, snap, linux, unix, default
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
In order of precedence:
|
|
146
|
+
|
|
147
|
+
1. Host: `claude-code`, `codex`, `codespaces`, `gitpod`, `ci`, `container`, `wsl`. Only present when you are in one of those. See "Knowing where you are" below.
|
|
148
|
+
2. Distro id and version: `ubuntu-24.04`, then `ubuntu-24`. On macOS `macos-15`, on Windows `windows-11` or `windows-10`.
|
|
149
|
+
3. Distro id from `/etc/os-release`: `ubuntu`, `linuxmint`, `debian`, `fedora`, `arch`, `alpine`, and so on.
|
|
150
|
+
4. What the distro says it is like (`ID_LIKE`). Mint lists `ubuntu` and `debian`. Rocky lists `rhel`, `centos` and `fedora`.
|
|
151
|
+
5. Package managers found on PATH: `apt`, `dnf`, `yum`, `pacman`, `zypper`, `apk`, `nix`, `brew`, `port`, `snap`, `flatpak`, `winget`, `choco`, `scoop`.
|
|
152
|
+
6. OS family: `linux`, `macos`, `windows`.
|
|
153
|
+
7. `unix` for Linux and macOS together. Handy for `curl | sh` installers.
|
|
154
|
+
8. `default`.
|
|
155
|
+
|
|
156
|
+
Most of the time the package manager keys are all you need. Reach for a distro key when one distro needs something different, and for a host key when a sandbox or CI runner does.
|
|
157
|
+
|
|
158
|
+
## Installer recipes
|
|
159
|
+
|
|
160
|
+
Most tools ship one of a few installer shapes. All of these are in [examples/installers.json](examples/installers.json).
|
|
161
|
+
|
|
162
|
+
**A bash script piped from curl.** `unix` covers Linux and macOS. The `<(...)` needs bash, which is the default shell when it exists.
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
"install": {
|
|
166
|
+
"unix": "bash <(curl -fsSL https://moonrepo.dev/install/proto.sh)",
|
|
167
|
+
"windows": "irm https://moonrepo.dev/install/proto.ps1 | iex"
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**A PowerShell script piped to iex.** That `windows` line above is it. It runs in `pwsh` when installed, otherwise Windows PowerShell 5.1. Both understand `irm | iex`.
|
|
172
|
+
|
|
173
|
+
**An installer that only needs sh.** Say so, and it runs even on a box without bash.
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
"install": { "run": "curl -LsSf https://astral.sh/uv/install.sh | sh", "shell": "sh" }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Downloading a binary by hand.** Use the array form so a failed download does not fall through to a broken extract.
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
"install": {
|
|
183
|
+
"linux": [
|
|
184
|
+
"curl -fsSLo /tmp/tool.tar.gz https://example.com/tool-linux-$(uname -m).tar.gz",
|
|
185
|
+
"mkdir -p \"$HOME/.local/bin\"",
|
|
186
|
+
"tar -xzf /tmp/tool.tar.gz -C \"$HOME/.local/bin\" tool"
|
|
187
|
+
],
|
|
188
|
+
"windows": [
|
|
189
|
+
"Invoke-WebRequest https://example.com/tool-windows.zip -OutFile \"$env:TEMP\\tool.zip\"",
|
|
190
|
+
"Expand-Archive \"$env:TEMP\\tool.zip\" -DestinationPath \"$env:LOCALAPPDATA\\Programs\\tool\" -Force"
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**With or without sudo.** `$ENVSYNC_SUDO` is `sudo` on a normal Linux or macOS account and empty when you are already root, as you usually are inside a container or an agent sandbox. One line then works in both places.
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
"apt": "$ENVSYNC_SUDO apt-get install -y gh"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Knowing where you are
|
|
202
|
+
|
|
203
|
+
The script can tell when it is not on a plain desktop. The host it finds itself in is passed on in two ways.
|
|
204
|
+
|
|
205
|
+
As selectors, so a platform map or a tool's `platforms` list can react to it:
|
|
206
|
+
|
|
207
|
+
| Selector | When |
|
|
208
|
+
| --- | --- |
|
|
209
|
+
| `claude-code` | Running under Claude Code, local or in its cloud sandbox |
|
|
210
|
+
| `codex` | Running under OpenAI Codex |
|
|
211
|
+
| `codespaces`, `gitpod` | Hosted workspaces |
|
|
212
|
+
| `ci` | `CI` or `GITHUB_ACTIONS` is set |
|
|
213
|
+
| `container` | Docker, Podman or Kubernetes |
|
|
214
|
+
| `wsl` | Windows Subsystem for Linux |
|
|
215
|
+
|
|
216
|
+
As environment variables, which every command can read (`$ENVSYNC_OS` in bash, `$env:ENVSYNC_OS` in PowerShell):
|
|
217
|
+
|
|
218
|
+
| Variable | Example |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| `ENVSYNC_OS` | `linux`, `macos`, `windows` |
|
|
221
|
+
| `ENVSYNC_ID`, `ENVSYNC_VERSION` | `linuxmint`, `22.1` |
|
|
222
|
+
| `ENVSYNC_ARCH` | `x64`, `arm64` |
|
|
223
|
+
| `ENVSYNC_HOST` | `claude-code,container` or empty |
|
|
224
|
+
| `ENVSYNC_ROOT` | `1` when uid 0 |
|
|
225
|
+
| `ENVSYNC_SUDO` | `sudo` or empty |
|
|
226
|
+
| `ENVSYNC_INTERACTIVE` | `1` when stdin and stdout are a terminal, so an installer may prompt |
|
|
227
|
+
| `ENVSYNC_TERMINAL` | `vscode`, `iTerm.app`, `windows-terminal` or empty |
|
|
228
|
+
| `ENVSYNC_SHELL` | The shell running this command: `bash`, `pwsh`, ... |
|
|
229
|
+
| `ENVSYNC_SELECTORS` | The full selector list, comma separated |
|
|
230
|
+
|
|
231
|
+
A tool that should only run inside a sandbox:
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{ "name": "sandbox-setup", "platforms": ["claude-code", "codex"], "install": "..." }
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
For Claude Code on the web or Codex, the simplest arrangement is a setup script that runs `npx -y envsync@latest -g you/dotfiles`. Those containers are Ubuntu or Debian as root, so the `apt` selector matches and `$ENVSYNC_SUDO` is empty.
|
|
238
|
+
|
|
239
|
+
## Shells
|
|
240
|
+
|
|
241
|
+
Defaults are `bash` (or `sh` if bash is missing) on Linux and macOS, and `pwsh` (or Windows PowerShell 5.1 if pwsh is missing) on Windows. Override per OS at the top level:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
"shell": { "windows": "cmd" }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
or per command with `{ "run": "...", "shell": "bash" }`. On Windows, `bash` means Git Bash. Available names: `bash`, `sh`, `zsh`, `pwsh`, `powershell`, `cmd`.
|
|
248
|
+
|
|
249
|
+
Windows PowerShell 5.1 does not understand `&&`. Use the array form for multi-step commands and it works in both.
|
|
250
|
+
|
|
251
|
+
## Options
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
-f, --file <path|url> Config file to use
|
|
255
|
+
-g, --github <repo> GitHub repo holding envsync.json at its root
|
|
256
|
+
-o, --only <name> Run only this tool (repeatable, or comma separated)
|
|
257
|
+
-s, --skip <name> Skip this tool (repeatable, or comma separated)
|
|
258
|
+
-n, --dry-run Show what would run. Checks still run; installs and updates do not.
|
|
259
|
+
--install-only Install what is missing, skip update commands
|
|
260
|
+
--status Run the checks and report, change nothing
|
|
261
|
+
--init Write a starter envsync.json into the current directory
|
|
262
|
+
--global With --init: write to the global config path instead
|
|
263
|
+
--info Print what this machine looks like to envsync and exit
|
|
264
|
+
--no-update-check Do not look for a newer envsync on npm
|
|
265
|
+
-v, --verbose Show commands, checks and environment refresh details
|
|
266
|
+
-V, --version Print version
|
|
267
|
+
-h, --help Show this help
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Exit code is 0 when everything ran, 1 when an install or update failed (or, with `--status`, when something is missing), 2 for bad arguments or an unreadable config. A failing tool does not stop the others.
|
|
271
|
+
|
|
272
|
+
## Keeping envsync itself current
|
|
273
|
+
|
|
274
|
+
Always run it as `npx -y envsync@latest`. Without `@latest`, npx will reuse a cached older version. Each run also asks the npm registry whether a newer version exists and prints a one-line note at the end if so. Set `ENVSYNC_NO_UPDATE_CHECK=1` or pass `--no-update-check` to turn that off.
|
|
275
|
+
|
|
276
|
+
## Releasing a new version
|
|
277
|
+
|
|
278
|
+
Releases are built by GitHub Actions in [.github/workflows/release.yml](.github/workflows/release.yml).
|
|
279
|
+
|
|
280
|
+
Day to day, open the Actions tab, pick "Release", click "Run workflow", choose patch, minor or major. The workflow runs the tests, bumps `package.json`, commits, tags `vX.Y.Z`, creates a GitHub release with generated notes, then publishes to npm with provenance.
|
|
281
|
+
|
|
282
|
+
One-time setup, pick one:
|
|
283
|
+
|
|
284
|
+
- **npm trusted publishing.** No secret to rotate. Publish the first version by hand with `npm publish` so the package exists. Then on npmjs.com open the package, Settings, "Trusted publisher", choose GitHub Actions, repository `hades200082/env-sync`, workflow `release.yml`.
|
|
285
|
+
- **An automation token.** Create one on npmjs.com under Access Tokens and add it to the repo as the `NPM_TOKEN` secret. The workflow uses it when present.
|
|
286
|
+
|
|
287
|
+
Creating a release by hand in the GitHub UI also works, as long as the tag matches the version in `package.json`. The publish job checks that and refuses otherwise.
|
|
288
|
+
|
|
289
|
+
## Development
|
|
290
|
+
|
|
291
|
+
```sh
|
|
292
|
+
npm install
|
|
293
|
+
npm test # builds with tsc, then runs node --test against dist/test
|
|
294
|
+
node dist/src/cli.js --file examples/installers.json --dry-run
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Source is TypeScript in [src/](src/), compiled to `dist/`. No runtime dependencies. [examples/envsync.json](examples/envsync.json) is generated from `starterConfig()` in [src/template.ts](src/template.ts) and a test fails if the two drift. Every file in `examples/` is validated by the tests.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
GPL-3.0. Copyright Lee Conlin.
|
|
302
|
+
|
|
303
|
+
You can use, change and share this freely, including at work. If you distribute a modified version, or a program built on it, it has to be under the GPL too, with source available. The copyright stays with me; the license grants you permission, it does not transfer ownership. Full text in [LICENSE](LICENSE).
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { globalConfigPath, loadConfig } from "./config.js";
|
|
7
|
+
import { c, log, setVerbose } from "./log.js";
|
|
8
|
+
import { describePlatform, detectPlatform, platformEnvVars } from "./platform.js";
|
|
9
|
+
import { runAll, summarize } from "./runner.js";
|
|
10
|
+
import { defaultShell } from "./shell.js";
|
|
11
|
+
import { starterConfig } from "./template.js";
|
|
12
|
+
import { checkForNewerVersion } from "./update-check.js";
|
|
13
|
+
import { ConfigError } from "./validate.js";
|
|
14
|
+
import { DEFAULT_CONFIG_FILENAME } from "./github.js";
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const pkg = require("../../package.json");
|
|
17
|
+
const HELP = `envsync ${pkg.version}
|
|
18
|
+
Install and update your CLI tools and agent skills from one JSON file.
|
|
19
|
+
|
|
20
|
+
Usage
|
|
21
|
+
npx -y envsync@latest [options]
|
|
22
|
+
|
|
23
|
+
Config lookup (first hit wins)
|
|
24
|
+
1. --file <path or URL> any local path or http(s) URL
|
|
25
|
+
2. --github <owner/repo[@ref][:path]> read from a GitHub repo via gh (falls back to raw URL)
|
|
26
|
+
3. ./${DEFAULT_CONFIG_FILENAME}
|
|
27
|
+
4. $XDG_CONFIG_HOME/envsync/${DEFAULT_CONFIG_FILENAME} (defaults to ~/.config/envsync/${DEFAULT_CONFIG_FILENAME})
|
|
28
|
+
5. %APPDATA%\\envsync\\${DEFAULT_CONFIG_FILENAME} (Windows only)
|
|
29
|
+
|
|
30
|
+
Options
|
|
31
|
+
-f, --file <path|url> Config file to use
|
|
32
|
+
-g, --github <repo> GitHub repo holding ${DEFAULT_CONFIG_FILENAME} at its root
|
|
33
|
+
-o, --only <name> Run only this tool (repeatable, or comma separated)
|
|
34
|
+
-s, --skip <name> Skip this tool (repeatable, or comma separated)
|
|
35
|
+
-n, --dry-run Show what would run. Checks still run; installs and updates do not.
|
|
36
|
+
--install-only Install what is missing, skip update commands
|
|
37
|
+
--status Run the checks and report, change nothing
|
|
38
|
+
--init Write a starter ${DEFAULT_CONFIG_FILENAME} into the current directory
|
|
39
|
+
--global With --init: write to the global config path instead
|
|
40
|
+
--info Print what this machine looks like to envsync and exit
|
|
41
|
+
--no-update-check Do not look for a newer envsync on npm
|
|
42
|
+
-v, --verbose Show commands, checks and environment refresh details
|
|
43
|
+
-V, --version Print version
|
|
44
|
+
-h, --help Show this help
|
|
45
|
+
|
|
46
|
+
Exit codes
|
|
47
|
+
0 everything ran (or, with --status, everything present)
|
|
48
|
+
1 at least one install or update failed (or, with --status, something is missing)
|
|
49
|
+
2 bad arguments or unreadable config
|
|
50
|
+
`;
|
|
51
|
+
async function main(argv) {
|
|
52
|
+
const options = {
|
|
53
|
+
file: { type: "string", short: "f" },
|
|
54
|
+
github: { type: "string", short: "g" },
|
|
55
|
+
only: { type: "string", short: "o", multiple: true },
|
|
56
|
+
skip: { type: "string", short: "s", multiple: true },
|
|
57
|
+
"dry-run": { type: "boolean", short: "n", default: false },
|
|
58
|
+
"install-only": { type: "boolean", default: false },
|
|
59
|
+
status: { type: "boolean", default: false },
|
|
60
|
+
init: { type: "boolean", default: false },
|
|
61
|
+
global: { type: "boolean", default: false },
|
|
62
|
+
info: { type: "boolean", default: false },
|
|
63
|
+
"no-update-check": { type: "boolean", default: false },
|
|
64
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
65
|
+
version: { type: "boolean", short: "V", default: false },
|
|
66
|
+
help: { type: "boolean", short: "h", default: false },
|
|
67
|
+
};
|
|
68
|
+
let flags;
|
|
69
|
+
const parse = () => parseArgs({ args: argv, options, allowPositionals: false, strict: true });
|
|
70
|
+
try {
|
|
71
|
+
flags = parse().values;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
log.error(null, err.message);
|
|
75
|
+
console.error(HELP);
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
setVerbose(Boolean(flags.verbose));
|
|
79
|
+
if (flags.help) {
|
|
80
|
+
console.log(HELP);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
if (flags.version) {
|
|
84
|
+
console.log(pkg.version);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
const platform = detectPlatform();
|
|
88
|
+
if (flags.info) {
|
|
89
|
+
console.log(`${c.bold("Platform:")} ${describePlatform(platform)}`);
|
|
90
|
+
console.log(`${c.bold("Selectors:")} ${platform.selectors.join(", ")}`);
|
|
91
|
+
console.log(c.dim("A platform map picks the first selector above that it has a key for."));
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log(c.bold("Variables your commands can read:"));
|
|
94
|
+
for (const [k, v] of Object.entries(platformEnvVars(platform, defaultShell(platform.os)))) {
|
|
95
|
+
console.log(` ${k}=${v}`);
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (flags.init)
|
|
100
|
+
return writeStarter(Boolean(flags.global));
|
|
101
|
+
const updatePromise = flags["no-update-check"] || process.env.ENVSYNC_NO_UPDATE_CHECK
|
|
102
|
+
? Promise.resolve(undefined)
|
|
103
|
+
: checkForNewerVersion(pkg.name, pkg.version);
|
|
104
|
+
let loaded;
|
|
105
|
+
try {
|
|
106
|
+
const source = {};
|
|
107
|
+
if (flags.file)
|
|
108
|
+
source.file = flags.file;
|
|
109
|
+
if (flags.github)
|
|
110
|
+
source.github = flags.github;
|
|
111
|
+
loaded = await loadConfig(source);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
log.error(null, err instanceof ConfigError ? err.message : err.message);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
const runnerOptions = {
|
|
118
|
+
dryRun: Boolean(flags["dry-run"]),
|
|
119
|
+
installOnly: Boolean(flags["install-only"]),
|
|
120
|
+
statusOnly: Boolean(flags.status),
|
|
121
|
+
only: splitList(flags.only),
|
|
122
|
+
skip: splitList(flags.skip),
|
|
123
|
+
};
|
|
124
|
+
const known = new Set(loaded.config.tools.map((t) => t.name));
|
|
125
|
+
for (const name of [...runnerOptions.only, ...runnerOptions.skip]) {
|
|
126
|
+
if (!known.has(name)) {
|
|
127
|
+
log.error(null, `Unknown tool "${name}". Tools in ${loaded.origin}: ${[...known].join(", ")}`);
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
Object.assign(process.env, platformEnvVars(platform));
|
|
132
|
+
log.info(`${c.bold("envsync")} ${pkg.version} ${c.dim(describePlatform(platform))}`);
|
|
133
|
+
log.info(`${c.dim("config:")} ${loaded.origin}`);
|
|
134
|
+
if (runnerOptions.dryRun)
|
|
135
|
+
log.info(c.yellow("dry run: nothing will be installed or updated"));
|
|
136
|
+
log.info("");
|
|
137
|
+
const outcomes = await runAll(loaded.config, platform, runnerOptions);
|
|
138
|
+
const summary = summarize(outcomes);
|
|
139
|
+
log.info("");
|
|
140
|
+
log.info(c.bold("Summary"));
|
|
141
|
+
log.info(summary.text);
|
|
142
|
+
const newer = await updatePromise;
|
|
143
|
+
if (newer) {
|
|
144
|
+
log.info("");
|
|
145
|
+
log.info(c.yellow(`A newer envsync is available (${newer}, you have ${pkg.version}). Run: npx -y envsync@latest`));
|
|
146
|
+
}
|
|
147
|
+
if (runnerOptions.statusOnly)
|
|
148
|
+
return outcomes.some((o) => o.status === "missing") ? 1 : 0;
|
|
149
|
+
return summary.failed > 0 ? 1 : 0;
|
|
150
|
+
}
|
|
151
|
+
function writeStarter(global) {
|
|
152
|
+
const target = global ? globalConfigPath() : path.resolve(DEFAULT_CONFIG_FILENAME);
|
|
153
|
+
if (fs.existsSync(target)) {
|
|
154
|
+
log.error(null, `${target} already exists, not overwriting`);
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
158
|
+
fs.writeFileSync(target, starterConfig(), "utf8");
|
|
159
|
+
log.info(`Wrote ${target}`);
|
|
160
|
+
log.info(c.dim("Edit it, then run: npx -y envsync@latest"));
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
function splitList(values) {
|
|
164
|
+
if (!values)
|
|
165
|
+
return [];
|
|
166
|
+
return values
|
|
167
|
+
.flatMap((v) => v.split(","))
|
|
168
|
+
.map((v) => v.trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
main(process.argv.slice(2)).then((code) => {
|
|
172
|
+
process.exitCode = code;
|
|
173
|
+
}, (err) => {
|
|
174
|
+
log.error(null, err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
175
|
+
process.exitCode = 2;
|
|
176
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseJsonc } from "./jsonc.js";
|
|
5
|
+
import { validateConfig } from "./validate.js";
|
|
6
|
+
import { DEFAULT_CONFIG_FILENAME, fetchFromGitHub, fetchText, parseGitHubRef } from "./github.js";
|
|
7
|
+
/** Search order when no source is given. First hit wins. */
|
|
8
|
+
export function candidatePaths(cwd = process.cwd(), env = process.env) {
|
|
9
|
+
const home = os.homedir();
|
|
10
|
+
const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0 ? env.XDG_CONFIG_HOME : path.join(home, ".config");
|
|
11
|
+
const list = [
|
|
12
|
+
path.join(cwd, DEFAULT_CONFIG_FILENAME),
|
|
13
|
+
path.join(xdg, "envsync", DEFAULT_CONFIG_FILENAME),
|
|
14
|
+
];
|
|
15
|
+
if (env.APPDATA)
|
|
16
|
+
list.push(path.join(env.APPDATA, "envsync", DEFAULT_CONFIG_FILENAME));
|
|
17
|
+
return list;
|
|
18
|
+
}
|
|
19
|
+
export function globalConfigPath(env = process.env) {
|
|
20
|
+
const home = os.homedir();
|
|
21
|
+
const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0 ? env.XDG_CONFIG_HOME : path.join(home, ".config");
|
|
22
|
+
return path.join(xdg, "envsync", DEFAULT_CONFIG_FILENAME);
|
|
23
|
+
}
|
|
24
|
+
export async function loadConfig(source) {
|
|
25
|
+
if (source.file && source.github)
|
|
26
|
+
throw new Error("Use either --file or --github, not both");
|
|
27
|
+
if (source.github) {
|
|
28
|
+
const ref = parseGitHubRef(source.github);
|
|
29
|
+
const text = await fetchFromGitHub(ref);
|
|
30
|
+
return parse(text, `github:${ref.owner}/${ref.repo}${ref.ref ? `@${ref.ref}` : ""}:${ref.path}`);
|
|
31
|
+
}
|
|
32
|
+
if (source.file) {
|
|
33
|
+
if (/^https?:\/\//i.test(source.file)) {
|
|
34
|
+
return parse(await fetchText(source.file), source.file);
|
|
35
|
+
}
|
|
36
|
+
const resolved = path.resolve(source.file);
|
|
37
|
+
if (!fs.existsSync(resolved))
|
|
38
|
+
throw new Error(`Config file not found: ${resolved}`);
|
|
39
|
+
return parse(fs.readFileSync(resolved, "utf8"), resolved);
|
|
40
|
+
}
|
|
41
|
+
for (const candidate of candidatePaths()) {
|
|
42
|
+
if (fs.existsSync(candidate))
|
|
43
|
+
return parse(fs.readFileSync(candidate, "utf8"), candidate);
|
|
44
|
+
}
|
|
45
|
+
throw new Error([
|
|
46
|
+
`No ${DEFAULT_CONFIG_FILENAME} found. Looked in:`,
|
|
47
|
+
...candidatePaths().map((p) => ` ${p}`),
|
|
48
|
+
"",
|
|
49
|
+
"Create one with `envsync --init`, or point at one with --file <path|url> or --github owner/repo.",
|
|
50
|
+
].join("\n"));
|
|
51
|
+
}
|
|
52
|
+
function parse(text, origin) {
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = parseJsonc(text);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
throw new Error(`Could not parse ${origin} as JSON: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
return { config: validateConfig(raw), origin };
|
|
61
|
+
}
|