@catalyst-cloud/cli 0.16.23 → 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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/args.js +15 -3
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/env/draft.js +56 -29
- package/dist/env/machine-values.js +63 -0
- package/dist/env/machine.js +119 -0
- package/dist/env/scan-claude-mcp.js +274 -0
- package/dist/env/scan-envrc.js +271 -0
- package/dist/env/settings-toml.js +8 -0
- package/dist/env/shell-plan.js +4 -1
- package/dist/env-import.js +274 -0
- package/dist/env.js +101 -9
- package/dist/hosts.js +2 -14
- package/dist/mcp-status.js +111 -0
- package/dist/mcp.js +5 -2
- package/dist/onboard-next.js +5 -1
- package/dist/onboard-runner.js +112 -20
- package/dist/prompt.js +15 -2
- package/dist/secret.js +3 -3
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/declaring-a-repository.md +6 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
|
@@ -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
|
}
|
package/dist/env/shell-plan.js
CHANGED
|
@@ -14,7 +14,10 @@ const HOME = "/home/catalyst";
|
|
|
14
14
|
export const ENV_SHELL_START_SCRIPT = [
|
|
15
15
|
"set -u",
|
|
16
16
|
'mode="$1"; shift',
|
|
17
|
-
|
|
17
|
+
// Only a spec nothing installed satisfies is installed. `mise install node@22` resolves "22" against
|
|
18
|
+
// the online list and downloads the newest 22.x even when the image has an older 22.x, while
|
|
19
|
+
// `mise where` (and `mise exec`) prefer an installed match (CTC-6389).
|
|
20
|
+
`for spec in "$@"; do mise where "$spec" >/dev/null 2>&1 || mise install "$spec" >&2 || exit ${START_EXIT_TOOLCHAIN}; done`,
|
|
18
21
|
'if [ "$mode" = check ]; then',
|
|
19
22
|
' for spec in "$@"; do printf \'toolchain\\t%s\\t%s\\n\' "$spec" "$(basename "$(mise where "$spec")")"; done',
|
|
20
23
|
" for name in ${CATALYST_ENV_NAMES:-}; do",
|