@telorun/kernel 0.41.0 → 0.43.0

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 (74) hide show
  1. package/dist/bundle/files-integrity.d.ts +25 -0
  2. package/dist/bundle/files-integrity.d.ts.map +1 -0
  3. package/dist/bundle/files-integrity.js +40 -0
  4. package/dist/bundle/files-integrity.js.map +1 -0
  5. package/dist/bundle/module-manifest.d.ts +22 -0
  6. package/dist/bundle/module-manifest.d.ts.map +1 -0
  7. package/dist/bundle/module-manifest.js +33 -0
  8. package/dist/bundle/module-manifest.js.map +1 -0
  9. package/dist/bundle/tar.d.ts +20 -0
  10. package/dist/bundle/tar.d.ts.map +1 -0
  11. package/dist/bundle/tar.js +63 -0
  12. package/dist/bundle/tar.js.map +1 -0
  13. package/dist/controller-loaders/bundle-builder.d.ts.map +1 -1
  14. package/dist/controller-loaders/bundle-builder.js +13 -2
  15. package/dist/controller-loaders/bundle-builder.js.map +1 -1
  16. package/dist/controllers/module/import-controller.d.ts +3 -0
  17. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  18. package/dist/controllers/module/import-controller.js +7 -2
  19. package/dist/controllers/module/import-controller.js.map +1 -1
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/kernel.d.ts.map +1 -1
  25. package/dist/kernel.js +8 -2
  26. package/dist/kernel.js.map +1 -1
  27. package/dist/manifest-sources/local-manifest-cache-source.d.ts +2 -2
  28. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  29. package/dist/manifest-sources/local-manifest-cache-source.js +24 -90
  30. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  31. package/dist/transports/oci/docker-credentials.d.ts +10 -0
  32. package/dist/transports/oci/docker-credentials.d.ts.map +1 -0
  33. package/dist/transports/oci/docker-credentials.js +75 -0
  34. package/dist/transports/oci/docker-credentials.js.map +1 -0
  35. package/dist/transports/oci/oci-client.d.ts +51 -0
  36. package/dist/transports/oci/oci-client.d.ts.map +1 -0
  37. package/dist/transports/oci/oci-client.js +170 -0
  38. package/dist/transports/oci/oci-client.js.map +1 -0
  39. package/dist/transports/oci/oci-ref.d.ts +21 -0
  40. package/dist/transports/oci/oci-ref.d.ts.map +1 -0
  41. package/dist/transports/oci/oci-ref.js +36 -0
  42. package/dist/transports/oci/oci-ref.js.map +1 -0
  43. package/dist/transports/oci/oci-transport.d.ts +29 -0
  44. package/dist/transports/oci/oci-transport.d.ts.map +1 -0
  45. package/dist/transports/oci/oci-transport.js +142 -0
  46. package/dist/transports/oci/oci-transport.js.map +1 -0
  47. package/dist/transports/registry-transport.d.ts +23 -0
  48. package/dist/transports/registry-transport.d.ts.map +1 -0
  49. package/dist/transports/registry-transport.js +225 -0
  50. package/dist/transports/registry-transport.js.map +1 -0
  51. package/dist/transports/transport-registry.d.ts +40 -0
  52. package/dist/transports/transport-registry.d.ts.map +1 -0
  53. package/dist/transports/transport-registry.js +70 -0
  54. package/dist/transports/transport-registry.js.map +1 -0
  55. package/dist/transports/transport.d.ts +92 -0
  56. package/dist/transports/transport.d.ts.map +1 -0
  57. package/dist/transports/transport.js +2 -0
  58. package/dist/transports/transport.js.map +1 -0
  59. package/package.json +6 -3
  60. package/src/bundle/files-integrity.ts +46 -0
  61. package/src/bundle/module-manifest.ts +49 -0
  62. package/src/bundle/tar.ts +78 -0
  63. package/src/controller-loaders/bundle-builder.ts +14 -2
  64. package/src/controllers/module/import-controller.ts +7 -2
  65. package/src/index.ts +22 -0
  66. package/src/kernel.ts +7 -2
  67. package/src/manifest-sources/local-manifest-cache-source.ts +25 -98
  68. package/src/transports/oci/docker-credentials.ts +85 -0
  69. package/src/transports/oci/oci-client.ts +237 -0
  70. package/src/transports/oci/oci-ref.ts +53 -0
  71. package/src/transports/oci/oci-transport.ts +192 -0
  72. package/src/transports/registry-transport.ts +281 -0
  73. package/src/transports/transport-registry.ts +91 -0
  74. package/src/transports/transport.ts +112 -0
