alchemy 0.92.2 → 0.93.1

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 (82) hide show
  1. package/bin/alchemy.js +2 -2
  2. package/lib/cloudflare/ai-search-namespace.d.ts +150 -0
  3. package/lib/cloudflare/ai-search-namespace.d.ts.map +1 -0
  4. package/lib/cloudflare/ai-search-namespace.js +278 -0
  5. package/lib/cloudflare/ai-search-namespace.js.map +1 -0
  6. package/lib/cloudflare/ai-search-token.d.ts +11 -1
  7. package/lib/cloudflare/ai-search-token.d.ts.map +1 -1
  8. package/lib/cloudflare/ai-search-token.js +35 -7
  9. package/lib/cloudflare/ai-search-token.js.map +1 -1
  10. package/lib/cloudflare/ai-search.d.ts +123 -18
  11. package/lib/cloudflare/ai-search.d.ts.map +1 -1
  12. package/lib/cloudflare/ai-search.js +222 -68
  13. package/lib/cloudflare/ai-search.js.map +1 -1
  14. package/lib/cloudflare/bindings.d.ts +29 -2
  15. package/lib/cloudflare/bindings.d.ts.map +1 -1
  16. package/lib/cloudflare/bindings.js.map +1 -1
  17. package/lib/cloudflare/bound.d.ts +4 -2
  18. package/lib/cloudflare/bound.d.ts.map +1 -1
  19. package/lib/cloudflare/container.d.ts +79 -6
  20. package/lib/cloudflare/container.d.ts.map +1 -1
  21. package/lib/cloudflare/container.js +130 -9
  22. package/lib/cloudflare/container.js.map +1 -1
  23. package/lib/cloudflare/custom-domain.d.ts +14 -0
  24. package/lib/cloudflare/custom-domain.d.ts.map +1 -1
  25. package/lib/cloudflare/custom-domain.js +3 -0
  26. package/lib/cloudflare/custom-domain.js.map +1 -1
  27. package/lib/cloudflare/index.d.ts +1 -0
  28. package/lib/cloudflare/index.d.ts.map +1 -1
  29. package/lib/cloudflare/index.js +1 -0
  30. package/lib/cloudflare/index.js.map +1 -1
  31. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
  32. package/lib/cloudflare/miniflare/build-worker-options.js +43 -0
  33. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
  34. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts.map +1 -1
  35. package/lib/cloudflare/miniflare/remote-binding-proxy.js +31 -13
  36. package/lib/cloudflare/miniflare/remote-binding-proxy.js.map +1 -1
  37. package/lib/cloudflare/website.d.ts +4 -0
  38. package/lib/cloudflare/website.d.ts.map +1 -1
  39. package/lib/cloudflare/website.js +15 -1
  40. package/lib/cloudflare/website.js.map +1 -1
  41. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  42. package/lib/cloudflare/worker-metadata.js +25 -1
  43. package/lib/cloudflare/worker-metadata.js.map +1 -1
  44. package/lib/cloudflare/worker.d.ts +6 -0
  45. package/lib/cloudflare/worker.d.ts.map +1 -1
  46. package/lib/cloudflare/worker.js +3 -0
  47. package/lib/cloudflare/worker.js.map +1 -1
  48. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  49. package/lib/cloudflare/wrangler.json.js +32 -0
  50. package/lib/cloudflare/wrangler.json.js.map +1 -1
  51. package/lib/docker/api.d.ts +4 -1
  52. package/lib/docker/api.d.ts.map +1 -1
  53. package/lib/docker/api.js +8 -2
  54. package/lib/docker/api.js.map +1 -1
  55. package/lib/docker/image.d.ts +1 -1
  56. package/lib/docker/image.d.ts.map +1 -1
  57. package/lib/docker/image.js +4 -44
  58. package/lib/docker/image.js.map +1 -1
  59. package/lib/docker/registry.d.ts +11 -0
  60. package/lib/docker/registry.d.ts.map +1 -0
  61. package/lib/docker/registry.js +41 -0
  62. package/lib/docker/registry.js.map +1 -0
  63. package/package.json +3 -3
  64. package/src/cloudflare/ai-search-namespace.ts +435 -0
  65. package/src/cloudflare/ai-search-token.ts +43 -9
  66. package/src/cloudflare/ai-search.ts +334 -73
  67. package/src/cloudflare/bindings.ts +33 -0
  68. package/src/cloudflare/bound.ts +87 -74
  69. package/src/cloudflare/container.ts +242 -18
  70. package/src/cloudflare/custom-domain.ts +25 -1
  71. package/src/cloudflare/index.ts +1 -0
  72. package/src/cloudflare/miniflare/build-worker-options.ts +50 -1
  73. package/src/cloudflare/miniflare/remote-binding-proxy.ts +58 -34
  74. package/src/cloudflare/website.ts +19 -1
  75. package/src/cloudflare/worker-metadata.ts +25 -1
  76. package/src/cloudflare/worker.ts +9 -0
  77. package/src/cloudflare/wrangler.json.ts +32 -0
  78. package/src/docker/api.ts +11 -2
  79. package/src/docker/image.ts +5 -56
  80. package/src/docker/registry.ts +62 -0
  81. package/workers/cloudflare-state-store.js +57 -57
  82. package/workers/tunnel-proxy.js +1 -1
