vyft 0.3.0-alpha → 0.4.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/docker.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from "pino";
2
2
  import type { ReverseProxy } from "./proxy.js";
3
- import type { Resource, ResourceType } from "./resource.js";
4
- import type { Runtime } from "./runtime.js";
3
+ import type { Resource, ResourceType, Service } from "./resource.js";
4
+ import type { CreateResult, Runtime } from "./runtime.js";
5
5
  export declare function parseRoute(route: string): {
6
6
  host: string;
7
7
  path?: string;
@@ -17,8 +17,10 @@ export declare class DockerClient implements Runtime {
17
17
  private docker;
18
18
  private localDocker;
19
19
  private isRemote;
20
+ private sshClient;
20
21
  private project;
21
22
  private secretValues;
23
+ private builtImages;
22
24
  private log;
23
25
  proxy: ReverseProxy;
24
26
  verbose: boolean;
@@ -26,10 +28,14 @@ export declare class DockerClient implements Runtime {
26
28
  host?: string;
27
29
  parentLogger?: Logger;
28
30
  });
31
+ /** Close the SSH connection (if any), allowing the process to exit. */
32
+ destroy(): void;
29
33
  private ensureNetwork;
30
34
  ensureInfrastructure(): Promise<void>;
31
35
  listManagedResources(): Promise<ManagedResource[]>;
32
- create(resource: Resource): Promise<void>;
36
+ create(resource: Resource, options?: {
37
+ silent?: boolean;
38
+ }): Promise<CreateResult>;
33
39
  exists(resource: Resource): Promise<boolean>;
34
40
  remove(resource: Resource): Promise<void>;
35
41
  private createVolume;
@@ -42,6 +48,7 @@ export declare class DockerClient implements Runtime {
42
48
  private lookupSecretId;
43
49
  private createDerivedSecret;
44
50
  private resolveEnv;
51
+ buildImage(service: Service): Promise<string | null>;
45
52
  private createService;
46
53
  private serviceExists;
47
54
  waitForHealthy(id: string, timeoutMs?: number): Promise<void>;
package/dist/docker.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import http from "node:http";
2
3
  import path from "node:path";
3
4
  import { PassThrough } from "node:stream";
4
- import { log } from "@clack/prompts";
5
5
  import Docker from "dockerode";
6
+ import { Client as SshClient } from "ssh2";
6
7
  import tar from "tar-fs";
7
8
  import { buildStatic } from "./build.js";
8
9
  import { logger as defaultLogger } from "./logger.js";
@@ -55,21 +56,74 @@ export function parseRoute(route) {
55
56
  path: route.slice(slashIndex),
56
57
  };
57
58
  }
59
+ /**
60
+ * Create a Docker client that reuses a single SSH connection instead of
61
+ * opening a new one for every API call (the docker-modem default).
62
+ */
63
+ function createSshDocker(sshUrl) {
64
+ const parsed = new URL(sshUrl);
65
+ const conn = new SshClient();
66
+ let connected = false;
67
+ const ready = new Promise((resolve, reject) => {
68
+ conn.once("ready", () => {
69
+ connected = true;
70
+ resolve();
71
+ });
72
+ conn.once("error", reject);
73
+ conn.connect({
74
+ host: parsed.hostname,
75
+ port: parsed.port ? parseInt(parsed.port, 10) : 22,
76
+ username: parsed.username || undefined,
77
+ agent: process.env.SSH_AUTH_SOCK,
78
+ });
79
+ });
80
+ const agent = new http.Agent({ keepAlive: false });
81
+ // biome-ignore lint/suspicious/noExplicitAny: monkey-patching internal Agent method for SSH tunneling
82
+ agent.createConnection = (_opts, fn) => {
83
+ const exec = () => conn.exec("docker system dial-stdio", (err, stream) => {
84
+ if (err)
85
+ return fn(err);
86
+ fn(null, stream);
87
+ });
88
+ if (connected)
89
+ exec();
90
+ else
91
+ ready.then(exec).catch((err) => fn(err));
92
+ };
93
+ return {
94
+ docker: new Docker({
95
+ protocol: "http",
96
+ host: parsed.hostname,
97
+ agent,
98
+ // biome-ignore lint/suspicious/noExplicitAny: dockerode typing doesn't expose agent option
99
+ }),
100
+ sshClient: conn,
101
+ };
102
+ }
58
103
  export class DockerClient {
59
104
  docker;
60
105
  localDocker;
61
106
  isRemote;
107
+ sshClient = null;
62
108
  project;
63
109
  secretValues = new Map();
110
+ builtImages = new Map();
64
111
  log;
65
112
  proxy;
66
113
  verbose = false;
67
114
  constructor(project, opts) {
68
115
  this.localDocker = new Docker({ socketPath: "/var/run/docker.sock" });
69
116
  const host = opts?.host;
70
- if (host)
71
- process.env.DOCKER_HOST = host;
72
- this.docker = new Docker();
117
+ if (host?.startsWith("ssh://")) {
118
+ const { docker, sshClient } = createSshDocker(host);
119
+ this.docker = docker;
120
+ this.sshClient = sshClient;
121
+ }
122
+ else {
123
+ if (host)
124
+ process.env.DOCKER_HOST = host;
125
+ this.docker = new Docker();
126
+ }
73
127
  this.isRemote = !!host;
74
128
  this.project = project;
75
129
  this.log = (opts?.parentLogger ?? defaultLogger).child({
@@ -80,6 +134,11 @@ export class DockerClient {
80
134
  if (host)
81
135
  this.log.debug({ host }, "using remote endpoint");
82
136
  }
137
+ /** Close the SSH connection (if any), allowing the process to exit. */
138
+ destroy() {
139
+ this.sshClient?.end();
140
+ this.sshClient = null;
141
+ }
83
142
  async ensureNetwork(name, labels = {}) {
84
143
  const networks = await this.docker.listNetworks({
85
144
  filters: JSON.stringify({ name: [name] }),
@@ -153,7 +212,7 @@ export class DockerClient {
153
212
  }
154
213
  return resources;
155
214
  }
156
- async create(resource) {
215
+ async create(resource, options) {
157
216
  switch (resource.type) {
158
217
  case "volume":
159
218
  return this.createVolume(resource);
@@ -162,7 +221,7 @@ export class DockerClient {
162
221
  case "service":
163
222
  return this.createService(resource);
164
223
  case "site":
165
- return this.createStatic(resource);
224
+ return this.createStatic(resource, options?.silent);
166
225
  }
167
226
  }
168
227
  async exists(resource) {
@@ -205,9 +264,9 @@ export class DockerClient {
205
264
  Name: volume.id,
206
265
  Labels: labels,
207
266
  });
208
- const durationMs = Math.round(performance.now() - start);
209
- this.log.debug({ volumeName: volume.id, durationMs }, "volume created");
210
- log.step(`Created ${volume.id}`);
267
+ const duration = Math.round(performance.now() - start);
268
+ this.log.debug({ volumeName: volume.id, durationMs: duration }, "volume created");
269
+ return { action: "created", duration };
211
270
  }
212
271
  async volumeExists(id) {
213
272
  const start = performance.now();
@@ -229,7 +288,6 @@ export class DockerClient {
229
288
  await this.docker.getVolume(id).remove();
230
289
  const durationMs = Math.round(performance.now() - start);
231
290
  this.log.debug({ volumeName: id, durationMs }, "volume removed");
232
- log.step(`Removed ${id}`);
233
291
  }
234
292
  async createSecret(secret) {
235
293
  const start = performance.now();
@@ -240,9 +298,9 @@ export class DockerClient {
240
298
  await this.storeSecretData(secret.id, value, {
241
299
  "vyft.length": length.toString(),
242
300
  });
243
- const durationMs = Math.round(performance.now() - start);
244
- this.log.debug({ secretName: secret.id, length, durationMs }, "secret created");
245
- log.step(`Created ${secret.id}`);
301
+ const duration = Math.round(performance.now() - start);
302
+ this.log.debug({ secretName: secret.id, length, durationMs: duration }, "secret created");
303
+ return { action: "created", duration };
246
304
  }
247
305
  async secretExists(id) {
248
306
  const start = performance.now();
@@ -264,7 +322,6 @@ export class DockerClient {
264
322
  await this.docker.getSecret(id).remove();
265
323
  const durationMs = Math.round(performance.now() - start);
266
324
  this.log.debug({ secretName: id, durationMs }, "secret removed");
267
- log.step(`Removed ${id}`);
268
325
  }
269
326
  async storeSecretData(name, value, labels = {}) {
270
327
  this.log.debug({ secretName: name }, "storing secret data");
@@ -291,7 +348,6 @@ export class DockerClient {
291
348
  "vyft.derived": "true",
292
349
  "vyft.parent-service": parentService,
293
350
  });
294
- log.step(`Created ${name}`);
295
351
  }
296
352
  async resolveEnv(serviceId, env) {
297
353
  const envList = [];
@@ -349,60 +405,70 @@ export class DockerClient {
349
405
  this.log.debug({ serviceId, envCount: envList.length, secretMountCount: secrets.length }, "env resolved");
350
406
  return { envList, secrets };
351
407
  }
408
+ async buildImage(service) {
409
+ const config = service.config;
410
+ if (typeof config.image !== "object")
411
+ return null;
412
+ const { context = ".", dockerfile = "Dockerfile" } = config.image;
413
+ const buildTag = `${service.id}:latest`;
414
+ this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
415
+ const buildStart = performance.now();
416
+ const tarStream = tar.pack(path.resolve(context));
417
+ const stream = await this.localDocker.buildImage(tarStream, {
418
+ t: buildTag,
419
+ dockerfile,
420
+ });
421
+ await new Promise((resolve, reject) => {
422
+ this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
423
+ if (event.error)
424
+ this.log.error({ error: event.error }, "build error");
425
+ else if (this.verbose && event.stream)
426
+ process.stdout.write(event.stream);
427
+ this.log.trace({ event }, "build progress");
428
+ });
429
+ });
430
+ const buildDurationMs = Math.round(performance.now() - buildStart);
431
+ this.log.debug({
432
+ resourceId: service.id,
433
+ image: buildTag,
434
+ durationMs: buildDurationMs,
435
+ }, "image build completed");
436
+ const images = await this.localDocker.listImages({
437
+ filters: JSON.stringify({ reference: [buildTag] }),
438
+ });
439
+ if (images.length === 0) {
440
+ throw new Error(`Image ${buildTag} was not built successfully`);
441
+ }
442
+ // Tag with content hash so the orchestrator detects the new image
443
+ const shortHash = images[0].Id.replace("sha256:", "").slice(0, 12);
444
+ const imageName = `${service.id}:${shortHash}`;
445
+ await this.localDocker.getImage(buildTag).tag({
446
+ repo: service.id,
447
+ tag: shortHash,
448
+ });
449
+ if (this.isRemote) {
450
+ this.log.debug({ image: imageName }, "transferring image to remote");
451
+ const transferStart = performance.now();
452
+ const imageStream = await this.localDocker.getImage(imageName).get();
453
+ await this.docker.loadImage(imageStream);
454
+ const transferDurationMs = Math.round(performance.now() - transferStart);
455
+ this.log.debug({ image: imageName, durationMs: transferDurationMs }, "image transferred to remote");
456
+ }
457
+ this.builtImages.set(service.id, imageName);
458
+ return imageName;
459
+ }
352
460
  async createService(service) {
353
461
  const start = performance.now();
354
- await this.ensureNetwork(`${this.project}-internal`, {
355
- "com.docker.stack.namespace": this.project,
356
- "vyft.managed": "true",
357
- "vyft.project": this.project,
358
- });
359
462
  const config = service.config;
360
463
  let imageName;
361
464
  if (typeof config.image === "object") {
362
- const { context = ".", dockerfile = "Dockerfile" } = config.image;
363
- const buildTag = `${service.id}:latest`;
364
- this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
365
- const buildStart = performance.now();
366
- const tarStream = tar.pack(path.resolve(context));
367
- const stream = await this.localDocker.buildImage(tarStream, {
368
- t: buildTag,
369
- dockerfile,
370
- });
371
- await new Promise((resolve, reject) => {
372
- this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
373
- if (event.error)
374
- log.error(event.error);
375
- else if (this.verbose && event.stream)
376
- process.stdout.write(event.stream);
377
- this.log.trace({ event }, "build progress");
378
- });
379
- });
380
- const buildDurationMs = Math.round(performance.now() - buildStart);
381
- this.log.debug({
382
- resourceId: service.id,
383
- image: buildTag,
384
- durationMs: buildDurationMs,
385
- }, "image build completed");
386
- const images = await this.localDocker.listImages({
387
- filters: JSON.stringify({ reference: [buildTag] }),
388
- });
389
- if (images.length === 0) {
390
- throw new Error(`Image ${buildTag} was not built successfully`);
465
+ const prebuilt = this.builtImages.get(service.id);
466
+ if (prebuilt) {
467
+ imageName = prebuilt;
391
468
  }
392
- // Tag with content hash so the orchestrator detects the new image
393
- const shortHash = images[0].Id.replace("sha256:", "").slice(0, 12);
394
- imageName = `${service.id}:${shortHash}`;
395
- await this.localDocker.getImage(buildTag).tag({
396
- repo: service.id,
397
- tag: shortHash,
398
- });
399
- if (this.isRemote) {
400
- this.log.debug({ image: imageName }, "transferring image to remote");
401
- const transferStart = performance.now();
402
- const imageStream = await this.localDocker.getImage(imageName).get();
403
- await this.docker.loadImage(imageStream);
404
- const transferDurationMs = Math.round(performance.now() - transferStart);
405
- this.log.debug({ image: imageName, durationMs: transferDurationMs }, "image transferred to remote");
469
+ else {
470
+ await this.buildImage(service);
471
+ imageName = this.builtImages.get(service.id);
406
472
  }
407
473
  }
408
474
  else {
@@ -526,14 +592,18 @@ export class DockerClient {
526
592
  port,
527
593
  });
528
594
  }
529
- const durationMs = Math.round(performance.now() - start);
595
+ const duration = Math.round(performance.now() - start);
530
596
  this.log.info({
531
597
  resourceId: service.id,
532
598
  image: imageName,
533
599
  hasRoute: !!config.route,
534
- durationMs,
600
+ durationMs: duration,
535
601
  }, existing ? "service updated" : "service created");
536
- log.step(`${existing ? "Updated" : "Created"} ${service.id}`);
602
+ return {
603
+ action: existing ? "updated" : "created",
604
+ route: config.route,
605
+ duration,
606
+ };
537
607
  }
538
608
  async serviceExists(id) {
539
609
  try {
@@ -588,7 +658,6 @@ export class DockerClient {
588
658
  }
589
659
  const durationMs = Math.round(performance.now() - start);
590
660
  this.log.info({ resourceId: id, durationMs }, "service removed");
591
- log.step(`Removed ${id}`);
592
661
  }
593
662
  async pullImage(image) {
594
663
  this.log.debug({ image }, "pulling image");
@@ -598,19 +667,15 @@ export class DockerClient {
598
667
  });
599
668
  this.log.debug({ image }, "image pulled");
600
669
  }
601
- async createStatic(static_) {
670
+ async createStatic(static_, silent) {
602
671
  const start = performance.now();
603
- await this.ensureNetwork(`${this.project}-internal`, {
604
- "com.docker.stack.namespace": this.project,
605
- "vyft.managed": "true",
606
- "vyft.project": this.project,
607
- });
608
672
  this.log.debug({ resourceId: static_.id, route: static_.config.route }, "creating static site");
609
673
  this.log.debug({ resourceId: static_.id }, "static build starting");
610
674
  const { outputPath } = await buildStatic(static_.config.build.cwd, {
611
675
  output: static_.config.build.output,
612
676
  command: static_.config.build.command,
613
677
  env: static_.config.build.env,
678
+ silent,
614
679
  }, this.log);
615
680
  const volumeName = `${static_.id}-files`;
616
681
  if (!(await this.volumeExists(volumeName))) {
@@ -706,9 +771,17 @@ export class DockerClient {
706
771
  host: static_.id,
707
772
  port: 80,
708
773
  });
709
- const durationMs = Math.round(performance.now() - start);
710
- this.log.info({ resourceId: static_.id, route: static_.config.route, durationMs }, existing ? "site updated" : "site created");
711
- log.step(`${existing ? "Updated" : "Created"} ${static_.id}`);
774
+ const duration = Math.round(performance.now() - start);
775
+ this.log.info({
776
+ resourceId: static_.id,
777
+ route: static_.config.route,
778
+ durationMs: duration,
779
+ }, existing ? "site updated" : "site created");
780
+ return {
781
+ action: existing ? "updated" : "created",
782
+ route: static_.config.route,
783
+ duration,
784
+ };
712
785
  }
713
786
  async removeStatic(id) {
714
787
  const start = performance.now();
@@ -744,7 +817,6 @@ export class DockerClient {
744
817
  }
745
818
  const durationMs = Math.round(performance.now() - start);
746
819
  this.log.info({ resourceId: id, durationMs }, "site removed");
747
- log.step(`Removed ${id}`);
748
820
  }
749
821
  async *serviceLogs(options) {
750
822
  const allServices = await this.docker.listServices({
@@ -765,26 +837,29 @@ export class DockerClient {
765
837
  });
766
838
  }
767
839
  if (matched.length === 0) {
768
- log.warn("No matching services found");
840
+ this.log.warn("no matching services found");
769
841
  return;
770
842
  }
771
843
  const tail = options.tail ?? 100;
772
- // Collect all containers with their short service name
773
- const targets = [];
774
- for (const svc of matched) {
844
+ // Collect all containers with their short service name (parallel)
845
+ const targets = (await Promise.all(matched.map(async (svc) => {
775
846
  const name = svc.Spec?.Name || "";
776
- const short = name.startsWith(prefix) ? name.slice(prefix.length) : name;
847
+ const short = name.startsWith(prefix)
848
+ ? name.slice(prefix.length)
849
+ : name;
777
850
  const containers = await this.docker.listContainers({
778
851
  filters: JSON.stringify({
779
852
  label: [`com.docker.swarm.service.name=${name}`],
780
853
  }),
781
854
  });
782
- for (const cInfo of containers) {
783
- targets.push({ container: this.docker.getContainer(cInfo.Id), short });
784
- }
785
- }
855
+ return containers.map((cInfo) => ({
856
+ container: this.docker.getContainer(cInfo.Id),
857
+ short,
858
+ }));
859
+ }))).flat();
786
860
  if (!options.follow) {
787
- for (const { container, short } of targets) {
861
+ // Fetch all logs in parallel
862
+ const results = await Promise.all(targets.map(async ({ container, short }) => {
788
863
  const buf = await container.logs({
789
864
  stdout: true,
790
865
  stderr: true,
@@ -792,19 +867,26 @@ export class DockerClient {
792
867
  tail,
793
868
  timestamps: true,
794
869
  });
870
+ const entries = [];
795
871
  let offset = 0;
796
872
  while (offset + 8 <= buf.length) {
797
873
  const type = buf[offset];
798
874
  const size = buf.readUInt32BE(offset + 4);
799
875
  offset += 8;
800
876
  const text = buf.subarray(offset, offset + size).toString();
801
- yield {
877
+ entries.push({
802
878
  service: short,
803
879
  stream: type === 2 ? "stderr" : "stdout",
804
880
  text,
805
- };
881
+ });
806
882
  offset += size;
807
883
  }
884
+ return entries;
885
+ }));
886
+ for (const entries of results) {
887
+ for (const entry of entries) {
888
+ yield entry;
889
+ }
808
890
  }
809
891
  return;
810
892
  }
@@ -820,7 +902,7 @@ export class DockerClient {
820
902
  n();
821
903
  }
822
904
  };
823
- for (const { container, short } of targets) {
905
+ await Promise.all(targets.map(async ({ container, short }) => {
824
906
  const stream = await container.logs({
825
907
  stdout: true,
826
908
  stderr: true,
@@ -851,7 +933,7 @@ export class DockerClient {
851
933
  n();
852
934
  }
853
935
  });
854
- }
936
+ }));
855
937
  while (active > 0 || buffer.length > 0) {
856
938
  if (buffer.length === 0) {
857
939
  await new Promise((r) => {
package/dist/exec.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  import type { Logger } from "pino";
2
- export declare function exec(command: string, cwd: string, env?: Record<string, string>, log?: Logger): Promise<void>;
2
+ export declare function exec(command: string, cwd: string, env?: Record<string, string>, log?: Logger, silent?: boolean): Promise<void>;
package/dist/exec.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
- export async function exec(command, cwd, env, log) {
2
+ export async function exec(command, cwd, env, log, silent) {
3
3
  const start = performance.now();
4
4
  log?.debug({ command, cwd }, "process spawning");
5
5
  return new Promise((resolve, reject) => {
6
6
  const proc = spawn(command, {
7
7
  cwd,
8
- stdio: "inherit",
8
+ stdio: silent ? "pipe" : "inherit",
9
9
  shell: true,
10
10
  env: env ? { ...process.env, ...env } : undefined,
11
11
  });
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { EnvValue, HealthCheckConfig, Interpolation, Reference, Resource, R
3
3
  export { isInterpolation, isReference, isSecret, validateId, validateRoute, } from "./resource.js";
4
4
  export type { Runtime } from "./runtime.js";
5
5
  export type { BackupConfig, Bucket, Minio, MinioConfig, Mongo, MongoConfig, Mysql, MysqlConfig, Nats, NatsConfig, Postgres, PostgresConfig, Rabbitmq, RabbitmqConfig, Redis, RedisConfig, Storage, StorageConfig, } from "./services/index.js";
6
+ export { isManaged } from "./services/index.js";
6
7
  export type { RuntimeMeta, RuntimeRef } from "./symbols.js";
7
- export { VYFT_RUNTIME } from "./symbols.js";
8
+ export { VYFT_MANAGED, VYFT_RUNTIME } from "./symbols.js";
8
9
  export declare const service: (id: string, config: import("./swarm/types.js").SwarmServiceConfig) => import("./resource.js").Service, secret: (id: string, config?: import("./resource.js").SecretConfig) => import("./resource.js").Secret, volume: (id: string, config?: import("./resource.js").VolumeConfig) => import("./resource.js").Volume, site: (id: string, config: import("./resource.js").SiteConfig) => import("./resource.js").Site, postgres: (id: string, config?: import("./index.js").PostgresConfig) => import("./index.js").Postgres, mysql: (id: string, config?: import("./index.js").MysqlConfig) => import("./index.js").Mysql, redis: (id: string, config?: import("./index.js").RedisConfig) => import("./index.js").Redis, rabbitmq: (id: string, config?: import("./index.js").RabbitmqConfig) => import("./index.js").Rabbitmq, nats: (id: string, config?: import("./index.js").NatsConfig) => import("./index.js").Nats, mongo: (id: string, config?: import("./index.js").MongoConfig) => import("./index.js").Mongo, minio: (id: string, config?: import("./index.js").MinioConfig) => import("./index.js").Minio, storage: (id: string, config?: import("./index.js").StorageConfig) => import("./index.js").Storage;
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { interpolate } from "./interpolate.js";
2
2
  export { isInterpolation, isReference, isSecret, validateId, validateRoute, } from "./resource.js";
3
- export { VYFT_RUNTIME } from "./symbols.js";
3
+ export { isManaged } from "./services/index.js";
4
+ export { VYFT_MANAGED, VYFT_RUNTIME } from "./symbols.js";
4
5
  import { createFactories } from "./swarm/factories.js";
5
6
  const factories = createFactories({});
6
7
  export const { service, secret, volume, site, postgres, mysql, redis, rabbitmq, nats, mongo, minio, storage, } = factories;
@@ -0,0 +1,31 @@
1
+ import type { EnvValue, Service } from "../resource.js";
2
+ export interface DevProcess {
3
+ service: Service;
4
+ command: string;
5
+ cwd: string;
6
+ env: Record<string, string>;
7
+ }
8
+ type LogHandler = (service: string, stream: "stdout" | "stderr", text: string) => void;
9
+ /**
10
+ * Manages user service child processes for local development.
11
+ */
12
+ export declare class DevRunner {
13
+ private processes;
14
+ private onLog;
15
+ constructor(opts?: {
16
+ onLog?: LogHandler;
17
+ });
18
+ start(devProcesses: DevProcess[]): void;
19
+ stop(): void;
20
+ }
21
+ /**
22
+ * Resolve env vars for a dev process.
23
+ *
24
+ * - Secrets → generated plaintext value
25
+ * - Interpolations → fully resolved, with known service hosts remapped to localhost
26
+ * - Plain strings → as-is, with known service hosts remapped to localhost
27
+ */
28
+ export declare function resolveDevEnv(env: Record<string, EnvValue>, secretValues: Map<string, string>, hostRemap: Map<string, string>): Record<string, string>;
29
+ /** Detect the project's package manager from lock files. */
30
+ export declare function detectPackageManager(projectRoot: string): string;
31
+ export {};
@@ -0,0 +1,109 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { isInterpolation, isReference } from "../resource.js";
5
+ /**
6
+ * Manages user service child processes for local development.
7
+ */
8
+ export class DevRunner {
9
+ processes = [];
10
+ onLog;
11
+ constructor(opts) {
12
+ this.onLog = opts?.onLog;
13
+ }
14
+ start(devProcesses) {
15
+ for (const dp of devProcesses) {
16
+ const child = spawn(dp.command, {
17
+ cwd: dp.cwd,
18
+ env: { ...process.env, ...dp.env },
19
+ shell: true,
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ });
22
+ const name = dp.service.id;
23
+ child.stdout?.on("data", (chunk) => {
24
+ for (const line of chunk.toString().split("\n")) {
25
+ if (line)
26
+ this.onLog?.(name, "stdout", line);
27
+ }
28
+ });
29
+ child.stderr?.on("data", (chunk) => {
30
+ for (const line of chunk.toString().split("\n")) {
31
+ if (line)
32
+ this.onLog?.(name, "stderr", line);
33
+ }
34
+ });
35
+ child.on("exit", (code) => {
36
+ this.onLog?.(name, "stderr", `process exited with code ${code}`);
37
+ });
38
+ this.processes.push(child);
39
+ }
40
+ }
41
+ stop() {
42
+ for (const child of this.processes) {
43
+ child.kill("SIGTERM");
44
+ }
45
+ this.processes = [];
46
+ }
47
+ }
48
+ /**
49
+ * Resolve env vars for a dev process.
50
+ *
51
+ * - Secrets → generated plaintext value
52
+ * - Interpolations → fully resolved, with known service hosts remapped to localhost
53
+ * - Plain strings → as-is, with known service hosts remapped to localhost
54
+ */
55
+ export function resolveDevEnv(env, secretValues, hostRemap) {
56
+ const result = {};
57
+ for (const [key, value] of Object.entries(env)) {
58
+ if (typeof value === "string") {
59
+ result[key] = remapHosts(value, hostRemap);
60
+ }
61
+ else if (isReference(value)) {
62
+ const secretValue = secretValues.get(value.id);
63
+ if (!secretValue) {
64
+ throw new Error(`Secret ${value.id} not yet generated`);
65
+ }
66
+ result[key] = secretValue;
67
+ }
68
+ else if (isInterpolation(value)) {
69
+ const parts = [];
70
+ for (let i = 0; i < value.strings.length; i++) {
71
+ parts.push(value.strings[i]);
72
+ if (i < value.values.length) {
73
+ const v = value.values[i];
74
+ if (isReference(v)) {
75
+ const secretValue = secretValues.get(v.id);
76
+ if (!secretValue) {
77
+ throw new Error(`Secret ${v.id} not yet generated`);
78
+ }
79
+ parts.push(secretValue);
80
+ }
81
+ else {
82
+ parts.push(remapHosts(v, hostRemap));
83
+ }
84
+ }
85
+ }
86
+ result[key] = parts.join("");
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+ function remapHosts(value, hostRemap) {
92
+ let result = value;
93
+ for (const [from, to] of hostRemap) {
94
+ result = result.replaceAll(from, to);
95
+ }
96
+ return result;
97
+ }
98
+ /** Detect the project's package manager from lock files. */
99
+ export function detectPackageManager(projectRoot) {
100
+ if (existsSync(path.join(projectRoot, "bun.lock")))
101
+ return "bun";
102
+ if (existsSync(path.join(projectRoot, "bun.lockb")))
103
+ return "bun";
104
+ if (existsSync(path.join(projectRoot, "pnpm-lock.yaml")))
105
+ return "pnpm";
106
+ if (existsSync(path.join(projectRoot, "yarn.lock")))
107
+ return "yarn";
108
+ return "npm";
109
+ }