@clawops/cli 0.2.0

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,295 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/providers/azure/index.ts
4
+ import process2 from "process";
5
+
6
+ // src/providers/azure/program.ts
7
+ var GATEWAY_PORT = 18789;
8
+ var SSH_PORT = 22;
9
+ var azureProgram = async () => {
10
+ const [pulumi, azure, { resolveIngressCidrs, detectEgressIp }] = await Promise.all([
11
+ import("@pulumi/pulumi"),
12
+ import("@pulumi/azure-native"),
13
+ import("./firewall-YYDOWDDP.js")
14
+ ]);
15
+ const cfg = new pulumi.Config();
16
+ const stackName = pulumi.getStack();
17
+ const instanceType = cfg.get("instanceType") ?? "Standard_B2s";
18
+ const location = cfg.get("region") ?? "eastus";
19
+ const openclawVersion = cfg.get("openclawVersion") ?? "stable";
20
+ const accessMode = cfg.get("accessMode") ?? "restricted";
21
+ const allowedCidrs = cfg.get("allowedCidrs") ?? "";
22
+ const sshCidrs = cfg.get("sshCidrs") ?? "";
23
+ const gatewayCidrs = cfg.get("gatewayCidrs") ?? "";
24
+ const keyVaultEnabled = cfg.get("keyVaultEnabled") === "true";
25
+ const resourceGroupName = cfg.get("resourceGroupName") ?? `clawops-${stackName}`;
26
+ const sshPublicKey = cfg.get("sshPublicKey");
27
+ if (!sshPublicKey) {
28
+ throw new Error(
29
+ 'Stack config "sshPublicKey" is required for the Azure adapter. Set it with: pulumi config set --stack <name> sshPublicKey "ssh-ed25519 ..."'
30
+ );
31
+ }
32
+ const detectedIp = accessMode === "auto" ? await detectEgressIp("https://ifconfig.me") : "";
33
+ if (accessMode === "open") {
34
+ process.stderr.write(
35
+ "[clawops] WARNING: accessMode=open allows 0.0.0.0/0 on SSH and gateway ports. Only use this for development/sandbox stacks.\n"
36
+ );
37
+ }
38
+ const sshIngressCidrs = resolveIngressCidrs(accessMode, allowedCidrs, sshCidrs, detectedIp);
39
+ const gatewayIngressCidrs = resolveIngressCidrs(accessMode, allowedCidrs, gatewayCidrs, detectedIp);
40
+ const rg = new azure.resources.ResourceGroup("clawops-rg", {
41
+ resourceGroupName,
42
+ location
43
+ });
44
+ const vnet = new azure.network.VirtualNetwork("clawops-vnet", {
45
+ resourceGroupName: rg.name,
46
+ location: rg.location,
47
+ addressSpace: { addressPrefixes: ["10.0.0.0/16"] }
48
+ });
49
+ const subnet = new azure.network.Subnet("clawops-subnet", {
50
+ resourceGroupName: rg.name,
51
+ virtualNetworkName: vnet.name,
52
+ addressPrefix: "10.0.1.0/24"
53
+ });
54
+ const securityRules = [];
55
+ let priority = 100;
56
+ for (const cidr of sshIngressCidrs) {
57
+ securityRules.push({
58
+ name: `allow-ssh-${priority}`,
59
+ priority: priority++,
60
+ direction: "Inbound",
61
+ access: "Allow",
62
+ protocol: "Tcp",
63
+ sourceAddressPrefix: cidr,
64
+ sourcePortRange: "*",
65
+ destinationAddressPrefix: "*",
66
+ destinationPortRange: String(SSH_PORT)
67
+ });
68
+ }
69
+ for (const cidr of gatewayIngressCidrs) {
70
+ securityRules.push({
71
+ name: `allow-gateway-${priority}`,
72
+ priority: priority++,
73
+ direction: "Inbound",
74
+ access: "Allow",
75
+ protocol: "Tcp",
76
+ sourceAddressPrefix: cidr,
77
+ sourcePortRange: "*",
78
+ destinationAddressPrefix: "*",
79
+ destinationPortRange: String(GATEWAY_PORT)
80
+ });
81
+ }
82
+ const nsg = new azure.network.NetworkSecurityGroup("clawops-nsg", {
83
+ resourceGroupName: rg.name,
84
+ location: rg.location,
85
+ securityRules
86
+ });
87
+ const publicIp = new azure.network.PublicIPAddress("clawops-pip", {
88
+ resourceGroupName: rg.name,
89
+ location: rg.location,
90
+ publicIPAllocationMethod: "Static",
91
+ sku: { name: "Standard" }
92
+ });
93
+ const nic = new azure.network.NetworkInterface("clawops-nic", {
94
+ resourceGroupName: rg.name,
95
+ location: rg.location,
96
+ networkSecurityGroup: { id: nsg.id },
97
+ ipConfigurations: [{
98
+ name: "clawops-ipconfig",
99
+ subnet: { id: subnet.id },
100
+ publicIPAddress: { id: publicIp.id },
101
+ privateIPAllocationMethod: "Dynamic"
102
+ }]
103
+ });
104
+ const vm = new azure.compute.VirtualMachine("clawops-vm", {
105
+ resourceGroupName: rg.name,
106
+ location: rg.location,
107
+ hardwareProfile: { vmSize: instanceType },
108
+ identity: { type: "SystemAssigned" },
109
+ osProfile: {
110
+ adminUsername: "clawops",
111
+ computerName: "clawops",
112
+ customData: Buffer.from(makeStartupScript(openclawVersion)).toString("base64"),
113
+ linuxConfiguration: {
114
+ disablePasswordAuthentication: true,
115
+ ssh: {
116
+ publicKeys: [{
117
+ keyData: sshPublicKey,
118
+ path: "/home/clawops/.ssh/authorized_keys"
119
+ }]
120
+ }
121
+ }
122
+ },
123
+ storageProfile: {
124
+ imageReference: {
125
+ publisher: "Canonical",
126
+ offer: "UbuntuServer",
127
+ sku: "22.04-LTS",
128
+ version: "latest"
129
+ },
130
+ osDisk: {
131
+ createOption: "FromImage",
132
+ managedDisk: { storageAccountType: "Premium_LRS" },
133
+ diskSizeGB: 30
134
+ }
135
+ },
136
+ networkProfile: {
137
+ networkInterfaces: [{ id: nic.id, primary: true }]
138
+ }
139
+ });
140
+ if (keyVaultEnabled) {
141
+ const rawKvName = `clawops-${stackName}-kv`;
142
+ const kvName = rawKvName.length > 24 ? rawKvName.slice(0, 24) : rawKvName;
143
+ const kv = new azure.keyvault.Vault("clawops-kv", {
144
+ resourceGroupName: rg.name,
145
+ location: rg.location,
146
+ vaultName: kvName,
147
+ properties: {
148
+ sku: { family: "A", name: "standard" },
149
+ tenantId: pulumi.output(vm.identity).apply((i) => i?.tenantId ?? ""),
150
+ enableRbacAuthorization: true
151
+ }
152
+ });
153
+ new azure.authorization.RoleAssignment("clawops-kv-role", {
154
+ scope: kv.id,
155
+ roleDefinitionId: "/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6",
156
+ // Key Vault Secrets User
157
+ principalId: pulumi.output(vm.identity).apply((i) => i?.principalId ?? ""),
158
+ principalType: "ServicePrincipal"
159
+ });
160
+ new azure.keyvault.Secret("clawops-gateway-token", {
161
+ resourceGroupName: rg.name,
162
+ vaultName: kv.name,
163
+ secretName: "gateway-token",
164
+ properties: { value: "CHANGEME" }
165
+ });
166
+ }
167
+ const resolvedIp = pulumi.output(publicIp.ipAddress).apply((ip) => ip ?? "");
168
+ return {
169
+ instanceId: vm.id,
170
+ publicIp: resolvedIp,
171
+ gatewayUrl: pulumi.interpolate`https://${resolvedIp}:${GATEWAY_PORT}`,
172
+ sshHost: resolvedIp,
173
+ sshPort: SSH_PORT,
174
+ sshUser: "clawops",
175
+ region: location,
176
+ provisionedAt: (/* @__PURE__ */ new Date()).toISOString()
177
+ };
178
+ };
179
+ function makeStartupScript(openclawVersion) {
180
+ return `#!/bin/bash
181
+ set -euo pipefail
182
+
183
+ # Install Docker if not present
184
+ if ! command -v docker &>/dev/null; then
185
+ apt-get update -q
186
+ apt-get install -y -q ca-certificates curl gnupg lsb-release
187
+ install -m 0755 -d /etc/apt/keyrings
188
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg \\
189
+ | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
190
+ chmod a+r /etc/apt/keyrings/docker.gpg
191
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \\
192
+ https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \\
193
+ > /etc/apt/sources.list.d/docker.list
194
+ apt-get update -q
195
+ apt-get install -y -q docker-ce docker-ce-cli containerd.io
196
+ systemctl enable --now docker
197
+ fi
198
+
199
+ usermod -aG docker clawops
200
+
201
+ # Pull OpenClaw image
202
+ OPENCLAW_VERSION="${openclawVersion}"
203
+ docker pull ghcr.io/openclaw/openclaw:\${OPENCLAW_VERSION}
204
+
205
+ # Create default openclaw.json if not present
206
+ OPENCLAW_CONFIG=/home/clawops/openclaw.json
207
+ if [ ! -f "\${OPENCLAW_CONFIG}" ]; then
208
+ cat > "\${OPENCLAW_CONFIG}" <<'OPENCLAWJSON'
209
+ {"version":"2026.4","gateway":{"port":18789,"auth":{"mode":"token"}},"models":{},"channels":[]}
210
+ OPENCLAWJSON
211
+ chown clawops:clawops "\${OPENCLAW_CONFIG}"
212
+ fi
213
+
214
+ # Start OpenClaw container
215
+ docker stop openclaw 2>/dev/null || true
216
+ docker rm openclaw 2>/dev/null || true
217
+ docker run -d \\
218
+ --name openclaw \\
219
+ --restart unless-stopped \\
220
+ -p 18789:18789 \\
221
+ -v "\${OPENCLAW_CONFIG}":/app/config.json:ro \\
222
+ ghcr.io/openclaw/openclaw:\${OPENCLAW_VERSION}
223
+ `;
224
+ }
225
+
226
+ // src/providers/azure/index.ts
227
+ var INSTANCE_TYPE_MAP = {
228
+ micro: "Standard_B1s",
229
+ small: "Standard_B2s",
230
+ medium: "Standard_B4ms",
231
+ large: "Standard_B8ms",
232
+ gpu: "Standard_NC6s_v3"
233
+ };
234
+ var azureAdapter = {
235
+ name: "azure",
236
+ get program() {
237
+ return azureProgram;
238
+ },
239
+ getConnectionInfo(outputs) {
240
+ return {
241
+ host: String(outputs["sshHost"] ?? ""),
242
+ port: Number(outputs["sshPort"] ?? 22),
243
+ user: String(outputs["sshUser"] ?? "clawops"),
244
+ privateKeyPath: String(outputs["privateKeyPath"] ?? ""),
245
+ knownHostsPath: String(outputs["knownHostsPath"] ?? "")
246
+ };
247
+ },
248
+ normalizeInstanceType(alias) {
249
+ const mapped = INSTANCE_TYPE_MAP[alias];
250
+ if (!mapped) throw new Error(`Unknown instance alias: ${alias}`);
251
+ return mapped;
252
+ },
253
+ defaultRegion() {
254
+ return "eastus";
255
+ },
256
+ stateBackendUrl(bucket) {
257
+ return `azblob://${bucket}`;
258
+ },
259
+ async validateConfig() {
260
+ const errors = [];
261
+ const clientId = process2.env["AZURE_CLIENT_ID"];
262
+ const tenantId = process2.env["AZURE_TENANT_ID"];
263
+ const clientSecret = process2.env["AZURE_CLIENT_SECRET"];
264
+ const federatedToken = process2.env["AZURE_FEDERATED_TOKEN_FILE"];
265
+ const hasServicePrincipal = Boolean(clientId && tenantId && clientSecret);
266
+ const hasOidc = Boolean(clientId && tenantId && federatedToken);
267
+ if (!hasServicePrincipal && !hasOidc) {
268
+ const onAzure = await checkImds();
269
+ if (!onAzure) {
270
+ errors.push(
271
+ "No Azure credentials found. Set AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_CLIENT_SECRET (service principal), or AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_FEDERATED_TOKEN_FILE (OIDC), or run on an Azure VM with a managed identity."
272
+ );
273
+ }
274
+ }
275
+ return { ok: errors.length === 0, errors };
276
+ }
277
+ };
278
+ async function checkImds() {
279
+ try {
280
+ const res = await fetch(
281
+ "http://169.254.169.254/metadata/instance?api-version=2021-02-01",
282
+ {
283
+ headers: { Metadata: "true" },
284
+ signal: AbortSignal.timeout(1e3)
285
+ }
286
+ );
287
+ return res.ok;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+ var azure_default = azureAdapter;
293
+ export {
294
+ azure_default as default
295
+ };
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ acquireSession
4
+ } from "./chunk-5XEZAU7V.js";
5
+ import {
6
+ writeLocalState
7
+ } from "./chunk-PRYLTCS4.js";
8
+ import "./chunk-ALSUDYA7.js";
9
+ import {
10
+ ProviderError
11
+ } from "./chunk-ZSE4QRKE.js";
12
+
13
+ // src/providers/local/bootstrap.ts
14
+ import { readFileSync } from "fs";
15
+ import path from "path";
16
+ import { fileURLToPath } from "url";
17
+ var GATEWAY_PORT = 18789;
18
+ var HEALTH_POLL_INTERVAL_MS = 3e3;
19
+ var HEALTH_TIMEOUT_MS = 12e4;
20
+ function loadTemplate() {
21
+ const dir = path.dirname(fileURLToPath(import.meta.url));
22
+ return readFileSync(path.join(dir, "bootstrap.sh.tmpl"), "utf-8");
23
+ }
24
+ function renderScript(openclawVersion) {
25
+ return loadTemplate().replace(/\{\{OPENCLAW_VERSION\}\}/g, openclawVersion);
26
+ }
27
+ async function localBootstrap(opts) {
28
+ const script = renderScript(opts.openclawVersion);
29
+ const b64 = Buffer.from(script, "utf-8").toString("base64");
30
+ const command = `echo '${b64}' | base64 -d | sudo bash`;
31
+ const { session, release } = await acquireSession({
32
+ host: opts.host,
33
+ port: opts.port,
34
+ user: opts.user,
35
+ privateKeyPath: opts.privateKeyPath,
36
+ knownHostsPath: opts.knownHostsPath,
37
+ signal: opts.signal
38
+ });
39
+ try {
40
+ const result = await session.exec(command, opts.signal);
41
+ if (result.code !== 0) {
42
+ throw new ProviderError(
43
+ `Bootstrap script failed (exit ${result.code}):
44
+ ${result.stderr || result.stdout}`
45
+ );
46
+ }
47
+ } finally {
48
+ release();
49
+ }
50
+ const state = {
51
+ instanceId: `local:${opts.host}`,
52
+ publicIp: opts.host,
53
+ gatewayUrl: `http://${opts.host}:${GATEWAY_PORT}`,
54
+ sshHost: opts.host,
55
+ sshPort: opts.port,
56
+ sshUser: opts.user,
57
+ region: "local",
58
+ provisionedAt: (/* @__PURE__ */ new Date()).toISOString(),
59
+ privateKeyPath: opts.privateKeyPath,
60
+ knownHostsPath: opts.knownHostsPath
61
+ };
62
+ writeLocalState(opts.stackName, state);
63
+ if (!opts.noWait) {
64
+ await waitForGateway(opts.host, GATEWAY_PORT, opts.signal);
65
+ }
66
+ return state;
67
+ }
68
+ async function waitForGateway(host, port, signal) {
69
+ const url = `http://${host}:${port}/health`;
70
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS;
71
+ while (Date.now() < deadline) {
72
+ if (signal?.aborted) {
73
+ throw new ProviderError("Bootstrap aborted while waiting for gateway");
74
+ }
75
+ try {
76
+ const res = await fetch(url, { signal });
77
+ if (res.ok) return;
78
+ } catch {
79
+ }
80
+ await sleep(HEALTH_POLL_INTERVAL_MS);
81
+ }
82
+ throw new ProviderError(
83
+ `Gateway at ${url} did not become healthy within ${HEALTH_TIMEOUT_MS / 1e3}s`
84
+ );
85
+ }
86
+ function sleep(ms) {
87
+ return new Promise((resolve) => setTimeout(resolve, ms));
88
+ }
89
+ export {
90
+ localBootstrap
91
+ };
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ NetworkError
4
+ } from "./chunk-ZSE4QRKE.js";
5
+
6
+ // src/transport/ssh.ts
7
+ import { readFileSync, appendFileSync, mkdirSync } from "fs";
8
+ import { createServer } from "net";
9
+ import path from "path";
10
+ import { Client } from "ssh2";
11
+ var Ssh2Session = class {
12
+ constructor(client) {
13
+ this.client = client;
14
+ }
15
+ client;
16
+ exec(command, signal) {
17
+ return new Promise((resolve, reject) => {
18
+ if (signal?.aborted) {
19
+ reject(new NetworkError("Operation aborted"));
20
+ return;
21
+ }
22
+ this.client.exec(command, (err, channel) => {
23
+ if (err) {
24
+ reject(new NetworkError(`SSH exec failed: ${err.message}`));
25
+ return;
26
+ }
27
+ let stdout = "";
28
+ let stderr = "";
29
+ const onAbort = () => {
30
+ channel.destroy();
31
+ reject(new NetworkError("Operation aborted"));
32
+ };
33
+ signal?.addEventListener("abort", onAbort, { once: true });
34
+ channel.on("data", (data) => {
35
+ stdout += data.toString("utf-8");
36
+ });
37
+ channel.stderr.on("data", (data) => {
38
+ stderr += data.toString("utf-8");
39
+ });
40
+ channel.on("close", (code) => {
41
+ signal?.removeEventListener("abort", onAbort);
42
+ resolve({ stdout, stderr, code: code ?? 0 });
43
+ });
44
+ channel.on("error", (chanErr) => {
45
+ signal?.removeEventListener("abort", onAbort);
46
+ reject(new NetworkError(`SSH channel error: ${chanErr.message}`));
47
+ });
48
+ });
49
+ });
50
+ }
51
+ stream(command, signal) {
52
+ return new Promise((resolve, reject) => {
53
+ if (signal?.aborted) {
54
+ reject(new NetworkError("Operation aborted"));
55
+ return;
56
+ }
57
+ this.client.exec(command, (err, channel) => {
58
+ if (err) {
59
+ reject(new NetworkError(`SSH exec failed: ${err.message}`));
60
+ return;
61
+ }
62
+ if (signal) {
63
+ signal.addEventListener("abort", () => channel.destroy(), { once: true });
64
+ }
65
+ resolve(channel);
66
+ });
67
+ });
68
+ }
69
+ tunnel(localPort, remoteHost, remotePort, signal) {
70
+ return new Promise((resolve, reject) => {
71
+ if (signal?.aborted) {
72
+ reject(new NetworkError("Operation aborted"));
73
+ return;
74
+ }
75
+ const sockets = /* @__PURE__ */ new Set();
76
+ const server = createServer((socket) => {
77
+ sockets.add(socket);
78
+ socket.on("close", () => sockets.delete(socket));
79
+ this.client.forwardOut("127.0.0.1", localPort, remoteHost, remotePort, (err, channel) => {
80
+ if (err) {
81
+ socket.destroy();
82
+ return;
83
+ }
84
+ socket.pipe(channel);
85
+ channel.pipe(socket);
86
+ channel.on("close", () => socket.destroy());
87
+ socket.on("close", () => channel.destroy());
88
+ });
89
+ });
90
+ const closeAll = () => {
91
+ for (const s of sockets) s.destroy();
92
+ server.close();
93
+ };
94
+ server.on("error", (err) => {
95
+ const msg = err.code === "EADDRINUSE" ? `Port ${localPort} is already in use` : `Tunnel server error: ${err.message}`;
96
+ reject(new NetworkError(msg));
97
+ });
98
+ server.listen(localPort, "127.0.0.1", () => {
99
+ const handle = { localPort, close: closeAll };
100
+ signal?.addEventListener("abort", closeAll, { once: true });
101
+ resolve(handle);
102
+ });
103
+ });
104
+ }
105
+ close() {
106
+ this.client.end();
107
+ }
108
+ };
109
+ async function connect(opts) {
110
+ let privateKey;
111
+ try {
112
+ privateKey = readFileSync(opts.privateKeyPath);
113
+ } catch (err) {
114
+ throw new NetworkError(
115
+ `Cannot read SSH private key at ${opts.privateKeyPath}: ${err.message}`
116
+ );
117
+ }
118
+ return new Promise((resolve, reject) => {
119
+ const client = new Client();
120
+ const onAbort = () => {
121
+ client.destroy();
122
+ reject(new NetworkError("Connection aborted"));
123
+ };
124
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
125
+ client.on("ready", () => {
126
+ opts.signal?.removeEventListener("abort", onAbort);
127
+ resolve(new Ssh2Session(client));
128
+ });
129
+ client.on("error", (err) => {
130
+ opts.signal?.removeEventListener("abort", onAbort);
131
+ reject(new NetworkError(`SSH connection failed: ${err.message}`));
132
+ });
133
+ const config = {
134
+ host: opts.host,
135
+ port: opts.port,
136
+ username: opts.user,
137
+ privateKey,
138
+ readyTimeout: 3e4,
139
+ hostVerifier: (keyHash) => verifyHostKey(opts.host, opts.port, keyHash, opts.knownHostsPath)
140
+ };
141
+ client.connect(config);
142
+ });
143
+ }
144
+ function verifyHostKey(host, port, keyHash, knownHostsPath) {
145
+ const keyHex = keyHash.toString("hex");
146
+ const hostEntry = port === 22 ? host : `[${host}]:${port}`;
147
+ let existing = null;
148
+ try {
149
+ const content = readFileSync(knownHostsPath, "utf-8");
150
+ for (const line of content.split("\n")) {
151
+ const parts = line.trim().split(/\s+/);
152
+ if (parts[0] === hostEntry && parts.length >= 2) {
153
+ existing = parts[1] ?? null;
154
+ break;
155
+ }
156
+ }
157
+ } catch {
158
+ }
159
+ if (existing !== null) {
160
+ return existing === keyHex;
161
+ }
162
+ try {
163
+ mkdirSync(path.dirname(knownHostsPath), { recursive: true });
164
+ appendFileSync(knownHostsPath, `${hostEntry} ${keyHex}
165
+ `, "utf-8");
166
+ } catch {
167
+ }
168
+ return true;
169
+ }
170
+
171
+ // src/transport/pool.ts
172
+ var IDLE_TTL_MS = 5 * 60 * 1e3;
173
+ var MAX_PER_HOST = 4;
174
+ var CLEANUP_INTERVAL_MS = 3e4;
175
+ var pool = /* @__PURE__ */ new Map();
176
+ var cleanupTimer = null;
177
+ function poolKey(opts) {
178
+ return `${opts.user}@${opts.host}:${opts.port}`;
179
+ }
180
+ function startCleanupTimer() {
181
+ if (cleanupTimer) return;
182
+ cleanupTimer = setInterval(() => {
183
+ const now = Date.now();
184
+ for (const [key, entries] of pool) {
185
+ const active = entries.filter((e) => {
186
+ if (!e.inUse && now - e.lastUsed > IDLE_TTL_MS) {
187
+ e.session.close();
188
+ return false;
189
+ }
190
+ return true;
191
+ });
192
+ if (active.length === 0) {
193
+ pool.delete(key);
194
+ } else {
195
+ pool.set(key, active);
196
+ }
197
+ }
198
+ if (pool.size === 0 && cleanupTimer) {
199
+ clearInterval(cleanupTimer);
200
+ cleanupTimer = null;
201
+ }
202
+ }, CLEANUP_INTERVAL_MS);
203
+ cleanupTimer.unref();
204
+ }
205
+ async function acquireSession(opts) {
206
+ const key = poolKey(opts);
207
+ const entries = pool.get(key) ?? [];
208
+ const idle = entries.find((e) => !e.inUse);
209
+ if (idle) {
210
+ idle.inUse = true;
211
+ idle.lastUsed = Date.now();
212
+ return {
213
+ session: idle.session,
214
+ release() {
215
+ idle.inUse = false;
216
+ idle.lastUsed = Date.now();
217
+ }
218
+ };
219
+ }
220
+ if (entries.length >= MAX_PER_HOST) {
221
+ throw new NetworkError(
222
+ `SSH connection pool exhausted for ${key} (max ${MAX_PER_HOST} concurrent connections).`
223
+ );
224
+ }
225
+ const session = await connect(opts);
226
+ const entry = { session, hostKey: key, lastUsed: Date.now(), inUse: true };
227
+ entries.push(entry);
228
+ pool.set(key, entries);
229
+ startCleanupTimer();
230
+ return {
231
+ session,
232
+ release() {
233
+ entry.inUse = false;
234
+ entry.lastUsed = Date.now();
235
+ }
236
+ };
237
+ }
238
+ function drainPool() {
239
+ for (const entries of pool.values()) {
240
+ for (const e of entries) {
241
+ e.session.close();
242
+ }
243
+ }
244
+ pool.clear();
245
+ if (cleanupTimer) {
246
+ clearInterval(cleanupTimer);
247
+ cleanupTimer = null;
248
+ }
249
+ }
250
+
251
+ export {
252
+ acquireSession,
253
+ drainPool
254
+ };