alchemy 0.85.0 → 0.85.2

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 (84) hide show
  1. package/bin/alchemy.js +32 -23
  2. package/lib/cloudflare/account-api-token.d.ts +5 -0
  3. package/lib/cloudflare/account-api-token.d.ts.map +1 -1
  4. package/lib/cloudflare/account-api-token.js +13 -2
  5. package/lib/cloudflare/account-api-token.js.map +1 -1
  6. package/lib/cloudflare/ai-crawler.d.ts +27 -0
  7. package/lib/cloudflare/ai-crawler.d.ts.map +1 -0
  8. package/lib/cloudflare/ai-crawler.js +75 -0
  9. package/lib/cloudflare/ai-crawler.js.map +1 -0
  10. package/lib/cloudflare/ai-search-token.d.ts +150 -0
  11. package/lib/cloudflare/ai-search-token.d.ts.map +1 -0
  12. package/lib/cloudflare/ai-search-token.js +171 -0
  13. package/lib/cloudflare/ai-search-token.js.map +1 -0
  14. package/lib/cloudflare/ai-search.d.ts +397 -0
  15. package/lib/cloudflare/ai-search.d.ts.map +1 -0
  16. package/lib/cloudflare/ai-search.js +307 -0
  17. package/lib/cloudflare/ai-search.js.map +1 -0
  18. package/lib/cloudflare/auth.d.ts +3 -2
  19. package/lib/cloudflare/auth.d.ts.map +1 -1
  20. package/lib/cloudflare/auth.js +17 -14
  21. package/lib/cloudflare/auth.js.map +1 -1
  22. package/lib/cloudflare/bucket-custom-domain.d.ts.map +1 -1
  23. package/lib/cloudflare/bucket-custom-domain.js +4 -2
  24. package/lib/cloudflare/bucket-custom-domain.js.map +1 -1
  25. package/lib/cloudflare/bucket.d.ts.map +1 -1
  26. package/lib/cloudflare/bucket.js +8 -2
  27. package/lib/cloudflare/bucket.js.map +1 -1
  28. package/lib/cloudflare/index.d.ts +3 -0
  29. package/lib/cloudflare/index.d.ts.map +1 -1
  30. package/lib/cloudflare/index.js +3 -0
  31. package/lib/cloudflare/index.js.map +1 -1
  32. package/lib/cloudflare/permission-groups.d.ts +5 -0
  33. package/lib/cloudflare/permission-groups.d.ts.map +1 -1
  34. package/lib/cloudflare/permission-groups.js +6 -0
  35. package/lib/cloudflare/permission-groups.js.map +1 -1
  36. package/lib/cloudflare/sveltekit/plugin.d.ts.map +1 -1
  37. package/lib/cloudflare/sveltekit/plugin.js +11 -5
  38. package/lib/cloudflare/sveltekit/plugin.js.map +1 -1
  39. package/lib/cloudflare/vite/vite.d.ts.map +1 -1
  40. package/lib/cloudflare/vite/vite.js +2 -1
  41. package/lib/cloudflare/vite/vite.js.map +1 -1
  42. package/lib/docker/api.d.ts +33 -2
  43. package/lib/docker/api.d.ts.map +1 -1
  44. package/lib/docker/api.js +8 -4
  45. package/lib/docker/api.js.map +1 -1
  46. package/lib/docker/container.d.ts +1 -1
  47. package/lib/docker/container.d.ts.map +1 -1
  48. package/lib/docker/container.js +244 -15
  49. package/lib/docker/container.js.map +1 -1
  50. package/lib/scope.d.ts +1 -1
  51. package/lib/scope.d.ts.map +1 -1
  52. package/lib/scope.js +10 -5
  53. package/lib/scope.js.map +1 -1
  54. package/lib/test/bun.d.ts +17 -0
  55. package/lib/test/bun.d.ts.map +1 -1
  56. package/lib/test/bun.js +2 -1
  57. package/lib/test/bun.js.map +1 -1
  58. package/lib/test/vitest.d.ts +17 -0
  59. package/lib/test/vitest.d.ts.map +1 -1
  60. package/lib/test/vitest.js +4 -1
  61. package/lib/test/vitest.js.map +1 -1
  62. package/lib/util/snake-to-camel.d.ts +21 -0
  63. package/lib/util/snake-to-camel.d.ts.map +1 -0
  64. package/lib/util/snake-to-camel.js +34 -0
  65. package/lib/util/snake-to-camel.js.map +1 -0
  66. package/package.json +1 -1
  67. package/src/cloudflare/account-api-token.ts +23 -6
  68. package/src/cloudflare/ai-crawler.ts +89 -0
  69. package/src/cloudflare/ai-search-token.ts +324 -0
  70. package/src/cloudflare/ai-search.ts +952 -0
  71. package/src/cloudflare/auth.ts +17 -14
  72. package/src/cloudflare/bucket-custom-domain.ts +4 -1
  73. package/src/cloudflare/bucket.ts +9 -2
  74. package/src/cloudflare/index.ts +3 -0
  75. package/src/cloudflare/permission-groups.ts +6 -0
  76. package/src/cloudflare/sveltekit/plugin.ts +17 -8
  77. package/src/cloudflare/vite/vite.ts +4 -2
  78. package/src/docker/api.ts +47 -4
  79. package/src/docker/container.ts +318 -18
  80. package/src/scope.ts +14 -5
  81. package/src/test/bun.ts +26 -1
  82. package/src/test/vitest.ts +34 -1
  83. package/src/util/snake-to-camel.ts +59 -0
  84. package/workers/tunnel-proxy.js +1 -1