@@ -0,0 +1,78 @@
1
+ import { extract as tarExtract, pack as tarPack } from "tar-stream";
2
+ import { gunzipSync, gzipSync } from "node:zlib";
3
+ import { Readable } from "node:stream";
4
+
5
+ import type { PayloadFile } from "./files-integrity.js";
6
+
7
+ export interface BundleEntry {
8
+ /** POSIX-relative path inside the archive (e.g. `telo.yaml`, `public/app.js`). */
9
+ name: string;
10
+ content: Buffer | string;
11
+ }
12
+
13
+ /** Normalize decoded tar entries to `PayloadFile`s (Buffer-backed content) —
14
+ * the fixed step both transports run after `readTarGz`. */
15
+ export function toPayloadFiles(entries: BundleEntry[]): PayloadFile[] {
16
+ return entries.map((e) => ({
17
+ name: e.name,
18
+ content: typeof e.content === "string" ? Buffer.from(e.content) : e.content,
19
+ }));
20
+ }
21
+
22
+ /**
23
+ * Pack `entries` into a gzipped tar (`module.tar.gz`) — the module-artifact
24
+ * writer shared by `telo publish` and the transports. Artifacts are small (a
25
+ * manifest plus a built frontend), so buffering the whole archive before gzip
26
+ * is fine. (Distinct from `apps/k8s-runner/src/tar.ts`, which is coupled to
27
+ * `@telorun/runner-core`'s `RunBundle`.)
28
+ */
29
+ export async function makeTarGz(entries: BundleEntry[]): Promise<Buffer> {
30
+ const pack = tarPack();
31
+ const chunks: Buffer[] = [];
32
+ pack.on("data", (c: Buffer) => chunks.push(c));
33
+
34
+ const done = new Promise<void>((resolve, reject) => {
35
+ pack.on("end", resolve);
36
+ pack.on("error", reject);
37
+ });
38
+
39
+ for (const entry of entries) {
40
+ const buf = typeof entry.content === "string" ? Buffer.from(entry.content, "utf-8") : entry.content;
41
+ await new Promise<void>((resolve, reject) => {
42
+ pack.entry({ name: entry.name }, buf, (err) => (err ? reject(err) : resolve()));
43
+ });
44
+ }
45
+ pack.finalize();
46
+ await done;
47
+
48
+ return gzipSync(Buffer.concat(chunks));
49
+ }
50
+
51
+ /** Decompress + untar a `module.tar.gz` buffer into its file entries. */
52
+ export async function readTarGz(buf: Buffer): Promise<BundleEntry[]> {
53
+ const tar = gunzipSync(buf);
54
+ const ex = tarExtract();
55
+ const entries: BundleEntry[] = [];
56
+
57
+ await new Promise<void>((resolve, reject) => {
58
+ ex.on("entry", (header, stream, next) => {
59
+ if (header.type !== "file") {
60
+ stream.on("end", next);
61
+ stream.resume();
62
+ return;
63
+ }
64
+ const chunks: Buffer[] = [];
65
+ stream.on("data", (c: Buffer) => chunks.push(c));
66
+ stream.on("end", () => {
67
+ entries.push({ name: header.name, content: Buffer.concat(chunks) });
68
+ next();
69
+ });
70
+ stream.on("error", reject);
71
+ });
72
+ ex.on("finish", resolve);
73
+ ex.on("error", reject);
74
+ Readable.from(tar).pipe(ex);
75
+ });
76
+
77
+ return entries;
78
+ }
@@ -45,6 +45,12 @@ function sanitize(s: string): string {
45
45
  return s.replace(/[^a-zA-Z0-9._-]/g, "_");
46
46
  }
47
47
 
