@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/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).
@@ -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
+ }