1import type * as Redacted from "effect/Redacted";
2
3export interface ImageRegistry {
4 /** Registry host, e.g. `ghcr.io`. */
5 server: string;
6 /** Registry username. */
7 username: string;
8 /** Registry password. Use `Redacted.make(...)` or `Config.Redacted(...)`. */
9 password: Redacted.Redacted<string>;
10}
11
12/** Strips the tag and digest from an image reference, leaving the repository. */
13export const repositoryFromImageRef = (imageRef: string): string => {
14 const withoutDigest = imageRef.includes("@")
15 ? imageRef.slice(0, imageRef.indexOf("@"))
16 : imageRef;
17 const tagSeparator = withoutDigest.lastIndexOf(":");
18 const pathSeparator = withoutDigest.lastIndexOf("/");
19 return tagSeparator > pathSeparator
20 ? withoutDigest.slice(0, tagSeparator)
21 : withoutDigest;
22};
23
24/**
25 * Prefixes an image reference with the registry host unless the reference
26 * already carries a registry prefix (a dotted host, a host:port, or `localhost`).
27 */
28export const withRegistryHost = (
29 imageRef: string,
30 registry: { server: string },
31): string => {
32 const registryHost = registry.server.replace(/\/$/, "");
33 const firstSegment = imageRef.split("/")[0];
34 const hasRegistryPrefix =
35 imageRef.includes("/") &&
36 (firstSegment.includes(".") ||
37 firstSegment.includes(":") ||
38 firstSegment === "localhost");
39 return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`;
40};
41
42/** Extracts the `repository@sha256:...` digest from `docker push` output. */
43export const parseRepoDigest = (
44 imageRef: string,
45 output: string,
46): string | undefined => {
47 const match = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/i.exec(output);
48 if (!match) return undefined;
49 return `${repositoryFromImageRef(imageRef)}@${match[1]}`;
50};
51
52/**
53 * Parses an image's RFC 3339 `Created` timestamp into epoch milliseconds.
54 *
55 * Docker only reports `Created` when the image config carries a creation time:
56 * API >= 1.44 omits it, older APIs backfill the year-1 zero value
57 * (`0001-01-01T00:00:00Z`), and 25.0.0–25.0.3 returned an empty string. In any
58 * of those cases there is no real build time, so we fall back to the wall clock.
59 */
60export const parseCreatedAt = (created: string | undefined): number => {
61 const parsed = created ? Date.parse(created) : Number.NaN;
62 return Number.isNaN(parsed) || parsed <= 0 ? Date.now() : parsed;
63};