48
+ /** Extensions of a package's own relative modules that are externalized (kept a
49
+ * single loose copy) rather than inlined per controller bundle. TS extensions
50
+ * are included because published modules are loaded from their `src/*.ts` under
51
+ * a TS-aware runtime (Bun), so the shared `.ts` file must keep one identity. */
52
+ const EXTERNALIZABLE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
53
+
48
54
  async function realpathOrNull(p: string): Promise<string | null> {
49
55
  try {
50
56
  return await fs.realpath(p);
@@ -320,9 +326,15 @@ function nativeExternalsPlugin(bundleDir: string, pkgRoot: string | null): impor
320
326
  const relToPkg = path.relative(pkgRoot, resolved.path);
321
327
  if (relToPkg.startsWith("..") || path.isAbsolute(relToPkg)) return null;
322
328
  if (relToPkg.split(path.sep).includes("node_modules")) return null;
323
- // Must be a runtime-loadable JS file; otherwise inline (transpile).
329
+ // Must be a runtime-loadable module; otherwise inline (transpile). The
330
+ // loose file is loaded by the SAME runtime as this bundle — and a package
331
+ // shipped as `.ts` source (loaded via Bun) has `.ts` entries, so a `.ts`
332
+ // sibling resolves for that runtime too. TS extensions MUST be
333
+ // externalized, not inlined: inlining gives each controller its own copy
334
+ // of a shared module (e.g. record-stream's `journal-store`), splitting
335
+ // `instanceof` / process-local state across the package's controllers.
324
336
  const ext = path.extname(resolved.path).toLowerCase();
325
- if (ext !== ".js" && ext !== ".mjs" && ext !== ".cjs") return null;
337
+ if (!EXTERNALIZABLE_EXTENSIONS.has(ext)) return null;
326
338
  const rel = path.relative(bundleDir, resolved.path).split(path.sep).join("/");
327
339
  return { path: rel.startsWith(".") ? rel : `./${rel}`, external: true };
328
340
  });
@@ -1,4 +1,4 @@
1
- import { AnalysisRegistry, DiagnosticSeverity, parseExportEntry, StaticAnalyzer } from "@telorun/analyzer";
1
+ import { AnalysisRegistry, DiagnosticSeverity, foldIntegrity, parseExportEntry, StaticAnalyzer } from "@telorun/analyzer";
2
2
  import type { ResourceInstance } from "@telorun/sdk";
3
3
  import { RuntimeError } from "@telorun/sdk";
4
4
  import type { BuiltinControllerContext } from "../../internal-context.js";
