@catalyst-cloud/cli 0.16.22 → 0.16.24

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,119 @@
1
+ // machine.ts — what this computer adds to `catalyst env draft`: the names the repository's `.envrc`
2
+ // exports and Claude Code's HTTP MCP servers. Names and references only; see `scan-envrc.ts` and
3
+ // `scan-claude-mcp.ts` for why neither returns a value.
4
+ //
5
+ // ⛔ `.env` and `.dev.vars` are never opened, even through an `.envrc` that is a link to one: the
6
+ // default reader resolves the real path first and refuses those basenames, the same rule the
7
+ // repository inventory keeps.
8
+ import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
9
+ import { basename, join } from "node:path";
10
+ import { isSecretName } from "./draft.js";
11
+ import { scanClaudeMcp } from "./scan-claude-mcp.js";
12
+ import { scanEnvrc } from "./scan-envrc.js";
13
+ const ENVRC_CAP = 1024 * 1024;
14
+ const CLAUDE_JSON_CAP = 32 * 1024 * 1024;
15
+ const REFUSED_BASENAMES = new Set([".env", ".dev.vars"]);
16
+ /** Read `.envrc` and `~/.claude.json` once, through the injected reader or the guarded default. */
17
+ export function readMachineFiles(root, options) {
18
+ const notes = [];
19
+ const read = (path, cap, label) => {
20
+ if (options.readMachineFile)
21
+ return options.readMachineFile(path, cap);
22
+ const result = readGuarded(path, cap);
23
+ if (result.note !== undefined)
24
+ notes.push(`${label} ${result.note}`);
25
+ return result.text;
26
+ };
27
+ const envrcText = read(join(root, ".envrc"), ENVRC_CAP, ".envrc");
28
+ const claudeText = read(join(options.home, ".claude.json"), CLAUDE_JSON_CAP, "~/.claude.json");
29
+ return { envrcText, claudeText, repoRoots: repoRootsFor(root), notes };
30
+ }
31
+ function repoRootsFor(root) {
32
+ const roots = [root];
33
+ try {
34
+ const real = realpathSync(root);
35
+ if (real !== root)
36
+ roots.push(real);
37
+ }
38
+ catch {
39
+ // An unreadable root has no realpath; the given path is the only key.
40
+ }
41
+ return roots;
42
+ }
43
+ /** Scan this computer for names. Missing files leave their half null, so the draft is unchanged.
44
+ * `repositoryNames` are the names the repository already declares; a derived MCP header variable
45
+ * may take neither one of those nor an `.envrc` export. */
46
+ export function scanMachine(root, options, repositoryNames = new Set()) {
47
+ const files = readMachineFiles(root, options);
48
+ const envrc = files.envrcText === undefined ? null : scanEnvrc(files.envrcText);
49
+ const names = new Set();
50
+ const secrets = new Set();
51
+ const sources = [];
52
+ for (const entry of envrc?.exported ?? []) {
53
+ names.add(entry.name);
54
+ if (isSecretName(entry.name))
55
+ secrets.add(entry.name);
56
+ }
57
+ if (envrc !== null && envrc.exported.length > 0)
58
+ sources.push(".envrc");
59
+ const claude = files.claudeText === undefined ? null : scanClaudeMcp(files.claudeText, files.repoRoots, new Set([...repositoryNames, ...names]));
60
+ for (const server of claude?.servers ?? []) {
61
+ for (const name of server.references) {
62
+ names.add(name);
63
+ secrets.add(name);
64
+ }
65
+ }
66
+ if (claude !== null && claude.servers.length > 0)
67
+ sources.push("~/.claude.json");
68
+ const sort = (values) => [...values].sort((a, b) => a.localeCompare(b, "en"));
69
+ return { envrc, claude, names: sort(names), secretNames: sort(secrets), sources, notes: files.notes };
70
+ }
71
+ /** UTF-8 (with or without a byte-order mark) or UTF-16LE with its mark, as Windows editors save it. */
72
+ function decode(bytes) {
73
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
74
+ return { text: bytes.subarray(2).toString("utf16le") };
75
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
76
+ return { note: "is UTF-16 big-endian, which this reader does not decode; it was not read" };
77
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
78
+ return { text: bytes.subarray(3).toString("utf8") };
79
+ if (bytes.includes(0))
80
+ return { note: "holds NUL bytes, so it is not UTF-8 text; it was not read" };
81
+ return { text: bytes.toString("utf8") };
82
+ }
83
+ function readGuarded(path, cap) {
84
+ let real;
85
+ try {
86
+ real = realpathSync(path);
87
+ }
88
+ catch {
89
+ return {};
90
+ }
91
+ if (REFUSED_BASENAMES.has(basename(real)))
92
+ return { note: `links to a ${basename(real)} file, which is never read; it was not read` };
93
+ let fd;
94
+ try {
95
+ fd = openSync(real, "r");
96
+ }
97
+ catch {
98
+ return { note: "could not be opened; it was not read" };
99
+ }
100
+ try {
101
+ const stat = fstatSync(fd);
102
+ if (!stat.isFile())
103
+ return { note: "is not a regular file; it was not read" };
104
+ if (stat.size > cap)
105
+ return { note: `is larger than ${Math.round(cap / 1024 / 1024)} MiB; it was not read` };
106
+ const buffer = Buffer.alloc(stat.size);
107
+ let offset = 0;
108
+ while (offset < stat.size) {
109
+ const n = readSync(fd, buffer, offset, stat.size - offset, offset);
110
+ if (n === 0)
111
+ break;
112
+ offset += n;
113
+ }
114
+ return decode(buffer.subarray(0, offset));
115
+ }
116
+ finally {
117
+ closeSync(fd);
118
+ }
119
+ }
@@ -0,0 +1,274 @@
1
+ // scan-claude-mcp.ts — Claude Code's HTTP MCP servers (`~/.claude.json`), turned into
2
+ // `[[environment.mcp_servers]]` declarations whose every header is a `$NAME` reference.
3
+ //
4
+ // Two sources are read, in Claude Code's own precedence: the top-level `mcpServers` (its user scope)
5
+ // and `projects[<this repository's path>].mcpServers` (its default local scope), the local entry
6
+ // winning a name clash. No server is contacted and no `claude` command runs.
7
+ //
8
+ // ⛔ `scanClaudeMcp` RETURNS NO HEADER VALUE. A header that references `${NAME}` keeps the name; a
9
+ // header that holds a literal becomes a new secret variable (`<SERVER>_MCP_TOKEN` and friends) and
10
+ // the literal is dropped with the parsed object. `claudeHeaderLiterals` is the one export that hands
11
+ // a literal back, and only the import's value resolver (`machine-values.ts`) calls it.
12
+ import { validateFullSettings } from "../../vendor/settings/index.js";
13
+ const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-_]{0,127}$/;
14
+ const REF_RE = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}|([A-Za-z_][A-Za-z0-9_]*))$/;
15
+ const UPPER_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
16
+ const MAX_SERVERS = 64;
17
+ const MAX_HEADERS = 32;
18
+ /** A Catalyst server name: lowercase, `[a-z0-9_-]`, starting with a letter, at most 64 characters. */
19
+ function toServerName(configName) {
20
+ const name = configName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 64);
21
+ return /^[a-z][a-z0-9_-]{0,63}$/.test(name) ? name : null;
22
+ }
23
+ /** An upper-case variable stem from a server or header name: `my-server` → `MY_SERVER`. */
24
+ function toVariableName(text) {
25
+ return text.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
26
+ }
27
+ function derivedVariable(serverName, header, bearer) {
28
+ const stem = toVariableName(serverName);
29
+ const prefix = /^[A-Z]/.test(stem) ? stem : `MCP_${stem}`;
30
+ if (header.toLowerCase() === "authorization")
31
+ return `${prefix}_MCP_${bearer ? "TOKEN" : "AUTHORIZATION"}`;
32
+ return `${prefix}_MCP_${toVariableName(header)}`;
33
+ }
34
+ export function scanClaudeMcp(text, repoRoots, declared) {
35
+ if (text === undefined)
36
+ return { servers: [], notDrafted: [], note: "~/.claude.json was not found" };
37
+ const entries = readEntries(text, repoRoots);
38
+ if (typeof entries === "string")
39
+ return { servers: [], notDrafted: [], note: entries };
40
+ const servers = [];
41
+ const notDrafted = [];
42
+ const taken = new Set(declared);
43
+ // Reserve explicit references before deriving names, including later servers and headers.
44
+ // Use the effective entries after project overrides, just as the import lookup does.
45
+ for (const entry of entries) {
46
+ const name = toServerName(entry.configName);
47
+ if (name === null || !isRecord(entry.raw) || !isRecord(entry.raw.headers))
48
+ continue;
49
+ for (const [header, value] of Object.entries(entry.raw.headers)) {
50
+ if (!HEADER_NAME_RE.test(header) || typeof value !== "string")
51
+ continue;
52
+ const read = readHeader(name, header, value);
53
+ if (read.kind === "reference")
54
+ taken.add(read.name);
55
+ }
56
+ }
57
+ const names = new Set();
58
+ for (const entry of entries) {
59
+ const outcome = draftServer(entry, taken);
60
+ if ("reason" in outcome) {
61
+ notDrafted.push({ name: entry.configName, reason: outcome.reason });
62
+ continue;
63
+ }
64
+ if (names.has(outcome.name)) {
65
+ notDrafted.push({ name: entry.configName, reason: `its name becomes ${outcome.name}, which another server already uses` });
66
+ continue;
67
+ }
68
+ if (servers.length >= MAX_SERVERS) {
69
+ notDrafted.push({ name: entry.configName, reason: `more than ${MAX_SERVERS} servers; add it by hand` });
70
+ continue;
71
+ }
72
+ if (!passesSettingsCheck(outcome)) {
73
+ notDrafted.push({ name: entry.configName, reason: "Catalyst's settings check refused it; add it by hand" });
74
+ continue;
75
+ }
76
+ names.add(outcome.name);
77
+ for (const { variable } of outcome.derived)
78
+ taken.add(variable);
79
+ servers.push(outcome);
80
+ }
81
+ return { servers, notDrafted };
82
+ }
83
+ /** Import only. The literal (without `Bearer `) behind each requested variable the draft derives. Only
84
+ * servers the draft itself drafts count, given the same `declared` names it saw (the `.envrc` exports):
85
+ * a server it refused, or one whose derived name clashed, never supplies a value. */
86
+ export function claudeHeaderLiterals(text, repoRoots, wanted, declared = new Set()) {
87
+ const values = new Map();
88
+ const entries = readEntries(text, repoRoots);
89
+ if (typeof entries === "string")
90
+ return values;
91
+ const drafted = new Map(scanClaudeMcp(text, repoRoots, declared).servers.map((server) => [server.configName, server]));
92
+ for (const entry of entries) {
93
+ const server = drafted.get(entry.configName);
94
+ if (server === undefined || !isRecord(entry.raw) || !isRecord(entry.raw.headers))
95
+ continue;
96
+ const derived = new Set(server.derived.map(({ variable }) => variable));
97
+ for (const [header, value] of Object.entries(entry.raw.headers)) {
98
+ if (typeof value !== "string")
99
+ continue;
100
+ const read = readHeader(server.name, header, value);
101
+ if (read.kind === "literal" && derived.has(read.variable) && wanted.has(read.variable))
102
+ values.set(read.variable, read.literal);
103
+ }
104
+ }
105
+ return values;
106
+ }
107
+ /** One `[[environment.mcp_servers]]` block. Header keys are JSON-quoted. */
108
+ export function mcpServerToml(server) {
109
+ const headers = Object.entries(server.headers).map(([name, value]) => `${JSON.stringify(name)} = ${JSON.stringify(value)}`);
110
+ return [
111
+ "[[environment.mcp_servers]]",
112
+ `name = ${JSON.stringify(server.name)}`,
113
+ 'transport = "http"',
114
+ `url = ${JSON.stringify(server.url)}`,
115
+ `headers = { ${headers.join(", ")} }`,
116
+ "",
117
+ ].join("\n");
118
+ }
119
+ function readEntries(text, repoRoots) {
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(text);
123
+ }
124
+ catch {
125
+ return "~/.claude.json is not valid JSON; no MCP server was drafted";
126
+ }
127
+ if (!isRecord(parsed))
128
+ return "~/.claude.json does not hold a JSON object; no MCP server was drafted";
129
+ const byName = new Map();
130
+ if (isRecord(parsed.mcpServers)) {
131
+ for (const [configName, raw] of Object.entries(parsed.mcpServers))
132
+ byName.set(configName, { configName, scope: "user", raw });
133
+ }
134
+ const projects = isRecord(parsed.projects) ? parsed.projects : {};
135
+ // Claude Code keys projects by path as it saw it: on Windows `C:/Users/…` while Node gives
136
+ // `C:\Users\…`. Compare the two spellings, not the exact key.
137
+ const projectKeys = new Map(Object.keys(projects).map((key) => [projectPathKey(key), key]));
138
+ for (const root of new Set(repoRoots.map(projectPathKey))) {
139
+ const key = projectKeys.get(root);
140
+ const project = key === undefined ? undefined : projects[key];
141
+ if (!isRecord(project) || !isRecord(project.mcpServers))
142
+ continue;
143
+ for (const [configName, raw] of Object.entries(project.mcpServers)) {
144
+ byName.delete(configName);
145
+ byName.set(configName, { configName, scope: "project", raw });
146
+ }
147
+ }
148
+ return [...byName.values()].sort((a, b) => a.configName.localeCompare(b.configName, "en"));
149
+ }
150
+ function draftServer(entry, taken) {
151
+ const raw = entry.raw;
152
+ if (!isRecord(raw))
153
+ return { reason: "its entry is not an object" };
154
+ const type = typeof raw.type === "string" ? raw.type : raw.command !== undefined ? "stdio" : raw.url !== undefined ? "http" : "unknown";
155
+ if (type === "stdio" || raw.command !== undefined)
156
+ return { reason: "a stdio server runs a command on this computer" };
157
+ if (type === "sse")
158
+ return { reason: "SSE servers are not drafted" };
159
+ if (type !== "http")
160
+ return { reason: `${type} servers are not drafted` };
161
+ const urlCheck = checkUrl(raw.url);
162
+ if (urlCheck !== null)
163
+ return { reason: urlCheck };
164
+ const url = String(raw.url).trim();
165
+ const name = toServerName(entry.configName);
166
+ if (name === null)
167
+ return { reason: "its name cannot become a Catalyst server name (letters, digits, - and _)" };
168
+ if (!isRecord(raw.headers) || Object.keys(raw.headers).length === 0) {
169
+ return { reason: "no header auth; it likely signs in with OAuth, which phases cannot reuse" };
170
+ }
171
+ const rawHeaders = Object.entries(raw.headers);
172
+ if (rawHeaders.length > MAX_HEADERS)
173
+ return { reason: `it has more than ${MAX_HEADERS} headers` };
174
+ const headers = {};
175
+ const references = new Set();
176
+ const derived = [];
177
+ for (const [header, value] of rawHeaders) {
178
+ if (!HEADER_NAME_RE.test(header))
179
+ return { reason: "a header name Catalyst cannot carry" };
180
+ if (typeof value !== "string")
181
+ return { reason: "a header value is not text" };
182
+ const read = readHeader(name, header, value);
183
+ if (read.kind === "refused")
184
+ return { reason: read.reason };
185
+ const variable = read.kind === "reference" ? read.name : read.variable;
186
+ if (read.kind === "literal") {
187
+ if (taken.has(variable) || references.has(variable)) {
188
+ return { reason: `its ${header} header held a value, and the name it would get, ${variable}, is already declared; add it by hand` };
189
+ }
190
+ derived.push({ variable, header, bearer: read.bearer });
191
+ }
192
+ references.add(variable);
193
+ headers[header] = read.bearer ? `Bearer $${variable}` : `$${variable}`;
194
+ }
195
+ return {
196
+ name,
197
+ configName: entry.configName,
198
+ scope: entry.scope,
199
+ url,
200
+ headers,
201
+ references: [...references].sort((a, b) => a.localeCompare(b, "en")),
202
+ derived,
203
+ };
204
+ }
205
+ function readHeader(serverName, header, value) {
206
+ const isAuthorization = header.toLowerCase() === "authorization";
207
+ const bearerMatch = isAuthorization ? /^Bearer\s+(.*)$/i.exec(value) : null;
208
+ const bearer = bearerMatch !== null;
209
+ const body = bearerMatch ? bearerMatch[1] : value;
210
+ const ref = REF_RE.exec(body);
211
+ if (ref) {
212
+ const name = ref[1] ?? ref[2];
213
+ if (!UPPER_NAME_RE.test(name))
214
+ return { kind: "refused", reason: "a header references a variable whose name is not upper-case" };
215
+ return { kind: "reference", name, bearer };
216
+ }
217
+ if (body.includes("$"))
218
+ return { kind: "refused", reason: "a header mixes text and a variable" };
219
+ return { kind: "literal", variable: derivedVariable(serverName, header, bearer), bearer, literal: body };
220
+ }
221
+ function checkUrl(raw) {
222
+ if (typeof raw !== "string")
223
+ return "it has no URL";
224
+ let url;
225
+ try {
226
+ url = new URL(raw.trim());
227
+ }
228
+ catch {
229
+ return "its URL could not be read";
230
+ }
231
+ if (url.username || url.password || url.search || url.hash || raw.includes("?") || raw.includes("#")) {
232
+ return "its URL carries a query string or sign-in details; add it by hand";
233
+ }
234
+ if (isPrivateHost(url.hostname))
235
+ return "it points at this computer or a private network";
236
+ if (url.protocol !== "https:")
237
+ return "its URL does not use https";
238
+ if (url.pathname.split("/").some(looksLikeKey))
239
+ return "its URL path looks like it carries a key; add it by hand";
240
+ if (url.hostname.split(".").some(looksLikeKey))
241
+ return "its URL host looks like it carries a key; add it by hand";
242
+ return null;
243
+ }
244
+ /** A path segment or host label shaped like a key: long, and mixing letters with digits, including
245
+ * base64's `+` and `=` and percent-escapes. `mcp`, `v1` and `sse` are not. */
246
+ function looksLikeKey(segment) {
247
+ return segment.length >= 20 && /^[A-Za-z0-9_.~+=%-]+$/.test(segment) && /[A-Za-z]/.test(segment) && /[0-9]/.test(segment);
248
+ }
249
+ /** One spelling of a project path: forward slashes, a lower-case drive letter, no trailing slash. */
250
+ function projectPathKey(path) {
251
+ const slashed = path.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `${drive.toLowerCase()}:`);
252
+ return slashed.length > 1 ? slashed.replace(/\/+$/, "") : slashed;
253
+ }
254
+ function isPrivateHost(hostname) {
255
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
256
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host === "0.0.0.0")
257
+ return true;
258
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
259
+ if (v4) {
260
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
261
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || a === 0;
262
+ }
263
+ if (host.includes(":"))
264
+ return host === "::1" || host === "::" || /^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith("::ffff:");
265
+ return false;
266
+ }
267
+ function passesSettingsCheck(server) {
268
+ const variables = server.references.map((name) => `[[environment.variables]]\nname = ${JSON.stringify(name)}\nsecret = true\nrequired = false\n`);
269
+ const doc = `[project]\nlinear_team = "DRAFT"\n\n[environment]\n${variables.join("\n")}\n${mcpServerToml(server)}`;
270
+ return validateFullSettings(doc).ok;
271
+ }
272
+ function isRecord(value) {
273
+ return typeof value === "object" && value !== null && !Array.isArray(value);
274
+ }
@@ -0,0 +1,271 @@
1
+ // scan-envrc.ts — the names a repository's `.envrc` exports, read as text and never run. direnv
2
+ // would run the file as shell; this reader only looks at `export` statements, so a name whose value
3
+ // a command computes is still listed, and nothing on the line executes.
4
+ //
5
+ // ⛔ `scanEnvrc` RETURNS NO VALUE. The value half of a line is looked at for two purposes only:
6
+ // deciding whether later lines continue it (a quote, a PEM block, a heredoc or a trailing
7
+ // backslash, the same idiom as `scan-env-example.ts`), and deciding whether it is a static literal.
8
+ // `envrcLiteralValues` is the one export that hands a value back, and only the import's value
9
+ // resolver (`machine-values.ts`) calls it.
10
+ //
11
+ // direnv's own commands (`dotenv`, `source_env`, `PATH_add`, `use`, `layout` and the rest) load
12
+ // names from other files or run tools. They are not followed: each is reported as a line not read.
13
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+ const EXPORT_RE = /^\s*export\s+(.*)$/;
15
+ const ASSIGN_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
16
+ const BARE_ASSIGN_RE = /^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
17
+ const NAMES_ONLY_RE = /^([A-Za-z_][A-Za-z0-9_]*(?:\s+[A-Za-z_][A-Za-z0-9_]*)*)\s*(?:#.*)?$/;
18
+ const HEREDOC_RE = /^<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/;
19
+ // `#` only starts a comment at the start of a word, so `foo#bar` is one literal.
20
+ const UNQUOTED_LITERAL_RE = /^[A-Za-z0-9_./:@%+,=#-]+$/;
21
+ // An `export` that is not the line's first word: `[ -n x ] && export D=1`, `A=1; export A`.
22
+ const INNER_EXPORT_RE = /(?:^|[\s;&|(){}])export\s/;
23
+ const DECLARE_EXPORT_RE = /^\s*(?:declare|typeset)\s+-[A-Za-z]*x/;
24
+ const PEM_BEGIN = "-----BEGIN";
25
+ const PEM_END = "-----END";
26
+ const DIRENV_COMMANDS = new Set([
27
+ "dotenv", "dotenv_if_exists", "source_env", "source_env_if_exists", "source_up", "source_up_if_exists",
28
+ "source_url", "PATH_add", "path_add", "MANPATH_add", "PATH_rm", "path_rm", "use", "layout", "watch_file",
29
+ "watch_dir", "env_vars_required", "load_prefix", "fetchurl", "on_git_branch", "strict_env", "unstrict_env",
30
+ ]);
31
+ /** Settings that describe this computer, not the repository. Exported by many `.envrc` files, never drafted. */
32
+ const MACHINE_NAMES = new Set(["PATH", "MANPATH", "HOME", "SHELL", "PWD", "OLDPWD", "TMPDIR", "LANG", "VIRTUAL_ENV"]);
33
+ const MACHINE_PREFIXES = ["LC_", "DIRENV_", "DYLD_", "OTEL_"];
34
+ function isMachineSetting(name) {
35
+ return MACHINE_NAMES.has(name) || MACHINE_PREFIXES.some((prefix) => name.startsWith(prefix));
36
+ }
37
+ /** The names an `.envrc` exports, sorted, with the line each was last exported on. Never a value. */
38
+ export function scanEnvrc(text) {
39
+ const parsed = parseEnvrc(text);
40
+ return {
41
+ exported: parsed.exported.map(({ name, line, computed, inherited }) => ({ name, line, computed, inherited })),
42
+ skippedNames: parsed.skippedNames,
43
+ notRead: parsed.notRead,
44
+ };
45
+ }
46
+ /** Import only. The static literal each requested name is exported with; a computed value is absent. */
47
+ export function envrcLiteralValues(text, names) {
48
+ const values = new Map();
49
+ for (const entry of parseEnvrc(text).exported) {
50
+ if (names.has(entry.name) && entry.literal !== undefined && entry.literal !== "")
51
+ values.set(entry.name, entry.literal);
52
+ }
53
+ return values;
54
+ }
55
+ function parseEnvrc(text) {
56
+ const lines = text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
57
+ const exported = new Map();
58
+ const bare = new Map();
59
+ const skipped = new Set();
60
+ const notRead = [];
61
+ // Non-null while a value opened on an earlier line is still open; those lines are value material.
62
+ let open = null;
63
+ let openedOn = 0;
64
+ const record = (name, line, value) => {
65
+ if (isMachineSetting(name)) {
66
+ skipped.add(name);
67
+ return;
68
+ }
69
+ const literal = value === null ? bare.get(name) : value.literal;
70
+ const computed = value === null ? literal === undefined : value.literal === undefined && !value.empty;
71
+ const inherited = value === null && !bare.has(name);
72
+ exported.delete(name);
73
+ exported.set(name, { name, line, computed, inherited, literal });
74
+ };
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const line = lines[i];
77
+ const lineNo = i + 1;
78
+ if (open !== null) {
79
+ if (open.kind === "pem") {
80
+ if (line.includes(PEM_END))
81
+ open = null;
82
+ }
83
+ else if (open.kind === "heredoc") {
84
+ if (line.trim() === open.word)
85
+ open = null;
86
+ }
87
+ else if (open.kind === "quote") {
88
+ const close = closingIndex(line, open.quote, 0);
89
+ if (close >= 0) {
90
+ open = null;
91
+ // `"…\n…" ; export B=2`: the words after the closing quote are a command this reader skips.
92
+ if (!isTrailingComment(line.slice(close + 1)))
93
+ notRead.push({ line: lineNo, reason: "a command after a multi-line value" });
94
+ }
95
+ }
96
+ else if (!line.endsWith("\\"))
97
+ open = null;
98
+ continue;
99
+ }
100
+ const trimmed = line.trim();
101
+ if (trimmed === "" || trimmed.startsWith("#"))
102
+ continue;
103
+ const heredoc = heredocMatch(line);
104
+ const exportMatch = EXPORT_RE.exec(line);
105
+ if (exportMatch) {
106
+ const rest = exportMatch[1].trim();
107
+ const assign = ASSIGN_RE.exec(rest);
108
+ if (assign) {
109
+ const value = readValue(assign[2]);
110
+ if (value.extra) {
111
+ notRead.push({ line: lineNo, reason: "an export this reader does not follow" });
112
+ }
113
+ else {
114
+ record(assign[1], lineNo, value);
115
+ }
116
+ open = heredoc ? { kind: "heredoc", word: heredoc[1] } : value.open;
117
+ if (open !== null)
118
+ openedOn = lineNo;
119
+ continue;
120
+ }
121
+ const namesOnly = NAMES_ONLY_RE.exec(rest);
122
+ if (namesOnly) {
123
+ for (const name of namesOnly[1].split(/\s+/))
124
+ record(name, lineNo, null);
125
+ continue;
126
+ }
127
+ notRead.push({ line: lineNo, reason: "an export this reader does not follow" });
128
+ if (heredoc)
129
+ open = { kind: "heredoc", word: heredoc[1] };
130
+ else if (line.endsWith("\\"))
131
+ open = { kind: "backslash" };
132
+ if (open !== null)
133
+ openedOn = lineNo;
134
+ continue;
135
+ }
136
+ const command = trimmed.split(/\s+/)[0];
137
+ if (DIRENV_COMMANDS.has(command)) {
138
+ notRead.push({ line: lineNo, reason: command });
139
+ continue;
140
+ }
141
+ if (DECLARE_EXPORT_RE.test(line)) {
142
+ notRead.push({ line: lineNo, reason: `${command} -x, which this reader does not follow` });
143
+ continue;
144
+ }
145
+ const bareAssign = BARE_ASSIGN_RE.exec(line);
146
+ if (bareAssign) {
147
+ const value = readValue(bareAssign[2]);
148
+ bare.set(bareAssign[1], value.extra ? undefined : value.literal);
149
+ if (value.extra && INNER_EXPORT_RE.test(line))
150
+ notRead.push({ line: lineNo, reason: "an export this reader does not follow" });
151
+ open = heredoc ? { kind: "heredoc", word: heredoc[1] } : value.open;
152
+ if (open !== null)
153
+ openedOn = lineNo;
154
+ continue;
155
+ }
156
+ if (INNER_EXPORT_RE.test(line))
157
+ notRead.push({ line: lineNo, reason: "an export this reader does not follow" });
158
+ if (heredoc)
159
+ open = { kind: "heredoc", word: heredoc[1] };
160
+ else if (line.endsWith("\\"))
161
+ open = { kind: "backslash" };
162
+ if (open !== null)
163
+ openedOn = lineNo;
164
+ }
165
+ if (open !== null && open.kind !== "backslash") {
166
+ // An unclosed quote, PEM block or heredoc swallowed every later line; say so instead of a short list.
167
+ notRead.push({ line: openedOn, reason: "a value that never closes; the lines after it were not read" });
168
+ }
169
+ return {
170
+ exported: [...exported.values()].filter((entry) => NAME_RE.test(entry.name)).sort((a, b) => a.name.localeCompare(b.name, "en")),
171
+ skippedNames: [...skipped].sort((a, b) => a.localeCompare(b, "en")),
172
+ notRead,
173
+ };
174
+ }
175
+ /** Find a heredoc operator outside quotes and comments. Substitutions have their own quote state. */
176
+ function heredocMatch(line) {
177
+ const stack = [{ quote: null, end: null }];
178
+ for (let i = 0; i < line.length; i++) {
179
+ const state = stack.at(-1);
180
+ if (state === undefined)
181
+ return null;
182
+ const char = line[i];
183
+ if (state.quote === "'") {
184
+ if (char === "'")
185
+ state.quote = null;
186
+ continue;
187
+ }
188
+ if (char === "\\") {
189
+ i++;
190
+ continue;
191
+ }
192
+ if (char === '"') {
193
+ state.quote = state.quote === '"' ? null : '"';
194
+ continue;
195
+ }
196
+ if (char === "$" && line[i + 1] === "(") {
197
+ stack.push({ quote: null, end: ")" });
198
+ i++;
199
+ continue;
200
+ }
201
+ if (char === "`") {
202
+ if (state.end === "`" && state.quote === null)
203
+ stack.pop();
204
+ else
205
+ stack.push({ quote: null, end: "`" });
206
+ continue;
207
+ }
208
+ if (state.quote !== null)
209
+ continue;
210
+ if (char === "'")
211
+ state.quote = "'";
212
+ else if (char === "#" && (i === 0 || /[\s;|&()<>]/.test(line[i - 1] ?? "")))
213
+ return null;
214
+ else if (char === "(" && state.end === ")")
215
+ stack.push({ quote: null, end: ")" });
216
+ else if (char === ")" && state.end === ")")
217
+ stack.pop();
218
+ else if (char === "<" && line[i - 1] !== "<" && line[i + 2] !== "<") {
219
+ const match = HEREDOC_RE.exec(line.slice(i));
220
+ if (match)
221
+ return match;
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+ function readValue(raw) {
227
+ if (raw.includes(PEM_BEGIN) && !raw.includes(PEM_END))
228
+ return { literal: undefined, empty: false, extra: false, open: { kind: "pem" } };
229
+ const first = raw[0];
230
+ if (first === "'" || first === '"') {
231
+ const close = closingIndex(raw, first, 1);
232
+ if (close < 0)
233
+ return { literal: undefined, empty: false, extra: false, open: { kind: "quote", quote: first } };
234
+ const inner = raw.slice(1, close);
235
+ const rest = raw.slice(close + 1);
236
+ const computed = first === '"' && /[$`\\]/.test(inner);
237
+ if (!isTrailingComment(rest)) {
238
+ return computed ? { literal: undefined, empty: false, extra: false, open: null } : { literal: undefined, empty: false, extra: true, open: null };
239
+ }
240
+ return { literal: computed ? undefined : inner, empty: inner === "", extra: false, open: null };
241
+ }
242
+ if (raw.endsWith("\\"))
243
+ return { literal: undefined, empty: false, extra: false, open: { kind: "backslash" } };
244
+ const space = raw.search(/\s/);
245
+ const word = space < 0 ? raw : raw.slice(0, space);
246
+ const rest = space < 0 ? "" : raw.slice(space);
247
+ if (word === "")
248
+ return { literal: undefined, empty: isTrailingComment(rest), extra: !isTrailingComment(rest), open: null };
249
+ const literal = UNQUOTED_LITERAL_RE.test(word) && !word.startsWith("~") ? word : undefined;
250
+ if (!isTrailingComment(rest)) {
251
+ // `$(gh auth token)` and backticks carry spaces of their own: still one computed value.
252
+ return /\$\(|`/.test(raw) ? { literal: undefined, empty: false, extra: false, open: null } : { literal: undefined, empty: false, extra: true, open: null };
253
+ }
254
+ return { literal, empty: false, extra: false, open: null };
255
+ }
256
+ function isTrailingComment(rest) {
257
+ const t = rest.trim();
258
+ return t === "" || (t.startsWith("#") && /^\s/.test(rest));
259
+ }
260
+ /** Index of the quote that closes a value opened before `from`, or -1. A double quote honours `\"`. */
261
+ function closingIndex(text, quote, from) {
262
+ for (let i = from; i < text.length; i++) {
263
+ if (quote === '"' && text[i] === "\\") {
264
+ i++;
265
+ continue;
266
+ }
267
+ if (text[i] === quote)
268
+ return i;
269
+ }
270
+ return -1;
271
+ }
@@ -61,6 +61,14 @@ export function validateSettingsToml(text) {
61
61
  ? { state: "invalid", errors }
62
62
  : { state: "valid", errors: [], variableNames };
63
63
  }
64
+ /** The declared variables and their secret flags, or null unless the file passes `validateSettingsToml`.
65
+ * Names and flags only: the check refuses a `value` key, so none can be read here. */
66
+ export function readDeclaredVariables(text) {
67
+ if (validateSettingsToml(text).state !== "valid")
68
+ return null;
69
+ const parsed = parse(text);
70
+ return (parsed.environment?.variables ?? []).map((raw) => ({ name: String(raw.name), secret: raw.secret === true }));
71
+ }
64
72
  function isRecord(value) {
65
73
  return typeof value === "object" && value !== null && !Array.isArray(value);
66
74
  }