@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,183 @@
1
+ import path from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { log } from "./log.js";
4
+ const BEGIN = "__ENVSYNC_ENV_BEGIN__";
5
+ const END = "__ENVSYNC_ENV_END__";
6
+ /**
7
+ * Re-read the environment the way a fresh terminal would see it and merge
8
+ * it into this process, so a tool installed by one step is on PATH for the next.
9
+ *
10
+ * Windows: PATH and other variables come from the registry (Machine + User).
11
+ * Unix: a login shell (interactive first, so ~/.bashrc counts) dumps its env.
12
+ */
13
+ export async function refreshEnvironment(platform, env = process.env) {
14
+ const fresh = platform.os === "windows" ? await readWindowsEnvironment() : await readUnixEnvironment(env);
15
+ if (!fresh) {
16
+ log.debug("environment refresh: could not read a fresh environment, keeping the current one");
17
+ return false;
18
+ }
19
+ const added = mergeEnvironment(env, fresh, platform.os === "windows");
20
+ if (added.length)
21
+ log.debug(`environment refresh: new PATH entries: ${added.join(path.delimiter)}`);
22
+ else
23
+ log.debug("environment refresh: PATH unchanged");
24
+ return true;
25
+ }
26
+ /**
27
+ * Merge `fresh` into `target`. PATH becomes the fresh PATH followed by any
28
+ * entries only the current process had. Other variables are only added when
29
+ * missing, so nothing the user set for this run is overwritten.
30
+ * Returns the PATH entries that were new.
31
+ *
32
+ * `windows` selects the PATH rules: `;` as the separator, case-insensitive
33
+ * names and entries. It is a parameter rather than read from the host so
34
+ * the Windows rules can be tested on any OS.
35
+ */
36
+ export function mergeEnvironment(target, fresh, windows) {
37
+ const caseInsensitive = windows;
38
+ const delimiter = windows ? ";" : ":";
39
+ const pathKey = findKey(target, "PATH", caseInsensitive) ?? "PATH";
40
+ const freshPathKey = findKey(fresh, "PATH", caseInsensitive);
41
+ const currentEntries = splitPath(target[pathKey] ?? "", delimiter);
42
+ const freshEntries = freshPathKey ? splitPath(fresh[freshPathKey] ?? "", delimiter) : [];
43
+ const norm = (p) => (caseInsensitive ? p.toLowerCase() : p).replace(/[\\/]+$/, "");
44
+ const seen = new Set();
45
+ const merged = [];
46
+ for (const entry of [...freshEntries, ...currentEntries]) {
47
+ const key = norm(entry);
48
+ if (seen.has(key))
49
+ continue;
50
+ seen.add(key);
51
+ merged.push(entry);
52
+ }
53
+ const currentSet = new Set(currentEntries.map(norm));
54
+ const added = [];
55
+ for (const entry of freshEntries) {
56
+ if (!currentSet.has(norm(entry)) && !added.includes(entry))
57
+ added.push(entry);
58
+ }
59
+ target[pathKey] = merged.join(delimiter);
60
+ for (const [key, value] of Object.entries(fresh)) {
61
+ if (key.toUpperCase() === "PATH")
62
+ continue;
63
+ if (findKey(target, key, caseInsensitive) === undefined)
64
+ target[key] = value;
65
+ }
66
+ return added;
67
+ }
68
+ function splitPath(value, delimiter) {
69
+ return value
70
+ .split(delimiter)
71
+ .map((s) => s.trim())
72
+ .filter(Boolean);
73
+ }
74
+ function findKey(obj, key, caseInsensitive) {
75
+ if (!caseInsensitive)
76
+ return key in obj ? key : undefined;
77
+ const upper = key.toUpperCase();
78
+ return Object.keys(obj).find((k) => k.toUpperCase() === upper);
79
+ }
80
+ async function readWindowsEnvironment() {
81
+ // Machine first, then User, matching how Windows builds PATH for a new process.
82
+ const script = [
83
+ "$m = [Environment]::GetEnvironmentVariables('Machine')",
84
+ "$u = [Environment]::GetEnvironmentVariables('User')",
85
+ "$out = @{}",
86
+ "foreach ($k in $m.Keys) { $out[$k] = [string]$m[$k] }",
87
+ "foreach ($k in $u.Keys) { if ($k -ieq 'Path') { $out['Path'] = ($out['Path'] + ';' + $u[$k]) } else { $out[$k] = [string]$u[$k] } }",
88
+ `Write-Output '${BEGIN}'`,
89
+ "Write-Output ($out | ConvertTo-Json -Compress)",
90
+ `Write-Output '${END}'`,
91
+ ].join("\n");
92
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
93
+ const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
94
+ const shells = ["pwsh", "powershell.exe", `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`];
95
+ for (const shell of shells) {
96
+ const out = await capture(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], 15000);
97
+ const parsed = extractJson(out);
98
+ if (parsed)
99
+ return expandWindowsVariables(parsed);
100
+ }
101
+ return undefined;
102
+ }
103
+ /** Registry values can hold %SystemRoot% style references; expand them. */
104
+ function expandWindowsVariables(vars) {
105
+ const lookup = (name) => {
106
+ const upper = name.toUpperCase();
107
+ const key = Object.keys(vars).find((k) => k.toUpperCase() === upper);
108
+ if (key !== undefined)
109
+ return vars[key];
110
+ const envKey = Object.keys(process.env).find((k) => k.toUpperCase() === upper);
111
+ return envKey !== undefined ? process.env[envKey] : undefined;
112
+ };
113
+ const out = {};
114
+ for (const [k, v] of Object.entries(vars)) {
115
+ out[k] = v.replace(/%([^%]+)%/g, (m, name) => lookup(name) ?? m);
116
+ }
117
+ return out;
118
+ }
119
+ async function readUnixEnvironment(env) {
120
+ const shell = env.SHELL && env.SHELL.length > 0 ? env.SHELL : "/bin/sh";
121
+ const dump = `${quote(process.execPath)} -e 'process.stdout.write("${BEGIN}"+JSON.stringify(process.env)+"${END}")'`;
122
+ // Interactive login shell first: Debian/Ubuntu ~/.bashrc returns early when not interactive.
123
+ const attempts = [
124
+ ["-ilc", dump],
125
+ ["-lc", dump],
126
+ ];
127
+ for (const args of attempts) {
128
+ const out = await capture(shell, args, 15000, { ...env, TERM: "dumb" });
129
+ const parsed = extractJson(out);
130
+ if (parsed)
131
+ return parsed;
132
+ }
133
+ return undefined;
134
+ }
135
+ function quote(s) {
136
+ return `'${s.replace(/'/g, `'\\''`)}'`;
137
+ }
138
+ function extractJson(out) {
139
+ if (!out)
140
+ return undefined;
141
+ const start = out.indexOf(BEGIN);
142
+ const end = out.lastIndexOf(END);
143
+ if (start === -1 || end === -1 || end <= start)
144
+ return undefined;
145
+ try {
146
+ const parsed = JSON.parse(out.slice(start + BEGIN.length, end).trim());
147
+ if (typeof parsed !== "object" || parsed === null)
148
+ return undefined;
149
+ const result = {};
150
+ for (const [k, v] of Object.entries(parsed)) {
151
+ if (typeof v === "string")
152
+ result[k] = v;
153
+ }
154
+ return result;
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ }
160
+ function capture(file, args, timeoutMs, env = process.env) {
161
+ return new Promise((resolve) => {
162
+ let child;
163
+ try {
164
+ child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"], env, windowsHide: true });
165
+ }
166
+ catch {
167
+ resolve(undefined);
168
+ return;
169
+ }
170
+ let stdout = "";
171
+ child.stdout?.on("data", (d) => (stdout += d.toString()));
172
+ child.stderr?.on("data", () => undefined);
173
+ const timer = setTimeout(() => child.kill(), timeoutMs);
174
+ child.on("error", () => {
175
+ clearTimeout(timer);
176
+ resolve(undefined);
177
+ });
178
+ child.on("close", () => {
179
+ clearTimeout(timer);
180
+ resolve(stdout);
181
+ });
182
+ });
183
+ }
@@ -0,0 +1,62 @@
1
+ import { spawn } from "node:child_process";
2
+ import { findOnPath } from "./which.js";
3
+ import { log } from "./log.js";
4
+ export const DEFAULT_CONFIG_FILENAME = "envsync.json";
5
+ /**
6
+ * Parse `owner/repo`, `owner/repo@ref`, `owner/repo:path/to/file.json`
7
+ * or `owner/repo@ref:path`. Path defaults to envsync.json at the repo root.
8
+ */
9
+ export function parseGitHubRef(input) {
10
+ const trimmed = input.trim().replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
11
+ const colon = trimmed.indexOf(":");
12
+ const repoPart = colon === -1 ? trimmed : trimmed.slice(0, colon);
13
+ const filePath = colon === -1 ? DEFAULT_CONFIG_FILENAME : trimmed.slice(colon + 1).replace(/^\/+/, "");
14
+ const at = repoPart.indexOf("@");
15
+ const ownerRepo = at === -1 ? repoPart : repoPart.slice(0, at);
16
+ const ref = at === -1 ? undefined : repoPart.slice(at + 1);
17
+ const [owner, repo, ...rest] = ownerRepo.split("/").filter(Boolean);
18
+ if (!owner || !repo || rest.length) {
19
+ throw new Error(`Expected "owner/repo[@ref][:path]", got "${input}"`);
20
+ }
21
+ return { owner, repo, ref: ref || undefined, path: filePath || DEFAULT_CONFIG_FILENAME };
22
+ }
23
+ /** Read a file from GitHub. Uses `gh` when present (private repos work), else raw.githubusercontent.com. */
24
+ export async function fetchFromGitHub(ref) {
25
+ if (findOnPath("gh")) {
26
+ const endpoint = `repos/${ref.owner}/${ref.repo}/contents/${ref.path}${ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : ""}`;
27
+ log.debug(`gh api ${endpoint}`);
28
+ const result = await run("gh", ["api", endpoint, "-H", "Accept: application/vnd.github.raw+json"]);
29
+ if (result.code === 0)
30
+ return result.stdout;
31
+ log.debug(`gh api failed (${result.code}): ${result.stderr.trim()}`);
32
+ log.warn(null, "gh could not read the file, trying raw.githubusercontent.com");
33
+ }
34
+ else {
35
+ log.debug("gh not found on PATH, using raw.githubusercontent.com (public repos only)");
36
+ }
37
+ const url = `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/${ref.ref ?? "HEAD"}/${ref.path}`;
38
+ const headers = {};
39
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
40
+ if (token)
41
+ headers.Authorization = `Bearer ${token}`;
42
+ return fetchText(url, headers);
43
+ }
44
+ export async function fetchText(url, headers = {}) {
45
+ log.debug(`GET ${url}`);
46
+ const response = await fetch(url, { headers, redirect: "follow" });
47
+ if (!response.ok) {
48
+ throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
49
+ }
50
+ return response.text();
51
+ }
52
+ function run(file, args) {
53
+ return new Promise((resolve) => {
54
+ const child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
55
+ let stdout = "";
56
+ let stderr = "";
57
+ child.stdout.on("data", (d) => (stdout += d.toString()));
58
+ child.stderr.on("data", (d) => (stderr += d.toString()));
59
+ child.on("error", (err) => resolve({ code: 1, stdout, stderr: err.message }));
60
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
61
+ });
62
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Strip `//` and `/* *\/` comments plus trailing commas so hand written
3
+ * config files can carry notes. String contents are left untouched.
4
+ */
5
+ export function stripJsonComments(input) {
6
+ let out = "";
7
+ let i = 0;
8
+ const n = input.length;
9
+ while (i < n) {
10
+ const ch = input[i];
11
+ const next = input[i + 1];
12
+ if (ch === '"') {
13
+ const j = endOfString(input, i);
14
+ out += input.slice(i, j + 1);
15
+ i = j + 1;
16
+ continue;
17
+ }
18
+ if (ch === "/" && next === "/") {
19
+ while (i < n && input[i] !== "\n")
20
+ i++;
21
+ continue;
22
+ }
23
+ if (ch === "/" && next === "*") {
24
+ const end = input.indexOf("*/", i + 2);
25
+ i = end === -1 ? n : end + 2;
26
+ continue;
27
+ }
28
+ out += ch;
29
+ i++;
30
+ }
31
+ return removeTrailingCommas(out);
32
+ }
33
+ function endOfString(input, start) {
34
+ let j = start + 1;
35
+ while (j < input.length) {
36
+ if (input[j] === "\\") {
37
+ j += 2;
38
+ continue;
39
+ }
40
+ if (input[j] === '"')
41
+ break;
42
+ j++;
43
+ }
44
+ return j;
45
+ }
46
+ function removeTrailingCommas(input) {
47
+ let out = "";
48
+ let i = 0;
49
+ const n = input.length;
50
+ while (i < n) {
51
+ const ch = input[i];
52
+ if (ch === '"') {
53
+ const j = endOfString(input, i);
54
+ out += input.slice(i, j + 1);
55
+ i = j + 1;
56
+ continue;
57
+ }
58
+ if (ch === ",") {
59
+ let j = i + 1;
60
+ while (j < n && /\s/.test(input[j]))
61
+ j++;
62
+ if (input[j] === "}" || input[j] === "]") {
63
+ i++;
64
+ continue;
65
+ }
66
+ }
67
+ out += ch;
68
+ i++;
69
+ }
70
+ return out;
71
+ }
72
+ export function parseJsonc(text) {
73
+ return JSON.parse(stripJsonComments(text));
74
+ }
@@ -0,0 +1,47 @@
1
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
2
+ const ESC = String.fromCharCode(27);
3
+ function paint(code, text) {
4
+ return useColor ? `${ESC}[${code}m${text}${ESC}[0m` : text;
5
+ }
6
+ export const c = {
7
+ dim: (t) => paint("2", t),
8
+ bold: (t) => paint("1", t),
9
+ green: (t) => paint("32", t),
10
+ yellow: (t) => paint("33", t),
11
+ red: (t) => paint("31", t),
12
+ cyan: (t) => paint("36", t),
13
+ };
14
+ let verbose = false;
15
+ export function setVerbose(on) {
16
+ verbose = on;
17
+ }
18
+ export function isVerbose() {
19
+ return verbose;
20
+ }
21
+ export const log = {
22
+ info(msg) {
23
+ console.log(msg);
24
+ },
25
+ step(tool, msg) {
26
+ console.log(`${c.cyan(`[${tool}]`)} ${msg}`);
27
+ },
28
+ ok(tool, msg) {
29
+ console.log(`${c.cyan(`[${tool}]`)} ${c.green(msg)}`);
30
+ },
31
+ warn(tool, msg) {
32
+ const prefix = tool ? `${c.cyan(`[${tool}]`)} ` : "";
33
+ console.log(`${prefix}${c.yellow(msg)}`);
34
+ },
35
+ error(tool, msg) {
36
+ const prefix = tool ? `${c.cyan(`[${tool}]`)} ` : "";
37
+ console.error(`${prefix}${c.red(msg)}`);
38
+ },
39
+ debug(msg) {
40
+ if (verbose)
41
+ console.log(c.dim(` ${msg}`));
42
+ },
43
+ command(cmd) {
44
+ for (const line of cmd.trim().split(/\r?\n/))
45
+ console.log(c.dim(` $ ${line}`));
46
+ },
47
+ };
@@ -0,0 +1,234 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import { findOnPath } from "./which.js";
4
+ const PACKAGE_MANAGERS = [
5
+ { selector: "apt", binaries: ["apt-get"], os: ["linux"] },
6
+ { selector: "dnf", binaries: ["dnf"], os: ["linux"] },
7
+ { selector: "yum", binaries: ["yum"], os: ["linux"] },
8
+ { selector: "pacman", binaries: ["pacman"], os: ["linux"] },
9
+ { selector: "zypper", binaries: ["zypper"], os: ["linux"] },
10
+ { selector: "apk", binaries: ["apk"], os: ["linux"] },
11
+ { selector: "nix", binaries: ["nix-env", "nix"], os: ["linux", "macos"] },
12
+ { selector: "brew", binaries: ["brew"], os: ["linux", "macos"] },
13
+ { selector: "port", binaries: ["port"], os: ["macos"] },
14
+ { selector: "snap", binaries: ["snap"], os: ["linux"] },
15
+ { selector: "flatpak", binaries: ["flatpak"], os: ["linux"] },
16
+ { selector: "winget", binaries: ["winget"], os: ["windows"] },
17
+ { selector: "choco", binaries: ["choco"], os: ["windows"] },
18
+ { selector: "scoop", binaries: ["scoop"], os: ["windows"] },
19
+ ];
20
+ export const KNOWN_PACKAGE_MANAGERS = PACKAGE_MANAGERS.map((p) => p.selector);
21
+ export function gatherFacts() {
22
+ const read = (file) => {
23
+ try {
24
+ return fs.readFileSync(file, "utf8");
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ };
30
+ const facts = {
31
+ nodePlatform: process.platform,
32
+ arch: process.arch,
33
+ release: os.release(),
34
+ hasBinary: (name) => findOnPath(name) !== undefined,
35
+ env: process.env,
36
+ hasFile: (file) => fs.existsSync(file),
37
+ uid: typeof process.getuid === "function" ? process.getuid() : undefined,
38
+ stdinTty: Boolean(process.stdin.isTTY),
39
+ stdoutTty: Boolean(process.stdout.isTTY),
40
+ };
41
+ if (process.platform === "linux") {
42
+ const osRelease = read("/etc/os-release") ?? read("/usr/lib/os-release");
43
+ if (osRelease !== undefined)
44
+ facts.osRelease = osRelease;
45
+ const procVersion = read("/proc/version");
46
+ if (procVersion !== undefined)
47
+ facts.procVersion = procVersion;
48
+ }
49
+ return facts;
50
+ }
51
+ export function detectPlatform(facts = gatherFacts()) {
52
+ const osFamily = toOsFamily(facts.nodePlatform);
53
+ const arch = facts.arch;
54
+ let id = osFamily;
55
+ let version = "";
56
+ let like = [];
57
+ let wsl = false;
58
+ if (osFamily === "linux") {
59
+ const rel = parseOsRelease(facts.osRelease ?? "");
60
+ id = (rel.ID ?? "linux").toLowerCase();
61
+ version = rel.VERSION_ID ?? "";
62
+ like = (rel.ID_LIKE ?? "")
63
+ .split(/\s+/)
64
+ .map((s) => s.toLowerCase())
65
+ .filter((s) => s && s !== id);
66
+ wsl = /microsoft/i.test(facts.procVersion ?? "") || /microsoft/i.test(facts.release);
67
+ }
68
+ else if (osFamily === "macos") {
69
+ version = darwinToMacosVersion(facts.release);
70
+ }
71
+ else {
72
+ version = windowsVersion(facts.release);
73
+ }
74
+ const packageManagers = PACKAGE_MANAGERS.filter((pm) => pm.os.includes(osFamily) && pm.binaries.some((b) => facts.hasBinary(b))).map((pm) => pm.selector);
75
+ const host = detectHost(facts, wsl);
76
+ const root = facts.uid === 0;
77
+ const sudo = osFamily !== "windows" && !root && facts.hasBinary("sudo") ? "sudo" : "";
78
+ const partial = {
79
+ os: osFamily,
80
+ id,
81
+ version,
82
+ like,
83
+ packageManagers,
84
+ arch,
85
+ wsl,
86
+ host,
87
+ interactive: facts.stdinTty && facts.stdoutTty,
88
+ root,
89
+ sudo,
90
+ terminal: detectTerminal(facts.env),
91
+ };
92
+ return { ...partial, selectors: buildSelectors(partial) };
93
+ }
94
+ /**
95
+ * Things about where we run that are not the OS. Ordered: agent sandboxes,
96
+ * hosted workspaces, CI, container, WSL.
97
+ */
98
+ export function detectHost(facts, wsl) {
99
+ const env = facts.env;
100
+ const out = [];
101
+ const truthy = (v) => v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false";
102
+ if (truthy(env.CLAUDECODE) || truthy(env.CLAUDE_CODE_ENTRYPOINT))
103
+ out.push("claude-code");
104
+ // Codex sets CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED and friends.
105
+ if (Object.keys(env).some((k) => k.startsWith("CODEX_")))
106
+ out.push("codex");
107
+ if (truthy(env.CODESPACES))
108
+ out.push("codespaces");
109
+ if (truthy(env.GITPOD_WORKSPACE_ID))
110
+ out.push("gitpod");
111
+ if (truthy(env.CI) || truthy(env.GITHUB_ACTIONS))
112
+ out.push("ci");
113
+ const inContainer = facts.hasFile("/.dockerenv") ||
114
+ facts.hasFile("/run/.containerenv") ||
115
+ truthy(env.container) ||
116
+ truthy(env.KUBERNETES_SERVICE_HOST);
117
+ if (inContainer)
118
+ out.push("container");
119
+ if (wsl)
120
+ out.push("wsl");
121
+ return out;
122
+ }
123
+ export function detectTerminal(env) {
124
+ if (env.TERM_PROGRAM)
125
+ return env.TERM_PROGRAM;
126
+ if (env.WT_SESSION)
127
+ return "windows-terminal";
128
+ return "";
129
+ }
130
+ /**
131
+ * Selector order, most specific first:
132
+ * host (claude-code, codex, codespaces, gitpod, ci, container, wsl),
133
+ * id-version, id-major, id, ID_LIKE entries, package managers, os family, unix, default
134
+ */
135
+ export function buildSelectors(p) {
136
+ const out = [];
137
+ const push = (s) => {
138
+ if (s && !out.includes(s))
139
+ out.push(s);
140
+ };
141
+ for (const e of p.host)
142
+ push(e);
143
+ if (p.version) {
144
+ push(`${p.id}-${p.version}`);
145
+ const major = p.version.split(".")[0];
146
+ if (major && major !== p.version)
147
+ push(`${p.id}-${major}`);
148
+ }
149
+ push(p.id);
150
+ for (const l of p.like)
151
+ push(l);
152
+ for (const pm of p.packageManagers)
153
+ push(pm);
154
+ push(p.os);
155
+ if (p.os !== "windows")
156
+ push("unix");
157
+ push("default");
158
+ return out;
159
+ }
160
+ export function toOsFamily(nodePlatform) {
161
+ if (nodePlatform === "win32")
162
+ return "windows";
163
+ if (nodePlatform === "darwin")
164
+ return "macos";
165
+ return "linux";
166
+ }
167
+ export function parseOsRelease(text) {
168
+ const out = {};
169
+ for (const rawLine of text.split(/\r?\n/)) {
170
+ const line = rawLine.trim();
171
+ if (!line || line.startsWith("#"))
172
+ continue;
173
+ const eq = line.indexOf("=");
174
+ if (eq === -1)
175
+ continue;
176
+ const key = line.slice(0, eq).trim();
177
+ let value = line.slice(eq + 1).trim();
178
+ const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"));
179
+ if (quoted)
180
+ value = value.slice(1, -1);
181
+ out[key] = value.replace(/\\(.)/g, "$1");
182
+ }
183
+ return out;
184
+ }
185
+ /** Darwin kernel major to marketing version. Darwin 20 is macOS 11, and so on. */
186
+ export function darwinToMacosVersion(release) {
187
+ const major = Number.parseInt(release.split(".")[0] ?? "", 10);
188
+ if (Number.isNaN(major))
189
+ return "";
190
+ if (major >= 20)
191
+ return String(major - 9);
192
+ if (major >= 5)
193
+ return `10.${major - 4}`;
194
+ return "";
195
+ }
196
+ /** NT build 22000 and above is Windows 11. */
197
+ export function windowsVersion(release) {
198
+ const parts = release.split(".");
199
+ const build = Number.parseInt(parts[2] ?? "", 10);
200
+ const major = Number.parseInt(parts[0] ?? "", 10);
201
+ if (major === 10 && !Number.isNaN(build))
202
+ return build >= 22000 ? "11" : "10";
203
+ if (!Number.isNaN(major))
204
+ return String(major);
205
+ return "";
206
+ }
207
+ export function describePlatform(p) {
208
+ const bits = [`${p.id}${p.version ? " " + p.version : ""}`, p.arch];
209
+ if (p.like.length)
210
+ bits.push(`like: ${p.like.join(", ")}`);
211
+ if (p.host.length)
212
+ bits.push(`host: ${p.host.join(", ")}`);
213
+ if (p.packageManagers.length)
214
+ bits.push(`package managers: ${p.packageManagers.join(", ")}`);
215
+ return bits.join(" | ");
216
+ }
217
+ /** Environment variables every command can read. */
218
+ export function platformEnvVars(p, shell) {
219
+ const vars = {
220
+ ENVSYNC_OS: p.os,
221
+ ENVSYNC_ID: p.id,
222
+ ENVSYNC_VERSION: p.version,
223
+ ENVSYNC_ARCH: p.arch,
224
+ ENVSYNC_SELECTORS: p.selectors.join(","),
225
+ ENVSYNC_HOST: p.host.join(","),
226
+ ENVSYNC_INTERACTIVE: p.interactive ? "1" : "0",
227
+ ENVSYNC_ROOT: p.root ? "1" : "0",
228
+ ENVSYNC_SUDO: p.sudo,
229
+ ENVSYNC_TERMINAL: p.terminal,
230
+ };
231
+ if (shell)
232
+ vars.ENVSYNC_SHELL = shell;
233
+ return vars;
234
+ }
@@ -0,0 +1,35 @@
1
+ export function isCommandObject(value) {
2
+ return typeof value === "object" && value !== null && "run" in value;
3
+ }
4
+ export function isPlatformMap(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !isCommandObject(value);
6
+ }
7
+ /**
8
+ * Pick the command for this machine. Selectors are ordered most specific
9
+ * first, so `linuxmint` beats `ubuntu` beats `apt` beats `linux` beats `default`.
10
+ */
11
+ export function resolveCommand(command, selectors) {
12
+ if (command === undefined)
13
+ return { kind: "undefined" };
14
+ if (!isPlatformMap(command))
15
+ return fromValue(command, "direct");
16
+ for (const selector of selectors) {
17
+ if (Object.prototype.hasOwnProperty.call(command, selector)) {
18
+ return fromValue(command[selector], selector);
19
+ }
20
+ }
21
+ return { kind: "no-match", available: Object.keys(command) };
22
+ }
23
+ function fromValue(value, selector) {
24
+ if (value === null)
25
+ return { kind: "skipped", selector };
26
+ if (typeof value === "string" || Array.isArray(value)) {
27
+ return { kind: "command", command: { ...toText(value), shell: undefined, selector } };
28
+ }
29
+ return { kind: "command", command: { ...toText(value.run), shell: value.shell, selector } };
30
+ }
31
+ function toText(value) {
32
+ if (typeof value === "string")
33
+ return { run: value, failFast: false };
34
+ return { run: value.join("\n"), failFast: true };
35
+ }