@elench/testkit 0.1.136 → 0.1.138

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.
@@ -224,12 +224,71 @@ function normalizeEnvironmentConfig(name, environment) {
224
224
  if (!Number.isInteger(portOffset) || portOffset < 0) {
225
225
  throw new Error(`Environment "${name}" portOffset must be a non-negative integer`);
226
226
  }
227
+ const driver = environment.driver || "host";
228
+ if (!["host", "kiln"].includes(driver)) {
229
+ throw new Error(`Environment "${name}" driver must be one of: host, kiln`);
230
+ }
231
+ const kiln = normalizeKilnEnvironmentConfig(name, environment.kiln);
227
232
  return {
228
233
  kind: "local",
234
+ driver,
229
235
  target,
230
236
  data,
231
237
  portOffset,
232
238
  env: normalizeEnvironmentEnv(environment.env),
239
+ resources: normalizeEnvironmentResources(name, environment.resources),
240
+ productionLike: Boolean(environment.productionLike),
241
+ ...(environment.publicHost ? { publicHost: String(environment.publicHost) } : {}),
242
+ ...(kiln ? { kiln } : {}),
243
+ };
244
+ }
245
+
246
+ function normalizeEnvironmentResources(environmentName, resources = {}) {
247
+ if (resources == null) return {};
248
+ if (typeof resources !== "object" || Array.isArray(resources)) {
249
+ throw new Error(`Environment "${environmentName}" resources must be an object`);
250
+ }
251
+ return Object.fromEntries(
252
+ Object.entries(resources).map(([name, resource]) => {
253
+ const normalizedName = normalizeDatabaseEnvToken(name, `Environment "${environmentName}" resource name`, false);
254
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
255
+ throw new Error(`Environment "${environmentName}" resource "${name}" must be an object`);
256
+ }
257
+ const kind = String(resource.kind || "").trim();
258
+ if (!["postgres", "server"].includes(kind)) {
259
+ throw new Error(`Environment "${environmentName}" resource "${name}" kind must be "postgres" or "server"`);
260
+ }
261
+ return [normalizedName, { ...resource, kind }];
262
+ })
263
+ );
264
+ }
265
+
266
+ function normalizeKilnEnvironmentConfig(name, kiln) {
267
+ if (!kiln) return null;
268
+ if (typeof kiln !== "object" || Array.isArray(kiln)) {
269
+ throw new Error(`Environment "${name}" kiln must be an object`);
270
+ }
271
+ const vm = kiln.vm || {};
272
+ if (vm && (typeof vm !== "object" || Array.isArray(vm))) {
273
+ throw new Error(`Environment "${name}" kiln.vm must be an object`);
274
+ }
275
+ const network = kiln.network || null;
276
+ if (network && (typeof network !== "object" || Array.isArray(network))) {
277
+ throw new Error(`Environment "${name}" kiln.network must be an object`);
278
+ }
279
+ const workspace = kiln.workspace || null;
280
+ if (workspace && (typeof workspace !== "object" || Array.isArray(workspace))) {
281
+ throw new Error(`Environment "${name}" kiln.workspace must be an object`);
282
+ }
283
+ return {
284
+ ...kiln,
285
+ vm: {
286
+ ...(vm || {}),
287
+ ...(vm.name ? { name: String(vm.name) } : {}),
288
+ ...(vm.profile ? { profile: String(vm.profile) } : {}),
289
+ },
290
+ ...(network ? { network: { ...network } } : {}),
291
+ ...(workspace ? { workspace: { ...workspace } } : {}),
233
292
  };
234
293
  }
235
294
 
@@ -240,13 +299,14 @@ function normalizeEnvironmentEnv(env) {
240
299
  }
241
300
  const hasPresetShape =
242
301
  Object.prototype.hasOwnProperty.call(env, "values") ||
243
- Object.prototype.hasOwnProperty.call(env, "databases");
302
+ Object.prototype.hasOwnProperty.call(env, "databases") ||
303
+ Object.prototype.hasOwnProperty.call(env, "resources");
244
304
  if (!hasPresetShape) {
245
305
  return Object.fromEntries(
246
306
  Object.entries(env).map(([key, value]) => [key, String(value)])
247
307
  );
248
308
  }
249
- const allowedKeys = new Set(["values", "databases"]);
309
+ const allowedKeys = new Set(["values", "databases", "resources"]);
250
310
  const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
