@alchemy.run/docker 0.0.0-stage → 2.0.0-beta.84

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.
Files changed (98) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +50 -2
  3. package/lib/Bootstrap.d.ts +7 -0
  4. package/lib/Bootstrap.d.ts.map +1 -0
  5. package/lib/Bootstrap.js +25 -0
  6. package/lib/Bootstrap.js.map +1 -0
  7. package/lib/BuildHash.d.ts +47 -0
  8. package/lib/BuildHash.d.ts.map +1 -0
  9. package/lib/BuildHash.js +165 -0
  10. package/lib/BuildHash.js.map +1 -0
  11. package/lib/Container.d.ts +352 -0
  12. package/lib/Container.d.ts.map +1 -0
  13. package/lib/Container.js +531 -0
  14. package/lib/Container.js.map +1 -0
  15. package/lib/Context.d.ts +86 -0
  16. package/lib/Context.d.ts.map +1 -0
  17. package/lib/Context.js +146 -0
  18. package/lib/Context.js.map +1 -0
  19. package/lib/Docker.d.ts +448 -0
  20. package/lib/Docker.d.ts.map +1 -0
  21. package/lib/Docker.js +416 -0
  22. package/lib/Docker.js.map +1 -0
  23. package/lib/Dockerfile.d.ts +72 -0
  24. package/lib/Dockerfile.d.ts.map +1 -0
  25. package/lib/Dockerfile.js +12 -0
  26. package/lib/Dockerfile.js.map +1 -0
  27. package/lib/HealthcheckCommand.d.ts +17 -0
  28. package/lib/HealthcheckCommand.d.ts.map +1 -0
  29. package/lib/HealthcheckCommand.js +33 -0
  30. package/lib/HealthcheckCommand.js.map +1 -0
  31. package/lib/Image.d.ts +151 -0
  32. package/lib/Image.d.ts.map +1 -0
  33. package/lib/Image.js +192 -0
  34. package/lib/Image.js.map +1 -0
  35. package/lib/ImageSource.d.ts +234 -0
  36. package/lib/ImageSource.d.ts.map +1 -0
  37. package/lib/ImageSource.js +469 -0
  38. package/lib/ImageSource.js.map +1 -0
  39. package/lib/Network.d.ts +70 -0
  40. package/lib/Network.d.ts.map +1 -0
  41. package/lib/Network.js +111 -0
  42. package/lib/Network.js.map +1 -0
  43. package/lib/Providers.d.ts +21 -0
  44. package/lib/Providers.d.ts.map +1 -0
  45. package/lib/Providers.js +26 -0
  46. package/lib/Providers.js.map +1 -0
  47. package/lib/Registry.d.ts +47 -0
  48. package/lib/Registry.d.ts.map +1 -0
  49. package/lib/Registry.js +67 -0
  50. package/lib/Registry.js.map +1 -0
  51. package/lib/RegistryError.d.ts +21 -0
  52. package/lib/RegistryError.d.ts.map +1 -0
  53. package/lib/RegistryError.js +28 -0
  54. package/lib/RegistryError.js.map +1 -0
  55. package/lib/RemoteImage.d.ts +118 -0
  56. package/lib/RemoteImage.d.ts.map +1 -0
  57. package/lib/RemoteImage.js +164 -0
  58. package/lib/RemoteImage.js.map +1 -0
  59. package/lib/Service.d.ts +478 -0
  60. package/lib/Service.d.ts.map +1 -0
  61. package/lib/Service.js +588 -0
  62. package/lib/Service.js.map +1 -0
  63. package/lib/ServiceImage.d.ts +78 -0
  64. package/lib/ServiceImage.d.ts.map +1 -0
  65. package/lib/ServiceImage.js +162 -0
  66. package/lib/ServiceImage.js.map +1 -0
  67. package/lib/Swarm.d.ts +119 -0
  68. package/lib/Swarm.d.ts.map +1 -0
  69. package/lib/Swarm.js +148 -0
  70. package/lib/Swarm.js.map +1 -0
  71. package/lib/Volume.d.ts +94 -0
  72. package/lib/Volume.d.ts.map +1 -0
  73. package/lib/Volume.js +133 -0
  74. package/lib/Volume.js.map +1 -0
  75. package/lib/index.d.ts +14 -0
  76. package/lib/index.d.ts.map +1 -0
  77. package/lib/index.js +13 -0
  78. package/lib/index.js.map +1 -0
  79. package/package.json +72 -3
  80. package/src/Bootstrap.ts +51 -0
  81. package/src/BuildHash.ts +255 -0
  82. package/src/Container.ts +792 -0
  83. package/src/Context.ts +215 -0
  84. package/src/Docker.ts +1079 -0
  85. package/src/Dockerfile.ts +81 -0
  86. package/src/HealthcheckCommand.ts +35 -0
  87. package/src/Image.ts +293 -0
  88. package/src/ImageSource.ts +769 -0
  89. package/src/Network.ts +169 -0
  90. package/src/Providers.ts +46 -0
  91. package/src/Registry.ts +91 -0
  92. package/src/RegistryError.ts +48 -0
  93. package/src/RemoteImage.ts +255 -0
  94. package/src/Service.ts +1080 -0
  95. package/src/ServiceImage.ts +252 -0
  96. package/src/Swarm.ts +221 -0
  97. package/src/Volume.ts +193 -0
  98. package/src/index.ts +17 -0
