@elench/testkit 0.1.136 → 0.1.137

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,544 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { execa } from "execa";
7
+ import { KilnClient } from "../kiln/client.mjs";
8
+ import {
9
+ createLocalEnvironmentLifecycle,
10
+ getEnvironmentDir,
11
+ isLocalEnvironmentActive,
12
+ readLocalEnvironmentManifest,
13
+ } from "./lifecycle.mjs";
14
+
15
+ const DEFAULT_REMOTE_PRODUCT_DIR = "/workspace/testkit-local";
16
+ const BOOTSTRAP_MANIFEST_PATH = ".testkit/kiln/bootstrap-manifest.json";
17
+ const DEFAULT_RSYNC_EXCLUDES = [
18
+ ".git/",
19
+ "node_modules/",
20
+ "frontend/node_modules/",
21
+ ".next/",
22
+ "frontend/.next/",
23
+ "dist/",
24
+ "coverage/",
25
+ "playwright-report/",
26
+ ".testkit/kiln/packages/",
27
+ ".testkit/results/",
28
+ ];
29
+
30
+ export async function kilnLocalUp(context, environment, options = {}) {
31
+ const vmConfig = normalizeVMConfig(environment);
32
+ const remoteProductDir = environment.kiln?.workspace?.remotePath || DEFAULT_REMOTE_PRODUCT_DIR;
33
+ const client = new KilnClient(environment.kiln?.api || {});
34
+ const existing = readLocalEnvironmentManifest(context.productDir, environment.name);
35
+ if (existing && isLocalEnvironmentActive(existing)) {
36
+ throw new Error(`Local environment "${environment.name}" is already running. Stop it with testkit local down ${environment.name}.`);
37
+ }
38
+ const lifecycle = createLocalEnvironmentLifecycle(context.productDir, environment.name, {
39
+ target: environment.target,
40
+ data: environment.data,
41
+ portOffset: environment.portOffset,
42
+ driver: "kiln",
43
+ });
44
+ let ssh = null;
45
+
46
+ try {
47
+ const network = await ensureNetwork(client, environment);
48
+ let vm = await ensureVM(client, vmConfig, network?.id || "");
49
+ vm = await ensureStarted(client, vm.name);
50
+ vm = await waitForVMReady(client, vm.name);
51
+
52
+ if (network && vm.network_id !== network.id) {
53
+ if (vm.state !== "stopped") {
54
+ await client.stopVM(vm.name);
55
+ await waitForVMState(client, vm.name, "stopped");
56
+ }
57
+ vm = await client.attachVMNetwork(vm.name, network.id);
58
+ vm = await ensureStarted(client, vm.name);
59
+ vm = await waitForVMReady(client, vm.name);
60
+ }
61
+
62
+ const targetHost = vm.private_ip || vm.ip;
63
+ if (!targetHost) throw new Error(`Kiln VM ${vm.name} has no reachable IP`);
64
+ ssh = await prepareSSH(client, vm);
65
+ await waitForSSHReady(ssh, vm.name);
66
+ await syncWorkspace({ ssh, productDir: context.productDir, remoteProductDir, environment });
67
+ await bootstrapRemoteWorkspace({ ssh, context, remoteProductDir });
68
+
69
+ const remote = await remoteTestkit(ssh, remoteProductDir, [
70
+ "local",
71
+ "up",
72
+ environment.name,
73
+ ...(options.service ? ["--service", options.service] : []),
74
+ ...(environment.data === "reset" ? ["--reset"] : []),
75
+ ...(environment.data === "rebuild" ? ["--rebuild"] : []),
76
+ ...(environment.portOffset ? ["--port-offset", String(environment.portOffset)] : []),
77
+ ], { TESTKIT_PUBLIC_HOST: targetHost });
78
+ if (remote.exitCode !== 0) {
79
+ throw new Error(`remote testkit local up failed\n${remote.stdout}${remote.stderr}`);
80
+ }
81
+
82
+ const status = await remoteLocalStatus(ssh, remoteProductDir, environment.name);
83
+ lifecycle.markRunning({
84
+ driver: "kiln",
85
+ target: environment.target,
86
+ remoteProductDir,
87
+ kiln: {
88
+ api: pickAPIConfig(environment.kiln?.api || {}),
89
+ vm: pickVM(vm),
90
+ network: network ? { id: network.id, name: network.name, cidr: network.cidr } : null,
91
+ },
92
+ endpoints: rewriteEndpoints(status?.manifest?.endpoints || {}, targetHost),
93
+ remoteStatus: status?.manifest || null,
94
+ });
95
+ return buildKilnLocalStatus(context.productDir, environment.name);
96
+ } catch (error) {
97
+ lifecycle.requestStop("startup-failed");
98
+ throw error;
99
+ } finally {
100
+ ssh?.cleanup?.();
101
+ }
102
+ }
103
+
104
+ export async function kilnLocalDown(context, name, options = {}) {
105
+ const manifest = readLocalEnvironmentManifest(context.productDir, name);
106
+ if (!manifest) return null;
107
+ if (!manifest.kiln?.vm?.name) {
108
+ writeKilnManifest(context.productDir, name, {
109
+ ...manifest,
110
+ status: "stopped",
111
+ stoppedAt: new Date().toISOString(),
112
+ services: [],
113
+ });
114
+ return manifest;
115
+ }
116
+ const ssh = await sshFromManifest(manifest);
117
+ const args = ["local", "down", name, ...(options.destroyState ? ["--destroy-state"] : [])];
118
+ try {
119
+ const result = await remoteTestkit(ssh, manifest.remoteProductDir, args);
120
+ if (result.exitCode !== 0) {
121
+ throw new Error(`remote testkit local down failed\n${result.stdout}${result.stderr}`);
122
+ }
123
+ writeKilnManifest(context.productDir, name, {
124
+ ...manifest,
125
+ status: "stopped",
126
+ stoppedAt: new Date().toISOString(),
127
+ services: [],
128
+ });
129
+ return manifest;
130
+ } finally {
131
+ ssh.cleanup?.();
132
+ }
133
+ }
134
+
135
+ export async function kilnLocalLogs(context, name, options = {}) {
136
+ const manifest = requireKilnManifest(context.productDir, name);
137
+ const ssh = await sshFromManifest(manifest);
138
+ const args = ["local", "logs", name, ...(options.service ? ["--service", options.service] : []), "--lines", String(options.lines || 200)];
139
+ try {
140
+ const result = await remoteTestkit(ssh, manifest.remoteProductDir, args);
141
+ if (result.exitCode !== 0) throw new Error(`remote testkit local logs failed\n${result.stdout}${result.stderr}`);
142
+ return { name, lines: splitOutput(result) };
143
+ } finally {
144
+ ssh.cleanup?.();
145
+ }
146
+ }
147
+
148
+ export async function kilnLocalEnv(context, name, options = {}) {
149
+ const manifest = requireKilnManifest(context.productDir, name);
150
+ const ssh = await sshFromManifest(manifest);
151
+ const args = ["local", "env", name, ...(options.service ? ["--service", options.service] : [])];
152
+ try {
153
+ const result = await remoteTestkit(ssh, manifest.remoteProductDir, args);
154
+ if (result.exitCode !== 0) throw new Error(`remote testkit local env failed\n${result.stdout}${result.stderr}`);
155
+ return { name, service: options.service || manifest.target, lines: splitOutput(result) };
156
+ } finally {
157
+ ssh.cleanup?.();
158
+ }
159
+ }
160
+
161
+ export async function kilnLocalShell(context, name, options = {}) {
162
+ const manifest = requireKilnManifest(context.productDir, name);
163
+ const ssh = await sshFromManifest(manifest);
164
+ const command = options.command || [];
165
+ if (command.length === 0) throw new Error("testkit local shell requires a command after --");
166
+ try {
167
+ return await remoteTestkit(ssh, manifest.remoteProductDir, [
168
+ "local",
169
+ "shell",
170
+ name,
171
+ ...(options.service ? ["--service", options.service] : []),
172
+ "--",
173
+ ...command,
174
+ ]);
175
+ } finally {
176
+ ssh.cleanup?.();
177
+ }
178
+ }
179
+
180
+ export function buildKilnLocalStatus(productDir, name) {
181
+ const manifest = readLocalEnvironmentManifest(productDir, name);
182
+ if (!manifest) return { name, exists: false, active: false, lines: [`Local Environment: ${name}`, " not found"] };
183
+ const endpoints = manifest.endpoints || {};
184
+ const lines = [
185
+ `Local Environment: ${name}`,
186
+ ` status: ${manifest.status || "unknown"}`,
187
+ ` driver: kiln`,
188
+ ` target: ${manifest.target || "unknown"}`,
189
+ ` vm: ${manifest.kiln?.vm?.name || "unknown"} ${manifest.kiln?.vm?.private_ip || manifest.kiln?.vm?.ip || ""}`.trimEnd(),
190
+ ` remote: ${manifest.remoteProductDir || "unknown"}`,
191
+ ];
192
+ if (Object.keys(endpoints).length > 0) {
193
+ lines.push(" endpoints:");
194
+ for (const [service, url] of Object.entries(endpoints)) lines.push(` ${service}: ${url}`);
195
+ }
196
+ return { name, exists: true, active: manifest.status === "running", manifest, services: [], lines };
197
+ }
198
+
199
+ async function ensureNetwork(client, environment) {
200
+ const networkConfig = environment.kiln?.network;
201
+ if (!networkConfig) return null;
202
+ if (networkConfig.id) {
203
+ const network = { id: networkConfig.id, name: networkConfig.name || networkConfig.id };
204
+ await attachExistingVMs(client, network, networkConfig.attachExistingVMs || []);
205
+ return network;
206
+ }
207
+ const name = networkConfig.name || `testkit-${environment.name}`;
208
+ const networks = await client.listNetworks();
209
+ const existing = networks.find((network) => network.name === name);
210
+ const network = existing || (await client.createNetwork({ name, ...(networkConfig.cidr ? { cidr: networkConfig.cidr } : {}) }));
211
+ await attachExistingVMs(client, network, networkConfig.attachExistingVMs || []);
212
+ return network;
213
+ }
214
+
215
+ async function attachExistingVMs(client, network, vmRefs) {
216
+ for (const ref of vmRefs || []) {
217
+ let vm = await client.getVM(ref);
218
+ if (vm.network_id === network.id) continue;
219
+ if (vm.state !== "stopped") {
220
+ await client.stopVM(ref);
221
+ await waitForVMState(client, ref, "stopped");
222
+ }
223
+ await client.attachVMNetwork(ref, network.id);
224
+ await client.startVM(ref);
225
+ await waitForVMReady(client, ref);
226
+ }
227
+ }
228
+
229
+ async function ensureVM(client, vmConfig, networkId) {
230
+ try {
231
+ const existing = await client.getVM(vmConfig.name);
232
+ if (existing.profile && existing.profile !== vmConfig.profile) {
233
+ throw new Error(`Kiln VM ${vmConfig.name} uses profile ${existing.profile}; expected ${vmConfig.profile}`);
234
+ }
235
+ return existing;
236
+ } catch (error) {
237
+ if (!String(error.message || "").includes("404")) throw error;
238
+ }
239
+ return client.createVM({
240
+ name: vmConfig.name,
241
+ profile: vmConfig.profile,
242
+ network_id: networkId || vmConfig.networkId || "",
243
+ disk_size_mb: vmConfig.diskSizeMB,
244
+ memory_mb: vmConfig.memoryMB,
245
+ vcpus: vmConfig.vcpus,
246
+ autostart: Boolean(vmConfig.autostart),
247
+ });
248
+ }
249
+
250
+ async function ensureStarted(client, ref) {
251
+ const vm = await client.getVM(ref);
252
+ if (vm.state === "running" || vm.state === "creating") return vm;
253
+ return client.startVM(ref);
254
+ }
255
+
256
+ async function waitForVMReady(client, ref) {
257
+ const startedAt = Date.now();
258
+ let last = null;
259
+ let lastError = null;
260
+ while (Date.now() - startedAt < 180_000) {
261
+ last = await client.getVM(ref);
262
+ if (last.state === "running") {
263
+ try {
264
+ const result = await client.execVM(ref, "echo testkit-kiln-ready");
265
+ if (result.exit_code === 0 && result.stdout.includes("testkit-kiln-ready")) return await client.getVM(ref);
266
+ } catch (error) {
267
+ lastError = error;
268
+ }
269
+ }
270
+ await sleep(1000);
271
+ }
272
+ const suffix = lastError ? `; last readiness error: ${lastError.message}` : "";
273
+ throw new Error(`Timed out waiting for Kiln VM ${ref}; last state ${last?.state || "unknown"}${suffix}`);
274
+ }
275
+
276
+ async function waitForVMState(client, ref, state) {
277
+ const startedAt = Date.now();
278
+ let last = null;
279
+ while (Date.now() - startedAt < 120_000) {
280
+ last = await client.getVM(ref);
281
+ if (last.state === state) return last;
282
+ await sleep(1000);
283
+ }
284
+ throw new Error(`Timed out waiting for Kiln VM ${ref} to become ${state}; last state ${last?.state || "unknown"}`);
285
+ }
286
+
287
+ async function prepareSSH(client, vm) {
288
+ const key = await client.sshKey(vm.machine_id);
289
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-kiln-ssh-"));
290
+ const keyPath = path.join(dir, "id_ed25519");
291
+ fs.writeFileSync(keyPath, key, { mode: 0o600 });
292
+ return { host: vm.ip, keyPath, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
293
+ }
294
+
295
+ async function sshFromManifest(manifest) {
296
+ const client = new KilnClient(manifest.kiln?.api || {});
297
+ const vm = await client.getVM(manifest.kiln.vm.name);
298
+ const started = vm.state === "running" ? vm : await ensureStarted(client, vm.name);
299
+ const running = started.state === "running" ? started : await waitForVMReady(client, vm.name);
300
+ const ssh = await prepareSSH(client, running);
301
+ await waitForSSHReady(ssh, vm.name);
302
+ return ssh;
303
+ }
304
+
305
+ async function waitForSSHReady(ssh, vmName) {
306
+ const startedAt = Date.now();
307
+ let lastError = null;
308
+ while (Date.now() - startedAt < 120_000) {
309
+ try {
310
+ await sshCommand(ssh, "true");
311
+ return;
312
+ } catch (error) {
313
+ lastError = error;
314
+ await sleep(1000);
315
+ }
316
+ }
317
+ throw new Error(`Timed out waiting for SSH in Kiln VM ${vmName}: ${lastError?.message || "unknown error"}`);
318
+ }
319
+
320
+ async function syncWorkspace({ ssh, productDir, remoteProductDir, environment }) {
321
+ await sshCommand(ssh, `mkdir -p ${shellQuote(remoteProductDir)}`);
322
+ const excludes = [...DEFAULT_RSYNC_EXCLUDES, ...(environment.kiln?.workspace?.exclude || [])];
323
+ const args = [
324
+ "-az",
325
+ "--delete",
326
+ ...excludes.flatMap((entry) => ["--exclude", entry]),
327
+ "-e",
328
+ sshTransport(ssh),
329
+ `${path.resolve(productDir)}/`,
330
+ `root@${ssh.host}:${remoteProductDir}/`,
331
+ ];
332
+ await execa("rsync", args, { stdio: "inherit" });
333
+ }
334
+
335
+ async function bootstrapRemoteWorkspace({ ssh, context, remoteProductDir }) {
336
+ const nodeVersion = readDesiredNodeVersion(context.productDir);
337
+ const packPath = await packCurrentTestkit(context.productDir);
338
+ const manifest = buildBootstrapManifest(context.productDir, { nodeVersion, testkitPackPath: packPath });
339
+ const remotePack = `/tmp/${path.basename(packPath)}`;
340
+ const localManifest = path.join(context.productDir, ".testkit", "kiln", "bootstrap-manifest.json");
341
+ fs.mkdirSync(path.dirname(localManifest), { recursive: true });
342
+ fs.writeFileSync(localManifest, `${JSON.stringify(manifest, null, 2)}\n`);
343
+ await execa("rsync", ["-az", "-e", sshTransport(ssh), packPath, `root@${ssh.host}:${remotePack}`], { stdio: "inherit" });
344
+ await execa("rsync", ["-az", "-e", sshTransport(ssh), localManifest, `root@${ssh.host}:/tmp/testkit-bootstrap-manifest.json`], { stdio: "inherit" });
345
+ await sshCommand(ssh, [
346
+ "set -eu",
347
+ `NODE_VERSION=${shellQuote(nodeVersion)}`,
348
+ `REMOTE_DIR=${shellQuote(remoteProductDir)}`,
349
+ `BOOTSTRAP_MANIFEST=${shellQuote(BOOTSTRAP_MANIFEST_PATH)}`,
350
+ "NODE_HOME=/opt/testkit-node-$NODE_VERSION",
351
+ "if [ ! -x \"$NODE_HOME/bin/node\" ]; then",
352
+ " mkdir -p /opt",
353
+ " curl -fsSL \"https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz\" -o /tmp/testkit-node.tar.xz",
354
+ " rm -rf \"$NODE_HOME\"",
355
+ " mkdir -p \"$NODE_HOME\"",
356
+ " tar -xJf /tmp/testkit-node.tar.xz -C \"$NODE_HOME\" --strip-components=1",
357
+ "fi",
358
+ "export PATH=\"$NODE_HOME/bin:$PATH\"",
359
+ "deadline=$(($(date +%s) + 120))",
360
+ "until docker info >/dev/null 2>&1; do",
361
+ " if [ \"$(date +%s)\" -ge \"$deadline\" ]; then tail -80 /var/log/dockerd.log >&2 || true; exit 1; fi",
362
+ " sleep 1",
363
+ "done",
364
+ "cd \"$REMOTE_DIR\"",
365
+ "BOOTSTRAP_READY=1",
366
+ "[ -x ./node_modules/.bin/testkit ] || BOOTSTRAP_READY=0",
367
+ "if [ -f frontend/package-lock.json ] && [ ! -d frontend/node_modules ]; then BOOTSTRAP_READY=0; fi",
368
+ "if [ -f \"$BOOTSTRAP_MANIFEST\" ] && cmp -s /tmp/testkit-bootstrap-manifest.json \"$BOOTSTRAP_MANIFEST\" && [ \"$BOOTSTRAP_READY\" = 1 ]; then",
369
+ " echo 'testkit kiln bootstrap cache hit'",
370
+ "else",
371
+ " npm ci",
372
+ " if [ -f frontend/package-lock.json ]; then npm --prefix frontend ci; fi",
373
+ ` npm install --no-save ${shellQuote(remotePack)}`,
374
+ " mkdir -p \"$(dirname \"$BOOTSTRAP_MANIFEST\")\"",
375
+ " cp /tmp/testkit-bootstrap-manifest.json \"$BOOTSTRAP_MANIFEST\"",
376
+ "fi",
377
+ ].join("\n"));
378
+ }
379
+
380
+ async function remoteTestkit(ssh, remoteProductDir, args, extraEnv = {}) {
381
+ const envPrefix = Object.entries({ TESTKIT_KILN_INNER: "1", TESTKIT_LOCAL_DRIVER: "host", ...extraEnv })
382
+ .map(([key, value]) => `${key}=${shellQuote(value)}`)
383
+ .join(" ");
384
+ const command = [
385
+ `cd ${shellQuote(remoteProductDir)}`,
386
+ `BOOTSTRAP_MANIFEST=${shellQuote(BOOTSTRAP_MANIFEST_PATH)}`,
387
+ "NODE_VERSION=$(sed -n 's/.*\"nodeVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$BOOTSTRAP_MANIFEST\" | head -1)",
388
+ "[ -n \"$NODE_VERSION\" ]",
389
+ "NODE_HOME=/opt/testkit-node-$NODE_VERSION",
390
+ "export PATH=\"$NODE_HOME/bin:$PATH\"",
391
+ `export ${envPrefix}`,
392
+ `./node_modules/.bin/testkit ${args.map(shellQuote).join(" ")}`,
393
+ ].join(" && ");
394
+ return sshCommand(ssh, command, { reject: false });
395
+ }
396
+
397
+ async function remoteLocalStatus(ssh, remoteProductDir, name) {
398
+ const result = await remoteTestkit(ssh, remoteProductDir, ["local", "status", name, "--json"]);
399
+ if (result.exitCode !== 0) return null;
400
+ try {
401
+ return JSON.parse(result.stdout)?.result || null;
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+
407
+ async function sshCommand(ssh, command, options = {}) {
408
+ const args = sshArgs(ssh, command);
409
+ return execa("ssh", args, { reject: options.reject !== false });
410
+ }
411
+
412
+ function sshArgs(ssh, command) {
413
+ return [
414
+ "-i",
415
+ ssh.keyPath,
416
+ "-o",
417
+ "StrictHostKeyChecking=no",
418
+ "-o",
419
+ "UserKnownHostsFile=/dev/null",
420
+ "-o",
421
+ "LogLevel=ERROR",
422
+ `root@${ssh.host}`,
423
+ command,
424
+ ];
425
+ }
426
+
427
+ function sshTransport(ssh) {
428
+ return `ssh -i ${ssh.keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR`;
429
+ }
430
+
431
+ function normalizeVMConfig(environment) {
432
+ const vm = environment.kiln?.vm || {};
433
+ const name = vm.name || `testkit-${environment.name}`;
434
+ return {
435
+ name,
436
+ profile: vm.profile || "ubuntu-docker",
437
+ diskSizeMB: parseDiskMB(vm.disk || vm.diskSize || vm.diskSizeMB || "64G"),
438
+ memoryMB: Number(vm.memoryMB || vm.memoryMb || vm.memory_mib || 8192),
439
+ vcpus: Number(vm.vcpus || 4),
440
+ autostart: Boolean(vm.autostart),
441
+ networkId: vm.networkId || vm.network_id || "",
442
+ };
443
+ }
444
+
445
+ function parseDiskMB(value) {
446
+ if (typeof value === "number") return value;
447
+ const raw = String(value || "").trim();
448
+ const match = raw.match(/^(\d+)([gGmM])?$/);
449
+ if (!match) throw new Error(`Invalid Kiln VM disk size: ${value}`);
450
+ const amount = Number(match[1]);
451
+ return match[2]?.toLowerCase() === "g" ? amount * 1024 : amount;
452
+ }
453
+
454
+ function readDesiredNodeVersion(productDir) {
455
+ try {
456
+ const pkg = JSON.parse(fs.readFileSync(path.join(productDir, "package.json"), "utf8"));
457
+ return pkg.volta?.node || String(pkg.engines?.node || "20.19.5").match(/\d+\.\d+\.\d+/)?.[0] || "20.19.5";
458
+ } catch {
459
+ return "20.19.5";
460
+ }
461
+ }
462
+
463
+ async function packCurrentTestkit(productDir) {
464
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
465
+ const outDir = path.join(productDir, ".testkit", "kiln", "packages");
466
+ fs.mkdirSync(outDir, { recursive: true });
467
+ const { stdout } = await execa("npm", ["pack", "--pack-destination", outDir, "--silent"], { cwd: packageRoot });
468
+ return path.join(outDir, stdout.trim().split(/\r?\n/).at(-1));
469
+ }
470
+
471
+ export function buildBootstrapManifest(productDir, { nodeVersion, testkitPackPath }) {
472
+ return {
473
+ version: 1,
474
+ nodeVersion,
475
+ testkitPackSha256: sha256File(testkitPackPath),
476
+ locks: Object.fromEntries(
477
+ ["package-lock.json", "frontend/package-lock.json"]
478
+ .filter((entry) => fs.existsSync(path.join(productDir, entry)))
479
+ .map((entry) => [entry, sha256File(path.join(productDir, entry))])
480
+ ),
481
+ };
482
+ }
483
+
484
+ function sha256File(filePath) {
485
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
486
+ }
487
+
488
+ export function rewriteEndpoints(endpoints, host) {
489
+ return Object.fromEntries(
490
+ Object.entries(endpoints || {}).map(([service, raw]) => {
491
+ try {
492
+ const url = new URL(raw);
493
+ url.hostname = host;
494
+ return [service, url.toString().replace(/\/$/, "")];
495
+ } catch {
496
+ return [service, raw];
497
+ }
498
+ })
499
+ );
500
+ }
501
+
502
+ function requireKilnManifest(productDir, name) {
503
+ const manifest = readLocalEnvironmentManifest(productDir, name);
504
+ if (!manifest) throw new Error(`Local environment "${name}" has not been started.`);
505
+ if (manifest.driver !== "kiln") throw new Error(`Local environment "${name}" is not managed by the Kiln driver.`);
506
+ if (!manifest.kiln?.vm?.name) throw new Error(`Local environment "${name}" has no Kiln VM recorded.`);
507
+ return manifest;
508
+ }
509
+
510
+ function writeKilnManifest(productDir, name, manifest) {
511
+ fs.writeFileSync(
512
+ path.join(getEnvironmentDir(productDir, name), "manifest.json"),
513
+ `${JSON.stringify(manifest, null, 2)}\n`
514
+ );
515
+ }
516
+
517
+ function pickVM(vm) {
518
+ return {
519
+ id: vm.id,
520
+ name: vm.name,
521
+ machine_id: vm.machine_id,
522
+ ip: vm.ip,
523
+ private_ip: vm.private_ip,
524
+ state: vm.state,
525
+ };
526
+ }
527
+
528
+ function pickAPIConfig(api) {
529
+ return {
530
+ ...(api.apiUrl ? { apiUrl: api.apiUrl } : {}),
531
+ };
532
+ }
533
+
534
+ function splitOutput(result) {
535
+ return [result.stdout, result.stderr].filter(Boolean).join("").split(/\r?\n/).filter(Boolean);
536
+ }
537
+
538
+ function shellQuote(value) {
539
+ return "'" + String(value ?? "").replaceAll("'", "'\"'\"'") + "'";
540
+ }
541
+
542
+ function sleep(ms) {
543
+ return new Promise((resolve) => setTimeout(resolve, ms));
544
+ }
@@ -19,6 +19,7 @@ export function createLocalEnvironmentLifecycle(productDir, name, options = {})
19
19
  const state = {
20
20
  schemaVersion: SCHEMA_VERSION,
21
21
  kind: "local",
22
+ driver: options.driver || "host",
22
23
  name,
23
24
  productDir,
24
25
  pid: process.pid,
@@ -200,6 +201,7 @@ export function findLocalPortOwner(productDir, { host, port }) {
200
201
  }
201
202
 
202
203
  export function isLocalEnvironmentActive(manifest) {
204
+ if (manifest.driver === "kiln") return manifest.status === "running";
203
205
  return (manifest.services || []).some(isLocalServiceActive);
204
206
  }
205
207
 
@@ -8,6 +8,14 @@ import { resolveRuntimeConfigs } from "../runner/planning.mjs";
8
8
  import { startLocalServices } from "../runner/services.mjs";
9
9
  import { buildExecutionEnv, resolveRuntimeInstanceConfigs } from "../runner/template.mjs";
10
10
  import { readDatabaseUrl } from "../runner/state-io.mjs";
11
+ import {
12
+ buildKilnLocalStatus,
13
+ kilnLocalDown,
14
+ kilnLocalEnv,
15
+ kilnLocalLogs,
16
+ kilnLocalShell,
17
+ kilnLocalUp,
18
+ } from "./kiln-driver.mjs";
11
19
  import {
12
20
  cleanupStaleLocalEnvironments,
13
21
  createLocalEnvironmentLifecycle,
@@ -21,6 +29,9 @@ import {
21
29
  export async function localUp(options = {}) {
22
30
  const context = await loadConfigContext({ dir: options.dir });
23
31
  const environment = resolveEnvironment(context, options);
32
+ if (environment.driver === "kiln") {
33
+ return kilnLocalUp(context, environment, options);
34
+ }
24
35
  const existing = readLocalEnvironmentManifest(context.productDir, environment.name);
25
36
  if (existing && isLocalEnvironmentActive(existing)) {
26
37
  throw new Error(`Local environment "${environment.name}" is already running. Stop it with testkit local down ${environment.name}.`);
@@ -77,21 +88,30 @@ export async function localUp(options = {}) {
77
88
  export async function localDown(options = {}) {
78
89
  const context = await loadConfigContext({ dir: options.dir });
79
90
  const name = normalizeEnvironmentName(options.name || "local");
80
- const manifest = await stopLocalEnvironment(context.productDir, name, {
91
+ const manifest = readLocalEnvironmentManifest(context.productDir, name);
92
+ if (manifest?.driver === "kiln") {
93
+ await kilnLocalDown(context, name, { destroyState: Boolean(options.destroyState) });
94
+ return buildKilnLocalStatus(context.productDir, name);
95
+ }
96
+ const stopped = await stopLocalEnvironment(context.productDir, name, {
81
97
  removeRuntimeState: Boolean(options.destroyState),
82
98
  });
83
- return manifest
99
+ return stopped
84
100
  ? buildLocalStatus(context.productDir, name)
85
101
  : { name, exists: false, lines: [`Local Environment: ${name}`, " not found"] };
86
102
  }
87
103
 
88
104
  export async function localStatus(options = {}) {
89
105
  const context = await loadConfigContext({ dir: options.dir });
90
- if (options.name) return buildLocalStatus(context.productDir, normalizeEnvironmentName(options.name));
106
+ if (options.name) {
107
+ const name = normalizeEnvironmentName(options.name);
108
+ const manifest = readLocalEnvironmentManifest(context.productDir, name);
109
+ return manifest?.driver === "kiln" ? buildKilnLocalStatus(context.productDir, name) : buildLocalStatus(context.productDir, name);
110
+ }
91
111
  return {
92
112
  productDir: context.productDir,
93
113
  environments: listLocalEnvironmentManifests(context.productDir).map((manifest) =>
94
- buildLocalStatus(context.productDir, manifest.name)
114
+ manifest.driver === "kiln" ? buildKilnLocalStatus(context.productDir, manifest.name) : buildLocalStatus(context.productDir, manifest.name)
95
115
  ),
96
116
  };
97
117
  }
@@ -101,6 +121,7 @@ export async function localEnv(options = {}) {
101
121
  const environment = resolveEnvironment(context, options);
102
122
  const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
103
123
  if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
124
+ if (manifest.driver === "kiln") return kilnLocalEnv(context, environment.name, options);
104
125
  const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
105
126
  const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
106
127
  const env = buildServiceEnv(serviceConfig, environment.env);
@@ -119,6 +140,7 @@ export async function localLogs(options = {}) {
119
140
  const name = normalizeEnvironmentName(options.name || "local");
120
141
  const manifest = readLocalEnvironmentManifest(context.productDir, name);
121
142
  if (!manifest) throw new Error(`Local environment "${name}" has not been started.`);
143
+ if (manifest.driver === "kiln") return kilnLocalLogs(context, name, options);
122
144
  const logsDir = path.join(getEnvironmentDir(context.productDir, name), "logs");
123
145
  const serviceNames = options.service
124
146
  ? [options.service]
@@ -140,6 +162,7 @@ export async function localShell(options = {}) {
140
162
  const environment = resolveEnvironment(context, options);
141
163
  const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
142
164
  if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
165
+ if (manifest.driver === "kiln") return kilnLocalShell(context, environment.name, options);
143
166
  const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
144
167
  const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
145
168
  const env = buildServiceEnv(serviceConfig, environment.env);
@@ -162,6 +185,7 @@ export function buildLocalRuntimeConfigs(allConfigs, environment, runtimeDir) {
162
185
  return resolveRuntimeInstanceConfigs(runtimeConfigs, "local", runtimeDir, {
163
186
  graphDirName: runtimeConfigs.map((config) => config.name).sort().join("__"),
164
187
  portOffset: environment.portOffset || 0,
188
+ publicHost: environment.publicHost || null,
165
189
  }).map((config) => applyLocalEnvironmentConfig(config, environment));
166
190
  }
167
191
 
@@ -202,17 +226,22 @@ export function buildLocalStatus(productDir, name) {
202
226
  };
203
227
  }
204
228
 
205
- function resolveEnvironment(context, options = {}) {
229
+ export function resolveEnvironment(context, options = {}) {
206
230
  const name = normalizeEnvironmentName(options.name || "local");
207
231
  const configured = context.environments[name] || null;
208
232
  const target = options.service || configured?.target || inferDefaultTarget(context.configs);
233
+ const requestedDriver = process.env.TESTKIT_LOCAL_DRIVER || options.driver || configured?.driver || "host";
234
+ const driver = process.env.TESTKIT_KILN_INNER === "1" ? "host" : requestedDriver;
209
235
  return {
210
236
  name,
211
237
  kind: "local",
238
+ driver,
212
239
  target,
213
240
  data: options.rebuild ? "rebuild" : options.reset ? "reset" : configured?.data || "reuse",
214
241
  portOffset: Number(options.portOffset ?? configured?.portOffset ?? 0),
215
242
  env: { ...(configured?.env || {}) },
243
+ kiln: configured?.kiln || null,
244
+ publicHost: process.env.TESTKIT_PUBLIC_HOST || configured?.publicHost || null,
216
245
  };
217
246
  }
218
247