251
311
  if (unexpectedKeys.length > 0) {
252
312
  throw new Error(
@@ -256,8 +316,11 @@ function normalizeEnvironmentEnv(env) {
256
316
  const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
257
317
  const databases =
258
318
  env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
319
+ const resources =
320
+ env.resources && typeof env.resources === "object" && !Array.isArray(env.resources) ? env.resources : {};
259
321
  return {
260
322
  ...expandDatabaseBindings(databases),
323
+ ...expandResourceBindings(resources),
261
324
  ...Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)])),
262
325
  };
263
326
  }
@@ -280,6 +343,22 @@ function expandDatabaseBindings(bindings) {
280
343
  return env;
281
344
  }
282
345
 
346
+ function expandResourceBindings(bindings) {
347
+ const env = {};
348
+ for (const [envName, binding] of Object.entries(bindings || {})) {
349
+ if (typeof binding === "string") {
350
+ const [resourceName, field = "url"] = binding.split(".");
351
+ env[envName] = `{resource:${normalizeDatabaseEnvToken(resourceName, `env.resources.${envName}`, false)}:${normalizeDatabaseEnvToken(field, `env.resources.${envName} field`, false)}}`;
352
+ continue;
353
+ }
354
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
355
+ throw new Error(`env.resources.${envName} must be a string or object`);
356
+ }
357
+ env[envName] = `{resource:${normalizeDatabaseEnvToken(binding.resource, `env.resources.${envName}.resource`, false)}:${normalizeDatabaseEnvToken(binding.field || "url", `env.resources.${envName}.field`, false)}}`;
358
+ }
359
+ return env;
360
+ }
361
+
283
362
  function normalizeDatabaseEnvToken(value, label, sanitize = true) {
284
363
  const raw = String(value || "").trim();
285
364
  const normalized = sanitize
@@ -280,13 +280,50 @@ export interface DatabaseBindingEnvConfig {
280
280
  export interface PresetEnvConfig {
281
281
  values?: Record<string, string>;
282
282
  databases?: Record<string, DatabaseBindingEnvConfig>;
283
+ resources?: Record<string, string | { resource: string; field?: string }>;
283
284
  }
284
285
 
286
+ export interface KilnVMResourceConfig {
287
+ autostart?: boolean;
288
+ disk?: string;
289
+ diskSize?: string | number;
290
+ diskSizeMB?: number;
291
+ memoryMB?: number;
292
+ name?: string;
293
+ networkId?: string;
294
+ profile?: string;
295
+ vcpus?: number;
296
+ }
297
+
298
+ export interface PostgresResourceConfig {
299
+ kind: "postgres";
300
+ data?: "reuse" | "reset" | "rebuild";
301
+ database?: string;
302
+ extensions?: string[];
303
+ password?: string;
304
+ port?: number;
305
+ user?: string;
306
+ version?: string;
307
+ vm?: KilnVMResourceConfig;
308
+ }
309
+
310
+ export interface ServerResourceConfig {
311
+ kind: "server";
312
+ vm?: KilnVMResourceConfig;
313
+ }
314
+
315
+ export type ResourceConfig = PostgresResourceConfig | ServerResourceConfig;
316
+
285
317
  export interface LocalEnvironmentConfig {
286
318
  kind: "local";
287
319
  data?: "reuse" | "reset" | "rebuild";
320
+ driver?: "host" | "kiln";
288
321
  env?: Record<string, string>;
322
+ kiln?: KilnLocalEnvironmentConfig;
289
323
  portOffset?: number;
324
+ productionLike?: boolean;
325
+ publicHost?: string;
326
+ resources?: Record<string, ResourceConfig>;
290
327
  target: string;
291
328
  }
292
329
 
@@ -294,6 +331,34 @@ export interface LocalEnvironmentOptions extends Omit<LocalEnvironmentConfig, "k
294
331
  env?: PresetEnvConfig;
295
332
  }
296
333
 
334
+ export interface KilnLocalEnvironmentConfig {
335
+ api?: {
336
+ apiUrl?: string;
337
+ token?: string;
338
+ };
339
+ network?: {
340
+ attachExistingVMs?: string[];
341
+ cidr?: string;
342
+ id?: string;
343
+ name?: string;
344
+ };
345
+ vm?: {
346
+ autostart?: boolean;
347
+ disk?: string;
348
+ diskSize?: string | number;
349
+ diskSizeMB?: number;
350
+ memoryMB?: number;
351
+ name?: string;
352
+ networkId?: string;
353
+ profile?: string;
354
+ vcpus?: number;
355
+ };
356
+ workspace?: {
357
+ exclude?: string[];
358
+ remotePath?: string;
359
+ };
360
+ }
361
+
297
362
  export interface TestkitFileMetadata {
298
363
  locks?: string[];
299
364
  skip?: string | { reason: string };
@@ -568,6 +633,17 @@ export declare const database: {
568
633
  }
569
634
  ): ServiceConfig;
570
635
  };