@@ -11,7 +11,11 @@ export async function create(
11
11
  ): Promise<ResourceInstance> {
12
12
  const alias = resource.metadata.name as string;
13
13
 
14
- const moduleSource: string = resource.module ?? resource.source;
14
+ const rawSource: string = resource.module ?? resource.source;
15
+ // A directly-authored Telo.Import may carry integrity as a sibling field;
16
+ // fold it into the ref as a `#sha256-...` fragment (the desugared inline
17
+ // imports already inline it) so the source chain verifies the fetched bytes.
18
+ const moduleSource: string = foldIntegrity(rawSource, resource.integrity);
15
19
 
16
20
  // Resolve relative source paths against the manifest's OWN file URL (stamped onto
17
21
  // `metadata.source` by the loader), not the parent module context's source. When a
@@ -334,6 +338,7 @@ export const schema = {
334
338
  additionalProperties: true,
335
339
  },
336
340
  source: { type: "string" },
341
+ integrity: { type: "string" },
337
342
  variables: { type: "object" },
338
343
  secrets: { type: "object" },
339
344
  runtime: {
package/src/index.ts CHANGED
@@ -10,6 +10,28 @@ export {
10
10
  writeManifestCache,
11
11
  } from "./manifest-sources/local-manifest-cache-source.js";
12
12
  export { MemorySource } from "./manifest-sources/memory-source.js";
13
+ export type { Transport } from "./transports/transport.js";
14
+ export { RegistryTransport } from "./transports/registry-transport.js";
15
+ export { OciTransport } from "./transports/oci/oci-transport.js";
16
+ export { OciClient } from "./transports/oci/oci-client.js";
17
+ export { isOciRef, parseOciRef, type ParsedOciRef } from "./transports/oci/oci-ref.js";
18
+ export {
19
+ TransportRegistry,
20
+ defaultTransports,
21
+ defaultTransportRegistry,
22
+ } from "./transports/transport-registry.js";
23
+ export { makeTarGz, readTarGz, type BundleEntry } from "./bundle/tar.js";
24
+ export {
25
+ computeFilesIntegrity,
26
+ injectFilesIntegrity,
27
+ type PayloadFile,
28
+ } from "./bundle/files-integrity.js";
29
+ export type {
30
+ FetchedArtifact,
31
+ PublishBundle,
32
+ PublishResult,
33
+ PublishOptions,
34
+ } from "./transports/transport.js";
13
35
  export { ExecutionContext } from "./execution-context.js";
14
36
  export { Kernel, type KernelOptions } from "./kernel.js";
15
37
  export { nodeCelHandlers } from "./cel-handlers.js";
package/src/kernel.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  AnalysisRegistry,
3
3
  buildEvalPaths,
4
- defaultSources,
5
4
  flattenForAnalyzer,
6
5
  flattenLoadedModule,
7
6
  isModuleKind,
@@ -48,6 +47,7 @@ import {
48
47
  import {
49
48
  resolveCacheRoot,
50
49
  } from "./manifest-sources/local-manifest-cache-source.js";
50
+ import { defaultTransportRegistry } from "./transports/transport-registry.js";
51
51
  import {
52
52
  collectDeclaredEnvKeys,
53
53
  precompileApplicationEnvSchemas,
@@ -156,7 +156,12 @@ export class Kernel implements IKernel {
156
156
  this.env = options.env ?? hostEnv();
157
157
  this.argv = options.argv ?? [];
158
158
  this.registryUrl = options.registryUrl;
159
- this.loader = new Loader(defaultSources(this.registryUrl), { celHandlers: nodeCelHandlers });
159
+ // Resolution sources come from the transport registry, so a scheme-owning
160
+ // transport (OCI, later S3) joins the loader's dispatch chain by being
161
+ // registered — no source-chain edits here.
162
+ this.loader = new Loader(defaultTransportRegistry(this.registryUrl).sources(), {
163
+ celHandlers: nodeCelHandlers,
164
+ });
160
165
  for (const source of options.sources) {
161
166
  this.loader.register(source);
162
167
  }
@@ -1,17 +1,14 @@
1
- import type { LoadedGraph, ManifestSource } from "@telorun/analyzer";
2
- import { createHash } from "crypto";
1
+ import { splitIntegrity, verifyIntegrity, type LoadedGraph, type ManifestSource } from "@telorun/analyzer";
3
2
  import { statSync } from "fs";
4
3
  import * as fs from "fs/promises";
5
4
  import * as path from "path";
6
5
  import { fileURLToPath, pathToFileURL } from "url";
7
6
 
8
7
  import { hostEnv } from "../host-env.js";
8
+ import { TransportRegistry, defaultTransportRegistry } from "../transports/transport-registry.js";
9
9
 
10
10
  const CACHE_SUBDIR = ".telo/manifests";
11
- const HTTP_NAMESPACE = "__http";
12
- const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
13
11
  const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
14
- const QUERY_HASH_LENGTH = 12;
15
12
 
16
13
  /** Verify that `candidate` resolves to a path under `root`. Returns the
17
14
  * candidate path on success, `null` when any segment escapes the root.
@@ -27,101 +24,22 @@ function joinUnder(root: string, ...segments: string[]): string | null {
27
24
  return candidate;
28
25
  }
29
26
 
30
- /** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
31
- * already point at a YAML file, append `/telo.yaml`. Used by both the
32
- * reader and writer so a raw import URL and the canonical source it
33
- * resolves to map to the same cache path. */
34
- function normalizePathname(url: string, parsed: URL): string {
35
- let pathname = parsed.pathname;
36
- if (!url.includes(".yaml")) {
37
- pathname = pathname.endsWith("/")
38
- ? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
39
- : `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
40
- }
41
- return pathname;
42
- }
43
-
44
- /** Compute a short hash of `search + hash` so two URLs that differ only in
45
- * query / fragment do not collide at the same cache path. Inserted before
46
- * the final extension so the filename stays readable. */
47
- function disambiguatePath(pathname: string, search: string, hash: string): string {
48
- if (!search && !hash) return pathname;
49
- const digest = createHash("sha256")
50
- .update(search + hash)
51
- .digest("hex")
52
- .slice(0, QUERY_HASH_LENGTH);
53
- const ext = path.extname(pathname);
54
- const base = pathname.slice(0, pathname.length - ext.length);
55
- return `${base}.${digest}${ext}`;
56
- }
57
-
58
27
  /** Single source of truth for URL → cache path. Used identically by the
59
28
  * reader (cache lookup) and writer (install-time persistence). For any
60
- * given import URL — registry ref, direct registry URL, or arbitrary
61
- * HTTP — both sides land on the same file.
29
+ * given import ref — registry ref, direct registry URL, arbitrary HTTP, or
30
+ * (once registered) `oci://` — both sides land on the same file, because the
31
+ * owning transport decides the layout via {@link Transport.cacheLocation}.
62
32
  *
63
- * Returns `null` for unsupported URLs (file://, memory://, relative paths)
64
- * or for path-traversal attempts that would escape `cacheRoot`. */
33
+ * Returns `null` for unsupported refs (file://, memory://, relative paths) or
34
+ * for path-traversal attempts that would escape `cacheRoot`. */
65
35
  function cachePathForUrl(
66
- url: string,
36
+ rawUrl: string,
67
37
  cacheRoot: string,
68
- registryUrl: string,
38
+ transports: TransportRegistry,
69
39
  ): string | null {
70
- const trimmedRegistry = registryUrl.replace(/\/+$/, "");
71
-
72
- // 1. Registry ref form: namespace/name@version
73
- if (
74
- !url.startsWith("http://") &&
75
- !url.startsWith("https://") &&
76
- !url.startsWith("/") &&
77
- !url.startsWith(".") &&
78
- !url.startsWith("file://") &&
79
- !url.startsWith("memory://") &&
80
- url.includes("@") &&
81
- url.includes("/")
82
- ) {
83
- const atIdx = url.lastIndexOf("@");
84
- if (atIdx <= 0 || atIdx === url.length - 1) return null;
85
- const modulePath = url.slice(0, atIdx);
86
- if (!modulePath.includes("/")) return null;
87
- const version = url.slice(atIdx + 1).replace(/^v/, "");
88
- if (!version) return null;
89
- return joinUnder(cacheRoot, modulePath, version, DEFAULT_MANIFEST_FILENAME);
90
- }
91
-
92
- // 2. HTTP(S) URL — could be a direct registry URL or arbitrary external.
93
- if (url.startsWith("http://") || url.startsWith("https://")) {
94
- let parsed: URL;
95
- try {
96
- parsed = new URL(url);
97
- } catch {
98
- return null;
99
- }
100
- const pathname = normalizePathname(url, parsed);
101
-
102
- // 2a. URL is on the configured registry, with no query/fragment:
103
- // fold into the registry layout so the writer that received a
104
- // registry-ref canonical and the reader that sees a direct URL
105
- // both resolve to the same file.
106
- const normalizedUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
107
- if (
108
- !parsed.search &&
109
- !parsed.hash &&
110
- (normalizedUrl === trimmedRegistry || normalizedUrl.startsWith(`${trimmedRegistry}/`))
111
- ) {
112
- const rel = normalizedUrl.slice(trimmedRegistry.length + 1);
113
- if (!rel) return null;
114
- return joinUnder(cacheRoot, ...rel.split("/"));
115
- }
116
-
117
- // 2b. Arbitrary HTTP(S) URL → __http subtree, with a short query-hash
118
- // suffix when query / fragment are present to prevent collisions.
119
- const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
120
- const disambiguated = disambiguatePath(cleanPath, parsed.search, parsed.hash);
121
- return joinUnder(cacheRoot, HTTP_NAMESPACE, parsed.host, ...disambiguated.split("/"));
122
- }
123
-
124
- return null;
40
+ const segments = transports.cacheLocation(rawUrl);
41
+ if (!segments) return null;
42
+ return joinUnder(cacheRoot, ...segments);
125
43
  }
126
44
 
127
45
  /**
@@ -133,7 +51,7 @@ function cachePathForUrl(
133
51
  */
134
52
  export class LocalManifestCacheSource implements ManifestSource {
135
53
  private readonly cacheRoot: string;
136
- private readonly registryUrl: string;
54
+ private readonly transports: TransportRegistry;
137
55
 
138
56
  constructor(
139
57
  entryDir: string,
@@ -144,7 +62,7 @@ export class LocalManifestCacheSource implements ManifestSource {
144
62
  // single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
145
63
  // back to the entry-anchored default so library/test callers are unchanged.
146
64
  this.cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
147
- this.registryUrl = registryUrl;
65
+ this.transports = defaultTransportRegistry(registryUrl);
148
66
  }
149
67
 
150
68
  supports(url: string): boolean {
@@ -158,6 +76,15 @@ export class LocalManifestCacheSource implements ManifestSource {
158
76
  `LocalManifestCacheSource does not support '${url}' (cache miss or unsupported scheme)`,
159
77
  );
160
78
  }
79
+ // Verify the cached bytes against the import's inline hash before serving.
80
+ // A mismatch is a terminal error — a poisoned cache must never be trusted,
81
+ // and unlike the compiled-validator cache this is not a self-healing miss.
82
+ const { integrity } = splitIntegrity(url);
83
+ if (integrity) {
84
+ const bytes = await fs.readFile(mapped);
85
+ await verifyIntegrity(new Uint8Array(bytes), integrity, splitIntegrity(url).base);
86
+ return { text: bytes.toString("utf-8"), source: pathToFileURL(mapped).href };
87
+ }
161
88
  const text = await fs.readFile(mapped, "utf-8");
162
89
  return { text, source: pathToFileURL(mapped).href };
163
90
  }
@@ -172,7 +99,7 @@ export class LocalManifestCacheSource implements ManifestSource {
172
99
  }
173
100
 
174
101
  private tryMap(url: string): string | null {
175
- const candidate = cachePathForUrl(url, this.cacheRoot, this.registryUrl);
102
+ const candidate = cachePathForUrl(url, this.cacheRoot, this.transports);
176
103
  if (!candidate) return null;
177
104
  // Require a regular file. A directory, dangling symlink, or stat failure
178
105
  // (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
@@ -201,7 +128,7 @@ export function cachePathForCanonical(
201
128
  manifestsDir?: string,
202
129
  ): string | null {
203
130
  const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
204
- return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
131
+ return cachePathForUrl(canonicalSource, cacheRoot, defaultTransportRegistry(registryUrl));
205
132
  }
206
133
 
207
134
  /**
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ import { hostEnv } from "../../host-env.js";
7
+
8
+ export interface DockerCredential {
9
+ username: string;
10
+ password: string;
11
+ }
12
+
13
+ interface DockerConfig {
14
+ auths?: Record<string, { auth?: string; username?: string; password?: string }>;
15
+ credHelpers?: Record<string, string>;
16
+ credsStore?: string;
17
+ }
18
+
19
+ /** Path to `config.json`, honouring `DOCKER_CONFIG`. */
20
+ function dockerConfigPath(): string {
21
+ const override = hostEnv().DOCKER_CONFIG;
22
+ const dir = override && override.trim() ? override.trim() : path.join(homedir(), ".docker");
23
+ return path.join(dir, "config.json");
24
+ }
25
+
26
+ async function readDockerConfig(): Promise<DockerConfig | null> {
27
+ try {
28
+ return JSON.parse(await readFile(dockerConfigPath(), "utf-8")) as DockerConfig;
29
+ } catch {
30
+ return null; // no config, unreadable, or malformed → anonymous
31
+ }
32
+ }
33
+
34
+ /** Run `docker-credential-<helper> get` with `host` on stdin, per the Docker
35
+ * credential-helper protocol, returning `{Username, Secret}` or null. */
36
+ function runCredentialHelper(helper: string, host: string): Promise<DockerCredential | null> {
37
+ return new Promise((resolve) => {
38
+ const proc = spawn(`docker-credential-${helper}`, ["get"], {
39
+ stdio: ["pipe", "pipe", "ignore"],
40
+ });
41
+ let out = "";
42
+ proc.stdout.on("data", (c) => (out += c));
43
+ proc.on("error", () => resolve(null)); // helper binary not installed
44
+ proc.on("close", (code) => {
45
+ if (code !== 0) return resolve(null);
46
+ try {
47
+ const parsed = JSON.parse(out) as { Username?: string; Secret?: string };
48
+ if (parsed.Username && parsed.Secret) {
49
+ resolve({ username: parsed.Username, password: parsed.Secret });
50
+ } else {
51
+ resolve(null);
52
+ }
53
+ } catch {
54
+ resolve(null);
55
+ }
56
+ });
57
+ proc.stdin.end(host);
58
+ });
59
+ }
60
+
61
+ /** Resolve credentials for `host` from the ambient Docker credential chain:
62
+ * a per-registry credential helper, the global credential store, then the
63
+ * inline `auths` entry (base64 `user:password` or explicit fields). Returns
64
+ * null for anonymous access — the caller falls back to an anonymous token. */
65
+ export async function resolveDockerCredential(host: string): Promise<DockerCredential | null> {
66
+ const config = await readDockerConfig();
67
+ if (!config) return null;
68
+
69
+ const helper = config.credHelpers?.[host];
70
+ if (helper) return runCredentialHelper(helper, host);
71
+ if (config.credsStore) return runCredentialHelper(config.credsStore, host);
72
+
73
+ const entry = config.auths?.[host];
74
+ if (entry?.auth) {
75
+ const decoded = Buffer.from(entry.auth, "base64").toString("utf-8");
76
+ const colon = decoded.indexOf(":");
77
+ if (colon > 0) {
78
+ return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
79
+ }
80
+ }
81
+ if (entry?.username && entry?.password) {
82
+ return { username: entry.username, password: entry.password };
83
+ }
84
+ return null;
85
+ }