package/src/Docker.ts ADDED
@@ -0,0 +1,1079 @@
1
+ import { createPhysicalName } from "alchemy/PhysicalName";
2
+ import type { ScopedPlanStatusSession } from "alchemy/Report";
3
+ import * as Config from "effect/Config";
4
+ import * as Context from "effect/Context";
5
+ import * as Effect from "effect/Effect";
6
+ import { Base64 } from "effect/encoding";
7
+ import * as FileSystem from "effect/FileSystem";
8
+ import { flow } from "effect/Function";
9
+ import * as Layer from "effect/Layer";
10
+ import * as Option from "effect/Option";
11
+ import * as Path from "effect/Path";
12
+ import { PlatformError, SystemError, type SystemErrorTag } from "effect/PlatformError";
13
+ import * as ChildProcess from "effect/process/ChildProcess";
14
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
15
+ import * as Redacted from "effect/Redacted";
16
+ import * as Result from "effect/Result";
17
+ import * as Schema from "effect/Schema";
18
+ import * as Sink from "effect/Sink";
19
+ import * as Stream from "effect/Stream";
20
+ import { classifyDockerRegistryError, type DockerImagePublicationError } from "./RegistryError.ts";
21
+
22
+ /** Credentials for a single image registry, scoped to a Docker command. */
23
+ export interface RegistryCredentials {
24
+ /** Registry hostname, without a URL scheme. */
25
+ server: string;
26
+ /** Registry authentication username. */
27
+ username: string;
28
+ /** Registry password or access token. */
29
+ password: string | Redacted.Redacted<string>;
30
+ }
31
+
32
+ const RegistryAuth = Schema.String.pipe(
33
+ Schema.check(
34
+ Schema.makeFilter((value) => {
35
+ const decoded = Base64.decode(value);
36
+ return Result.isSuccess(decoded) && decoded.success.indexOf(58) > 0;
37
+ }),
38
+ ),
39
+ );
40
+
41
+ const decodeRegistryAuthConfig = Schema.Struct({
42
+ auths: Schema.Record(Schema.String, Schema.Struct({ auth: RegistryAuth })).pipe(
43
+ Schema.NullOr,
44
+ Schema.optional,
45
+ ),
46
+ }).pipe(Schema.fromJsonString, (schema) =>
47
+ Schema.decodeEffect(schema, { onExcessProperty: "error" }),
48
+ );
49
+
50
+ export class Docker extends Context.Service<
51
+ Docker,
52
+ {
53
+ /** Runs a Docker command and returns the output. Use this to run a command that doesn't have a dedicated method. */
54
+ readonly run: (args: Array<string>) => Effect.Effect<CommandOutput, PlatformError>;
55
+ /** Writes build files and an inline Dockerfile to the given context directory. */
56
+ readonly materialize: (options: {
57
+ context: string;
58
+ dockerfile: string;
59
+ files: ReadonlyArray<{ path: string; content: string | Uint8Array }>;
60
+ }) => Effect.Effect<void, PlatformError>;
61
+ readonly container: {
62
+ /** Creates a new container. */
63
+ readonly create: (options: {
64
+ name: string;
65
+ image: string;
66
+ volume: Array<string> | undefined;
67
+ env: Record<string, string> | undefined;
68
+ /** Paths to Docker env files, forwarded as repeated `--env-file`. */
69
+ "env-file"?: Array<string> | undefined;
70
+ restart: "no" | "always" | "on-failure" | "unless-stopped";
71
+ rm: boolean;
72
+ "health-cmd": string | undefined;
73
+ /** `--no-healthcheck`: disable any healthcheck defined by the image. */
74
+ "no-healthcheck"?: boolean;
75
+ "health-interval": string | undefined;
76
+ "health-timeout": string | undefined;
77
+ "health-retries": number | undefined;
78
+ "health-start-period": string | undefined;
79
+ "health-start-interval": string | undefined;
80
+ "stop-timeout": string | undefined;
81
+ p: Array<string> | undefined;
82
+ /** `--add-host` entries, each `hostname:address`. */
83
+ "add-host"?: Array<string> | undefined;
84
+ /** Docker network namespace, including `container:<id>`. */
85
+ network?: string | undefined;
86
+ /** Linux capabilities to add. */
87
+ "cap-add"?: Array<string> | undefined;
88
+ /** Host devices in Docker's `host:container[:permissions]` form. */
89
+ device?: Array<string> | undefined;
90
+ command: Array<string> | undefined;
91
+ label?: Record<string, string>;
92
+ context?: string;
93
+ }) => Effect.Effect<CommandOutput, PlatformError>;
94
+ /** Inspects a container. */
95
+ readonly inspect: (
96
+ name: string,
97
+ context?: string,
98
+ ) => Effect.Effect<Docker.Container, PlatformError>;
99
+ /** Removes a container. */
100
+ readonly remove: (
101
+ name: string,
102
+ force?: boolean,
103
+ context?: string,
104
+ ) => Effect.Effect<CommandOutput, PlatformError>;
105
+ /** Starts a container. */
106
+ readonly start: (
107
+ name: string,
108
+ context?: string,
109
+ ) => Effect.Effect<CommandOutput, PlatformError>;
110
+ /** Stops a container. */
111
+ readonly stop: (
112
+ name: string,
113
+ context?: string,
114
+ ) => Effect.Effect<CommandOutput, PlatformError>;
115
+ };
116
+ readonly image: {
117
+ /**
118
+ * Builds locally, or publishes to a registry when credentials are
119
+ * supplied. With Buildx 0.26.0 or newer the image is exported straight
120
+ * from BuildKit (`buildx build --push`); older plugins build into the
121
+ * local image store and then `push`. If a session is provided, build
122
+ * logs will be emitted as session notes.
123
+ */
124
+ readonly build: (
125
+ options: {
126
+ context: string;
127
+ /** Image tags to build and publish together. */
128
+ tag: string | [string, ...string[]];
129
+ file?: string;
130
+ platform?: string;
131
+ target?: string;
132
+ "build-arg"?: Record<string, string>;
133
+ "cache-from"?: Array<string>;
134
+ "cache-to"?: Array<string>;
135
+ args?: Array<string>;
136
+ engineContext?: string;
137
+ /** Registry auth for the build itself (base images, caches); publishes nothing. */
138
+ credentials?: RegistryCredentials;
139
+ },
140
+ session?: ScopedPlanStatusSession,
141
+ registry?: RegistryCredentials,
142
+ ) => Effect.Effect<CommandOutput, DockerImagePublicationError>;
143
+ /** Pulls an image. */
144
+ readonly pull: (
145
+ ref: string,
146
+ platform?: string,
147
+ context?: string,
148
+ session?: Pick<ScopedPlanStatusSession, "note">,
149
+ ) => Effect.Effect<CommandOutput, PlatformError>;
150
+ /**
151
+ * Pushes an image to a registry. When `platform` is given, only that
152
+ * platform's manifest is pushed (`docker push --platform`) — with the
153
+ * containerd image store, a bare push sends every locally-present
154
+ * variant of a multi-arch tag, so a stale other-arch variant in the
155
+ * local cache would otherwise reach the registry. Engines whose store
156
+ * doesn't support the flag fall back to a plain push (their local tag
157
+ * is already narrowed by `pull --platform`).
158
+ */
159
+ readonly push: (
160
+ ref: string,
161
+ credentials: RegistryCredentials,
162
+ platform?: string,
163
+ context?: string,
164
+ session?: Pick<ScopedPlanStatusSession, "note">,
165
+ ) => Effect.Effect<CommandOutput, DockerImagePublicationError>;
166
+ /** Tags an image. */
167
+ readonly tag: (
168
+ source: string,
169
+ target: string,
170
+ context?: string,
171
+ ) => Effect.Effect<CommandOutput, PlatformError>;
172
+ /** Inspects an image. */
173
+ readonly inspect: (
174
+ ref: string,
175
+ context?: string,
176
+ ) => Effect.Effect<Docker.Image, PlatformError>;
177
+ /** Removes an image. */
178
+ readonly remove: (
179
+ ref: string | Array<string>,
180
+ force?: boolean,
181
+ context?: string,
182
+ ) => Effect.Effect<CommandOutput, PlatformError>;
183
+ };
184
+ readonly volume: {
185
+ /** Creates a new volume. */
186
+ readonly create: (options: {
187
+ name: string;
188
+ driver?: string;
189
+ opt?: Record<string, string>;
190
+ label?: Record<string, string>;
191
+ context?: string;
192
+ }) => Effect.Effect<CommandOutput, PlatformError>;
193
+ /** Removes a volume. */
194
+ readonly remove: (
195
+ name: string,
196
+ context?: string,
197
+ ) => Effect.Effect<CommandOutput, PlatformError>;
198
+ /** Inspects a volume. */
199
+ readonly inspect: (
200
+ name: string,
201
+ context?: string,
202
+ ) => Effect.Effect<Docker.Volume, PlatformError>;
203
+ };
204
+ readonly context: {
205
+ /** Creates a new Docker context. */
206
+ readonly create: (options: {
207
+ name: string;
208
+ description?: string;
209
+ /** Raw value for `--docker`, for example `host=ssh://user@host`. */
210
+ docker?: string;
211
+ }) => Effect.Effect<CommandOutput, PlatformError>;
212
+ /** Updates an existing Docker context. */
213
+ readonly update: (options: {
214
+ name: string;
215
+ description?: string;
216
+ /** Raw value for `--docker`, for example `host=ssh://user@host`. */
217
+ docker?: string;
218
+ }) => Effect.Effect<CommandOutput, PlatformError>;
219
+ /** Inspects a Docker context. */
220
+ readonly inspect: (name: string) => Effect.Effect<Docker.Context, PlatformError>;
221
+ /** Removes a Docker context. */
222
+ readonly remove: (
223
+ name: string,
224
+ force?: boolean,
225
+ ) => Effect.Effect<CommandOutput, PlatformError>;
226
+ };
227
+ readonly network: {
228
+ /** Creates a new network. */
229
+ readonly create: (options: {
230
+ name: string;
231
+ driver: string;
232
+ ipv6?: boolean;
233
+ label?: Record<string, string>;
234
+ context?: string;
235
+ }) => Effect.Effect<CommandOutput, PlatformError>;
236
+ /** Connects a container to a network. */
237
+ readonly connect: (options: {
238
+ network: string;
239
+ container: string;
240
+ alias?: string[];
241
+ context?: string;
242
+ }) => Effect.Effect<CommandOutput, PlatformError>;
243
+ /** Disconnects a container from a network. */
244
+ readonly disconnect: (options: {
245
+ network: string;
246
+ container: string;
247
+ context?: string;
248
+ }) => Effect.Effect<CommandOutput, PlatformError>;
249
+ /** Inspects a network. */
250
+ readonly inspect: (
251
+ name: string,
252
+ context?: string,
253
+ ) => Effect.Effect<Docker.Network, PlatformError>;
254
+ /** Removes a network. */
255
+ readonly remove: (
256
+ id: string,
257
+ context?: string,
258
+ ) => Effect.Effect<CommandOutput, PlatformError>;
259
+ };
260
+ readonly swarm: {
261
+ /** Initializes swarm mode on the engine (`docker swarm init`). */
262
+ readonly init: (options: {
263
+ context?: string;
264
+ "advertise-addr"?: string;
265
+ "listen-addr"?: string;
266
+ "default-addr-pool"?: string[];
267
+ "default-addr-pool-mask-length"?: number;
268
+ }) => Effect.Effect<CommandOutput, PlatformError>;
269
+ /** Reads the engine's swarm state (`docker info --format '{{json .Swarm}}'`). */
270
+ readonly info: (context?: string) => Effect.Effect<Docker.SwarmInfo, PlatformError>;
271
+ /** Leaves the swarm (`docker swarm leave`). */
272
+ readonly leave: (
273
+ force?: boolean,
274
+ context?: string,
275
+ ) => Effect.Effect<CommandOutput, PlatformError>;
276
+ };
277
+ readonly service: {
278
+ /** Creates a new service. */
279
+ readonly create: (options: {
280
+ name: string;
281
+ image: string;
282
+ context?: string;
283
+ replicas?: number;
284
+ "endpoint-mode"?: "vip" | "dnsrr";
285
+ network?: string[];
286
+ constraint?: string[];
287
+ "replicas-max-per-node"?: number;
288
+ "placement-pref"?: string[];
289
+ "update-parallelism"?: number;
290
+ "update-delay"?: string;
291
+ "update-monitor"?: string;
292
+ "update-failure-action"?: "pause" | "continue" | "rollback";
293
+ "update-max-failure-ratio"?: number;
294
+ "update-order"?: "stop-first" | "start-first";
295
+ "rollback-parallelism"?: number;
296
+ "rollback-delay"?: string;
297
+ "rollback-monitor"?: string;
298
+ "rollback-failure-action"?: "pause" | "continue" | "rollback";
299
+ "rollback-max-failure-ratio"?: number;
300
+ "rollback-order"?: "stop-first" | "start-first";
301
+ "restart-condition"?: "none" | "on-failure" | "any";
302
+ "restart-delay"?: string;
303
+ "restart-max-attempts"?: number;
304
+ "restart-window"?: string;
305
+ "health-cmd"?: string;
306
+ /** `--no-healthcheck`: disable any healthcheck defined by the image. */
307
+ "no-healthcheck"?: boolean;
308
+ "health-interval"?: string;
309
+ "health-timeout"?: string;
310
+ "health-retries"?: number;
311
+ "health-start-period"?: string;
312
+ "stop-grace-period"?: string;
313
+ mount?: string[];
314
+ secret?: string[];
315
+ config?: string[];
316
+ "read-only"?: boolean;
317
+ publish?: string[];
318
+ label?: Record<string, string>;
319
+ env?: Record<string, string>;
320
+ command?: string[];
321
+ args?: string[];
322
+ }) => Effect.Effect<CommandOutput, PlatformError>;
323
+ /** Updates an existing service. */
324
+ readonly update: (options: {
325
+ id: string;
326
+ image: string;
327
+ context?: string;
328
+ replicas?: number;
329
+ "endpoint-mode"?: "vip" | "dnsrr";
330
+ "constraint-add"?: string[];
331
+ "constraint-rm"?: string[];
332
+ "replicas-max-per-node"?: number;
333
+ "placement-pref"?: string[];
334
+ "update-parallelism"?: number;
335
+ "update-delay"?: string;
336
+ "update-monitor"?: string;
337
+ "update-failure-action"?: "pause" | "continue" | "rollback";
338
+ "update-max-failure-ratio"?: number;
339
+ "update-order"?: "stop-first" | "start-first";
340
+ "rollback-parallelism"?: number;
341
+ "rollback-delay"?: string;
342
+ "rollback-monitor"?: string;
343
+ "rollback-failure-action"?: "pause" | "continue" | "rollback";
344
+ "rollback-max-failure-ratio"?: number;
345
+ "rollback-order"?: "stop-first" | "start-first";
346
+ "restart-condition"?: "none" | "on-failure" | "any";
347
+ "restart-delay"?: string;
348
+ "restart-max-attempts"?: number;
349
+ "restart-window"?: string;
350
+ "health-cmd"?: string;
351
+ /** `--no-healthcheck`: disable any healthcheck defined by the image. */
352
+ "no-healthcheck"?: boolean;
353
+ "health-interval"?: string;
354
+ "health-timeout"?: string;
355
+ "health-retries"?: number;
356
+ "health-start-period"?: string;
357
+ "stop-grace-period"?: string;
358
+ "mount-add"?: string[];
359
+ "secret-add"?: string[];
360
+ "config-add"?: string[];
361
+ "read-only"?: boolean;
362
+ "publish-add"?: string[];
363
+ "label-add"?: Record<string, string>;
364
+ "label-rm"?: string[];
365
+ "env-add"?: Record<string, string>;
366
+ args?: string;
367
+ }) => Effect.Effect<CommandOutput, PlatformError>;
368
+ /** Inspects a service. */
369
+ readonly inspect: (
370
+ id: string,
371
+ context?: string,
372
+ ) => Effect.Effect<CommandOutput, PlatformError>;
373
+ /** Removes a service. */
374
+ readonly remove: (
375
+ id: string,
376
+ context?: string,
377
+ ) => Effect.Effect<CommandOutput, PlatformError>;
378
+ };
379
+ }
380
+ >()("@alchemy/Docker") {}
381
+
382
+ export declare namespace Docker {
383
+ export type ContextRef = string | { name: string };
384
+
385
+ /**
386
+ * A reference to the Docker engine an operation runs against: a context
387
+ * name, a `Docker.Context` resource, or a `Docker.Swarm` resource
388
+ * (narrowed structurally by its `nodeId` attribute) — passing the swarm
389
+ * also orders the operation after the swarm is initialized.
390
+ */
391
+ export type EngineRef = ContextRef | { nodeId: string; context?: string };
392
+
393
+ export interface SwarmInfo {
394
+ NodeID: string;
395
+ LocalNodeState: "inactive" | "pending" | "active" | "error" | "locked" | (string & {});
396
+ ControlAvailable: boolean;
397
+ Cluster?: { ID: string; CreatedAt?: string } | null;
398
+ RemoteManagers?: Array<{ NodeID: string; Addr: string }> | null;
399
+ Managers?: number;
400
+ Nodes?: number;
401
+ }
402
+
403
+ export type ContainerStatus =
404
+ | "created"
405
+ | "running"
406
+ | "paused"
407
+ | "restarting"
408
+ | "removing"
409
+ | "exited"
410
+ | "dead";
411
+
412
+ export interface Container {
413
+ Id: string;
414
+ Image: string;
415
+ Name?: string;
416
+ State: {
417
+ Status: ContainerStatus;
418
+ /** Present when the container has a healthcheck. */
419
+ Health?: { Status: "starting" | "healthy" | "unhealthy" };
420
+ };
421
+ Created: string;
422
+ Config: {
423
+ Image: string;
424
+ Cmd: string[] | null;
425
+ Env: string[] | null;
426
+ Labels: Record<string, string> | null;
427
+ StopTimeout?: number;
428
+ Healthcheck?: {
429
+ Test: string[] | null;
430
+ Interval?: number;
431
+ Timeout?: number;
432
+ Retries?: number;
433
+ StartPeriod?: number;
434
+ StartInterval?: number;
435
+ } | null;
436
+ };
437
+ HostConfig: {
438
+ PortBindings: Record<string, Array<{ HostIp: string; HostPort: string }> | null> | null;
439
+ Binds: string[] | null;
440
+ ExtraHosts: string[] | null;
441
+ RestartPolicy: { Name: string; MaximumRetryCount: number };
442
+ AutoRemove: boolean;
443
+ NetworkMode?: string;
444
+ CapAdd?: string[] | null;
445
+ Devices?: Array<{
446
+ PathOnHost: string;
447
+ PathInContainer: string;
448
+ CgroupPermissions: string;
449
+ }> | null;
450
+ };
451
+ NetworkSettings: {
452
+ Networks: Record<string, { NetworkID: string; Aliases: string[] | null }> | null;
453
+ Ports?: Record<string, Array<{ HostIp: string; HostPort: string }> | null> | null;
454
+ };
455
+ }
456
+
457
+ export interface Image {
458
+ Id: string;
459
+ Created?: string;
460
+ RepoTags?: string[] | null;
461
+ RepoDigests?: string[] | null;
462
+ }
463
+
464
+ export interface Volume {
465
+ CreatedAt: string;
466
+ Driver: string;
467
+ Labels: Record<string, string> | null;
468
+ Mountpoint: string;
469
+ Name: string;
470
+ Options: Record<string, string> | null;
471
+ Scope: string;
472
+ }
473
+
474
+ export interface Context {
475
+ Name: string;
476
+ Metadata?: { Description?: string };
477
+ Endpoints?: { docker?: string };
478
+ }
479
+
480
+ export interface Network {
481
+ Name: string;
482
+ Id: string;
483
+ Created: string;
484
+ Scope: string;
485
+ Driver: string;
486
+ EnableIPv6: boolean;
487
+ Labels: Record<string, string> | null;
488
+ }
489
+ }
490
+
491
+ export interface CommandOutput {
492
+ exitCode: ChildProcessSpawner.ExitCode;
493
+ stdout: string;
494
+ stderr: string;
495
+ }
496
+
497
+ export interface DockerOptions {
498
+ /**
499
+ * The Docker-compatible CLI to run, e.g. `"podman"`. The `DOCKER_BIN`
500
+ * environment variable, when set, overrides it (per machine or CI).
501
+ * @default "docker"
502
+ */
503
+ bin?: string;
504
+ }
505
+
506
+ /**
507
+ * Extra environment for every Docker CLI invocation, layered over the
508
+ * process environment (per-call variables still win). Scope it with
509
+ * `Effect.provideService(DockerEnvironment, { BUILDX_BUILDER: "ci" })` to
510
+ * target a buildx builder or Docker context without mutating `process.env`.
511
+ * @default {}
512
+ */
513
+ export const DockerEnvironment = Context.Reference<Record<string, string>>(
514
+ "alchemy/Docker/DockerEnvironment",
515
+ { defaultValue: () => ({}) },
516
+ );
517
+
518
+ /** The Docker CLI client, running `options.bin` (`DOCKER_BIN` overrides). */
519
+ export const dockerLive = (options: DockerOptions = {}) =>
520
+ Layer.effect(Docker, makeDocker(options));
521
+
522
+ const makeDocker = (options: DockerOptions) =>
523
+ Effect.gen(function* () {
524
+ const fs = yield* FileSystem.FileSystem;
525
+ const path = yield* Path.Path;
526
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
527
+ const bin = yield* Config.String("DOCKER_BIN").pipe(
528
+ Effect.orElseSucceed(() => options.bin ?? "docker"),
529
+ );
530
+
531
+ const run = (
532
+ args: Array<string>,
533
+ env?: Record<string, string>,
534
+ tap: (
535
+ stream: Stream.Stream<string, PlatformError, never>,
536
+ ) => Stream.Stream<string, PlatformError, never> = Stream.tap(Effect.logDebug),
537
+ ) =>
538
+ Effect.gen(function* () {
539
+ const ambient = yield* DockerEnvironment;
540
+ return yield* spawner.spawn(
541
+ ChildProcess.make(bin, args, {
542
+ stdin: "ignore",
543
+ stdout: "pipe",
544
+ stderr: "pipe",
545
+ detached: false,
546
+ env: { ...ambient, ...env },
547
+ extendEnv: true,
548
+ }),
549
+ );
550
+ }).pipe(
551
+ Effect.flatMap((child) =>
552
+ Effect.all(
553
+ {
554
+ exitCode: child.exitCode,
555
+ stdout: child.stdout.pipe(
556
+ Stream.decodeText,
557
+ tap,
558
+ Stream.mkString,
559
+ Effect.map((stdout) => stdout.trim()),
560
+ ),
561
+ stderr: child.stderr.pipe(
562
+ Stream.decodeText,
563
+ tap,
564
+ Stream.mkString,
565
+ Effect.map((stderr) => stderr.trim()),
566
+ ),
567
+ },
568
+ { concurrency: "unbounded" },
569
+ ),
570
+ ),
571
+ Effect.mapError((error) =>
572
+ systemError({
573
+ _tag: "Unknown",
574
+ args,
575
+ // Name the CLI: a misconfigured `bin` / DOCKER_BIN fails here.
576
+ description: `Failed to run \`${bin}\`; is it installed and on PATH?`,
577
+ cause: error.reason,
578
+ }),
579
+ ),
580
+ Effect.tap((result) => {
581
+ if (result.exitCode === 0) return Effect.void;
582
+ const stderr = result.stderr.replace(/^Error response from daemon: /, "");
583
+ if (stderr.match(/not found|not known|no such/i)) {
584
+ return systemError({ _tag: "NotFound", args, description: stderr });
585
+ }
586
+ if (stderr.match(/already exists/i)) {
587
+ return systemError({ _tag: "AlreadyExists", args, description: stderr });
588
+ }
589
+ return systemError({
590
+ _tag: "Unknown",
591
+ args,
592
+ description: `Command exited with code ${result.exitCode}: ${failureOutput(
593
+ stderr,
594
+ result.stdout,
595
+ )}`,
596
+ });
597
+ }),
598
+ Effect.scoped,
599
+ );
600
+
601
+ const runInspect = <T>(args: Array<string>) =>
602
+ run(args).pipe(
603
+ Effect.map((result) => {
604
+ const [item] = JSON.parse(result.stdout) as T[];
605
+ return item;
606
+ }),
607
+ );
608
+
609
+ // Merge `credentials` into the ambient auth config: the process's
610
+ // DOCKER_AUTH_CONFIG, overlaid by one provided through DockerEnvironment
611
+ // (as `run` layers DockerEnvironment over the process environment).
612
+ // Merging, not replacing, keeps the user's own registry logins.
613
+ const registryEnvironment = Effect.fn("registryEnvironment")(
614
+ (credentials: RegistryCredentials) =>
615
+ Effect.gen(function* () {
616
+ const value = yield* Config.Redacted("DOCKER_AUTH_CONFIG").pipe(
617
+ Config.withDefault(Redacted.make("{}")),
618
+ );
619
+ const base = yield* decodeRegistryAuthConfig(Redacted.value(value) || "{}");
620
+ const ambient = yield* DockerEnvironment;
621
+ const overlay =
622
+ ambient.DOCKER_AUTH_CONFIG === undefined
623
+ ? undefined
624
+ : yield* decodeRegistryAuthConfig(ambient.DOCKER_AUTH_CONFIG || "{}");
625
+ return { auths: { ...base.auths, ...overlay?.auths } };
626
+ }).pipe(
627
+ // Schema diagnostics may include credential input. Do not retain them.
628
+ Effect.mapError(() =>
629
+ systemError({
630
+ _tag: "InvalidData",
631
+ args: ["buildx", "build"],
632
+ description: "Invalid DOCKER_AUTH_CONFIG; expected an auths map.",
633
+ }),
634
+ ),
635
+ Effect.map((current) => {
636
+ const password = Redacted.isRedacted(credentials.password)
637
+ ? Redacted.value(credentials.password)
638
+ : credentials.password;
639
+ const auth = Base64.encode(`${credentials.username}:${password}`);
640
+ // Preserve Docker's file/helper fallback, contexts, and builders.
641
+ return {
642
+ DOCKER_AUTH_CONFIG: JSON.stringify({
643
+ auths: { ...current.auths, [credentials.server]: { auth } },
644
+ }),
645
+ };
646
+ }),
647
+ ),
648
+ );
649
+
650
+ // How a build reaches a registry. Buildx 0.26 is the first release
651
+ // embedding Docker CLI 28.3's DOCKER_AUTH_CONFIG credential store, so it
652
+ // can `buildx build --push` straight from BuildKit. Older plugins (e.g.
653
+ // the buildx bundled with Docker Desktop < 4.44) silently ignore the
654
+ // variable, so the image is instead `--load`ed into the local store and
655
+ // published with the isolated-config `push`. Without any buildx plugin
656
+ // the legacy builder always loads, so a plain `image build` suffices.
657
+ // Probed once per Docker service instance.
658
+ const publication = yield* Effect.cached(
659
+ Effect.gen(function* () {
660
+ const version = yield* run(["buildx", "version"]).pipe(Effect.option);
661
+ if (Option.isNone(version)) return "legacy" as const;
662
+ // Numeric template captures can consume version separators as decimals.
663
+ const match =
664
+ /^github\.com\/docker\/buildx v?(\d+)\.(\d+)\.\d+(?:[-+][^\s]+)?(?:\s.*)?$/.exec(
665
+ version.value.stdout,
666
+ );
667
+ if (!match) return "load" as const;
668
+ const major = Number(match[1]);
669
+ const minor = Number(match[2]);
670
+ return major >= 1 || minor >= 26 ? ("export" as const) : ("load" as const);
671
+ }),
672
+ );
673
+
674
+ const outputTap = (session?: Pick<ScopedPlanStatusSession, "note">) =>
675
+ session
676
+ ? Stream.tapSink(
677
+ Sink.make<string>()(
678
+ flow(
679
+ Stream.splitLines,
680
+ Stream.runForEach((line) => session.note(line, { kind: "output" })),
681
+ ),
682
+ ),
683
+ )
684
+ : undefined;
685
+
686
+ // `podman push` prints no `digest:` line, and Podman's RepoDigests can
687
+ // list a source digest under the pushed name, so Podman pushes report the
688
+ // published digest through `--digestfile` instead. Probed once.
689
+ const isPodman = yield* Effect.cached(
690
+ run(["--version"]).pipe(
691
+ Effect.map((result) => /^podman\b/i.test(result.stdout.trim())),
692
+ Effect.orElseSucceed(() => false),
693
+ ),
694
+ );
695
+
696
+ const push: Docker["Service"]["image"]["push"] = Effect.fn(
697
+ function* (ref, credentials, platform, context, session) {
698
+ // Write the registry credentials directly into an isolated docker config
699
+ // as a plaintext `auths` entry and skip `docker login` entirely.
700
+ //
701
+ // `docker login` is the wrong tool here: on macOS Docker Desktop it routes
702
+ // through the shared `osxkeychain`/`desktop` credential helper *regardless*
703
+ // of an isolated DOCKER_CONFIG, so concurrent deploys either race the system
704
+ // keychain (`The specified item already exists in the keychain (-25299)`) or
705
+ // land the credential in the helper — leaving this isolated config without
706
+ // an `auths` entry, so the subsequent `docker push` fails with "no basic
707
+ // auth credentials". Embedding the base64 `auth` inline (the same thing
708
+ // `docker login` would write when no credsStore is configured) makes each
709
+ // deploy fully self-contained: no credential helper, no keychain, no login
710
+ // race. Only `push` reads this config; `build`/`pull`/`tag` keep using the
711
+ // global docker config (buildx builders, `docker context`, etc. intact).
712
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-docker-" });
713
+ // The isolated DOCKER_CONFIG holds none of the CLI's contexts. Ask the CLI
714
+ // (same environment as the push) which context it would use, and copy just
715
+ // that context's metadata and TLS material in.
716
+ const selected =
717
+ context ??
718
+ (yield* run(["context", "show"]).pipe(
719
+ Effect.map((result) => result.stdout),
720
+ Effect.orElseSucceed(() => "default"),
721
+ ));
722
+ if (selected && selected !== "default") {
723
+ const inspected = yield* runInspect<{
724
+ Storage: { MetadataPath: string; TLSPath: string };
725
+ }>(["context", "inspect", selected]);
726
+ for (const [kind, source] of [
727
+ ["meta", inspected.Storage.MetadataPath],
728
+ ["tls", inspected.Storage.TLSPath],
729
+ ] as const) {
730
+ if (!(yield* fs.exists(source))) continue;
731
+ const target = path.join(dir, "contexts", kind, path.basename(source));
732
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
733
+ yield* fs.copy(source, target);
734
+ }
735
+ }
736
+ const pushContext = selected && selected !== "default" ? selected : context;
737
+ const config = yield* Effect.sync(() => {
738
+ const password = Redacted.isRedacted(credentials.password)
739
+ ? Redacted.value(credentials.password)
740
+ : credentials.password;
741
+ const auth = Buffer.from(`${credentials.username}:${password}`).toString("base64");
742
+ return JSON.stringify({ auths: { [credentials.server]: { auth } } });
743
+ });
744
+ yield* fs.writeFileString(path.join(dir, "config.json"), config);
745
+ const digestFile = path.join(dir, "digest");
746
+ const digestArgs = (yield* isPodman) ? ["--digestfile", digestFile] : [];
747
+ const result =
748
+ platform === undefined
749
+ ? yield* run(
750
+ [...formatArgs({ context: pushContext }), "push", ...digestArgs, ref],
751
+ { DOCKER_CONFIG: dir },
752
+ outputTap(session),
753
+ )
754
+ : yield* run(
755
+ [
756
+ ...formatArgs({ context: pushContext }),
757
+ "push",
758
+ "--platform",
759
+ platform,
760
+ ...digestArgs,
761
+ ref,
762
+ ],
763
+ { DOCKER_CONFIG: dir },
764
+ outputTap(session),
765
+ ).pipe(
766
+ // Engines without the containerd image store reject `--platform`
767
+ // on push; their local tag is already narrowed to the requested
768
+ // platform by `pull --platform`, so a plain push is equivalent.
769
+ Effect.catchIf(
770
+ (error) => /--platform|unknown flag|containerd/i.test(String(error)),
771
+ () =>
772
+ run(
773
+ [...formatArgs({ context: pushContext }), "push", ...digestArgs, ref],
774
+ { DOCKER_CONFIG: dir },
775
+ outputTap(session),
776
+ ),
777
+ ),
778
+ );
779
+ if (digestArgs.length === 0) return result;
780
+ const digest = (yield* fs
781
+ .readFileString(digestFile)
782
+ .pipe(Effect.orElseSucceed(() => ""))).trim();
783
+ // Report it the way `docker push` does, so callers parse one format.
784
+ return digest ? { ...result, stdout: `${result.stdout}\ndigest: ${digest}` } : result;
785
+ },
786
+ Effect.scoped,
787
+ Effect.mapError(classifyDockerRegistryError),
788
+ );
789
+
790
+ return Docker.of({
791
+ run,
792
+ materialize: Effect.fn((options) =>
793
+ Effect.forEach(
794
+ [...options.files, { path: "Dockerfile", content: options.dockerfile }],
795
+ (file) => {
796
+ const fullPath = path.join(options.context, file.path);
797
+ return fs
798
+ .makeDirectory(path.dirname(fullPath), { recursive: true })
799
+ .pipe(
800
+ Effect.andThen(
801
+ typeof file.content === "string"
802
+ ? fs.writeFileString(fullPath, file.content)
803
+ : fs.writeFile(fullPath, file.content),
804
+ ),
805
+ );
806
+ },
807
+ { concurrency: "unbounded" },
808
+ ),
809
+ ),
810
+ container: {
811
+ create: ({ image, env, command, context, ...options }) =>
812
+ // `--env KEY` (name-only) keeps values off CLI arguments; Docker
813
+ // copies each value from the CLI process environment, so the
814
+ // normalized container env must be passed to the child process.
815
+ run(
816
+ [
817
+ ...formatArgs({ context }),
818
+ "container",
819
+ "create",
820
+ ...formatArgs({ ...options, env: env ? Object.keys(env) : undefined }),
821
+ image,
822
+ ...(command ?? []),
823
+ ],
824
+ env,
825
+ ),
826
+ inspect: (name, context) =>
827
+ runInspect<Docker.Container>([...formatArgs({ context }), "container", "inspect", name]),
828
+ remove: (name, force, context) =>
829
+ run([...formatArgs({ context }), "container", "rm", name, ...(force ? ["-f"] : [])]),
830
+ start: (name, context) => run([...formatArgs({ context }), "container", "start", name]),
831
+ stop: (name, context) => run([...formatArgs({ context }), "container", "stop", name]),
832
+ },
833
+ image: {
834
+ build: Effect.fn("Docker.image.build")(function* (
835
+ { context: buildContext, engineContext, args, credentials, ...options },
836
+ session,
837
+ registry,
838
+ ) {
839
+ const tap = outputTap(session);
840
+ const buildArgs = [buildContext, ...formatArgs(options), ...(args ?? [])];
841
+ const engine = formatArgs({ context: engineContext });
842
+ if (registry === undefined) {
843
+ return yield* run(
844
+ [...engine, "image", "build", ...buildArgs],
845
+ credentials ? yield* registryEnvironment(credentials) : undefined,
846
+ tap,
847
+ );
848
+ }
849
+ const mode = yield* publication;
850
+ if (mode === "export") {
851
+ // Export straight from BuildKit to the registry; the image never
852
+ // round-trips through the local image store.
853
+ const env = yield* registryEnvironment(registry);
854
+ return yield* run(
855
+ [...engine, "buildx", "build", "--push", ...buildArgs],
856
+ env,
857
+ tap,
858
+ ).pipe(Effect.mapError(classifyDockerRegistryError));
859
+ }
860
+ // Older Buildx ignores DOCKER_AUTH_CONFIG, so a `--push` export
861
+ // would fail with "no basic auth credentials". Build into the local
862
+ // store and publish with the isolated-config `push` instead.
863
+ // `--load` matters for non-loading builders (docker-container):
864
+ // without it the result stays in the build cache and `push` sees no
865
+ // such tag.
866
+ yield* run(
867
+ [
868
+ ...engine,
869
+ ...(mode === "load" ? ["buildx", "build", "--load"] : ["image", "build"]),
870
+ ...buildArgs,
871
+ ],
872
+ undefined,
873
+ tap,
874
+ );
875
+ const [tag, ...tags] =
876
+ typeof options.tag === "string" ? ([options.tag] as const) : options.tag;
877
+ return yield* push(tag, registry, options.platform, engineContext, session).pipe(
878
+ Effect.tap(() =>
879
+ Effect.forEach(tags, (tag) =>
880
+ push(tag, registry, options.platform, engineContext, session),
881
+ ),
882
+ ),
883
+ );
884
+ }),
885
+ pull: (ref, platform, context, session) =>
886
+ run(
887
+ [
888
+ ...formatArgs({ context }),
889
+ "image",
890
+ "pull",
891
+ ref,
892
+ ...(platform ? ["--platform", platform] : []),
893
+ ],
894
+ undefined,
895
+ outputTap(session),
896
+ ),
897
+ inspect: (ref, context) =>
898
+ runInspect<Docker.Image>([...formatArgs({ context }), "image", "inspect", ref]),
899
+ remove: (ref, force, context) =>
900
+ run([
901
+ ...formatArgs({ context }),
902
+ "image",
903
+ "rm",
904
+ ...(Array.isArray(ref) ? ref : [ref]),
905
+ ...(force ? ["-f"] : []),
906
+ ]),
907
+ tag: (source, target, context) =>
908
+ run([...formatArgs({ context }), "image", "tag", source, target]),
909
+ push,
910
+ },
911
+ volume: {
912
+ create: ({ context, ...options }) =>
913
+ run([...formatArgs({ context }), "volume", "create", ...formatArgs(options)]),
914
+ remove: (name, context) => run([...formatArgs({ context }), "volume", "rm", name]),
915
+ inspect: (name, context) =>
916
+ runInspect<Docker.Volume>([...formatArgs({ context }), "volume", "inspect", name]),
917
+ },
918
+ context: {
919
+ create: ({ name, description, docker }) =>
920
+ run(["context", "create", name, ...formatArgs({ description, docker })]),
921
+ update: ({ name, description, docker }) =>
922
+ run(["context", "update", name, ...formatArgs({ description, docker })]),
923
+ inspect: (name) => runInspect<Docker.Context>(["context", "inspect", name]),
924
+ remove: (name, force) => run(["context", "rm", ...(force ? ["-f"] : []), name]),
925
+ },
926
+ network: {
927
+ create: ({ name, driver, ipv6, label, context }) =>
928
+ run([
929
+ ...formatArgs({ context }),
930
+ "network",
931
+ "create",
932
+ name,
933
+ ...formatArgs({ driver, ipv6, label }),
934
+ ]),
935
+ connect: ({ network, container, alias, context }) =>
936
+ run([
937
+ ...formatArgs({ context }),
938
+ "network",
939
+ "connect",
940
+ network,
941
+ container,
942
+ ...(alias ? alias.flatMap((a) => ["--alias", a]) : []),
943
+ ]),
944
+ disconnect: ({ network, container, context }) =>
945
+ run([...formatArgs({ context }), "network", "disconnect", network, container]),
946
+ inspect: (name, context) =>
947
+ runInspect<Docker.Network>([...formatArgs({ context }), "network", "inspect", name]),
948
+ remove: (id, context) => run([...formatArgs({ context }), "network", "rm", id]),
949
+ },
950
+ swarm: {
951
+ init: ({ context, ...options }) =>
952
+ run([...formatArgs({ context }), "swarm", "init", ...formatArgs(options)]),
953
+ info: (context) =>
954
+ run([...formatArgs({ context }), "info", "--format", "{{json .Swarm}}"]).pipe(
955
+ Effect.map((result) => JSON.parse(result.stdout) as Docker.SwarmInfo),
956
+ ),
957
+ leave: (force, context) =>
958
+ run([...formatArgs({ context }), "swarm", "leave", ...(force ? ["--force"] : [])]),
959
+ },
960
+ service: {
961
+ create: ({ context, image, command, args, ...options }) =>
962
+ run([
963
+ ...formatArgs({ context }),
964
+ "service",
965
+ "create",
966
+ ...formatArgs({ ...options }),
967
+ image,
968
+ ...(command ?? []),
969
+ ...(args ?? []),
970
+ ]),
971
+ update: ({ context, id, ...options }) =>
972
+ run([
973
+ ...formatArgs({ context }),
974
+ "service",
975
+ "update",
976
+ "--detach=false",
977
+ ...formatArgs({ ...options }),
978
+ id,
979
+ ]),
980
+ inspect: (id, context) =>
981
+ runInspect([...formatArgs({ context }), "service", "inspect", id]),
982
+ remove: (id, context) => run([...formatArgs({ context }), "service", "rm", id]),
983
+ },
984
+ });
985
+ });
986
+
987
+ /** The Docker CLI client, running `docker` (or `DOCKER_BIN`). */
988
+ export const DockerLive = dockerLive();
989
+
990
+ export const dockerContextName = (context: Docker.ContextRef | undefined): string | undefined => {
991
+ const value = typeof context === "string" ? context : context?.name;
992
+ const normalized = value?.trim();
993
+ return normalized && normalized.length > 0 ? normalized : undefined;
994
+ };
995
+
996
+ /**
997
+ * Resolve an {@link Docker.EngineRef} to a context name. A `Docker.Swarm`
998
+ * reference (narrowed by its `nodeId` attribute) contributes the context the
999
+ * swarm was initialized on.
1000
+ */
1001
+ export const dockerEngineContextName = (ref: Docker.EngineRef | undefined): string | undefined => {
1002
+ if (typeof ref === "object" && ref !== null && "nodeId" in ref) {
1003
+ return dockerContextName((ref as { context?: string }).context);
1004
+ }
1005
+ return dockerContextName(ref as Docker.ContextRef | undefined);
1006
+ };
1007
+
1008
+ export const dockerPhysicalName = (
1009
+ id: string,
1010
+ props: { name?: string } | undefined,
1011
+ instanceId: string,
1012
+ // Swarm service names are capped at 63 characters; other Docker object
1013
+ // names accept much longer values.
1014
+ maxLength = 128,
1015
+ ) =>
1016
+ props?.name
1017
+ ? Effect.succeed(props.name)
1018
+ : createPhysicalName({ id, instanceId, maxLength, lowercase: true });
1019
+
1020
+ /**
1021
+ * A failing `docker` command splits its diagnosis over both streams: the
1022
+ * builder writes the step log to one and the reason it stopped to the other,
1023
+ * and which carries which depends on the builder, the progress mode and
1024
+ * whether the output is a terminal. A build that fails inside a `RUN` step
1025
+ * therefore reports nothing but its exit code when only `stderr` is kept.
1026
+ * Keep both, the reason first, so the error says why the build failed.
1027
+ */
1028
+ const failureOutput = (stderr: string, stdout: string) => {
1029
+ const output = [stderr, stdout].filter((text) => text.length > 0);
1030
+ return output.length > 0 ? output.join("\n") : "the command wrote no output.";
1031
+ };
1032
+
1033
+ /** Constructs a PlatformError from a command execution result. */
1034
+ const systemError = (input: {
1035
+ _tag: SystemErrorTag;
1036
+ args: Array<string>;
1037
+ description?: string;
1038
+ cause?: unknown;
1039
+ }) =>
1040
+ new PlatformError(
1041
+ new SystemError({
1042
+ _tag: input._tag,
1043
+ module: "Docker",
1044
+ method: input.args.slice(0, 2).join("."),
1045
+ pathOrDescriptor: input.args.join(" "),
1046
+ description: input.description,
1047
+ cause: input.cause,
1048
+ }),
1049
+ );
1050
+
1051
+ /** Formats a set of options into a list of command-line arguments. */
1052
+ const formatArgs = (
1053
+ options: Record<
1054
+ string,
1055
+ boolean | string | number | undefined | Record<string, string> | Array<string>
1056
+ >,
1057
+ ) => {
1058
+ const args: Array<string> = [];
1059
+ for (const [key, value] of Object.entries(options)) {
1060
+ if (!value) continue;
1061
+ const prefix = key.length > 1 ? `--${key}` : `-${key}`;
1062
+ if (value === true) {
1063
+ args.push(prefix);
1064
+ } else if (typeof value === "string") {
1065
+ args.push(prefix, value);
1066
+ } else if (typeof value === "number") {
1067
+ args.push(prefix, String(value));
1068
+ } else if (Array.isArray(value)) {
1069
+ for (const item of value) {
1070
+ args.push(prefix, item);
1071
+ }
1072
+ } else if (value !== null && typeof value === "object") {
1073
+ for (const [k, v] of Object.entries(value)) {
1074
+ args.push(prefix, `${k}=${v}`);
1075
+ }
1076
+ }
1077
+ }
1078
+ return args;
1079
+ };