636
+ export declare const resource: {
637
+ postgres(options?: Omit<PostgresResourceConfig, "kind">): PostgresResourceConfig;
638
+ server(options?: Omit<ServerResourceConfig, "kind">): ServerResourceConfig;
639
+ field(resourceName: string, field?: string): string;
640
+ url(resourceName: string): string;
641
+ host(resourceName: string): string;
642
+ port(resourceName: string): string;
643
+ database(resourceName: string): string;
644
+ user(resourceName: string): string;
645
+ password(resourceName: string): string;
646
+ };
571
647
  export declare const step: {
572
648
  command(run: string, options?: TemplateLifecycleStepOptions): TemplateCommandStepConfig;
573
649
  module(target: string, options?: TemplateLifecycleStepOptions): TemplateModuleStepConfig;
@@ -584,6 +660,7 @@ export declare const ui: {
584
660
  };
585
661
  export declare const environment: {
586
662
  local(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
663
+ productionLike(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
587
664
  };
588
665
  export declare const auth: {
589
666
  fixture(options: { contract: JsonSessionContract; topology: AuthTopology }): AuthFixture;
@@ -33,6 +33,33 @@ function postgresDatabase(options = {}) {
33
33
  };
34
34
  }
35
35
 
36
+ function postgresResource(options = {}) {
37
+ return {
38
+ kind: "postgres",
39
+ version: options.version || "16",
40
+ database: options.database || options.name || "app",
41
+ user: options.user || "app",
42
+ ...(options.password ? { password: options.password } : {}),
43
+ ...(options.port ? { port: options.port } : {}),
44
+ ...(options.vm ? { vm: { ...options.vm } } : {}),
45
+ ...(options.data ? { data: options.data } : {}),
46
+ ...(options.extensions ? { extensions: [...options.extensions] } : {}),
47
+ };
48
+ }
49
+
50
+ function serverResource(options = {}) {
51
+ return {
52
+ kind: "server",
53
+ ...(options.vm ? { vm: { ...options.vm } } : {}),
54
+ };
55
+ }
56
+
57
+ function resourceField(resourceName, field = "url") {
58
+ const normalizedResourceName = normalizeDatabaseEnvToken(resourceName, "resource name", false);
59
+ const normalizedField = normalizeDatabaseEnvToken(field, "resource field", false);
60
+ return `{resource:${normalizedResourceName}:${normalizedField}}`;
61
+ }
62
+
36
63
  function buildDatabaseTemplateConfig(options = {}) {
37
64
  for (const legacyKey of ["schema"]) {
38
65
  if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
@@ -412,6 +439,30 @@ export const database = {
412
439
  fixture: postgresFixture,
413
440
  };
414
441
 
442
+ export const resource = {
443
+ postgres: postgresResource,
444
+ server: serverResource,
445
+ field: resourceField,
446
+ url(resourceName) {
447
+ return resourceField(resourceName, "url");
448
+ },
449
+ host(resourceName) {
450
+ return resourceField(resourceName, "host");
451
+ },
452
+ port(resourceName) {
453
+ return resourceField(resourceName, "port");
454
+ },
455
+ database(resourceName) {
456
+ return resourceField(resourceName, "database");
457
+ },
458
+ user(resourceName) {
459
+ return resourceField(resourceName, "user");
460
+ },
461
+ password(resourceName) {
462
+ return resourceField(resourceName, "password");
463
+ },
464
+ };
465
+
415
466
  export const step = {
416
467
  command: commandStep,
417
468
  module: moduleStep,
@@ -456,6 +507,13 @@ function localEnvironment(options = {}) {
456
507
 
457
508
  export const environment = {
458
509
  local: localEnvironment,
510
+ productionLike(options = {}) {
511
+ return localEnvironment({
512
+ driver: "kiln",
513
+ ...options,
514
+ productionLike: true,
515
+ });
516
+ },
459
517
  };
460
518
 
461
519
  export const auth = {
@@ -497,7 +555,7 @@ function normalizePresetEnv(env) {
497
555
  if (typeof env !== "object" || Array.isArray(env)) {
498
556
  throw new Error("Preset env must be an object");
499
557
  }
500
- const allowedKeys = new Set(["values", "databases"]);
558
+ const allowedKeys = new Set(["values", "databases", "resources"]);
501
559
  const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
502
560
  if (unexpectedKeys.length > 0) {
503
561
  throw new Error(
@@ -508,9 +566,12 @@ function normalizePresetEnv(env) {
508
566
  const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
509
567
  const databases =
510
568
  env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
569
+ const resources =
570
+ env.resources && typeof env.resources === "object" && !Array.isArray(env.resources) ? env.resources : {};
511
571
 
512
572
  return {
513
573
  ...expandDatabaseBindings(databases),
574
+ ...expandResourceBindings(resources),
514
575
  ...values,
515
576
  };
516
577
  }
@@ -533,6 +594,22 @@ function expandDatabaseBindings(bindings) {
533
594
  return env;
534
595
  }
535
596
 
597
+ function expandResourceBindings(bindings) {
598
+ const env = {};
599
+ for (const [envName, binding] of Object.entries(bindings || {})) {
600
+ if (typeof binding === "string") {
601
+ const [resourceName, field = "url"] = binding.split(".");
602
+ env[envName] = resourceField(resourceName, field);
603
+ continue;
604
+ }
605
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
606
+ throw new Error(`env.resources.${envName} must be a string or object`);
607
+ }
608
+ env[envName] = resourceField(binding.resource, binding.field || "url");
609
+ }
610
+ return env;
611
+ }
612
+
536
613
  function normalizeDatabaseEnvToken(value, label, sanitize = true) {
537
614
  const raw = String(value || "").trim();
538
615
  const normalized = sanitize
@@ -0,0 +1,135 @@
1
+ export const DEFAULT_DOCKER_COMPAT_MATRIX = Object.freeze([
2
+ dockerCompatEntry({ dockerVersion: "24.0.9", image: "docker:24.0.9-dind" }),
3
+ dockerCompatEntry({ dockerVersion: "25.0.5", image: "docker:25.0.5-dind" }),
4
+ dockerCompatEntry({ dockerVersion: "26.1.4", image: "docker:26.1.4-dind" }),
5
+ dockerCompatEntry({ dockerVersion: "27.5.1", image: "docker:27.5.1-dind" }),
6
+ dockerCompatEntry({ dockerVersion: "28.5.2", image: "docker:28.5.2-dind" }),
7
+ dockerCompatEntry({ dockerVersion: "29.0.0", image: "docker:29.0.0-dind" }),
8
+ ]);
9
+
10
+ export function dockerCompatEntry(entry = {}) {
11
+ const dockerVersion = normalizeNonEmptyString(entry.dockerVersion, "dockerVersion");
12
+ const image = normalizeNonEmptyString(entry.image, "image");
13
+ return Object.freeze({
14
+ dockerVersion,
15
+ image,
16
+ label: entry.label || `Docker ${dockerVersion}`,
17
+ rootfsSizeMB: normalizePositiveInteger(entry.rootfsSizeMB ?? 4096, "rootfsSizeMB"),
18
+ vcpus: normalizePositiveInteger(entry.vcpus ?? 1, "vcpus"),
19
+ memoryMB: normalizePositiveInteger(entry.memoryMB ?? 2048, "memoryMB"),
20
+ });
21
+ }
22
+
23
+ export function selectDockerCompatMatrix(matrix = DEFAULT_DOCKER_COMPAT_MATRIX, selection = []) {
24
+ const requested = normalizeSelection(selection);
25
+ if (requested.length === 0) return [...matrix];
26
+
27
+ const byVersion = new Map(matrix.map((entry) => [entry.dockerVersion, entry]));
28
+ const byLabel = new Map(matrix.map((entry) => [entry.label, entry]));
29
+ const selected = [];
30
+ const unknown = [];
31
+
32
+ for (const key of requested) {
33
+ const entry = byVersion.get(key) || byLabel.get(key);
34
+ if (entry) {
35
+ selected.push(entry);
36
+ } else {
37
+ unknown.push(key);
38
+ }
39
+ }
40
+
41
+ if (unknown.length > 0) {
42
+ throw new Error(`Unknown Docker compatibility matrix selection: ${unknown.join(", ")}`);
43
+ }
44
+ return selected;
45
+ }
46
+
47
+ export function parseDockerCompatSelection(value) {
48
+ if (Array.isArray(value)) return normalizeSelection(value);
49
+ return normalizeSelection(String(value || "").split(","));
50
+ }
51
+
52
+ export function buildDockerCompatDockerfile(entry) {
53
+ const normalized = dockerCompatEntry(entry);
54
+ return [
55
+ `FROM ${normalized.image}`,
56
+ "RUN apk add --no-cache postgresql-client",
57
+ "ENV DOCKER_TLS_CERTDIR=",
58
+ "ENTRYPOINT []",
59
+ "CMD [\"dockerd-entrypoint.sh\"]",
60
+ "",
61
+ ].join("\n");
62
+ }
63
+
64
+ export function buildDockerCompatProbeCommand(entry, options = {}) {
65
+ const normalized = dockerCompatEntry(entry);
66
+ const postgresImage = options.postgresImage || "pgvector/pgvector:pg16";
67
+ const containerName = options.containerName || `testkit-docker-compat-${normalized.dockerVersion.replaceAll(".", "-")}`;
68
+ return [
69
+ "set -eu",
70
+ "deadline=$(($(date +%s) + 90))",
71
+ "until docker version >/tmp/testkit-docker-version.txt 2>/tmp/testkit-docker-version.err; do",
72
+ " if [ \"$(date +%s)\" -ge \"$deadline\" ]; then",
73
+ " cat /tmp/testkit-docker-version.err >&2 || true",
74
+ " exit 1",
75
+ " fi",
76
+ " sleep 1",
77
+ "done",
78
+ "actual=$(docker version --format '{{.Server.Version}}')",
79
+ `case "$actual" in ${shellCasePattern(normalized.dockerVersion)}*) ;; *) echo "expected Docker ${normalized.dockerVersion}, got $actual" >&2; exit 1 ;; esac`,
80
+ `docker rm -f ${shellQuote(containerName)} >/dev/null 2>&1 || true`,
81
+ [
82
+ "docker run -d",
83
+ "--name",
84
+ shellQuote(containerName),
85
+ "-e",
86
+ "POSTGRES_USER=testkit",
87
+ "-e",
88
+ "POSTGRES_PASSWORD=testkit",
89
+ "-e",
90
+ "POSTGRES_DB=postgres",
91
+ "-p",
92
+ "127.0.0.1::5432",
93
+ shellQuote(postgresImage),
94
+ ].join(" "),
95
+ "ready_deadline=$(($(date +%s) + 120))",
96
+ "until docker exec " + shellQuote(containerName) + " pg_isready -U testkit -d postgres >/dev/null 2>&1; do",
97
+ " if [ \"$(date +%s)\" -ge \"$ready_deadline\" ]; then",
98
+ " docker logs " + shellQuote(containerName) + " >&2 || true",
99
+ " docker rm -f " + shellQuote(containerName) + " >/dev/null 2>&1 || true",
100
+ " exit 1",
101
+ " fi",
102
+ " sleep 1",
103
+ "done",
104
+ "published_port=$(docker inspect --format '{{(index (index .NetworkSettings.Ports \"5432/tcp\") 0).HostPort}}' " + shellQuote(containerName) + ")",
105
+ "test -n \"$published_port\"",
106
+ "docker rm -f " + shellQuote(containerName) + " >/dev/null",
107
+ "echo \"Docker $actual compatibility probe passed\"",
108
+ ].join("\n");
109
+ }
110
+
111
+ function normalizeSelection(selection) {
112
+ return selection.map((entry) => String(entry || "").trim()).filter(Boolean);
113
+ }
114
+
115
+ function normalizeNonEmptyString(value, name) {
116
+ const normalized = String(value || "").trim();
117
+ if (!normalized) throw new Error(`${name} is required`);
118
+ return normalized;
119
+ }
120
+
121
+ function normalizePositiveInteger(value, name) {
122
+ const parsed = Number(value);
123
+ if (!Number.isInteger(parsed) || parsed <= 0) {
124
+ throw new Error(`${name} must be a positive integer`);
125
+ }
126
+ return parsed;
127
+ }
128
+
129
+ function shellQuote(value) {
130
+ return "'" + String(value).replaceAll("'", "'\"'\"'") + "'";
131
+ }
132
+
133
+ function shellCasePattern(value) {
134
+ return String(value).replaceAll(".", "\\.");
135
+ }
@@ -0,0 +1,120 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+
5
+ export class KilnClient {
6
+ constructor(options = {}) {
7
+ const config = readKilnConfig();
8
+ this.apiUrl = String(options.apiUrl || process.env.KILN_API_URL || config.api_url || "").replace(/\/+$/, "");
9
+ this.token = String(options.token || process.env.KILN_TOKEN || config.token || "");
10
+ if (!this.apiUrl) throw new Error("Kiln API URL is not configured; run kiln login or set KILN_API_URL");
11
+ if (!this.token) throw new Error("Kiln token is not configured; run kiln login or set KILN_TOKEN");
12
+ }
13
+
14
+ async request(method, requestPath, body = null) {
15
+ const response = await fetch(`${this.apiUrl}${requestPath}`, {
16
+ method,
17
+ headers: {
18
+ authorization: `Bearer ${this.token}`,
19
+ ...(body ? { "content-type": "application/json" } : {}),
20
+ },
21
+ body: body ? JSON.stringify(body) : undefined,
22
+ });
23
+ const text = await response.text();
24
+ if (!response.ok) {
25
+ throw new Error(`${method} ${requestPath} returned ${response.status}: ${text.trim()}`);
26
+ }
27
+ if (!text || response.status === 204) return null;
28
+ return JSON.parse(text);
29
+ }
30
+
31
+ getVM(ref) {
32
+ return this.request("GET", `/vms/${encodeURIComponent(ref)}`);
33
+ }
34
+
35
+ listVMs() {
36
+ return this.request("GET", "/vms");
37
+ }
38
+
39
+ createVM(req) {
40
+ return this.request("POST", "/vms", req);
41
+ }
42
+
43
+ startVM(ref) {
44
+ return this.request("POST", `/vms/${encodeURIComponent(ref)}/start`);
45
+ }
46
+
47
+ stopVM(ref) {
48
+ return this.request("POST", `/vms/${encodeURIComponent(ref)}/stop`);
49
+ }
50
+
51
+ deleteVM(ref) {
52
+ return this.request("DELETE", `/vms/${encodeURIComponent(ref)}`);
53
+ }
54
+
55
+ attachVMNetwork(ref, networkId) {
56
+ return this.request("POST", `/vms/${encodeURIComponent(ref)}/network`, { network_id: networkId });
57
+ }
58
+
59
+ listNetworks() {
60
+ return this.request("GET", "/networks");
61
+ }
62
+
63
+ createNetwork(req) {
64
+ return this.request("POST", "/networks", req);
65
+ }
66
+
67
+ deleteNetwork(ref) {
68
+ return this.request("DELETE", `/networks/${encodeURIComponent(ref)}`);
69
+ }
70
+
71
+ execVM(ref, command) {
72
+ return this.request("POST", `/vms/${encodeURIComponent(ref)}/exec`, { command });
73
+ }
74
+
75
+ ensureAppliance(req) {
76
+ return this.request("POST", "/appliances/ensure", req);
77
+ }
78
+
79
+ getAppliance(ref) {
80
+ return this.request("GET", `/appliances/${encodeURIComponent(ref)}`);
81
+ }
82
+
83
+ listAppliances() {
84
+ return this.request("GET", "/appliances");
85
+ }
86
+
87
+ stopAppliance(ref) {
88
+ return this.request("POST", `/appliances/${encodeURIComponent(ref)}/stop`);
89
+ }
90
+
91
+ deleteAppliance(ref) {
92
+ return this.request("DELETE", `/appliances/${encodeURIComponent(ref)}`);
93
+ }
94
+
95
+ sshKey(machineId) {
96
+ return this.requestRaw("GET", `/machines/${encodeURIComponent(machineId)}/ssh-key`);
97
+ }
98
+
99
+ async requestRaw(method, requestPath) {
100
+ const response = await fetch(`${this.apiUrl}${requestPath}`, {
101
+ method,
102
+ headers: { authorization: `Bearer ${this.token}` },
103
+ });
104
+ const text = await response.text();
105
+ if (!response.ok) {
106
+ throw new Error(`${method} ${requestPath} returned ${response.status}: ${text.trim()}`);
107
+ }
108
+ return text;
109
+ }
110
+ }
111
+
112
+ function readKilnConfig() {
113
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
114
+ const configPath = path.join(configHome, "kiln", "config.json");
115
+ try {
116
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
117
+ } catch {
118
+ return {};
119
+ }
120
+ }