@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.
@@ -0,0 +1,213 @@
1
+ import { refreshEnvironment } from "./env.js";
2
+ import { c, log } from "./log.js";
3
+ import { resolveCommand } from "./resolve.js";
4
+ import { defaultShell, runShell } from "./shell.js";
5
+ import { findOnPath } from "./which.js";
6
+ export async function runAll(config, platform, options) {
7
+ const ctx = { config, platform, options };
8
+ const outcomes = [];
9
+ for (const tool of config.tools) {
10
+ if (options.only.length && !options.only.includes(tool.name))
11
+ continue;
12
+ if (options.skip.includes(tool.name)) {
13
+ outcomes.push({ name: tool.name, status: "skipped", detail: "--skip" });
14
+ continue;
15
+ }
16
+ outcomes.push(await runTool(tool, ctx));
17
+ }
18
+ return outcomes;
19
+ }
20
+ async function runTool(tool, ctx) {
21
+ const { platform, options } = ctx;
22
+ const name = tool.name;
23
+ if (tool.platforms && !tool.platforms.some((p) => platform.selectors.includes(p))) {
24
+ log.step(name, c.dim(`skipped (platforms: ${tool.platforms.join(", ")})`));
25
+ return { name, status: "skipped", detail: "platform filter" };
26
+ }
27
+ const check = resolveCommand(tool.check, platform.selectors);
28
+ const install = resolveCommand(tool.install, platform.selectors);
29
+ const update = resolveCommand(tool.update, platform.selectors);
30
+ if (options.statusOnly)
31
+ return await reportStatus(tool, check, install, ctx);
32
+ if (check.kind === "command") {
33
+ const present = await runCheck(name, check.command, ctx);
34
+ if (present) {
35
+ log.step(name, "already installed");
36
+ return await maybeUpdate(tool, update, ctx, "up-to-date");
37
+ }
38
+ log.step(name, "not installed");
39
+ const installed = await runInstall(tool, install, ctx);
40
+ if (installed.status !== "installed")
41
+ return installed;
42
+ if (options.dryRun)
43
+ return installed;
44
+ const verified = await runCheck(name, check.command, ctx);
45
+ if (!verified) {
46
+ log.warn(name, "install finished but the check still fails. A new terminal may be needed for PATH changes.");
47
+ return { name, status: "unverified" };
48
+ }
49
+ log.ok(name, "installed");
50
+ return installed;
51
+ }
52
+ if (check.kind === "skipped") {
53
+ log.step(name, c.dim(`skipped (check is null for ${check.selector})`));
54
+ return { name, status: "skipped", detail: `check null for ${check.selector}` };
55
+ }
56
+ // No usable check: run install (if any) and then update (if any).
57
+ let installed;
58
+ if (install.kind !== "undefined") {
59
+ installed = await runInstall(tool, install, ctx);
60
+ if (installed.status === "failed" || installed.status === "unsupported")
61
+ return installed;
62
+ }
63
+ return await maybeUpdate(tool, update, ctx, installed?.status ?? "unsupported");
64
+ }
65
+ async function reportStatus(tool, check, install, ctx) {
66
+ const name = tool.name;
67
+ if (check.kind !== "command") {
68
+ const detail = install.kind === "command" ? "no check command" : "no check or install for this machine";
69
+ log.step(name, c.dim(detail));
70
+ return { name, status: "skipped", detail };
71
+ }
72
+ const present = await runCheck(name, check.command, ctx);
73
+ if (present) {
74
+ log.ok(name, "present");
75
+ return { name, status: "present" };
76
+ }
77
+ log.warn(name, "missing");
78
+ return { name, status: "missing" };
79
+ }
80
+ /**
81
+ * Run the update command if there is one for this machine. `fallback` is the
82
+ * status to report when nothing runs: "up-to-date" when the check passed,
83
+ * "installed" when install just ran, "unsupported" when nothing at all ran.
84
+ */
85
+ async function maybeUpdate(tool, update, ctx, fallback) {
86
+ const name = tool.name;
87
+ if (update.kind === "undefined") {
88
+ if (fallback === "unsupported")
89
+ log.warn(name, "nothing to run: no install or update command defined");
90
+ return { name, status: fallback };
91
+ }
92
+ // When nothing else ran, an update that is deliberately not run counts as a skip, not a failure.
93
+ const nothingRan = fallback === "unsupported";
94
+ if (ctx.options.installOnly) {
95
+ return nothingRan ? { name, status: "skipped", detail: "--install-only" } : { name, status: fallback };
96
+ }
97
+ if (update.kind === "skipped") {
98
+ log.debug(`${name}: update is null for ${update.selector}`);
99
+ return nothingRan ? { name, status: "skipped", detail: `update null for ${update.selector}` } : { name, status: fallback };
100
+ }
101
+ if (update.kind === "no-match") {
102
+ log.warn(name, `no update command for this machine (have: ${update.available.join(", ")})`);
103
+ return { name, status: fallback };
104
+ }
105
+ if (ctx.options.dryRun) {
106
+ log.step(name, `would run update (${update.command.selector}):`);
107
+ log.command(update.command.run);
108
+ return { name, status: "would-update" };
109
+ }
110
+ log.step(name, `updating (${update.command.selector})`);
111
+ log.command(update.command.run);
112
+ const result = await execute(update.command, ctx);
113
+ if (result !== 0) {
114
+ log.error(name, `update failed with exit code ${result}`);
115
+ return { name, status: "failed", detail: `update exit ${result}` };
116
+ }
117
+ await refreshEnvironment(ctx.platform);
118
+ log.ok(name, "updated");
119
+ return { name, status: "updated" };
120
+ }
121
+ async function runInstall(tool, install, ctx) {
122
+ const name = tool.name;
123
+ if (install.kind === "undefined") {
124
+ log.warn(name, "no install command defined");
125
+ return { name, status: "unsupported", detail: "no install command" };
126
+ }
127
+ if (install.kind === "skipped") {
128
+ log.step(name, c.dim(`install skipped (null for ${install.selector})`));
129
+ return { name, status: "skipped", detail: `install null for ${install.selector}` };
130
+ }
131
+ if (install.kind === "no-match") {
132
+ log.warn(name, `no install command for this machine (have: ${install.available.join(", ")}; this machine matches: ${ctx.platform.selectors.join(", ")})`);
133
+ return { name, status: "unsupported", detail: "no install command for this machine" };
134
+ }
135
+ if (ctx.options.dryRun) {
136
+ log.step(name, `would run install (${install.command.selector}):`);
137
+ log.command(install.command.run);
138
+ return { name, status: "would-install" };
139
+ }
140
+ log.step(name, `installing (${install.command.selector})`);
141
+ log.command(install.command.run);
142
+ const code = await execute(install.command, ctx);
143
+ if (code !== 0) {
144
+ log.error(name, `install failed with exit code ${code}`);
145
+ return { name, status: "failed", detail: `install exit ${code}` };
146
+ }
147
+ await refreshEnvironment(ctx.platform);
148
+ return { name, status: "installed" };
149
+ }
150
+ /** A bare word is looked up on PATH; anything else runs and must exit 0. */
151
+ async function runCheck(name, command, ctx) {
152
+ const run = command.run.trim();
153
+ if (run !== "" && !/\s/.test(run)) {
154
+ const found = findOnPath(run);
155
+ log.debug(`${name}: check "${run}" on PATH: ${found ?? "not found"}`);
156
+ return found !== undefined;
157
+ }
158
+ log.debug(`${name}: check: ${run}`);
159
+ try {
160
+ const shell = pickShell(command, ctx);
161
+ const result = await runShell(shell, run, ctx.platform, {
162
+ capture: true,
163
+ failFast: command.failFast,
164
+ timeoutMs: 120_000,
165
+ env: { ...process.env, ENVSYNC_SHELL: shell },
166
+ });
167
+ log.debug(`${name}: check exit ${result.code}`);
168
+ return result.code === 0;
169
+ }
170
+ catch (err) {
171
+ log.debug(`${name}: check could not start: ${err.message}`);
172
+ return false;
173
+ }
174
+ }
175
+ async function execute(command, ctx) {
176
+ try {
177
+ const shell = pickShell(command, ctx);
178
+ const result = await runShell(shell, command.run, ctx.platform, {
179
+ failFast: command.failFast,
180
+ env: { ...process.env, ENVSYNC_SHELL: shell },
181
+ });
182
+ return result.code;
183
+ }
184
+ catch (err) {
185
+ log.error(null, `could not start shell: ${err.message}`);
186
+ return 127;
187
+ }
188
+ }
189
+ function pickShell(command, ctx) {
190
+ return command.shell ?? ctx.config.shell?.[ctx.platform.os] ?? defaultShell(ctx.platform.os);
191
+ }
192
+ export function summarize(outcomes) {
193
+ const label = {
194
+ installed: c.green,
195
+ updated: c.green,
196
+ "up-to-date": c.green,
197
+ present: c.green,
198
+ missing: c.yellow,
199
+ unverified: c.yellow,
200
+ skipped: c.dim,
201
+ unsupported: c.yellow,
202
+ failed: c.red,
203
+ "would-install": c.cyan,
204
+ "would-update": c.cyan,
205
+ };
206
+ const width = Math.max(4, ...outcomes.map((o) => o.name.length));
207
+ const lines = outcomes.map((o) => {
208
+ const detail = o.detail ? c.dim(` (${o.detail})`) : "";
209
+ return ` ${o.name.padEnd(width)} ${label[o.status](o.status)}${detail}`;
210
+ });
211
+ const failed = outcomes.filter((o) => o.status === "failed").length;
212
+ return { text: lines.join("\n"), failed };
213
+ }
@@ -0,0 +1,101 @@
1
+ import { spawn } from "node:child_process";
2
+ import { findOnPath } from "./which.js";
3
+ /** Pick the shell to use when neither the command nor the config names one. */
4
+ export function defaultShell(os, has = (b) => findOnPath(b) !== undefined) {
5
+ if (os === "windows")
6
+ return has("pwsh") ? "pwsh" : "powershell";
7
+ return has("bash") ? "bash" : "sh";
8
+ }
9
+ /**
10
+ * Build the argv for a shell. The command text is passed as a single argument
11
+ * (base64 for PowerShell), so no quoting is needed and it can span lines.
12
+ */
13
+ export function buildInvocation(shell, run, os, failFast = false) {
14
+ switch (shell) {
15
+ case "bash":
16
+ case "sh":
17
+ case "zsh":
18
+ return { file: locateUnixShell(shell, os), args: ["-c", failFast ? `set -e\n${run}` : run] };
19
+ case "pwsh":
20
+ case "powershell":
21
+ return {
22
+ file: shell === "pwsh" ? "pwsh" : "powershell.exe",
23
+ args: [
24
+ "-NoLogo",
25
+ "-NoProfile",
26
+ "-ExecutionPolicy",
27
+ "Bypass",
28
+ "-EncodedCommand",
29
+ encodePowerShell(wrapPowerShell(run, failFast)),
30
+ ],
31
+ };
32
+ case "cmd":
33
+ return { file: "cmd.exe", args: ["/d", "/s", "/c", failFast ? joinCmdSteps(run) : run] };
34
+ }
35
+ }
36
+ function locateUnixShell(shell, os) {
37
+ if (os !== "windows")
38
+ return shell;
39
+ // Git Bash is the usual case on Windows. Prefer PATH, then the default install dir.
40
+ const onPath = findOnPath(shell);
41
+ if (onPath)
42
+ return onPath;
43
+ const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
44
+ const exe = shell === "zsh" ? "bash" : shell;
45
+ return `${programFiles}\\Git\\bin\\${exe}.exe`;
46
+ }
47
+ /**
48
+ * PowerShell does not propagate a native command's exit code from -Command
49
+ * reliably, and a failed cmdlet leaves the exit code at 0. Wrap the script so
50
+ * both cases surface as a non-zero exit.
51
+ */
52
+ export function wrapPowerShell(run, failFast = false) {
53
+ return [
54
+ ...(failFast ? ["$ErrorActionPreference = 'Stop'", "$PSNativeCommandUseErrorActionPreference = $true"] : []),
55
+ "$global:LASTEXITCODE = 0",
56
+ run,
57
+ "if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 } }",
58
+ "if ($LASTEXITCODE) { exit $LASTEXITCODE }",
59
+ "exit 0",
60
+ ].join("\n");
61
+ }
62
+ /** cmd.exe only reads the first line of /c, so steps become one && chain. */
63
+ export function joinCmdSteps(run) {
64
+ return run
65
+ .split(/\r?\n/)
66
+ .map((s) => s.trim())
67
+ .filter(Boolean)
68
+ .join(" && ");
69
+ }
70
+ export function encodePowerShell(script) {
71
+ return Buffer.from(script, "utf16le").toString("base64");
72
+ }
73
+ export function runShell(shell, run, platform, options = {}) {
74
+ const { file, args } = buildInvocation(shell, run, platform.os, options.failFast ?? false);
75
+ return new Promise((resolve, reject) => {
76
+ const child = spawn(file, args, {
77
+ cwd: options.cwd ?? process.cwd(),
78
+ env: options.env ?? process.env,
79
+ stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
80
+ windowsHide: true,
81
+ });
82
+ let stdout = "";
83
+ let stderr = "";
84
+ child.stdout?.on("data", (d) => (stdout += d.toString()));
85
+ child.stderr?.on("data", (d) => (stderr += d.toString()));
86
+ let timer;
87
+ if (options.timeoutMs) {
88
+ timer = setTimeout(() => child.kill(), options.timeoutMs);
89
+ }
90
+ child.on("error", (err) => {
91
+ if (timer)
92
+ clearTimeout(timer);
93
+ reject(err);
94
+ });
95
+ child.on("close", (code, signal) => {
96
+ if (timer)
97
+ clearTimeout(timer);
98
+ resolve({ code: code ?? (signal ? 1 : 0), stdout, stderr });
99
+ });
100
+ });
101
+ }
@@ -0,0 +1,43 @@
1
+ export const SCHEMA_URL = "https://raw.githubusercontent.com/hades200082/env-sync/master/schema.json";
2
+ /** Starter config written by `envsync --init`. Mirrors examples/envsync.json. */
3
+ export function starterConfig() {
4
+ const config = {
5
+ $schema: SCHEMA_URL,
6
+ tools: [
7
+ {
8
+ name: "gh",
9
+ description: "GitHub CLI",
10
+ check: "gh",
11
+ install: {
12
+ apt: [
13
+ "type -p wget >/dev/null || (sudo apt-get update && sudo apt-get install wget -y)",
14
+ "sudo mkdir -p -m 755 /etc/apt/keyrings",
15
+ "out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg",
16
+ "cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null",
17
+ "sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg",
18
+ "sudo mkdir -p -m 755 /etc/apt/sources.list.d",
19
+ 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null',
20
+ "sudo apt-get update",
21
+ "sudo apt-get install gh -y",
22
+ ],
23
+ dnf: "sudo dnf install -y 'dnf-command(config-manager)' && sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && sudo dnf install -y gh --repo gh-cli",
24
+ brew: "brew install gh",
25
+ winget: "winget install --id GitHub.cli --exact --accept-source-agreements --accept-package-agreements",
26
+ },
27
+ update: {
28
+ apt: "sudo apt-get update && sudo apt-get install -y --only-upgrade gh",
29
+ dnf: "sudo dnf upgrade -y gh",
30
+ brew: "brew upgrade gh || true",
31
+ winget: "winget upgrade --id GitHub.cli --exact --accept-source-agreements --accept-package-agreements",
32
+ },
33
+ },
34
+ {
35
+ name: "skills",
36
+ description: "Agent skills installed with the skills CLI",
37
+ install: "npx -y skills@latest add mattpocock/skills -a claude-code -g -y",
38
+ update: "npx -y skills@latest update -g -y",
39
+ },
40
+ ],
41
+ };
42
+ return JSON.stringify(config, null, 2) + "\n";
43
+ }
@@ -0,0 +1 @@
1
+ export const SHELL_NAMES = ["bash", "sh", "zsh", "pwsh", "powershell", "cmd"];
@@ -0,0 +1,44 @@
1
+ import { log } from "./log.js";
2
+ /** Returns the newer version on the registry, or undefined. Never throws; times out fast. */
3
+ export async function checkForNewerVersion(name, current, timeoutMs = 3000) {
4
+ try {
5
+ const response = await fetch(`https://registry.npmjs.org/${name}/latest`, {
6
+ signal: AbortSignal.timeout(timeoutMs),
7
+ headers: { Accept: "application/json" },
8
+ });
9
+ if (!response.ok)
10
+ return undefined;
11
+ const body = (await response.json());
12
+ if (typeof body.version !== "string")
13
+ return undefined;
14
+ return compareVersions(body.version, current) > 0 ? body.version : undefined;
15
+ }
16
+ catch (err) {
17
+ log.debug(`update check skipped: ${err.message}`);
18
+ return undefined;
19
+ }
20
+ }
21
+ /** Compare two dotted versions. Positive when a > b. Pre-release suffixes sort before the release. */
22
+ export function compareVersions(a, b) {
23
+ const parse = (v) => {
24
+ const [core = "", pre] = v.replace(/^v/, "").split("-", 2);
25
+ const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
26
+ while (nums.length < 3)
27
+ nums.push(0);
28
+ return { nums, pre };
29
+ };
30
+ const pa = parse(a);
31
+ const pb = parse(b);
32
+ for (let i = 0; i < 3; i++) {
33
+ const diff = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
34
+ if (diff !== 0)
35
+ return diff;
36
+ }
37
+ if (pa.pre && !pb.pre)
38
+ return -1;
39
+ if (!pa.pre && pb.pre)
40
+ return 1;
41
+ if (pa.pre && pb.pre)
42
+ return pa.pre.localeCompare(pb.pre);
43
+ return 0;
44
+ }
@@ -0,0 +1,127 @@
1
+ import { SHELL_NAMES } from "./types.js";
2
+ export class ConfigError extends Error {
3
+ problems;
4
+ constructor(problems) {
5
+ super(`Invalid envsync config:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
6
+ this.problems = problems;
7
+ this.name = "ConfigError";
8
+ }
9
+ }
10
+ const OS_FAMILIES = ["linux", "macos", "windows"];
11
+ const TOOL_KEYS = new Set(["name", "description", "check", "install", "update", "platforms"]);
12
+ const ROOT_KEYS = new Set(["$schema", "shell", "tools"]);
13
+ /** Throws ConfigError with every problem found, not only the first. */
14
+ export function validateConfig(input) {
15
+ const problems = [];
16
+ if (!isRecord(input))
17
+ throw new ConfigError(["root must be an object"]);
18
+ for (const key of Object.keys(input)) {
19
+ if (!ROOT_KEYS.has(key))
20
+ problems.push(`unknown root key "${key}"`);
21
+ }
22
+ if (input.shell !== undefined) {
23
+ if (!isRecord(input.shell))
24
+ problems.push("shell must be an object like { \"windows\": \"pwsh\" }");
25
+ else {
26
+ for (const [os, shell] of Object.entries(input.shell)) {
27
+ if (!OS_FAMILIES.includes(os))
28
+ problems.push(`shell.${os}: unknown OS, expected one of ${OS_FAMILIES.join(", ")}`);
29
+ if (typeof shell !== "string" || !SHELL_NAMES.includes(shell)) {
30
+ problems.push(`shell.${os}: expected one of ${SHELL_NAMES.join(", ")}`);
31
+ }
32
+ }
33
+ }
34
+ }
35
+ if (!Array.isArray(input.tools)) {
36
+ problems.push("tools must be an array");
37
+ throw new ConfigError(problems);
38
+ }
39
+ const names = new Set();
40
+ input.tools.forEach((tool, index) => {
41
+ const where = `tools[${index}]`;
42
+ if (!isRecord(tool)) {
43
+ problems.push(`${where}: must be an object`);
44
+ return;
45
+ }
46
+ for (const key of Object.keys(tool)) {
47
+ if (!TOOL_KEYS.has(key))
48
+ problems.push(`${where}: unknown key "${key}"`);
49
+ }
50
+ if (typeof tool.name !== "string" || tool.name.trim() === "") {
51
+ problems.push(`${where}.name: required, must be a non-empty string`);
52
+ }
53
+ else {
54
+ if (names.has(tool.name))
55
+ problems.push(`${where}.name: duplicate tool name "${tool.name}"`);
56
+ names.add(tool.name);
57
+ }
58
+ if (tool.description !== undefined && typeof tool.description !== "string") {
59
+ problems.push(`${where}.description: must be a string`);
60
+ }
61
+ for (const field of ["check", "install", "update"]) {
62
+ if (tool[field] !== undefined)
63
+ checkCommand(tool[field], `${where}.${field}`, problems);
64
+ }
65
+ if (tool.install === undefined && tool.update === undefined) {
66
+ problems.push(`${where}: needs at least one of install or update`);
67
+ }
68
+ if (tool.platforms !== undefined) {
69
+ if (!Array.isArray(tool.platforms) || !tool.platforms.every((p) => typeof p === "string")) {
70
+ problems.push(`${where}.platforms: must be an array of selector strings`);
71
+ }
72
+ }
73
+ });
74
+ if (problems.length)
75
+ throw new ConfigError(problems);
76
+ return input;
77
+ }
78
+ function isCommandText(value) {
79
+ return typeof value === "string" || (Array.isArray(value) && value.every((v) => typeof v === "string"));
80
+ }
81
+ function checkCommand(value, where, problems) {
82
+ if (value === null || isCommandText(value))
83
+ return;
84
+ if (Array.isArray(value)) {
85
+ problems.push(`${where}: array steps must all be strings`);
86
+ return;
87
+ }
88
+ if (!isRecord(value)) {
89
+ problems.push(`${where}: expected a string, array of strings, null, { "run": ... } or a platform map`);
90
+ return;
91
+ }
92
+ if ("run" in value) {
93
+ checkCommandObject(value, where, problems);
94
+ return;
95
+ }
96
+ if (Object.keys(value).length === 0) {
97
+ problems.push(`${where}: platform map is empty`);
98
+ return;
99
+ }
100
+ for (const [selector, entry] of Object.entries(value)) {
101
+ const inner = `${where}.${selector}`;
102
+ if (entry === null || isCommandText(entry))
103
+ continue;
104
+ if (isRecord(entry) && "run" in entry) {
105
+ checkCommandObject(entry, inner, problems);
106
+ continue;
107
+ }
108
+ problems.push(`${inner}: expected a string, array of strings, null or { "run": ... } (platform maps do not nest)`);
109
+ }
110
+ }
111
+ function checkCommandObject(value, where, problems) {
112
+ if (!isCommandText(value.run))
113
+ problems.push(`${where}.run: must be a string or array of strings`);
114
+ if (value.shell !== undefined && (typeof value.shell !== "string" || !SHELL_NAMES.includes(value.shell))) {
115
+ problems.push(`${where}.shell: expected one of ${SHELL_NAMES.join(", ")}`);
116
+ }
117
+ for (const key of Object.keys(value)) {
118
+ if (key !== "run" && key !== "shell")
119
+ problems.push(`${where}: unknown key "${key}"`);
120
+ }
121
+ }
122
+ function isRecord(value) {
123
+ return typeof value === "object" && value !== null && !Array.isArray(value);
124
+ }
125
+ export function toolNames(config) {
126
+ return config.tools.map((t) => t.name);
127
+ }
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /** Find an executable on PATH. Honours PATHEXT on Windows. Returns the full path or undefined. */
4
+ export function findOnPath(name, env = process.env, platform = process.platform) {
5
+ if (name.includes("/") || name.includes("\\")) {
6
+ return isExecutable(name, platform) ? name : undefined;
7
+ }
8
+ const pathValue = env.PATH ?? env.Path ?? env.path ?? "";
9
+ const dirs = pathValue.split(path.delimiter).filter(Boolean);
10
+ const exts = platform === "win32"
11
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
12
+ : [""];
13
+ const hasExt = platform === "win32" && path.extname(name) !== "";
14
+ for (const dir of dirs) {
15
+ if (hasExt || platform !== "win32") {
16
+ const candidate = path.join(dir, name);
17
+ if (isExecutable(candidate, platform))
18
+ return candidate;
19
+ }
20
+ if (platform === "win32") {
21
+ for (const ext of exts) {
22
+ const candidate = path.join(dir, name + ext.toLowerCase());
23
+ if (isExecutable(candidate, platform))
24
+ return candidate;
25
+ }
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+ function isExecutable(file, platform) {
31
+ try {
32
+ const stat = fs.statSync(file);
33
+ if (!stat.isFile())
34
+ return false;
35
+ if (platform === "win32")
36
+ return true;
37
+ fs.accessSync(file, fs.constants.X_OK);
38
+ return true;
39
+ }
40
+ catch {
41
+ if (platform !== "win32")
42
+ return false;
43
+ // App execution aliases (winget, python from the Store) are reparse points
44
+ // that stat() refuses with EACCES. lstat() sees them as symlinks.
45
+ try {
46
+ const l = fs.lstatSync(file);
47
+ return l.isSymbolicLink() || l.isFile();
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@hades200082/envsync",
3
+ "version": "0.0.2",
4
+ "description": "Install and update your CLI tools and agent skills on every machine from one JSON file.",
5
+ "keywords": [
6
+ "cli",
7
+ "dotfiles",
8
+ "provisioning",
9
+ "install",
10
+ "tooling",
11
+ "agent-skills",
12
+ "claude-code",
13
+ "cross-platform"
14
+ ],
15
+ "license": "GPL-3.0-only",
16
+ "author": "Lee Conlin",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/hades200082/env-sync.git"
20
+ },
21
+ "homepage": "https://github.com/hades200082/env-sync#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/hades200082/env-sync/issues"
24
+ },
25
+ "type": "module",
26
+ "bin": {
27
+ "envsync": "dist/src/cli.js"
28
+ },
29
+ "files": [
30
+ "dist/src",
31
+ "schema.json",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
44
+ "test": "npm run build && node scripts/run-tests.mjs",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "prepublishOnly": "npm run clean && npm run build"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^20.14.0",
50
+ "typescript": "^5.5.0"
51
+ }
52
+ }