@@ -75,8 +75,9 @@ export namespace CloudflareAuth {
75
75
  "queues:write": "See and change Cloudflare Queues settings and data",
76
76
  "r2_catalog:write": "Grants write level access to R2 Data Catalog",
77
77
  "radar:read": "Grants access to read Cloudflare Radar data",
78
- "rag:read": "Grants read level access to Auto Rag",
79
- "rag:write": "Grants write level access to Auto Rag",
78
+ "ai-search:read": "Grants read level access to AI Search",
79
+ "ai-search:write": "Grants write level access to AI Search",
80
+ "ai-search:run": "Grants run level access to AI Search",
80
81
  "secrets_store:read": "Grants read level access to Secrets Store",
81
82
  "secrets_store:write": "Grants write level access to Secrets Store",
82
83
  "ssl_certs:write":
@@ -120,24 +121,26 @@ export namespace CloudflareAuth {
120
121
  export type Scope = keyof typeof ALL_SCOPES;
121
122
  export const DEFAULT_SCOPES = [
122
123
  "account:read",
124
+ "ai-search:write",
125
+ "ai-search:run",
126
+ "ai:write",
127
+ "cloudchamber:write",
128
+ "connectivity:admin",
129
+ "containers:write",
130
+ "d1:write",
131
+ "pages:write",
132
+ "pipelines:write",
133
+ "queues:write",
134
+ "secrets_store:write",
135
+ "ssl_certs:write",
123
136
  "user:read",
124
- "workers:write",
137
+ "vectorize:write",
125
138
  "workers_kv:write",
126
139
  "workers_routes:write",
127
140
  "workers_scripts:write",
128
141
  "workers_tail:read",
129
- "d1:write",
130
- "pages:write",
142
+ "workers:write",
131
143
  "zone:read",
132
- "ssl_certs:write",
133
- "ai:write",
134
- "queues:write",
135
- "pipelines:write",
136
- "secrets_store:write",
137
- "containers:write",
138
- "cloudchamber:write",
139
- "vectorize:write",
140
- "connectivity:admin",
141
144
  ];
142
145
 
143
146
  /**
@@ -100,6 +100,8 @@ export type R2BucketCustomDomain = {
100
100
  minTLS?: "1.0" | "1.1" | "1.2" | "1.3" | (string & {});
101
101
  };
102
102
 
103
+ const DEV_NOOP_ZONE = "noop-zone";
104
+
103
105
  export const R2BucketCustomDomain = Resource(
104
106
  "cloudflare::R2BucketCustomDomain",
105
107
  async function (
@@ -114,7 +116,7 @@ export const R2BucketCustomDomain = Resource(
114
116
  zoneId:
115
117
  typeof props.zone === "string"
116
118
  ? props.zone
117
- : (props.zone?.id ?? "noop-zone"),
119
+ : (props.zone?.id ?? DEV_NOOP_ZONE),
118
120
  ciphers: props.ciphers,
119
121
  minTLS: props.minTLS,
120
122
  };
@@ -158,6 +160,7 @@ export const R2BucketCustomDomain = Resource(
158
160
  }
159
161
  case "update": {
160
162
  if (
163
+ this.output.zoneId !== DEV_NOOP_ZONE && // dev mode placeholder should not trigger replacement
161
164
  diff(this.output, payload).some(
162
165
  (property) => property === "domain" || property === "zoneId",
163
166
  )
@@ -689,6 +689,13 @@ const _R2Bucket = Resource(
689
689
 
690
690
  if (this.phase === "delete") {
691
691
  if (props.delete !== false) {
692
+ // Use bucket's actual jurisdiction from output, falling back to props
693
+ // This ensures EU/FedRAMP buckets are correctly addressed during cleanup
694
+ const deleteProps: BucketProps = {
695
+ ...props,
696
+ jurisdiction: this.output?.jurisdiction ?? props.jurisdiction,
697
+ };
698
+
692
699
  if (this.output?.catalog) {
693
700
  await disableDataCatalog(api, bucketName);
694
701
  }
@@ -696,9 +703,9 @@ const _R2Bucket = Resource(
696
703
  await deleteMiniflareBinding(this.scope, "r2", this.output.dev.id);
697
704
  }
698
705
  if (props.empty) {
699
- await emptyBucket(api, bucketName, props);
706
+ await emptyBucket(api, bucketName, deleteProps);
700
707
  }
701
- await deleteBucket(api, bucketName, props);
708
+ await deleteBucket(api, bucketName, deleteProps);
702
709
  }
703
710
  return this.destroy();
704
711
  }
@@ -2,7 +2,10 @@
2
2
 
3
3
  export * from "./account-api-token.ts";
4
4
  export * from "./account-id.ts";
5
+ export * from "./ai-crawler.ts";
5
6
  export * from "./ai-gateway.ts";
7
+ export * from "./ai-search-token.ts";
8
+ export * from "./ai-search.ts";
6
9
  export * from "./ai.ts";
7
10
  export * from "./analytics-engine.ts";
8
11
  export * from "./api-error.ts";
@@ -98,6 +98,12 @@ export const PERMISSION_GROUPS = [
98
98
  description: "Grants access to reading AI Gateways",
99
99
  scopes: ["com.cloudflare.api.account"],
100
100
  },
101
+ {
102
+ id: "9e9b428a0bcd46fd80e580b46a69963c",
103
+ name: "AI Search Index Engine",
104
+ description: "Grants access to run AI Search Index Engine",
105
+ scopes: ["com.cloudflare.api.account"],
106
+ },
101
107
  {
102
108
  id: "644535f4ed854494a59cb289d634b257",
103
109
  name: "AI Gateway Run",
@@ -2,17 +2,11 @@ import adapter, { type AdapterOptions } from "@sveltejs/adapter-cloudflare";
2
2
  import type { Adapter } from "@sveltejs/kit";
3
3
  import { getPlatformProxyOptions } from "../cloudflare-env-proxy.ts";
4
4
 
5
- const isSvelteLanguageServer = !!process.argv.find((arg) =>
6
- arg.includes("svelte-language-server"),
7
- );
8
-
9
5
  export default (options?: AdapterOptions): Adapter => {
10
- if (isSvelteLanguageServer) {
11
- // the svelte language server runs from the root and therefore may be running in a different cwd than the `Website.cwd`
12
- // for now, we work around this by returning a noop adapter to avoid breaking svelte intellisense
6
+ if (shouldDisable()) {
13
7
  return {
14
- adapt() {},
15
8
  name: "alchemy-noop",
9
+ adapt() {},
16
10
  };
17
11
  }
18
12
  const { platformProxy: proxyOptions, ...config } = options ?? {};
@@ -23,3 +17,18 @@ export default (options?: AdapterOptions): Adapter => {
23
17
  ...config,
24
18
  });
25
19
  };
20
+
21
+ /**
22
+ * Return true for `svelte-language-server`, `svelte-check`, and `svelte-kit sync`. This is because:
23
+ * - The svelte language server runs from the root and therefore may be running in a different cwd than the `Website.cwd`, so disable to avoid breaking intellisense
24
+ * - The `svelte-check` and `svelte-kit sync` commands do not require Cloudflare-specific configuration, so disable to avoid breaking the commands
25
+ */
26
+ function shouldDisable(argv = process.argv): boolean {
27
+ return (
28
+ argv.some(
29
+ (arg) =>
30
+ arg.includes("svelte-check") || arg.includes("svelte-language-server"),
31
+ ) ||
32
+ (argv.some((arg) => arg.includes("svelte-kit")) && argv.includes("sync"))
33
+ );
34
+ }
@@ -2,6 +2,7 @@ import path from "pathe";
2
2
  import { getPackageManagerRunner } from "../../util/detect-package-manager.ts";
3
3
  import type { Assets } from "../assets.ts";
4
4
  import type { Bindings } from "../bindings.ts";
5
+ import { withSkipPathValidation } from "../miniflare/paths.ts";
5
6
  import {
6
7
  spreadBuildProps,
7
8
  spreadDevProps,
@@ -34,8 +35,9 @@ export async function Vite<B extends Bindings>(
34
35
  port = args[index + 1];
35
36
  } else {
36
37
  try {
37
- const config = await import(
38
- path.resolve(props.cwd ?? process.cwd(), "vite.config.ts")
38
+ const config = await withSkipPathValidation(
39
+ () =>
40
+ import(path.resolve(props.cwd ?? process.cwd(), "vite.config.ts")),
39
41
  );
40
42
  port = config.default?.server?.port ?? 5173;
41
43
  } catch {}
package/src/docker/api.ts CHANGED
@@ -69,10 +69,44 @@ type VolumeInfo = {
69
69
  Scope: string;
70
70
  };
71
71
 
72
- type ContainerInfo = {
72
+ export type ContainerInfo = {
73
73
  Id: string;
74
74
  State: { Status: "created" | "running" | "paused" | "stopped" | "exited" };
75
75
  Created: string;
76
+ Config: {
77
+ Image: string;
78
+ Cmd: string[] | null;
79
+ Env: string[] | null;
80
+ Healthcheck?: {
81
+ Test: string[] | null;
82
+ Interval?: number;
83
+ Timeout?: number;
84
+ Retries?: number;
85
+ StartPeriod?: number;
86
+ StartInterval?: number;
87
+ } | null;
88
+ };
89
+ HostConfig: {
90
+ PortBindings: Record<
91
+ string,
92
+ Array<{ HostIp: string; HostPort: string }> | null
93
+ > | null;
94
+ Binds: string[] | null;
95
+ RestartPolicy: {
96
+ Name: string;
97
+ MaximumRetryCount: number;
98
+ };
99
+ AutoRemove: boolean;
100
+ };
101
+ NetworkSettings: {
102
+ Networks: Record<
103
+ string,
104
+ {
105
+ NetworkID: string;
106
+ Aliases: string[] | null;
107
+ }
108
+ > | null;
109
+ };
76
110
  };
77
111
 
78
112
  /**
@@ -103,6 +137,7 @@ export class DockerApi {
103
137
  async exec(
104
138
  args: string[],
105
139
  remainingAttempts = 3,
140
+ quiet = false,
106
141
  ): Promise<{ stdout: string; stderr: string }> {
107
142
  // If a custom config directory is provided, ensure all commands use it by
108
143
  // setting the DOCKER_CONFIG env variable for the spawned process.
@@ -124,13 +159,17 @@ export class DockerApi {
124
159
 
125
160
  // Stream stdout in real-time
126
161
  subprocess.stdout?.on("data", (chunk: string) => {
127
- process.stdout.write(chunk);
162
+ if (!quiet) {
163
+ process.stdout.write(chunk);
164
+ }
128
165
  stdout += chunk;
129
166
  });
130
167
 
131
168
  // Stream stderr in real-time
132
169
  subprocess.stderr?.on("data", (chunk: string) => {
133
- process.stderr.write(chunk);
170
+ if (!quiet) {
171
+ process.stderr.write(chunk);
172
+ }
134
173
  stderr += chunk;
135
174
  });
136
175
 
@@ -364,7 +403,11 @@ export class DockerApi {
364
403
  * @returns Container details in JSON format
365
404
  */
366
405
  async inspectContainer(containerId: string): Promise<ContainerInfo[]> {
367
- const { stdout } = await this.exec(["container", "inspect", containerId]);
406
+ const { stdout } = await this.exec(
407
+ ["container", "inspect", containerId],
408
+ undefined,
409
+ true,
410
+ );
368
411
  try {
369
412
  return JSON.parse(stdout.trim()) as ContainerInfo[];
370
413
  } catch (_error) {
@@ -1,6 +1,6 @@
1
1
  import type { Context } from "../context.ts";
2
2
  import { Resource } from "../resource.ts";
3
- import { DockerApi } from "./api.ts";
3
+ import { DockerApi, normalizeDuration, type ContainerInfo } from "./api.ts";
4
4
  import type { Image } from "./image.ts";
5
5
  import type { RemoteImage } from "./remote-image.ts";
6
6
 
@@ -201,7 +201,7 @@ export interface Container extends ContainerProps {
201
201
  /**
202
202
  * Container state
203
203
  */
204
- state?: "created" | "running" | "paused" | "stopped" | "exited";
204
+ state: "created" | "running" | "paused" | "stopped" | "exited";
205
205
 
206
206
  /**
207
207
  * Time when the container was created
@@ -277,6 +277,7 @@ export interface Container extends ContainerProps {
277
277
  */
278
278
  export const Container = Resource(
279
279
  "docker::Container",
280
+ { alwaysUpdate: true },
280
281
  async function (
281
282
  this: Context<Container>,
282
283
  id: string,
@@ -311,39 +312,63 @@ export const Container = Resource(
311
312
  return this.destroy();
312
313
  }
313
314
 
314
- let containerState: NonNullable<Container["state"]> = "created";
315
+ let containerState: Container["state"] = "created";
315
316
 
316
317
  // Check if container already exists
317
318
  const containerExists = await api.containerExists(containerName);
318
319
 
319
320
  if (containerExists) {
320
- if (this.phase === "update") {
321
- // Remove existing container for update
322
- await api.removeContainer(containerName, true);
321
+ // Create phase - check for adoption
322
+ if (this.phase === "create" && !props.adopt) {
323
+ throw new Error(
324
+ `Container "${containerName}" already exists. Use adopt: true to adopt it.`,
325
+ );
326
+ }
327
+
328
+ const [containerInfo] = await api.inspectContainer(containerName);
329
+
330
+ // Compute what changes are needed
331
+ if (shouldReplace(imageRef, props, containerInfo)) {
332
+ // Need to recreate - remove existing container
333
+ if (this.phase === "update") {
334
+ // In update phase, we can replace the resource
335
+ // Force because we need to delete the old one first if the name is the same
336
+ return this.replace(true);
337
+ } else {
338
+ // In create phase, we cannot replace the resource, so manually delete instead
339
+ await api.removeContainer(containerName, true);
340
+ }
323
341
  } else {
324
- // Create phase - check for adoption
325
- if (!props.adopt) {
326
- throw new Error(
327
- `Container "${containerName}" already exists. Use adopt: true to adopt it.`,
328
- );
342
+ // Apply incremental changes without recreating the container
343
+
344
+ const { toConnect, toDisconnect } = getNetworkChanges(
345
+ props,
346
+ containerInfo,
347
+ );
348
+
349
+ // Apply network disconnections
350
+ for (const network of toDisconnect) {
351
+ await api.disconnectNetwork(containerInfo.Id, network);
329
352
  }
330
353
 
331
- // Adopt existing container
332
- const containerInfos = await api.inspectContainer(containerName);
333
- const containerInfo = containerInfos[0];
334
- let adoptedState = containerInfo.State.Status;
354
+ // Apply network connections
355
+ for (const network of toConnect) {
356
+ await api.connectNetwork(containerInfo.Id, network.name, {
357
+ aliases: network.aliases,
358
+ });
359
+ }
335
360
 
336
361
  // Optionally start the container if requested
337
362
  if (props.start && containerInfo.State.Status !== "running") {
338
- await api.startContainer(containerName);
339
- adoptedState = "running";
363
+ await api.startContainer(containerInfo.Id);
364
+ containerState = "running";
340
365
  }
341
366
 
342
367
  return {
343
368
  ...props,
344
369
  id: containerInfo.Id,
345
370
  name: containerName,
346
- state: adoptedState,
371
+ state: containerState,
347
372
  createdAt: new Date(containerInfo.Created).getTime(),
348
373
  };
349
374
  }
@@ -402,3 +427,278 @@ export const Container = Resource(
402
427
  };
403
428
  },
404
429
  );
430
+
431
+ function getNetworkChanges(
432
+ props: ContainerProps,
433
+ containerInfo: ContainerInfo,
434
+ ): { toConnect: NetworkMapping[]; toDisconnect: string[] } {
435
+ const currentNetworks = new Set(
436
+ Object.keys(containerInfo.NetworkSettings.Networks || {}),
437
+ );
438
+ const desiredNetworks = new Map(
439
+ (props.networks || []).map((n) => [n.name, n]),
440
+ );
441
+ const toConnect: NetworkMapping[] = [];
442
+ const toDisconnect: string[] = [];
443
+ for (const network of currentNetworks) {
444
+ if (!desiredNetworks.has(network) && network !== "bridge") {
445
+ toDisconnect.push(network);
446
+ }
447
+ }
448
+ for (const [name, config] of desiredNetworks) {
449
+ if (!currentNetworks.has(name)) {
450
+ toConnect.push(config);
451
+ }
452
+ }
453
+ return { toConnect, toDisconnect };
454
+ }
455
+
456
+ function shouldReplace(
457
+ imageRef: string,
458
+ props: ContainerProps,
459
+ containerInfo: ContainerInfo,
460
+ ): boolean {
461
+ // Check immutable properties that require recreation
462
+
463
+ // Image change - compare the image ID/digest if available
464
+ // The container stores the resolved image ID, so we compare against imageRef
465
+ if (containerInfo.Config.Image !== imageRef) {
466
+ return true;
467
+ }
468
+
469
+ // Command change
470
+ const containerCmd = containerInfo.Config.Cmd || [];
471
+ if (
472
+ props.command && // only compare if command is set; otherwise we'd be comparing against the image's default command
473
+ (props.command.length !== containerCmd.length ||
474
+ !props.command.every((c, i) => c === containerCmd[i]))
475
+ ) {
476
+ return true;
477
+ }
478
+
479
+ // Environment variables
480
+ if (!compareEnv(props.environment, containerInfo.Config.Env)) {
481
+ return true;
482
+ }
483
+
484
+ // Port bindings
485
+ if (!comparePorts(props.ports, containerInfo.HostConfig.PortBindings)) {
486
+ return true;
487
+ }
488
+
489
+ // Volume bindings
490
+ if (!compareVolumes(props.volumes, containerInfo.HostConfig.Binds)) {
491
+ return true;
492
+ }
493
+
494
+ // Healthcheck
495
+ if (
496
+ !compareHealthcheck(props.healthcheck, containerInfo.Config.Healthcheck)
497
+ ) {
498
+ return true;
499
+ }
500
+
501
+ // Restart policy
502
+ if (
503
+ !compareRestartPolicy(props.restart, containerInfo.HostConfig.RestartPolicy)
504
+ ) {
505
+ return true;
506
+ }
507
+
508
+ // AutoRemove (removeOnExit)
509
+ if ((props.removeOnExit || false) !== containerInfo.HostConfig.AutoRemove) {
510
+ return true;
511
+ }
512
+
513
+ return false;
514
+ }
515
+
516
+ /**
517
+ * Normalize port mappings to a comparable format
518
+ * @internal
519
+ */
520
+ function normalizePortMappings(
521
+ ports: PortMapping[] | undefined,
522
+ ): Map<string, string> {
523
+ const map = new Map<string, string>();
524
+ if (!ports) return map;
525
+ for (const port of ports) {
526
+ const protocol = port.protocol || "tcp";
527
+ map.set(`${port.external}`, `${port.internal}/${protocol}`);
528
+ }
529
+ return map;
530
+ }
531
+
532
+ /**
533
+ * Normalize volume mappings to a comparable format
534
+ * @internal
535
+ */
536
+ function normalizeVolumeMappings(
537
+ volumes: VolumeMapping[] | undefined,
538
+ ): Set<string> {
539
+ const set = new Set<string>();
540
+ if (!volumes) return set;
541
+ for (const volume of volumes) {
542
+ const readOnlyFlag = volume.readOnly ? ":ro" : "";
543
+ set.add(`${volume.hostPath}:${volume.containerPath}${readOnlyFlag}`);
544
+ }
545
+ return set;
546
+ }
547
+
548
+ /**
549
+ * Compare environment variables
550
+ * @internal
551
+ */
552
+ function compareEnv(
553
+ propsEnv: Record<string, string> | undefined,
554
+ containerEnv: string[] | null,
555
+ ): boolean {
556
+ const propsEntries = Object.entries(propsEnv || {}).sort(([a], [b]) =>
557
+ a.localeCompare(b),
558
+ );
559
+ const containerEntries = (containerEnv || [])
560
+ .map((e) => {
561
+ const idx = e.indexOf("=");
562
+ return idx >= 0 ? ([e.slice(0, idx), e.slice(idx + 1)] as const) : null;
563
+ })
564
+ .filter((e): e is [string, string] => e !== null)
565
+ // Filter out PATH and other system env vars that Docker adds
566
+ .filter(([key]) => propsEnv && key in propsEnv)
567
+ .sort(([a], [b]) => a.localeCompare(b));
568
+
569
+ if (propsEntries.length !== containerEntries.length) return false;
570
+ for (let i = 0; i < propsEntries.length; i++) {
571
+ if (
572
+ propsEntries[i][0] !== containerEntries[i][0] ||
573
+ propsEntries[i][1] !== containerEntries[i][1]
574
+ ) {
575
+ return false;
576
+ }
577
+ }
578
+ return true;
579
+ }
580
+
581
+ /**
582
+ * Compare port bindings
583
+ * @internal
584
+ */
585
+ function comparePorts(
586
+ propsPorts: PortMapping[] | undefined,
587
+ containerPorts: Record<
588
+ string,
589
+ Array<{ HostIp: string; HostPort: string }> | null
590
+ > | null,
591
+ ): boolean {
592
+ const propsMap = normalizePortMappings(propsPorts);
593
+
594
+ // Extract container port mappings
595
+ const containerMap = new Map<string, string>();
596
+ if (containerPorts) {
597
+ for (const [containerPort, bindings] of Object.entries(containerPorts)) {
598
+ if (bindings && bindings.length > 0) {
599
+ containerMap.set(bindings[0].HostPort, containerPort);
600
+ }
601
+ }
602
+ }
603
+
604
+ if (propsMap.size !== containerMap.size) return false;
605
+ for (const [hostPort, containerPort] of propsMap) {
606
+ if (containerMap.get(hostPort) !== containerPort) return false;
607
+ }
608
+ return true;
609
+ }
610
+
611
+ /**
612
+ * Compare volume bindings
613
+ * @internal
614
+ */
615
+ function compareVolumes(
616
+ propsVolumes: VolumeMapping[] | undefined,
617
+ containerBinds: string[] | null,
618
+ ): boolean {
619
+ const propsSet = normalizeVolumeMappings(propsVolumes);
620
+ const containerSet = new Set(containerBinds || []);
621
+
622
+ if (propsSet.size !== containerSet.size) return false;
623
+ for (const bind of propsSet) {
624
+ if (!containerSet.has(bind)) return false;
625
+ }
626
+ return true;
627
+ }
628
+
629
+ /**
630
+ * Compare healthcheck configuration
631
+ * @internal
632
+ */
633
+ function compareHealthcheck(
634
+ propsHc: HealthcheckConfig | undefined,
635
+ containerHc:
636
+ | {
637
+ Test: string[] | null;
638
+ Interval?: number;
639
+ Timeout?: number;
640
+ Retries?: number;
641
+ StartPeriod?: number;
642
+ StartInterval?: number;
643
+ }
644
+ | null
645
+ | undefined,
646
+ ): boolean {
647
+ // Both undefined/null
648
+ if (!propsHc && !containerHc) return true;
649
+ // One defined, one not
650
+ if (!propsHc || !containerHc) return false;
651
+
652
+ // Compare command
653
+ const propsCmd = Array.isArray(propsHc.cmd)
654
+ ? propsHc.cmd.join(" ")
655
+ : propsHc.cmd;
656
+ const containerCmd = containerHc.Test
657
+ ? containerHc.Test.slice(1).join(" ")
658
+ : "";
659
+ if (propsCmd !== containerCmd) return false;
660
+
661
+ // Helper to convert Duration to nanoseconds for comparison
662
+ const toNanos = (d: Duration | undefined): number => {
663
+ if (d === undefined) return 0;
664
+ const str = normalizeDuration(d);
665
+ const match = str.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/);
666
+ if (!match) return 0;
667
+ const value = parseFloat(match[1]);
668
+ const unit = match[2];
669
+ switch (unit) {
670
+ case "ms":
671
+ return value * 1_000_000;
672
+ case "s":
673
+ return value * 1_000_000_000;
674
+ case "m":
675
+ return value * 60 * 1_000_000_000;
676
+ case "h":
677
+ return value * 3600 * 1_000_000_000;
678
+ default:
679
+ return 0;
680
+ }
681
+ };
682
+
683
+ if (toNanos(propsHc.interval) !== (containerHc.Interval || 0)) return false;
684
+ if (toNanos(propsHc.timeout) !== (containerHc.Timeout || 0)) return false;
685
+ if ((propsHc.retries || 0) !== (containerHc.Retries || 0)) return false;
686
+ if (toNanos(propsHc.startPeriod) !== (containerHc.StartPeriod || 0))
687
+ return false;
688
+ if (toNanos(propsHc.startInterval) !== (containerHc.StartInterval || 0))
689
+ return false;
690
+
691
+ return true;
692
+ }
693
+
694
+ /**
695
+ * Compare restart policy
696
+ * @internal
697
+ */
698
+ function compareRestartPolicy(
699
+ propsRestart: ContainerProps["restart"] | undefined,
700
+ containerRestart: { Name: string; MaximumRetryCount: number },
701
+ ): boolean {
702
+ const propsPolicy = propsRestart || "no";
703
+ return propsPolicy === containerRestart.Name;
704
+ }
package/src/scope.ts CHANGED
@@ -342,12 +342,21 @@ export class Scope {
342
342
  return state !== undefined && (type === undefined || state.kind === type);
343
343
  }
344
344
 
345
- public createPhysicalName(id: string, delimiter = "-"): string {
346
- const app = this.appName;
347
- const stage = this.stage;
348
- return [app, ...this.chain.slice(2), id, stage]
349
- .map((s) => s.replaceAll(/[^a-z0-9_-]/gi, delimiter))
345
+ public createPhysicalName(
346
+ id: string,
347
+ delimiter = "-",
348
+ maxLength?: number,
349
+ ): string {
350
+ let name = [this.appName, ...this.chain.slice(2), id, this.stage]
351
+ .map((part) => part.replaceAll(/[^a-z0-9_-]/gi, delimiter))
350
352
  .join(delimiter);
353
+ while (maxLength && name.length > maxLength) {
354
+ name = name
355
+ .split(delimiter)
356
+ .map((part) => part.slice(0, -1))
357
+ .join(delimiter);
358
+ }
359
+ return name;
351
360
  }
352
361
 
353
362
  public async spawn<