@@ -4,6 +4,8 @@ import { assertNever } from "../../util/assert-never.ts";
4
4
  import { reservePort } from "../../util/find-open-port.ts";
5
5
  import type { HTTPServer } from "../../util/http.ts";
6
6
  import { logger } from "../../util/logger.ts";
7
+ import { isAiSearchNamespace } from "../ai-search-namespace.ts";
8
+ import { isAiSearch } from "../ai-search.ts";
7
9
  import type { CloudflareApi } from "../api.ts";
8
10
  import type {
9
11
  Binding,
@@ -53,7 +55,13 @@ type RemoteBinding =
53
55
  | Extract<
54
56
  WorkerBindingSpec,
55
57
  { type: "send_email" | "service" | "vpc_service" }
56
- >;
58
+ >
59
+ // AI Search bindings reject the `raw` flag at the Cloudflare API
60
+ // validation layer (10333). Wrangler's upload form deliberately omits
61
+ // `raw` for these two binding types in
62
+ // packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts,
63
+ // so we mirror that here for the remote-binding-proxy upload.
64
+ | Extract<WorkerBindingSpec, { type: "ai_search" | "ai_search_namespace" }>;
57
65
 
58
66
  type BaseWorkerOptions = {
59
67
  [K in keyof miniflare.WorkerOptions]: K extends
@@ -90,6 +98,35 @@ export const buildWorkerOptions = async (
90
98
  (options.bindings ??= {})[key] = binding;
91
99
  continue;
92
100
  }
101
+ if (isAiSearch(binding)) {
102
+ // AI Search instance bindings are not supported natively by Miniflare;
103
+ // proxy them to the deployed instance via the `remote-binding-proxy`
104
+ // worker (same mechanism used by `ai`, `vectorize`, etc.). Instance
105
+ // bindings are always scoped to the default namespace on the CF side,
106
+ // so the namespace need not be surfaced in the remote-proxy metadata.
107
+ //
108
+ // Note: unlike `ai`/`browser`/`vectorize`, AI Search bindings must NOT
109
+ // carry `raw: true` — CF's API rejects that field on `ai_search` and
110
+ // `ai_search_namespace` bindings (error 10333). This mirrors wrangler's
111
+ // create-worker-upload-form.ts which also omits `raw` for these two
112
+ // binding types.
113
+ remoteBindings.push({
114
+ type: "ai_search",
115
+ name: key,
116
+ instance_name: binding.name,
117
+ });
118
+ continue;
119
+ }
120
+ if (isAiSearchNamespace(binding)) {
121
+ // See comment above on `ai_search`: `raw: true` is rejected by CF for
122
+ // this binding type, so we push without it.
123
+ remoteBindings.push({
124
+ type: "ai_search_namespace",
125
+ name: key,
126
+ namespace: binding.namespace,
127
+ });
128
+ continue;
129
+ }
93
130
  if (binding.type === "cloudflare::Worker::Self") {
94
131
  (options.serviceBindings ??= {})[key] = miniflare.kCurrentWorker;
95
132
  continue;
@@ -527,6 +564,18 @@ export const buildWorkerOptions = async (
527
564
  remoteProxyConnectionString: remoteProxy.connectionString,
528
565
  };
529
566
  break;
567
+ case "ai_search":
568
+ (options.aiSearchInstances ??= {})[binding.name] = {
569
+ instance_name: binding.instance_name,
570
+ remoteProxyConnectionString: remoteProxy.connectionString,
571
+ };
572
+ break;
573
+ case "ai_search_namespace":
574
+ (options.aiSearchNamespaces ??= {})[binding.name] = {
575
+ namespace: binding.namespace,
576
+ remoteProxyConnectionString: remoteProxy.connectionString,
577
+ };
578
+ break;
530
579
  default: {
531
580
  assertNever(binding);
532
581
  }
@@ -39,7 +39,12 @@ export async function createRemoteProxyWorker(input: {
39
39
  bindings: WorkerBindingSpec[];
40
40
  }): Promise<RemoteBindingProxy> {
41
41
  const script = await getInternalWorkerBundle("remote-binding-proxy");
42
- const [token, { host, accessToken }] = await Promise.all([
42
+ // We must create the preview token BEFORE probing for Cloudflare Access:
43
+ // a preview worker URL only returns the Access 302 when the request carries
44
+ // the preview token (otherwise CF responds with 404 regardless of Access
45
+ // configuration). Probing without the token would incorrectly report
46
+ // "no Access" for accounts that do gate workers.dev behind Access.
47
+ const [token, subdomain] = await Promise.all([
43
48
  createWorkersPreviewToken(input.api, {
44
49
  name: input.name,
45
50
  metadata: {
@@ -54,14 +59,10 @@ export async function createRemoteProxyWorker(input: {
54
59
  minimal_mode: true,
55
60
  },
56
61
  }),
57
- getAccountSubdomain(input.api).then(async (subdomain) => {
58
- const host = `${input.name}.${subdomain}.workers.dev`;
59
- return {
60
- host,
61
- accessToken: await getAccessToken(host),
62
- };
63
- }),
62
+ getAccountSubdomain(input.api),
64
63
  ]);
64
+ const host = `${input.name}.${subdomain}.workers.dev`;
65
+ const accessToken = await getAccessToken(host, token);
65
66
 
66
67
  const baseHeaders: Record<string, string> = {
67
68
  "cf-workers-preview-token": token,
@@ -158,7 +159,13 @@ async function createWorkersPreviewToken(
158
159
 
159
160
  async function prewarm(url: string, previewToken: string) {
160
161
  try {
161
- const accessToken = await getAccessToken(new URL(url).hostname);
162
+ // Pass the preview token to the Access probe — prewarm targets a preview
163
+ // worker URL, which only surfaces an Access 302 when the request carries
164
+ // the preview token.
165
+ const accessToken = await getAccessToken(
166
+ new URL(url).hostname,
167
+ previewToken,
168
+ );
162
169
  await fetch(url, {
163
170
  method: "POST",
164
171
  headers: {
@@ -197,42 +204,59 @@ async function createWorkersPreviewSession(api: CloudflareApi) {
197
204
  /**
198
205
  * If the given domain uses Cloudflare Access, fetches the access token for the domain.
199
206
  * Otherwise, returns undefined.
207
+ *
208
+ * For workers.dev preview URLs, pass the `cf-workers-preview-token` alongside
209
+ * the HEAD probe: the preview worker only surfaces its Access 302 when the
210
+ * request carries the preview token (a bare HEAD returns 404 regardless of
211
+ * Access configuration).
212
+ *
200
213
  * @see https://github.com/cloudflare/workers-sdk/blob/a5eb5134f73d3983655d325a4de71c6370c57faa/packages/wrangler/src/user/access.ts#L10
201
214
  */
202
- const getAccessToken = memoize(async (hostname: string) => {
203
- if (!(await domainUsesAccess(hostname))) {
204
- return undefined;
205
- }
206
- const result = await Scope.current
207
- .exec(`access-${hostname}`, `cloudflared access login ${hostname}`)
208
- .catch(() => {
209
- throw new Error(
210
- [
211
- `The \`cloudflared\` CLI is not installed, but is required to access the domain "${hostname}".`,
212
- `Please install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation and run \`cloudflare access login ${hostname}\`.`,
213
- ].join("\n"),
214
- );
215
- });
216
- const matches = result.stdout.match(/fetched your token:\n\n(.*)/m);
217
- if (matches?.[1]) {
218
- return matches[1];
219
- }
220
- const error = new Error(
221
- `Failed to get access token for domain "${hostname}".`,
222
- );
223
- Object.assign(error, result);
224
- throw error;
225
- });
215
+ const getAccessToken = memoize(
216
+ async (hostname: string, previewToken?: string) => {
217
+ if (!(await domainUsesAccess(hostname, previewToken))) {
218
+ return undefined;
219
+ }
220
+ const result = await Scope.current
221
+ .exec(`access-${hostname}`, `cloudflared access login ${hostname}`)
222
+ .catch(() => {
223
+ throw new Error(
224
+ [
225
+ `The \`cloudflared\` CLI is not installed, but is required to access the domain "${hostname}".`,
226
+ `Please install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation and run \`cloudflare access login ${hostname}\`.`,
227
+ ].join("\n"),
228
+ );
229
+ });
230
+ const matches = result.stdout.match(/fetched your token:\n\n(.*)/m);
231
+ if (matches?.[1]) {
232
+ return matches[1];
233
+ }
234
+ const error = new Error(
235
+ `Failed to get access token for domain "${hostname}".`,
236
+ );
237
+ Object.assign(error, result);
238
+ throw error;
239
+ },
240
+ (hostname) => hostname,
241
+ );
226
242
 
227
243
  /**
228
244
  * Returns true if the domain uses Cloudflare Access.
245
+ *
246
+ * @param hostname the workers.dev preview host
247
+ * @param previewToken optional preview token; required when probing a
248
+ * preview-worker URL because CF only serves the Access 302 to requests
249
+ * that carry the token.
229
250
  */
230
- async function domainUsesAccess(hostname: string) {
251
+ async function domainUsesAccess(hostname: string, previewToken?: string) {
231
252
  try {
232
253
  const response = await fetch(`https://${hostname}`, {
233
254
  method: "HEAD",
234
255
  redirect: "manual",
235
256
  signal: AbortSignal.timeout(1000),
257
+ headers: previewToken
258
+ ? { "cf-workers-preview-token": previewToken }
259
+ : undefined,
236
260
  });
237
261
  return !!(
238
262
  response.status === 302 &&
@@ -10,6 +10,7 @@ import { Assets } from "./assets.ts";
10
10
  import type { Bindings } from "./bindings.ts";
11
11
  import { DEFAULT_COMPATIBILITY_DATE } from "./compatibility-date.ts";
12
12
  import { unionCompatibilityFlags } from "./compatibility-presets.ts";
13
+ import { quickTunnel } from "./quick-tunnel.ts";
13
14
  import {
14
15
  extractStringAndSecretBindings,
15
16
  unencryptSecrets,
@@ -78,6 +79,10 @@ export interface WebsiteProps<
78
79
  * The local domain to use for the dev server
79
80
  */
80
81
  domain?: string;
82
+ /**
83
+ * Whether to use a Cloudflare Tunnel for the dev server
84
+ */
85
+ tunnel?: boolean;
81
86
  /**
82
87
  * Additional environment variables to set when running the dev command
83
88
  */
@@ -363,6 +368,10 @@ export async function Website<
363
368
  ALCHEMY_ROOT: Scope.current.rootDir,
364
369
  },
365
370
  });
371
+ if (url && ((typeof dev === "object" && dev.tunnel) || scope.tunnel)) {
372
+ const { tunnelUrl } = await quickTunnel(scope, url);
373
+ url = tunnelUrl;
374
+ }
366
375
  }
367
376
 
368
377
  return (await Worker(id, {
@@ -377,7 +386,16 @@ export async function Website<
377
386
  }
378
387
  : {}),
379
388
  },
380
- dev: url ? { url } : undefined,
389
+ dev: worker.dev
390
+ ? {
391
+ url: url as never,
392
+ port: worker.dev.port,
393
+ remote: worker.dev.remote,
394
+ tunnel: worker.dev.tunnel,
395
+ }
396
+ : url
397
+ ? { url }
398
+ : undefined,
381
399
  })) as Website<B, RPC>;
382
400
  }
383
401
 
@@ -2,6 +2,8 @@ import { assertNever } from "../util/assert-never.ts";
2
2
  import { camelToSnakeObjectDeep } from "../util/camel-to-snake.ts";
3
3
  import { logger } from "../util/logger.ts";
4
4
  import { memoize } from "../util/memoize.ts";
5
+ import { isAiSearchNamespace } from "./ai-search-namespace.ts";
6
+ import { isAiSearch } from "./ai-search.ts";
5
7
  import { extractCloudflareResult } from "./api-response.ts";
6
8
  import type { CloudflareApi } from "./api.ts";
7
9
  import type {
@@ -418,7 +420,29 @@ export async function prepareWorkerMetadata(
418
420
  for (const [bindingName, binding] of Object.entries(bindings)) {
419
421
  // Create a copy of the binding to avoid modifying the original
420
422
 
421
- if (typeof binding === "string") {
423
+ if (isAiSearch(binding)) {
424
+ // Single-instance bindings (ai_search) are always scoped to the `default` namespace.
425
+ if (binding.namespace !== undefined && binding.namespace !== "default") {
426
+ throw new Error(
427
+ `Worker binding "${bindingName}" uses a single-instance AiSearch binding (type: "ai_search"), ` +
428
+ `but the bound AiSearch instance "${binding.name}" is in namespace "${binding.namespace}". ` +
429
+ `Single-instance bindings only support the "default" namespace.\n` +
430
+ `Fix: either (1) create the AiSearch without a custom namespace, or ` +
431
+ `(2) bind the enclosing AiSearchNamespace and use \`env.${bindingName}.get("${binding.name}")\`.`,
432
+ );
433
+ }
434
+ meta.bindings.push({
435
+ type: "ai_search",
436
+ name: bindingName,
437
+ instance_name: binding.name,
438
+ });
439
+ } else if (isAiSearchNamespace(binding)) {
440
+ meta.bindings.push({
441
+ type: "ai_search_namespace",
442
+ name: bindingName,
443
+ namespace: binding.namespace,
444
+ });
445
+ } else if (typeof binding === "string") {
422
446
  meta.bindings.push({
423
447
  type: "plain_text",
424
448
  name: bindingName,
@@ -329,6 +329,12 @@ export interface BaseWorkerProps<
329
329
  * @default false
330
330
  */
331
331
  adopt?: boolean;
332
+ /**
333
+ * Forcibly transfer the hostname when it is currently bound to a
334
+ * different Worker service. See {@link CustomDomainProps.overrideExistingOrigin}.
335
+ * @default false
336
+ */
337
+ overrideExistingOrigin?: boolean;
332
338
  }
333
339
  )[];
334
340
 
@@ -1497,6 +1503,7 @@ async function provisionResources<B extends Bindings>(
1497
1503
  name: domain,
1498
1504
  zoneId: undefined,
1499
1505
  adopt: props.adopt,
1506
+ overrideExistingOrigin: undefined,
1500
1507
  };
1501
1508
  }
1502
1509
  if (domain.domainName === "") {
@@ -1506,6 +1513,7 @@ async function provisionResources<B extends Bindings>(
1506
1513
  name: domain.domainName,
1507
1514
  zoneId: domain.zoneId,
1508
1515
  adopt: domain.adopt ?? props.adopt,
1516
+ overrideExistingOrigin: domain.overrideExistingOrigin,
1509
1517
  };
1510
1518
  }),
1511
1519
  eventSources: props.eventSources?.map((eventSource) => {
@@ -1578,6 +1586,7 @@ async function provisionResources<B extends Bindings>(
1578
1586
  name: domain.name,
1579
1587
  zoneId: domain.zoneId,
1580
1588
  adopt: domain.adopt,
1589
+ overrideExistingOrigin: domain.overrideExistingOrigin,
1581
1590
  delete: props.delete,
1582
1591
  workerName: options.name,
1583
1592
  dev: options.local,
@@ -6,6 +6,8 @@ import { Resource } from "../resource.ts";
6
6
  import { Scope } from "../scope.ts";
7
7
  import { isSecret } from "../secret.ts";
8
8
  import { assertNever } from "../util/assert-never.ts";
9
+ import { isAiSearchNamespace } from "./ai-search-namespace.ts";
10
+ import { isAiSearch } from "./ai-search.ts";
9
11
  import { createCloudflareApi } from "./api.ts";
10
12
  import type { Bindings } from "./bindings.ts";
11
13
  import { getCloudflareRegistryWithAccountNamespace } from "./container.ts";
@@ -289,6 +291,8 @@ async function processBindings(
289
291
  const containers: WranglerJsonConfig["containers"] = [];
290
292
  const workerLoaders: WranglerJsonConfig["worker_loaders"] = [];
291
293
  const vpcServices: WranglerJsonConfig["vpc_services"] = [];
294
+ const aiSearchInstances: WranglerJsonConfig["ai_search"] = [];
295
+ const aiSearchNamespaces: WranglerJsonConfig["ai_search_namespaces"] = [];
292
296
 
293
297
  for (const eventSource of eventSources ?? []) {
294
298
  if (isQueueEventSource(eventSource)) {
@@ -321,6 +325,26 @@ async function processBindings(
321
325
  } else if (writeSecrets && isSecret(binding)) {
322
326
  spec.vars ??= {};
323
327
  spec.vars[bindingName] = binding as any;
328
+ } else if (isAiSearch(binding)) {
329
+ // AI Search discriminates via ResourceKind (isAiSearch) rather than
330
+ // binding.type — `AiSearch.type` is the *source* type ("r2" |
331
+ // "web-crawler") and so would collide with `r2_bucket` string
332
+ // matching below. Checking here at the top of the chain mirrors the
333
+ // ordering in worker-metadata.ts.
334
+ aiSearchInstances.push({
335
+ binding: bindingName,
336
+ instance_name: binding.name,
337
+ // AI Search is not natively supported by Miniflare — emitting
338
+ // `remote: true` lets `wrangler dev` proxy to the deployed
339
+ // instance (matches vectorize / browser / ai / vpc_service).
340
+ remote: true,
341
+ });
342
+ } else if (isAiSearchNamespace(binding)) {
343
+ aiSearchNamespaces.push({
344
+ binding: bindingName,
345
+ namespace: binding.namespace,
346
+ remote: true,
347
+ });
324
348
  } else if (binding.type === "cloudflare::Worker::Self") {
325
349
  // Self(service) binding
326
350
  services.push({
@@ -646,4 +670,12 @@ async function processBindings(
646
670
  if (workerLoaders.length > 0) {
647
671
  spec.worker_loaders = workerLoaders;
648
672
  }
673
+
674
+ if (aiSearchInstances.length > 0) {
675
+ spec.ai_search = aiSearchInstances;
676
+ }
677
+
678
+ if (aiSearchNamespaces.length > 0) {
679
+ spec.ai_search_namespaces = aiSearchNamespaces;
680
+ }
649
681
  }
package/src/docker/api.ts CHANGED
@@ -256,10 +256,19 @@ export class DockerApi {
256
256
  * Pull Docker image
257
257
  *
258
258
  * @param image Image name and tag
259
+ * @param options Pull options
259
260
  * @returns Result of the pull command
260
261
  */
261
- async pullImage(image: string): Promise<{ stdout: string; stderr: string }> {
262
- return this.exec(["pull", image]);
262
+ async pullImage(
263
+ image: string,
264
+ options?: { platform?: string },
265
+ ): Promise<{ stdout: string; stderr: string }> {
266
+ const args = ["pull"];
267
+ if (options?.platform) {
268
+ args.push("--platform", options.platform);
269
+ }
270
+ args.push(image);
271
+ return this.exec(args);
263
272
  }
264
273
 
265
274
  async tagImage(
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
- import os from "node:os";
3
2
  import path from "pathe";
4
3
  import type { Context } from "../context.ts";
5
4
  import { Resource } from "../resource.ts";
6
5
  import type { Secret } from "../secret.ts";
7
6
  import { DockerApi } from "./api.ts";
7
+ import { pushImageToRegistry } from "./registry.ts";
8
8
  import type { RemoteImage } from "./remote-image.ts";
9
9
 
10
10
  /**
@@ -66,9 +66,9 @@ export interface DockerBuildOptions {
66
66
  }
67
67
 
68
68
  export interface ImageRegistry {
69
+ server: string;
69
70
  username: string;
70
71
  password: Secret;
71
- server: string;
72
72
  }
73
73
 
74
74
  /**
@@ -285,63 +285,12 @@ export const Image = Resource(
285
285
  let repoDigest: string | undefined;
286
286
  let finalImageRef = imageRef;
287
287
  if (props.registry && !props.skipPush) {
288
- const { server, username, password } = props.registry;
289
-
290
- // Ensure the registry server does not have trailing slash
291
- const registryHost = server.replace(/\/$/, "");
292
-
293
- // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
294
- const firstSegment = imageRef.split("/")[0];
295
- const hasRegistryPrefix = firstSegment.includes(".");
296
-
297
- // Compose the target image reference that will be pushed
298
- const targetImage = hasRegistryPrefix
299
- ? imageRef // already fully-qualified
300
- : `${registryHost}/${imageRef}`;
301
-
302
- try {
303
- // Create a temporary directory that will act as an isolated Docker config
304
- // (credentials) directory. This prevents race-conditions when multiple
305
- // concurrent tests perform `docker login` / `logout` by ensuring each
306
- // Image operation has its own credential store.
307
- const tempConfigDir = await fs.mkdtemp(
308
- path.join(os.tmpdir(), "docker-config-"),
309
- );
310
- const api = new DockerApi({ configDir: tempConfigDir });
311
-
312
- // Authenticate to registry using the isolated config directory
313
- await api.login(registryHost, username, password.unencrypted);
314
-
315
- // Tag local image with fully qualified name if necessary
316
- if (targetImage !== imageRef) {
317
- await api.exec(["tag", imageRef, targetImage]);
318
- }
319
-
320
- // Push the image
321
- const { stdout: pushOut } = await api.exec(["push", targetImage]);
322
-
323
- // Attempt to extract the repo digest from push output
324
- const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(pushOut);
325
- if (digestMatch) {
326
- const digestHash = digestMatch[1];
327
- // Strip tag (anything after last :) to build image@digest reference
328
- const [repoWithoutTag] =
329
- targetImage.split(":").length > 2
330
- ? [targetImage] // unlikely but safety
331
- : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
332
- repoDigest = `${repoWithoutTag}@${digestHash}`;
333
- }
334
-
335
- // Update the final image reference to point at the pushed image
336
- finalImageRef = targetImage;
337
- } finally {
338
- // Clean up credentials from the isolated config
339
- await api.logout(registryHost);
340
- }
288
+ const pushedImage = await pushImageToRegistry(imageRef, props.registry);
289
+ finalImageRef = pushedImage.imageRef;
290
+ repoDigest = pushedImage.repoDigest;
341
291
  }
342
292
  return {
343
293
  kind: "Image",
344
- ...props,
345
294
  tag,
346
295
  name,
347
296
  imageRef: finalImageRef,
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "pathe";
4
+ import { Secret, type Secret as SecretValue } from "../secret.ts";
5
+ import { DockerApi } from "./api.ts";
6
+
7
+ export interface RegistryPushCredentials {
8
+ server: string;
9
+ username: string;
10
+ password: string | SecretValue;
11
+ }
12
+
13
+ export async function pushImageToRegistry(
14
+ imageRef: string,
15
+ registry: RegistryPushCredentials,
16
+ ): Promise<{
17
+ imageRef: string;
18
+ repoDigest?: string;
19
+ }> {
20
+ const registryHost = registry.server.replace(/\/$/, "");
21
+ const password = Secret.unwrap(registry.password);
22
+
23
+ const firstSegment = imageRef.split("/")[0];
24
+ const hasRegistryPrefix = firstSegment.includes(".");
25
+ const targetImage = hasRegistryPrefix
26
+ ? imageRef
27
+ : `${registryHost}/${imageRef}`;
28
+
29
+ let repoDigest: string | undefined;
30
+ let api: DockerApi | undefined;
31
+
32
+ try {
33
+ const tempConfigDir = await fs.mkdtemp(
34
+ path.join(os.tmpdir(), "docker-config-"),
35
+ );
36
+ api = new DockerApi({ configDir: tempConfigDir });
37
+
38
+ await api.login(registryHost, registry.username, password);
39
+
40
+ if (targetImage !== imageRef) {
41
+ await api.exec(["tag", imageRef, targetImage]);
42
+ }
43
+
44
+ const { stdout } = await api.exec(["push", targetImage]);
45
+ const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(stdout);
46
+ if (digestMatch) {
47
+ const digestHash = digestMatch[1];
48
+ const [repoWithoutTag] =
49
+ targetImage.split(":").length > 2
50
+ ? [targetImage]
51
+ : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
52
+ repoDigest = `${repoWithoutTag}@${digestHash}`;
53
+ }
54
+
55
+ return {
56
+ imageRef: targetImage,
57
+ repoDigest,
58
+ };
59
+ } finally {
60
+ await api?.logout(registryHost);
61
+ }
62
+ }