@esuyo/coding-container 1.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,308 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import net from "node:net";
4
+ import tls from "node:tls";
5
+ import { localRegistryAuth, credentialHelperAuth, registryHost } from "./registry.js";
6
+
7
+ // Docker Registry HTTP API V2 client used by `gc` (list/delete tags).
8
+ // Uses the same docker credentials as the pull-secret resolution
9
+ // (config.json or credential helper) and follows the standard
10
+ // WWW-Authenticate Bearer/Basic challenge, like the docker CLI does.
11
+ //
12
+ // Plain node:http/https with keepAlive disabled (not global fetch): the
13
+ // sockets must not outlive the command, or the CLI would hang after gc.
14
+
15
+ // Per-process state: the registry scheme (detected once), the last
16
+ // Authorization header that worked, and bearer tokens cached per
17
+ // (realm, service, scope).
18
+ const state = { scheme: null, authHeader: null, tokens: new Map() };
19
+
20
+ // Safety net so a hung registry cannot stall gc indefinitely.
21
+ const REQUEST_TIMEOUT_MS = 30000;
22
+
23
+ const agents = {
24
+ http: new http.Agent({ keepAlive: false }),
25
+ https: new https.Agent({ keepAlive: false }),
26
+ };
27
+
28
+ // Error codes meaning "host is unreachable", where the scheme cannot be
29
+ // inferred. Anything else (a dropped TLS handshake, a protocol error) means
30
+ // the port is not speaking TLS, i.e. a plain-http registry.
31
+ const UNREACHABLE = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]);
32
+
33
+ function hostPort(registry) {
34
+ const host = registryHost(registry);
35
+ const m = host.match(/^(.*):(\d+)$/);
36
+ return m ? { host: m[1], port: Number(m[2]) } : { host, port: 443 };
37
+ }
38
+
39
+ /**
40
+ * Detect whether the registry speaks TLS by attempting a handshake (without
41
+ * verifying the certificate — the real requests do their own verification).
42
+ * Resolves to "https" or "http"; on an unreachable host it defaults to
43
+ * "https" so the subsequent request reports a proper "cannot reach" error.
44
+ */
45
+ function detectScheme(registry) {
46
+ if (state.scheme) return Promise.resolve(state.scheme);
47
+ const { host, port } = hostPort(registry);
48
+ return new Promise((resolve) => {
49
+ let settled = false;
50
+ let handshakeOk = false;
51
+ // SNI (servername) must be a hostname, not an IP literal.
52
+ const options = { host, port, rejectUnauthorized: false };
53
+ if (!net.isIP(host)) options.servername = host;
54
+ const socket = tls.connect(options, () => {
55
+ handshakeOk = true;
56
+ finish("https");
57
+ });
58
+ function finish(scheme) {
59
+ if (settled) return;
60
+ settled = true;
61
+ socket.destroy();
62
+ state.scheme = scheme;
63
+ resolve(scheme);
64
+ }
65
+ socket.setTimeout(5000, () => finish("http"));
66
+ socket.on("error", (err) => finish(UNREACHABLE.has(err.code) ? "https" : "http"));
67
+ socket.on("close", () => finish(handshakeOk ? "https" : "http"));
68
+ });
69
+ }
70
+
71
+ /**
72
+ * The V2 repository path for the configured image: the registry reference
73
+ * minus the host, plus the image name.
74
+ * "registry.example.com/you" + "coding-container" -> "you/coding-container"
75
+ */
76
+ export function registryRepoName(cfg) {
77
+ const host = registryHost(cfg.image.registry);
78
+ const ns = cfg.image.registry.slice(host.length).replace(/^\/+/, "");
79
+ return ns ? `${ns}/${cfg.image.name}` : cfg.image.name;
80
+ }
81
+
82
+ function credentials(cfg) {
83
+ return localRegistryAuth(cfg.image.registry) || credentialHelperAuth(cfg.image.registry);
84
+ }
85
+
86
+ /**
87
+ * Parse the first challenge of a WWW-Authenticate header. A registry may
88
+ * send several challenges in one header (Gitea sends both Bearer and
89
+ * Basic); only the first is usable, and the second challenge's parameters
90
+ * must not leak into the first (its `realm` would overwrite the bearer
91
+ * token endpoint and break the token exchange).
92
+ */
93
+ function parseChallenge(header) {
94
+ if (!header) return null;
95
+ // Split on commas outside quoted values (scopes may contain commas).
96
+ const challenges = [];
97
+ let current = "";
98
+ let inQuotes = false;
99
+ for (const ch of header) {
100
+ if (ch === '"') inQuotes = !inQuotes;
101
+ if (ch === "," && !inQuotes) {
102
+ challenges.push(current);
103
+ current = "";
104
+ continue;
105
+ }
106
+ current += ch;
107
+ }
108
+ challenges.push(current);
109
+ for (const c of challenges) {
110
+ const m = c.match(/^\s*(Bearer|Basic)\s*(.*)$/i);
111
+ if (!m) continue;
112
+ const params = {};
113
+ for (const pm of m[2].matchAll(/([a-z]+)="([^"]*)"/gi)) params[pm[1].toLowerCase()] = pm[2];
114
+ return { type: m[1].toLowerCase(), ...params };
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Minimal fetch-Response-like wrapper over a core http response.
121
+ */
122
+ function makeResponse(status, statusText, headers, body) {
123
+ return {
124
+ status,
125
+ statusText,
126
+ ok: status >= 200 && status < 300,
127
+ headers: { get: (name) => headers[name.toLowerCase()] || null },
128
+ async json() {
129
+ return JSON.parse(body.toString("utf8"));
130
+ },
131
+ async text() {
132
+ return body.toString("utf8");
133
+ },
134
+ };
135
+ }
136
+
137
+ function request(url, method, headers = {}) {
138
+ const u = new URL(url);
139
+ const isHttps = u.protocol === "https:";
140
+ return new Promise((resolve, reject) => {
141
+ const req = (isHttps ? https : http).request(
142
+ {
143
+ protocol: u.protocol,
144
+ hostname: u.hostname,
145
+ port: u.port || (isHttps ? 443 : 80),
146
+ method,
147
+ path: u.pathname + u.search,
148
+ headers,
149
+ agent: isHttps ? agents.https : agents.http,
150
+ timeout: REQUEST_TIMEOUT_MS,
151
+ },
152
+ (res) => {
153
+ const chunks = [];
154
+ res.on("data", (chunk) => chunks.push(chunk));
155
+ res.on("end", () =>
156
+ resolve(makeResponse(res.statusCode, res.statusMessage, res.headers, Buffer.concat(chunks)))
157
+ );
158
+ res.on("error", reject);
159
+ }
160
+ );
161
+ req.on("timeout", () => {
162
+ req.destroy(new Error(`request to ${u.href} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`));
163
+ });
164
+ req.on("error", reject);
165
+ req.end();
166
+ });
167
+ }
168
+
169
+ async function send(cfg, method, path, authHeader, extraHeaders = {}) {
170
+ const scheme = await detectScheme(cfg.image.registry);
171
+ const host = registryHost(cfg.image.registry);
172
+ const url = `${scheme}://${host}${path}`;
173
+ try {
174
+ return await request(url, method, {
175
+ ...(authHeader ? { authorization: authHeader } : {}),
176
+ ...extraHeaders,
177
+ });
178
+ } catch (err) {
179
+ const code = err.code ? ` (${err.code})` : "";
180
+ throw new Error(`cannot reach registry at ${url}${code}: ${err.message}`);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Request with the standard registry auth dance: try with the cached
186
+ * Authorization header, and on 401 follow the WWW-Authenticate challenge
187
+ * (Bearer token exchange or plain Basic) with the local docker credentials.
188
+ */
189
+ async function apiRequest(cfg, method, path, extraHeaders = {}) {
190
+ let res = await send(cfg, method, path, state.authHeader, extraHeaders);
191
+ if (res.status !== 401) return res;
192
+
193
+ let challenge = parseChallenge(res.headers.get("www-authenticate"));
194
+ if (!challenge) {
195
+ const probe = await send(cfg, "GET", "/v2/", null);
196
+ challenge = parseChallenge(probe.headers.get("www-authenticate"));
197
+ }
198
+ if (!challenge) {
199
+ throw new Error(
200
+ `registry ${registryHost(cfg.image.registry)} returned 401 without a usable ` +
201
+ `WWW-Authenticate challenge (unsupported auth scheme)`
202
+ );
203
+ }
204
+ const creds = credentials(cfg);
205
+ if (!creds) {
206
+ throw new Error(
207
+ `registry ${registryHost(cfg.image.registry)} requires authentication but no ` +
208
+ `docker credentials for it were found. Run: docker login ${registryHost(cfg.image.registry)}`
209
+ );
210
+ }
211
+ const basic = `Basic ${Buffer.from(`${creds.username}:${creds.password}`, "utf8").toString("base64")}`;
212
+ let authHeader;
213
+ if (challenge.type === "basic") {
214
+ authHeader = basic;
215
+ } else if (challenge.type === "bearer") {
216
+ const repo = registryRepoName(cfg);
217
+ const scope = challenge.scope || `repository:${repo}:pull,push`;
218
+ const key = `${challenge.realm}|${challenge.service || ""}|${scope}`;
219
+ let token = state.tokens.get(key);
220
+ if (!token) {
221
+ const tokenUrl = new URL(challenge.realm);
222
+ if (challenge.service) tokenUrl.searchParams.set("service", challenge.service);
223
+ if (scope) tokenUrl.searchParams.set("scope", scope);
224
+ let tokenRes;
225
+ try {
226
+ tokenRes = await request(tokenUrl.href, "GET", { authorization: basic });
227
+ } catch (err) {
228
+ throw new Error(`cannot reach the registry token endpoint at ${tokenUrl.origin}: ${err.message}`);
229
+ }
230
+ if (!tokenRes.ok) {
231
+ throw new Error(
232
+ `registry token request failed with ${tokenRes.status} — check: docker login ${registryHost(cfg.image.registry)}`
233
+ );
234
+ }
235
+ const body = await tokenRes.json().catch(() => ({}));
236
+ token = body.token || body.access_token;
237
+ if (!token) throw new Error("registry token response did not contain a token");
238
+ state.tokens.set(key, token);
239
+ }
240
+ authHeader = `Bearer ${token}`;
241
+ } else {
242
+ throw new Error(`unsupported registry auth scheme: ${challenge.type}`);
243
+ }
244
+
245
+ res = await send(cfg, method, path, authHeader, extraHeaders);
246
+ if (res.status === 401) {
247
+ throw new Error(
248
+ `registry authentication failed for ${registryHost(cfg.image.registry)} — check: ` +
249
+ `docker login ${registryHost(cfg.image.registry)}`
250
+ );
251
+ }
252
+ if (res.status === 403) {
253
+ throw new Error(
254
+ `registry ${registryHost(cfg.image.registry)} denied access (403) — the credentials ` +
255
+ `lack permission for ${registryRepoName(cfg)}`
256
+ );
257
+ }
258
+ state.authHeader = authHeader;
259
+ return res;
260
+ }
261
+
262
+ /**
263
+ * All tags of the image, following the V2 pagination Link header.
264
+ */
265
+ export async function listTags(cfg) {
266
+ const repo = registryRepoName(cfg);
267
+ const host = registryHost(cfg.image.registry);
268
+ const tags = new Set();
269
+ let path = `/v2/${repo}/tags/list`;
270
+ for (let page = 0; page < 1000; page++) {
271
+ const res = await apiRequest(cfg, "GET", path, { Accept: "application/json" });
272
+ if (res.status === 404) {
273
+ throw new Error(`repository ${cfg.image.registry}/${repo} was not found in the registry`);
274
+ }
275
+ if (!res.ok) {
276
+ throw new Error(`registry tag list failed with ${res.status} ${res.statusText}`.trim());
277
+ }
278
+ const body = await res.json().catch(() => ({}));
279
+ for (const tag of body.tags || []) tags.add(tag);
280
+ const link = res.headers.get("link");
281
+ const next = link && link.match(/<([^>]+)>;\s*rel="next"/);
282
+ if (!next) break;
283
+ const u = new URL(next[1], `${state.scheme}://${host}`);
284
+ path = u.pathname + u.search;
285
+ }
286
+ return [...tags].sort();
287
+ }
288
+
289
+ /**
290
+ * Delete one tag. Returns normally on success (2xx); throws otherwise.
291
+ */
292
+ export async function deleteTag(cfg, tag) {
293
+ const repo = registryRepoName(cfg);
294
+ const host = registryHost(cfg.image.registry);
295
+ const res = await apiRequest(cfg, "DELETE", `/v2/${repo}/tags/${encodeURIComponent(tag)}`);
296
+ if (res.status === 404) {
297
+ throw new Error(`tag ${tag} not found (already deleted?)`);
298
+ }
299
+ if (res.status === 405) {
300
+ throw new Error(
301
+ `registry ${host} does not support deleting tags through the registry API (405) — ` +
302
+ `e.g. AWS ECR requires the AWS BatchDeleteImage API instead`
303
+ );
304
+ }
305
+ if (!res.ok) {
306
+ throw new Error(`deleting tag ${tag} failed with ${res.status} ${res.statusText}`.trim());
307
+ }
308
+ }
@@ -0,0 +1,204 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { exec, runWithInput } from "./exec.js";
6
+
7
+ function dockerConfigPath() {
8
+ return path.join(os.homedir(), ".docker", "config.json");
9
+ }
10
+
11
+ function readDockerConfig() {
12
+ try {
13
+ const file = dockerConfigPath();
14
+ if (!fs.existsSync(file)) return null;
15
+ return JSON.parse(fs.readFileSync(file, "utf8"));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Extract the registry host from a registry reference that may include a
23
+ * namespace path, e.g. "registry.example.com/you" -> "registry.example.com".
24
+ */
25
+ export function registryHost(registry) {
26
+ return registry.split("/")[0];
27
+ }
28
+
29
+ /**
30
+ * Read registry credentials stored inline in ~/.docker/config.json.
31
+ * Docker stores auths keyed by host only, so the host is derived here.
32
+ * Returns { server, username, password } or null.
33
+ */
34
+ export function localRegistryAuth(registry) {
35
+ const cfg = readDockerConfig();
36
+ if (!cfg) return null;
37
+ const host = registryHost(registry);
38
+ const auth = (cfg.auths || {})[host];
39
+ if (!auth) return null;
40
+ let username = auth.username;
41
+ let password = auth.password;
42
+ if (!username && auth.auth) {
43
+ const decoded = Buffer.from(auth.auth, "base64").toString("utf8");
44
+ const idx = decoded.indexOf(":");
45
+ username = decoded.slice(0, idx);
46
+ password = decoded.slice(idx + 1);
47
+ }
48
+ if (!username || !password) return null;
49
+ return { server: host, username, password };
50
+ }
51
+
52
+ /**
53
+ * Query the docker credential helper (e.g. docker-credential-desktop) for the
54
+ * registry host. Covers setups where credentials live in the OS keychain
55
+ * instead of config.json.
56
+ */
57
+ export function credentialHelperAuth(registry, spawn = spawnSync) {
58
+ const cfg = readDockerConfig();
59
+ if (!cfg) return null;
60
+ const host = registryHost(registry);
61
+ const store =
62
+ (cfg.credHelpers && cfg.credHelpers[host]) || cfg.credsStore || null;
63
+ if (!store) return null;
64
+ return queryCredentialHelper(store, host, spawn);
65
+ }
66
+
67
+ /**
68
+ * Ask each docker-credential-<store> candidate for the host's credentials.
69
+ * `spawn` is injectable for tests.
70
+ */
71
+ export function queryCredentialHelper(store, host, spawn = spawnSync) {
72
+ const candidates = [
73
+ `docker-credential-${store}`,
74
+ `docker-credential-${store}.exe`,
75
+ path.join("C:/Program Files/Docker/Docker/resources/bin", `docker-credential-${store}.exe`),
76
+ path.join("C:/Program Files/Docker/Docker", `credentialHelper${store[0].toUpperCase()}${store.slice(1)}.exe`),
77
+ ];
78
+
79
+ for (const bin of candidates) {
80
+ for (const serverURL of [host, `https://${host}`]) {
81
+ try {
82
+ // The credential-helper protocol passes the server URL as a plain
83
+ // string on stdin for `get` (JSON is only used for `store`); a JSON
84
+ // payload makes the helper look up a credential literally named
85
+ // after that JSON string and always report "not found".
86
+ const res = spawn(bin, ["get"], {
87
+ input: serverURL,
88
+ encoding: "utf8",
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ timeout: 15000,
91
+ });
92
+ if (res.error || res.status !== 0 || !res.stdout) continue;
93
+ const parsed = JSON.parse(res.stdout.trim());
94
+ if (parsed.Username && parsed.Secret) {
95
+ return { server: host, username: parsed.Username, password: parsed.Secret };
96
+ }
97
+ } catch {
98
+ /* try the next candidate */
99
+ }
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ export function pullSecretName(registry) {
106
+ return `coding-${registryHost(registry).replace(/[^a-z0-9]/gi, "-").toLowerCase()}`;
107
+ }
108
+
109
+ /**
110
+ * Find an existing kubernetes.io/dockerconfigjson secret in the namespace
111
+ * whose dockerconfigjson contains an auth entry for the registry host.
112
+ */
113
+ async function discoverExistingSecret(cfg) {
114
+ let items = [];
115
+ try {
116
+ const out = exec("kubectl", ["get", "secret", "-n", cfg.k8s.namespace, "-o", "json"]);
117
+ items = JSON.parse(out).items || [];
118
+ } catch {
119
+ return null;
120
+ }
121
+ const host = registryHost(cfg.image.registry);
122
+ for (const s of items) {
123
+ if (s.type !== "kubernetes.io/dockerconfigjson") continue;
124
+ try {
125
+ const dcj = JSON.parse(
126
+ Buffer.from(s.data[".dockerconfigjson"], "base64").toString("utf8")
127
+ );
128
+ const auths = dcj.auths || {};
129
+ if (Object.keys(auths).some((key) => registryHost(key) === host)) {
130
+ return s.metadata.name;
131
+ }
132
+ } catch {
133
+ /* not parseable, skip */
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Resolve a pull secret for the image registry, in priority order:
141
+ * 1. k8s.imagePullSecret from config (must exist in the namespace)
142
+ * 2. a previously created "coding-<host>" managed secret
143
+ * 3. a new managed secret built from local docker credentials
144
+ * 4. an existing dockerconfigjson secret in the namespace matching the host
145
+ * 5. null (with a warning) if the registry is expected to be public
146
+ */
147
+ export async function ensurePullSecret(cfg) {
148
+ const ns = cfg.k8s.namespace;
149
+ const name = pullSecretName(cfg.image.registry);
150
+
151
+ if (cfg.k8s.imagePullSecret) {
152
+ try {
153
+ exec("kubectl", ["get", "secret", cfg.k8s.imagePullSecret, "-n", ns]);
154
+ return cfg.k8s.imagePullSecret;
155
+ } catch {
156
+ throw new Error(
157
+ `config: k8s.imagePullSecret "${cfg.k8s.imagePullSecret}" not found in namespace ${ns}`
158
+ );
159
+ }
160
+ }
161
+
162
+ try {
163
+ exec("kubectl", ["get", "secret", name, "-n", ns]);
164
+ return name;
165
+ } catch {
166
+ /* not created yet */
167
+ }
168
+
169
+ const auth = localRegistryAuth(cfg.image.registry) || credentialHelperAuth(cfg.image.registry);
170
+ if (auth) {
171
+ const dockerConfig = JSON.stringify({
172
+ auths: {
173
+ [auth.server]: {
174
+ username: auth.username,
175
+ password: auth.password,
176
+ auth: Buffer.from(`${auth.username}:${auth.password}`, "utf8").toString("base64"),
177
+ },
178
+ },
179
+ });
180
+ const secret = {
181
+ apiVersion: "v1",
182
+ kind: "Secret",
183
+ metadata: { name, namespace: ns },
184
+ type: "kubernetes.io/dockerconfigjson",
185
+ data: { ".dockerconfigjson": Buffer.from(dockerConfig, "utf8").toString("base64") },
186
+ };
187
+ await runWithInput("kubectl", ["apply", "-f", "-"], JSON.stringify(secret));
188
+ console.log(`Created pull secret ${name} in ${ns}`);
189
+ return name;
190
+ }
191
+
192
+ const discovered = await discoverExistingSecret(cfg);
193
+ if (discovered) {
194
+ console.log(`Using existing pull secret ${discovered} in ${ns}`);
195
+ return discovered;
196
+ }
197
+
198
+ console.log(
199
+ `No local docker credentials for ${cfg.image.registry} found and no matching ` +
200
+ `pull secret in ${ns}. The cluster must already be able to pull the image, ` +
201
+ `or set k8s.imagePullSecret in the config.`
202
+ );
203
+ return null;
204
+ }
@@ -0,0 +1,42 @@
1
+ // Strict vMAJOR.MINOR.PATCH semver (with a leading 'v'). No pre-release or
2
+ // build metadata: the global image's versions are human-picked and immutable.
3
+ const SEMVER_RE = /^v(\d+)\.(\d+)\.(\d+)$/;
4
+
5
+ export function isSemver(tag) {
6
+ return typeof tag === "string" && SEMVER_RE.test(tag);
7
+ }
8
+
9
+ /**
10
+ * Numeric comparison of two semver tags (assumes both are semver).
11
+ * Returns -1, 0 or 1. Orders numerically, so v1.10.0 > v1.9.9.
12
+ */
13
+ export function compareSemver(a, b) {
14
+ const pa = a.match(SEMVER_RE).slice(1).map(Number);
15
+ const pb = b.match(SEMVER_RE).slice(1).map(Number);
16
+ for (let i = 0; i < 3; i++) {
17
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
18
+ }
19
+ return 0;
20
+ }
21
+
22
+ /**
23
+ * The semver tags of a list, sorted ascending (oldest first). Non-semver
24
+ * tags (e.g. legacy timestamp tags) are excluded.
25
+ */
26
+ export function semverSortAsc(tags) {
27
+ return [...tags].filter(isSemver).sort(compareSemver);
28
+ }
29
+
30
+ /**
31
+ * All tags oldest-first: semver tags numerically ascending, non-semver tags
32
+ * after them (lexicographically). Display order for kept tags.
33
+ */
34
+ export function allTagsSortAsc(tags) {
35
+ return [...tags].sort((a, b) => {
36
+ const as = isSemver(a);
37
+ const bs = isSemver(b);
38
+ if (as && bs) return compareSemver(a, b);
39
+ if (as !== bs) return as ? -1 : 1;
40
+ return a < b ? -1 : a > b ? 1 : 0;
41
+ });
42
+ }
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // The CLI owns the block between these markers in the user's ~/.ssh/config.
6
+ // Everything outside the markers is never touched.
7
+ const BEGIN = "# BEGIN coding-container (managed - do not edit between markers)";
8
+ const END = "# END coding-container";
9
+
10
+ export function sshConfigPath() {
11
+ return path.join(os.homedir(), ".ssh", "config");
12
+ }
13
+
14
+ /**
15
+ * Alias convention for container hosts: coding-<project>.
16
+ */
17
+ export function sshAlias(project) {
18
+ return `coding-${project}`;
19
+ }
20
+
21
+ function readManaged(file) {
22
+ if (!fs.existsSync(file)) return { before: "", entries: {}, after: "" };
23
+ const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
24
+ const bi = lines.findIndex((l) => l.trim() === BEGIN);
25
+ if (bi === -1) return { before: lines.join("\n"), entries: {}, after: "" };
26
+ let ei = lines.findIndex((l, i) => i > bi && l.trim() === END);
27
+ if (ei === -1) ei = lines.length;
28
+ const before = lines.slice(0, bi).join("\n");
29
+ const after = lines.slice(ei + 1).join("\n");
30
+ const entries = {};
31
+ let current = null;
32
+ for (const line of lines.slice(bi + 1, ei)) {
33
+ const m = line.match(/^Host (coding-[a-z0-9-]+)\s*$/);
34
+ if (m) {
35
+ current = m[1];
36
+ entries[current] = [line];
37
+ } else if (current && line.trim() !== "") {
38
+ entries[current].push(line);
39
+ } else {
40
+ current = null;
41
+ }
42
+ }
43
+ return { before, entries, after };
44
+ }
45
+
46
+ function writeManaged(file, before, entries, after) {
47
+ const parts = [];
48
+ if (before.trim() !== "") parts.push(before.replace(/\n+$/, ""));
49
+ parts.push(
50
+ [BEGIN, ...Object.values(entries).flat(), END].join("\n")
51
+ );
52
+ if (after.trim() !== "") parts.push(after.replace(/^\n+/, "").replace(/\n+$/, ""));
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ fs.writeFileSync(file, parts.join("\n") + "\n");
55
+ }
56
+
57
+ /**
58
+ * Add or update a container host entry in the managed section.
59
+ */
60
+ export function addHostEntry(entry, file = sshConfigPath()) {
61
+ const { before, entries, after } = readManaged(file);
62
+ entries[entry.alias] = [
63
+ `Host ${entry.alias}`,
64
+ ` HostName ${entry.hostName}`,
65
+ ` Port ${entry.port}`,
66
+ ` User ${entry.user}`,
67
+ ` IdentityFile ${entry.identityFile}`,
68
+ ` StrictHostKeyChecking no`,
69
+ ` UserKnownHostsFile ${entry.knownHostsFile}`,
70
+ ];
71
+ writeManaged(file, before, entries, after);
72
+ }
73
+
74
+ /**
75
+ * Remove a container host entry. Returns true if an entry was removed.
76
+ */
77
+ export function removeHostEntry(alias, file = sshConfigPath()) {
78
+ const { before, entries, after } = readManaged(file);
79
+ if (!(alias in entries)) return false;
80
+ delete entries[alias];
81
+ writeManaged(file, before, entries, after);
82
+ return true;
83
+ }