@izykitten/ce 0.0.1 → 0.0.3-dev.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.
package/src/ce.ts DELETED
@@ -1,1189 +0,0 @@
1
- /**
2
- * Container engine wrapper.
3
- *
4
- * A python -> Bun port of the `ce` script. `ce` with no args prints help and
5
- * exits 0; otherwise `ce [--tag-prefix=<p>] [--cache-prefix=<p>] <command|engine>
6
- * [args...]`.
7
- *
8
- * For engine passthrough, the wrapper rewrites:
9
- * - `-t`/`--tag` values: strip https://, sanitize, move leading path
10
- * segments to the repo side, and rewrite "<path>/renovate_<x>" to
11
- * "<path>/renovate:<x>". Each tag gets the sanitized `--tag-prefix`
12
- * prepended.
13
- * - `--cache-from`/`--cache-to` (and the `--cache` shorthand): gha cache
14
- * scopes get the sanitized `--cache-prefix` prepended; cache-from values
15
- * that match a cache-to value are namespaced, unmatched cache-from
16
- * values pass through unchanged so reads from a shared cache still work.
17
- *
18
- * The final engine invocation uses execve(2) so the wrapper is replaced in
19
- * place; docker/podman/buildah inherits the same PID, signals, TTY and exit
20
- * code. `login` spawns the engines because it loops and must continue on
21
- * partial failure.
22
- */
23
-
24
- import fs from "node:fs";
25
- import { homedir } from "node:os";
26
- import path from "node:path";
27
- import { execve, flock, LOCK_EX, strerror } from "./libc.ts";
28
-
29
- export const SELF = "ce";
30
-
31
- const FORGEJO_HELPER_SUFFIX = "ce-forgejo";
32
- const FORGEJO_STATE_FILE = "ce-forgejo-credentials.json";
33
- const FORGEJO_REFRESH_WINDOW = 5 * 60;
34
-
35
- /** Raised for bad input; the entry point prints it and exits 2. */
36
- export class ValueError extends Error {
37
- override name = "ValueError";
38
- }
39
-
40
- function isOSError(exc: unknown): exc is Error & { code: string } {
41
- return exc instanceof Error && typeof (exc as { code?: unknown }).code === "string";
42
- }
43
-
44
- function message(exc: unknown): string {
45
- return exc instanceof Error ? exc.message : String(exc);
46
- }
47
-
48
- /** Python-style repr() of a string, used in the messages carried over from `ce`. */
49
- function repr(value: string): string {
50
- const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
51
- const body = value
52
- .replaceAll("\\", "\\\\")
53
- .replaceAll(quote, `\\${quote}`)
54
- .replaceAll("\n", "\\n")
55
- .replaceAll("\r", "\\r")
56
- .replaceAll("\t", "\\t");
57
- return `${quote}${body}${quote}`;
58
- }
59
-
60
- export interface Writer {
61
- write(text: string): void;
62
- }
63
-
64
- /**
65
- * Process I/O. Writes are synchronous so nothing is lost when the process is
66
- * replaced by execve. Tests swap these members out.
67
- */
68
- export const sys = {
69
- stdout: { write: (text: string) => void fs.writeSync(1, text) } as Writer,
70
- stderr: { write: (text: string) => void fs.writeSync(2, text) } as Writer,
71
- stdin: { read: (): string => fs.readFileSync(0, "utf8") },
72
- argv0: (): string => process.argv[1] ?? SELF,
73
- };
74
-
75
- function print(text: string, stream: Writer = sys.stdout): void {
76
- stream.write(`${text}\n`);
77
- }
78
-
79
- function env(): Record<string, string> {
80
- return Object.fromEntries(
81
- Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
82
- );
83
- }
84
-
85
- export interface RunResult {
86
- returncode: number;
87
- stderr: Uint8Array;
88
- }
89
-
90
- /** Calls out to the system. Tests swap these members out. */
91
- export const os = {
92
- which: (command: string): string | null => Bun.which(command, { PATH: process.env.PATH ?? "" }),
93
-
94
- /** Run with `input` on stdin, stdout discarded, stderr captured. Throws ENOENT if missing. */
95
- run: (argv: string[], input: Uint8Array): RunResult => {
96
- const result = Bun.spawnSync(argv, { stdin: input, stdout: "ignore", stderr: "pipe", env: env() });
97
- return { returncode: result.exitCode ?? 1, stderr: result.stderr ?? new Uint8Array() };
98
- },
99
-
100
- /** Run with stdout/stderr discarded and return the exit code. */
101
- call: (argv: string[]): number =>
102
- Bun.spawnSync(argv, { stdin: "ignore", stdout: "ignore", stderr: "ignore", env: env() }).exitCode ?? 1,
103
-
104
- sleep: (seconds: number): void => Bun.sleepSync(seconds * 1000),
105
-
106
- urlopen: (url: string, headers: Record<string, string>, timeout: number): Promise<Response> =>
107
- fetch(url, { headers, signal: AbortSignal.timeout(timeout * 1000) }),
108
-
109
- /** Replace this process with `file` looked up on PATH. Only returns by throwing. */
110
- execvp: (file: string, argv: string[]): never => {
111
- const resolved = file.includes("/") ? file : os.which(file);
112
- if (!resolved) {
113
- throw Object.assign(new Error(`[Errno 2] No such file or directory: ${repr(file)}`), { code: "ENOENT" });
114
- }
115
- const code = execve(resolved, argv, env());
116
- throw new Error(`[Errno ${code}] ${strerror(code)}: ${repr(file)}`);
117
- },
118
- };
119
-
120
- /**
121
- * Build the usage help string lazily from the current invocation name, so the
122
- * `Usage:` line reflects how `ce` was actually invoked.
123
- */
124
- export function helpText(): string {
125
- return `Usage: ${sys.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [command|engine] [args]
126
-
127
- Options:
128
- --tag-prefix=<prefix>
129
- Prepend <prefix> to every tag passed via -t/--tag.
130
- A trailing "-" is appended automatically if the prefix
131
- does not already end with "-", "_", or ".".
132
- Also available via TAG_PREFIX environment variable.
133
- --cache-prefix=<prefix>
134
- Prepend <prefix> to every gha cache scope passed via
135
- --cache-from/--cache-to. Defaults to the tag prefix
136
- (TAG_PREFIX); set to "" to keep cache scopes shared.
137
- Also available via CACHE_PREFIX environment variable.
138
- --cache=<spec> Shorthand for passing <spec> to both --cache-from and
139
- --cache-to. Supports the same forms as --cache-from/--cache-to.
140
-
141
- Commands:
142
- login Log in to all available engines
143
-
144
- Engines:
145
- docker [args] Passthrough to docker
146
- podman [args] Passthrough to podman (via gosu if available)
147
- buildah [args] Passthrough to buildah (via gosu if available)
148
-
149
- Environment variables (used by login):
150
- Each registry is a "group". The default group uses the bare variables
151
- below; additional registries use a __<name> suffix that ties the three
152
- variables together.
153
- REGISTRY_USER[__<name>] username
154
- REGISTRY_PASSWORD[__<name>] password
155
- REGISTRY_HOST[__<name>] registry host(s) (https:// stripped);
156
- whitespace-separated list logs in to each
157
- with the same creds, e.g. "docker.io dhi.io"
158
- AUD Forgejo Authorized Integration audience.
159
- When set, the registry matching the Forgejo
160
- Actions token-request host uses a short-lived
161
- OIDC JWT instead of REGISTRY_PASSWORD.
162
- Docker uses a per-host credential helper that
163
- refreshes the JWT five minutes before expiry.
164
- Requires ACTIONS_ID_TOKEN_REQUEST_URL and
165
- ACTIONS_ID_TOKEN_REQUEST_TOKEN from a job
166
- with enable-openid-connect: true.
167
- Defining REGISTRY_HOST__<name> adds a group; its creds come from
168
- REGISTRY_USER__<name> and REGISTRY_PASSWORD__<name>. Example:
169
- REGISTRY_HOST__DOCKER="docker.io dhi.io" + REGISTRY_USER__DOCKER +
170
- REGISTRY_PASSWORD__DOCKER logs in to both Docker Hub and dhi.io with one
171
- set of creds, alongside the default registry. The bare (unsuffixed)
172
- variables are the default group — no suffix needed for the primary
173
- registry.
174
- `;
175
- }
176
-
177
- export const INVALID_TAG_CHARS = /[^A-Za-z0-9_.\-]/gu;
178
- export const INVALID_SCOPE_CHARS = /[^A-Za-z0-9_.:/\-]/gu;
179
-
180
- // Rewrite "<path>/renovate_<suffix>" to "<path>/renovate:<suffix>" so the
181
- // dependency/version lands on the tag side under a renovate repo namespace.
182
- const RENOVATE_RE = /^(.*)\/renovate_(.*)$/u;
183
-
184
- // Matches a gha cache value of the form `type=gha[,mode=max][,scope=<rest>]`.
185
- // Group 1 is ",mode=max" when mode=max is present. Group 2 is the raw scope
186
- // (possibly empty) when scope= is present. A bare `type=gha` or
187
- // `type=gha,mode=max` (no scope=) is matched and treated as an empty scope;
188
- // buildx would otherwise default it to the shared "buildkit" scope, bypassing
189
- // the cache prefix namespacing.
190
- const GHA_RE = /^type=gha(,mode=max)?(?:,scope=(.*))?$/u;
191
-
192
- export function sanitizeChars(value: string, charRegex: RegExp): string {
193
- return value.replace(charRegex, "_");
194
- }
195
-
196
- /**
197
- * Reject non-ASCII characters rather than silently replacing them.
198
- *
199
- * ASCII characters outside the safe set are still sanitized to '_' (that is
200
- * the documented branch-name -> tag contract), but a non-ASCII character would
201
- * silently change the published image name, so we fail loud and name the
202
- * offending character instead of mangling it.
203
- */
204
- export function assertAscii(label: string, value: string): void {
205
- for (const ch of value) {
206
- const code = ch.codePointAt(0)!;
207
- if (code >= 0x80) {
208
- const hex = code.toString(16).toUpperCase().padStart(4, "0");
209
- throw new ValueError(
210
- `ce: refusing to sanitize non-ASCII character ${repr(ch)} ` +
211
- `(U+${hex}) in ${label} ${repr(value)} — rename the ref ` +
212
- `or pass an explicit ASCII tag.`,
213
- );
214
- }
215
- }
216
- }
217
-
218
- function stripProto(value: string): string {
219
- for (const proto of ["https://", "http://"]) {
220
- if (value.startsWith(proto)) return value.slice(proto.length);
221
- }
222
- return value;
223
- }
224
-
225
- /** str.partition(sep): split at the first `sep`. */
226
- function partition(value: string, sep: string): [string, string] {
227
- const i = value.indexOf(sep);
228
- return i < 0 ? [value, ""] : [value.slice(0, i), value.slice(i + sep.length)];
229
- }
230
-
231
- /**
232
- * Sanitize a tag reference.
233
- *
234
- * Strip a leading https:// or http://, then in the tag portion (after the
235
- * first ":") replace any character not in [A-Za-z0-9_.-] with "_". If a "/"
236
- * appears in the tag, move it (and any subsequent path segments) to the repo
237
- * side. Finally, rewrite a "<path>/renovate_<suffix>" prefix to
238
- * "<path>/renovate:<suffix>".
239
- *
240
- * e.g. code.izy.sh/templates/preact/renovate_typescript-native-preview-7.x
241
- * -> code.izy.sh/templates/preact/renovate:typescript-native-preview-7.x
242
- */
243
- export function sanitizeTag(value: string): string {
244
- assertAscii("tag", value);
245
- value = stripProto(value);
246
- let out: string;
247
- if (value.includes(":")) {
248
- const [repo, tag] = partition(value, ":");
249
- if (tag.includes("/")) {
250
- const [first, rest] = partition(tag, "/");
251
- out = `${repo}/${sanitizeChars(first, INVALID_TAG_CHARS)}:${sanitizeChars(rest, INVALID_TAG_CHARS)}`;
252
- } else {
253
- out = `${repo}:${sanitizeChars(tag, INVALID_TAG_CHARS)}`;
254
- }
255
- } else {
256
- out = value;
257
- }
258
- const m = RENOVATE_RE.exec(out);
259
- if (m) out = `${m[1]}/renovate:${m[2]}`;
260
- return out;
261
- }
262
-
263
- /**
264
- * Sanitize a prefix value, appending a "-" separator unless it already ends
265
- * with one of [-_.]. Empty input yields empty output.
266
- */
267
- export function computeSafePrefix(prefix: string): string {
268
- assertAscii("tag prefix", prefix);
269
- let p = sanitizeChars(prefix, INVALID_TAG_CHARS);
270
- if (p && !"-_.".includes(p.at(-1)!)) p += "-";
271
- return p;
272
- }
273
-
274
- /**
275
- * Extract the scope name from a gha cache value, ignoring type/mode wrappers.
276
- * Non-gha values are returned unchanged. A bare gha value with no scope=
277
- * yields an empty scope name so it can be matched against a namespaced
278
- * cache-to. The scope name is sanitized to a safe character set; ":" and "/"
279
- * are kept because they appear in common scope conventions.
280
- */
281
- export function cacheScopeName(value: string): string {
282
- const m = GHA_RE.exec(value);
283
- if (!m) return value;
284
- const scope = m[2];
285
- if (scope === undefined) return "";
286
- assertAscii("cache scope", scope);
287
- return sanitizeChars(scope, INVALID_SCOPE_CHARS);
288
- }
289
-
290
- /**
291
- * Prepend the sanitized cache prefix to gha cache scope values.
292
- *
293
- * A bare gha value with no scope= is treated as an empty scope so the prefix
294
- * is used as the scope, preventing the buildx default "buildkit" scope from
295
- * leaking through. If the scope value is empty, the prefix is used alone with
296
- * no trailing separator. If the prefix does not end with one of [-_.], a "-"
297
- * is inserted between prefix and scope. Non-gha values are returned unchanged.
298
- */
299
- export function normalizeCacheScope(value: string, cachePrefix: string): string {
300
- const m = GHA_RE.exec(value);
301
- if (!m) return value;
302
-
303
- // Nothing to namespace onto: leave the value (and any buildx-default
304
- // scope) untouched.
305
- if (!cachePrefix) return value;
306
-
307
- assertAscii("cache prefix", cachePrefix);
308
- const prefix = sanitizeChars(cachePrefix, INVALID_TAG_CHARS);
309
- // Drop any further ",..." wrapper suffix the scope may carry.
310
- let scope = partition(cacheScopeName(value), ",")[0];
311
-
312
- if (!scope) {
313
- scope = prefix.replace(/[-_.]+$/u, "");
314
- } else if (prefix) {
315
- scope = "-_.".includes(prefix.at(-1)!) ? `${prefix}${scope}` : `${prefix}-${scope}`;
316
- }
317
-
318
- return m[1] ? `type=gha,mode=max,scope=${scope}` : `type=gha,scope=${scope}`;
319
- }
320
-
321
- /** Wait for an engine to be ready. Returns true once any engine responds. */
322
- export function doWait(engines: string[]): boolean {
323
- for (let i = 1; i <= 30; i++) {
324
- for (const engine of engines) {
325
- // buildah has no daemon — always considered ready.
326
- if (engine === "buildah") return true;
327
- if (os.call([engine, "info"]) === 0) return true;
328
- }
329
- print(`waiting for ${engines.join(" ")}... (${i}/30)`, sys.stderr);
330
- os.sleep(1);
331
- }
332
- print(`no engine available: ${engines.join(" ")}`, sys.stderr);
333
- return false;
334
- }
335
-
336
- interface Authority {
337
- scheme: string;
338
- hostname: string | null;
339
- port: number | null;
340
- }
341
-
342
- /**
343
- * Split "[scheme://]host[:port][/path]" into its parts, the way urlsplit does
344
- * for the registry and token-request values `ce` handles. The hostname is
345
- * lowercased and IPv6 brackets are removed.
346
- */
347
- function splitAuthority(raw: string): Authority {
348
- let scheme = "";
349
- let rest = raw;
350
- const sep = raw.indexOf("://");
351
- if (sep >= 0) {
352
- scheme = raw.slice(0, sep).toLowerCase();
353
- rest = raw.slice(sep + 3);
354
- }
355
- let netloc = rest.split(/[/?#]/u, 1)[0]!;
356
- netloc = netloc.slice(netloc.lastIndexOf("@") + 1);
357
- let host = netloc;
358
- let portText: string | null = null;
359
- if (netloc.startsWith("[")) {
360
- const end = netloc.indexOf("]");
361
- if (end < 0) throw new ValueError("Invalid IPv6 URL");
362
- host = netloc.slice(1, end);
363
- const after = netloc.slice(end + 1);
364
- if (after.startsWith(":")) portText = after.slice(1);
365
- } else if (netloc.includes(":")) {
366
- [host, portText] = [netloc.slice(0, netloc.lastIndexOf(":")), netloc.slice(netloc.lastIndexOf(":") + 1)];
367
- }
368
- let port: number | null = null;
369
- if (portText) {
370
- port = /^\d+$/u.test(portText) ? Number(portText) : NaN;
371
- if (!(port >= 0 && port <= 65535)) {
372
- throw new ValueError(`Port could not be cast to integer value as ${repr(portText)}`);
373
- }
374
- }
375
- return { scheme, hostname: host ? host.toLowerCase() : null, port };
376
- }
377
-
378
- /**
379
- * Return a normalized [hostname, effectivePort] for a URL/host.
380
- *
381
- * Registry entries commonly omit their scheme while Forgejo's Actions token
382
- * request URL includes one and may include a path. Comparing normalized
383
- * authorities lets those forms match without treating a service on another
384
- * port as the same registry and leaking the JWT to it.
385
- */
386
- export function urlAuthority(value: string, defaultScheme = "https"): [string, number] {
387
- const raw = value.trim();
388
- if (!raw) throw new ValueError("empty URL or host");
389
-
390
- const parsed = splitAuthority(raw);
391
- const scheme = parsed.scheme || defaultScheme;
392
- if (scheme !== "http" && scheme !== "https") {
393
- throw new ValueError(`unsupported URL scheme ${repr(scheme)}`);
394
- }
395
- if (!parsed.hostname) throw new ValueError(`missing hostname in ${repr(value)}`);
396
- let port = parsed.port;
397
- if (port === null) port = scheme === "http" ? 80 : 443;
398
- return [parsed.hostname, port];
399
- }
400
-
401
- /** Validate that `value` is an absolute HTTP(S) URL with a hostname. */
402
- function assertAbsoluteHttpUrl(value: string, error: string): void {
403
- if (!value.includes("://")) throw new ValueError(error);
404
- const parsed = splitAuthority(value);
405
- if ((parsed.scheme !== "http" && parsed.scheme !== "https") || !parsed.hostname) {
406
- throw new ValueError(error);
407
- }
408
- }
409
-
410
- /** Fetch a Forgejo Actions OIDC JWT for an Authorized Integration. */
411
- export async function forgejoOidcJwt(audience: string, requestUrl: string, requestToken: string): Promise<string> {
412
- assertAbsoluteHttpUrl(requestUrl, "token request URL must be an absolute HTTP(S) URL");
413
-
414
- let tokenUrl: string;
415
- let headers: Record<string, string>;
416
- try {
417
- const url = new URL(requestUrl);
418
- // Replace, rather than duplicate, an audience supplied by a caller.
419
- url.searchParams.delete("audience");
420
- url.searchParams.append("audience", audience);
421
- tokenUrl = url.href;
422
- headers = { Accept: "application/json", Authorization: `bearer ${requestToken}` };
423
- new Headers(headers);
424
- } catch {
425
- // Some URL/header errors include their raw input in the exception.
426
- // Avoid reflecting the request credential into workflow logs.
427
- throw new ValueError("token request URL or headers are invalid");
428
- }
429
-
430
- let body: Uint8Array;
431
- try {
432
- const response = await os.urlopen(tokenUrl, headers, 30);
433
- if (!response.ok) throw new Error(`HTTP Error ${response.status}: ${response.statusText}`);
434
- body = new Uint8Array(await response.arrayBuffer());
435
- } catch (exc) {
436
- throw new ValueError(`token request failed: ${message(exc)}`);
437
- }
438
-
439
- let text: string;
440
- try {
441
- text = new TextDecoder("utf-8", { fatal: true }).decode(body);
442
- } catch {
443
- throw new ValueError("token response is not valid UTF-8 JSON");
444
- }
445
- let payload: unknown;
446
- try {
447
- payload = JSON.parse(text);
448
- } catch {
449
- throw new ValueError("token response is not valid JSON");
450
- }
451
-
452
- if (!isObject(payload)) throw new ValueError("token response is not a JSON object");
453
- const jwt = payload.value;
454
- if (typeof jwt !== "string" || !jwt.trim()) {
455
- throw new ValueError("token response has no non-empty string 'value'");
456
- }
457
- return jwt.trim();
458
- }
459
-
460
- function isObject(value: unknown): value is Record<string, unknown> {
461
- return typeof value === "object" && value !== null && !Array.isArray(value);
462
- }
463
-
464
- /** Read the numeric `exp` claim without treating it as validation. */
465
- export function jwtExpiry(jwt: string): number {
466
- const parts = jwt.split(".");
467
- if (parts.length !== 3) throw new ValueError("OIDC token is not a three-part JWT");
468
- let payload: unknown;
469
- try {
470
- const bytes = Buffer.from(parts[1]!, "base64url");
471
- payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
472
- } catch {
473
- throw new ValueError("OIDC token has an invalid JWT payload");
474
- }
475
- const expiry = isObject(payload) ? payload.exp : undefined;
476
- if (typeof expiry !== "number") throw new ValueError("OIDC token has no numeric 'exp' claim");
477
- return expiry;
478
- }
479
-
480
- /**
481
- * The directory holding the installed `ce` and `docker-credential-ce-forgejo`
482
- * executables (the package's bin/), wherever bunx or an install put it.
483
- */
484
- export function scriptDir(): string {
485
- return fs.realpathSync(path.join(import.meta.dir, "..", "bin"));
486
- }
487
-
488
- /** Make the installed script directory visible to Docker. */
489
- export function ensureHelperOnPath(): void {
490
- const dir = scriptDir();
491
- const parts = (process.env.PATH ?? "").split(path.delimiter);
492
- if (!parts.includes(dir)) process.env.PATH = [dir, ...parts].join(path.delimiter);
493
- }
494
-
495
- function expandUser(value: string): string {
496
- if (value === "~" || value.startsWith("~/")) return path.join(homeDir(), value.slice(1));
497
- return value;
498
- }
499
-
500
- function homeDir(): string {
501
- return homedir();
502
- }
503
-
504
- export function dockerConfigDir(): string {
505
- const configured = process.env.DOCKER_CONFIG;
506
- if (configured) return path.resolve(expandUser(configured));
507
- return path.join(homeDir(), ".docker");
508
- }
509
-
510
- export function forgejoStatePath(): string {
511
- return path.join(dockerConfigDir(), FORGEJO_STATE_FILE);
512
- }
513
-
514
- /** Return the Docker registry server name, excluding scheme and path. */
515
- export function registryServer(value: string): string {
516
- const parsed = splitAuthority(value.trim());
517
- if (!parsed.hostname) throw new ValueError(`missing hostname in ${repr(value)}`);
518
- let hostname = parsed.hostname;
519
- if (hostname.includes(":")) hostname = `[${hostname}]`;
520
- return parsed.port !== null ? `${hostname}:${parsed.port}` : hostname;
521
- }
522
-
523
- function registryKey(value: string): string {
524
- return registryServer(value).toLowerCase();
525
- }
526
-
527
- export function readJsonFile(file: string, fallback: unknown): unknown {
528
- try {
529
- return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(file)));
530
- } catch (exc) {
531
- if (isOSError(exc) && exc.code === "ENOENT") return fallback;
532
- throw new ValueError(`cannot read JSON file ${repr(file)}: ${message(exc)}`);
533
- }
534
- }
535
-
536
- /** JSON with sorted keys and compact separators, as `ce` has always written it. */
537
- function dumpJson(payload: unknown): string {
538
- return JSON.stringify(payload, (_key, value: unknown) =>
539
- isObject(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : value,
540
- );
541
- }
542
-
543
- export function writePrivateJson(file: string, payload: unknown): void {
544
- const directory = path.dirname(file);
545
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
546
- const temporary = path.join(directory, `.ce-forgejo-${crypto.randomUUID().slice(0, 8)}`);
547
- const fd = fs.openSync(temporary, "wx", 0o600);
548
- try {
549
- try {
550
- fs.fchmodSync(fd, 0o600);
551
- fs.writeSync(fd, `${dumpJson(payload)}\n`);
552
- fs.fsyncSync(fd);
553
- } finally {
554
- fs.closeSync(fd);
555
- }
556
- fs.renameSync(temporary, file);
557
- } finally {
558
- fs.rmSync(temporary, { force: true });
559
- }
560
- }
561
-
562
- interface HelperState {
563
- version?: number;
564
- registries: Record<string, Record<string, unknown>>;
565
- }
566
-
567
- type LockCallback<T> = (state: HelperState) => [T, boolean] | Promise<[T, boolean]>;
568
-
569
- /** Run `callback(state)` under the helper's cross-process lock. */
570
- export async function withForgejoStateLock<T>(callback: LockCallback<T>): Promise<T> {
571
- const statePath = forgejoStatePath();
572
- fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
573
- const lockPath = `${statePath}.lock`;
574
- const lock = fs.openSync(lockPath, "a+");
575
- try {
576
- fs.chmodSync(lockPath, 0o600);
577
- const code = flock(lock, LOCK_EX);
578
- if (code !== 0) {
579
- throw Object.assign(new Error(`[Errno ${code}] ${strerror(code)}: ${repr(lockPath)}`), { code: "EFLOCK" });
580
- }
581
- const state = readJsonFile(statePath, { version: 1, registries: {} });
582
- if (!isObject(state) || !isObject(state.registries)) {
583
- throw new ValueError("Forgejo credential-helper state is invalid");
584
- }
585
- const [result, changed] = await callback(state as unknown as HelperState);
586
- if (changed) writePrivateJson(statePath, state);
587
- return result;
588
- } finally {
589
- fs.closeSync(lock);
590
- }
591
- }
592
-
593
- export async function saveForgejoHelperState(
594
- registry: string,
595
- username: string,
596
- audience: string,
597
- authority: [string, number],
598
- jwt: string,
599
- ): Promise<void> {
600
- const expiry = jwtExpiry(jwt);
601
- if (expiry <= Date.now() / 1000 + FORGEJO_REFRESH_WINDOW) {
602
- throw new ValueError("OIDC token expires within the five-minute refresh window");
603
- }
604
- await withForgejoStateLock((state) => {
605
- state.registries[registryKey(registry)] = {
606
- audience,
607
- authority: [authority[0], authority[1]],
608
- expires_at: expiry,
609
- token: jwt,
610
- username,
611
- };
612
- return [undefined, true];
613
- });
614
- }
615
-
616
- /** Atomically register the per-host helper after a successful login. */
617
- export async function configureDockerCredentialHelper(
618
- registry: string,
619
- username: string,
620
- audience: string,
621
- authority: [string, number],
622
- jwt: string,
623
- ): Promise<void> {
624
- await saveForgejoHelperState(registry, username, audience, authority, jwt);
625
-
626
- const configPath = path.join(dockerConfigDir(), "config.json");
627
- const config = readJsonFile(configPath, {});
628
- if (!isObject(config)) throw new ValueError("Docker config is not a JSON object");
629
- config.credHelpers ??= {};
630
- const helpers = config.credHelpers;
631
- if (!isObject(helpers)) throw new ValueError("Docker config 'credHelpers' is not a JSON object");
632
- helpers[registry] = FORGEJO_HELPER_SUFFIX;
633
-
634
- // A helper takes precedence, but remove the now-stale plaintext copy so the
635
- // JWT has one source of truth and cannot be used after helper-state expiry.
636
- const auths = config.auths;
637
- if (isObject(auths)) {
638
- for (const key of Object.keys(auths)) {
639
- if (registryKey(key) === registryKey(registry)) delete auths[key];
640
- }
641
- }
642
- writePrivateJson(configPath, config);
643
- }
644
-
645
- export async function helperGet(registry: string): Promise<{ Username: string; Secret: string }> {
646
- const key = registryKey(registry);
647
-
648
- return withForgejoStateLock(async (state) => {
649
- const entry = state.registries[key];
650
- if (!isObject(entry)) throw new ValueError(`no Forgejo OIDC credentials configured for ${repr(registry)}`);
651
- const { username, token, expires_at: expiry } = entry;
652
- if (typeof username !== "string" || !username) {
653
- throw new ValueError("Forgejo credential-helper username is missing");
654
- }
655
- if (typeof token !== "string" || typeof expiry !== "number") {
656
- throw new ValueError("Forgejo credential-helper token state is invalid");
657
- }
658
- if (expiry > Date.now() / 1000 + FORGEJO_REFRESH_WINDOW) {
659
- return [{ Username: username, Secret: token }, false];
660
- }
661
-
662
- const { audience, authority: storedAuthority } = entry;
663
- const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
664
- const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
665
- if (typeof audience !== "string" || !audience) {
666
- throw new ValueError("Forgejo credential-helper audience is missing");
667
- }
668
- if (!(requestUrl && requestToken)) {
669
- throw new ValueError("OIDC refresh requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN");
670
- }
671
- const [host, port] = urlAuthority(requestUrl);
672
- if (!Array.isArray(storedAuthority) || storedAuthority.length !== 2 || storedAuthority[0] !== host || storedAuthority[1] !== port) {
673
- throw new ValueError("OIDC token-request host does not match the registry");
674
- }
675
-
676
- const refreshed = await forgejoOidcJwt(audience, requestUrl, requestToken);
677
- const refreshedExpiry = jwtExpiry(refreshed);
678
- if (refreshedExpiry <= Date.now() / 1000 + FORGEJO_REFRESH_WINDOW) {
679
- throw new ValueError("refreshed OIDC token expires within the five-minute window");
680
- }
681
- entry.token = refreshed;
682
- entry.expires_at = refreshedExpiry;
683
- return [{ Username: username, Secret: refreshed }, true];
684
- });
685
- }
686
-
687
- /** Implement Docker's credential-helper protocol for Forgejo OIDC. */
688
- export async function cmdCredentialHelper(argv: string[]): Promise<number> {
689
- const command = argv[0];
690
- if (argv.length !== 1 || !["get", "store", "erase", "list"].includes(command!)) {
691
- print("ce: credential-helper requires get|store|erase|list", sys.stderr);
692
- return 1;
693
- }
694
- try {
695
- if (command === "get") {
696
- const result = await helperGet(sys.stdin.read().trim());
697
- sys.stdout.write(`${JSON.stringify(result)}\n`);
698
- return 0;
699
- }
700
-
701
- if (command === "store") {
702
- const payload: unknown = JSON.parse(sys.stdin.read());
703
- if (!isObject(payload)) throw new ValueError("credential-helper store payload is invalid");
704
- const { ServerURL: registry, Username: username, Secret: secret } = payload;
705
- if (![registry, username, secret].every((v) => typeof v === "string" && v)) {
706
- throw new ValueError("credential-helper store fields are incomplete");
707
- }
708
- const expiry = jwtExpiry(secret as string);
709
- await withForgejoStateLock((state) => {
710
- const entry = (state.registries[registryKey(registry as string)] ??= {});
711
- Object.assign(entry, { expires_at: expiry, token: secret, username });
712
- return [undefined, true];
713
- });
714
- return 0;
715
- }
716
-
717
- if (command === "erase") {
718
- const registry = sys.stdin.read().trim();
719
- await withForgejoStateLock((state) => {
720
- const key = registryKey(registry);
721
- const removed = key in state.registries;
722
- delete state.registries[key];
723
- return [undefined, removed];
724
- });
725
- return 0;
726
- }
727
-
728
- const result = await withForgejoStateLock((state) => {
729
- const entries: Record<string, unknown> = {};
730
- for (const [key, value] of Object.entries(state.registries)) {
731
- if (isObject(value)) entries[key] = value.username ?? "";
732
- }
733
- return [entries, false];
734
- });
735
- sys.stdout.write(`${JSON.stringify(result)}\n`);
736
- return 0;
737
- } catch (exc) {
738
- if (!(exc instanceof ValueError || exc instanceof SyntaxError || isOSError(exc))) throw exc;
739
- print(`ce: Forgejo credential helper: ${message(exc)}`, sys.stderr);
740
- return 1;
741
- }
742
- }
743
-
744
- /**
745
- * Log in to every available engine for each registry group.
746
- *
747
- * Groups are processed in sorted order (default group first, then
748
- * REGISTRY_HOST__<suffix> groups alphabetically by suffix) so that the order
749
- * is deterministic and derivable from the variable names. This matters when
750
- * two groups target the same registry with different credentials: the
751
- * credential stores are last-write-wins, so the final group wins.
752
- */
753
- export async function cmdLogin(): Promise<number> {
754
- ensureHelperOnPath();
755
- const environ = process.env;
756
- const groups: string[] = [];
757
- if (environ.REGISTRY_HOST) groups.push("");
758
- for (const name of Object.keys(environ).sort()) {
759
- if (name.startsWith("REGISTRY_HOST__")) {
760
- const suffix = name.slice("REGISTRY_HOST__".length);
761
- if (suffix) groups.push(suffix);
762
- }
763
- }
764
-
765
- if (groups.length === 0) {
766
- print("Skipping login: no REGISTRY_HOST env vars set");
767
- return 0;
768
- }
769
-
770
- const audience = environ.AUD ?? "";
771
- let oidcAuthority: [string, number] | null = null;
772
- let oidcRequestUrl = "";
773
- let oidcRequestToken = "";
774
- if (audience) {
775
- oidcRequestUrl = environ.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
776
- oidcRequestToken = environ.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
777
- if (!(oidcRequestUrl && oidcRequestToken)) {
778
- print(
779
- "Forgejo OIDC login config is incomplete: AUD requires " +
780
- "ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN",
781
- sys.stderr,
782
- );
783
- return 1;
784
- }
785
- try {
786
- assertAbsoluteHttpUrl(oidcRequestUrl, "must be an absolute HTTP(S) URL");
787
- oidcAuthority = urlAuthority(oidcRequestUrl);
788
- } catch (exc) {
789
- if (!(exc instanceof ValueError)) throw exc;
790
- print(`Invalid ACTIONS_ID_TOKEN_REQUEST_URL: ${exc.message}`, sys.stderr);
791
- return 1;
792
- }
793
- }
794
-
795
- // Resolve and validate every target before logging in. In particular, this
796
- // lets an OIDC fetch failure stop the operation before any engine receives
797
- // credentials.
798
- const targets: { registry: string; label: string; user: string; pwd: string; useOidc: boolean }[] = [];
799
- let failed = 0;
800
- for (const group of groups) {
801
- const suffix = group ? `__${group}` : "";
802
- const host = environ[`REGISTRY_HOST${suffix}`] ?? "";
803
- const user = environ[`REGISTRY_USER${suffix}`] ?? "";
804
- const pwd = environ[`REGISTRY_PASSWORD${suffix}`] ?? "";
805
- const label = group || "default";
806
-
807
- if (!(host && user)) {
808
- print(`Login config for '${label}' is incomplete: host/user/password not all set`, sys.stderr);
809
- failed = 1;
810
- continue;
811
- }
812
-
813
- // A group's host may list multiple registries separated by whitespace;
814
- // log in to each with the same creds.
815
- for (const rawRegistry of host.split(/\s+/u).filter(Boolean)) {
816
- let authority: [string, number];
817
- let registry: string;
818
- try {
819
- authority = urlAuthority(rawRegistry);
820
- registry = registryServer(rawRegistry);
821
- } catch (exc) {
822
- if (!(exc instanceof ValueError)) throw exc;
823
- print(`Invalid registry host for '${label}' (${rawRegistry}): ${exc.message}`, sys.stderr);
824
- failed = 1;
825
- continue;
826
- }
827
- const useOidc = oidcAuthority !== null && authority[0] === oidcAuthority[0] && authority[1] === oidcAuthority[1];
828
- if (!useOidc && !pwd) {
829
- print(`Login config for '${label}' is incomplete: host/user/password not all set`, sys.stderr);
830
- failed = 1;
831
- continue;
832
- }
833
- targets.push({ registry, label, user, pwd, useOidc });
834
- }
835
- }
836
-
837
- let oidcJwt: string | null = null;
838
- if (targets.some((t) => t.useOidc)) {
839
- try {
840
- oidcJwt = await forgejoOidcJwt(audience, oidcRequestUrl, oidcRequestToken);
841
- } catch (exc) {
842
- if (!(exc instanceof ValueError)) throw exc;
843
- print(`Failed to fetch Forgejo OIDC JWT: ${exc.message}`, sys.stderr);
844
- return 1;
845
- }
846
- }
847
-
848
- for (const { registry, label, user, pwd, useOidc } of targets) {
849
- const password = useOidc ? oidcJwt : pwd;
850
- // oidcJwt is guaranteed above for OIDC targets. Keep this guard so a
851
- // future refactor cannot accidentally fall back to a static pwd.
852
- if (password === null) {
853
- print(`Forgejo OIDC JWT unavailable for '${label}' (${registry})`, sys.stderr);
854
- failed = 1;
855
- continue;
856
- }
857
- print(`Logging in to ${registry} (${label})...`);
858
- let loggedIn: string | null = null;
859
- for (const engine of ["docker", "podman", "buildah"]) {
860
- if (!os.which(engine)) continue;
861
- let result: RunResult;
862
- try {
863
- result = os.run([engine, "login", "-u", user, "--password-stdin", registry], Buffer.from(`${password}\n`));
864
- } catch (exc) {
865
- if (isOSError(exc) && exc.code === "ENOENT") continue;
866
- throw exc;
867
- }
868
- if (result.returncode === 0) {
869
- loggedIn = engine;
870
- break;
871
- }
872
- print(`${engine} login failed for '${label}' (${registry}):`, sys.stderr);
873
- sys.stderr.write(new TextDecoder().decode(result.stderr));
874
- }
875
- if (loggedIn) {
876
- print(
877
- `Logged in to ${registry} (${label}) via ${loggedIn} ` +
878
- `(and all engines at or below it: docker < podman < buildah share credentials)`,
879
- );
880
- if (useOidc) {
881
- try {
882
- if (oidcAuthority === null || oidcJwt === null) throw new ValueError("OIDC helper state is unavailable");
883
- await configureDockerCredentialHelper(registry, user, audience, oidcAuthority, oidcJwt);
884
- } catch (exc) {
885
- if (!(exc instanceof ValueError || isOSError(exc))) throw exc;
886
- print(`Failed to configure Forgejo credential helper for '${label}' (${registry}): ${message(exc)}`, sys.stderr);
887
- failed = 1;
888
- }
889
- }
890
- } else {
891
- print(`Login failed for '${label}' (${registry})`, sys.stderr);
892
- failed = 1;
893
- }
894
- }
895
- return failed;
896
- }
897
-
898
- /**
899
- * Return the rewritten tag refs for a -t/--tag value (no flag prefix).
900
- *
901
- * Strips a leading https:// or http://, splits comma/whitespace-separated
902
- * tags, prepends the sanitized tagPrefix, sanitizes each, and returns the
903
- * list of refs. Bare refs (no colon) pass through unchanged.
904
- */
905
- export function rewriteTagValue(value: string, tagPrefix: string): string[] {
906
- const v = stripProto(value);
907
- const refs: string[] = [];
908
- if (v.includes(":")) {
909
- const [repo, tags] = partition(v, ":");
910
- const safePrefix = computeSafePrefix(tagPrefix);
911
- // Run each through sanitizeTag so "/" in a tag gets moved to the repo.
912
- for (const tag of tags.split(/[,\s]+/u)) {
913
- if (!tag) continue;
914
- const ref = sanitizeTag(`${repo}:${safePrefix}${tag}`);
915
- if (ref) refs.push(ref);
916
- }
917
- } else {
918
- assertAscii("tag", v);
919
- refs.push(v);
920
- }
921
- return refs;
922
- }
923
-
924
- /**
925
- * Consume the next token of `argv` as the value for `flag`. Errors if `argv`
926
- * is empty or the next token looks like another flag (starts with '-' and
927
- * isn't a bare '-'). A bare '-' is allowed (e.g. `docker build -f -`).
928
- */
929
- export function consumeValue(argv: string[], flag: string): [string, string[]] {
930
- if (argv.length === 0) throw new ValueError(`ce: ${flag} requires a value but none was provided.`);
931
- const v = argv[0]!;
932
- if (v.length > 1 && v.startsWith("-")) {
933
- throw new ValueError(`ce: ${flag} requires a value but the next token ${repr(v)} looks like a flag.`);
934
- }
935
- return [v, argv.slice(1)];
936
- }
937
-
938
- /** Return [value, nextIndex] for a flag that takes a separate value. */
939
- export function nextValue(argv: string[], i: number, flag: string): [string, number] {
940
- return [consumeValue(argv.slice(i + 1), flag)[0], i + 1];
941
- }
942
-
943
- export interface BuildSummary {
944
- tags: string[];
945
- cache_from: string[];
946
- cache_to: string[];
947
- build_args: string[];
948
- dockerfile: string;
949
- target: string;
950
- platform: string;
951
- context: string;
952
- }
953
-
954
- /**
955
- * Parse a build/bud argv once into exec args + a summary record.
956
- *
957
- * Returns [execArgs, summary] where execArgs is the ordered argument list to
958
- * pass to the engine (tags inline, cache-from/cache-to appended at the end)
959
- * and summary holds display fields with already-rewritten tag/cache values.
960
- * The context is the last non-flag positional.
961
- */
962
- export function parseBuildArgs(argv: string[], tagPrefix: string, cachePrefix: string): [string[], BuildSummary] {
963
- const execArgs: string[] = [];
964
- const rawCacheFrom: string[] = [];
965
- const rawCacheTo: string[] = [];
966
- const summary: BuildSummary = {
967
- tags: [],
968
- cache_from: [],
969
- cache_to: [],
970
- build_args: [],
971
- dockerfile: "",
972
- target: "",
973
- platform: "",
974
- context: "",
975
- };
976
- const addTags = (value: string, flag: "-t" | "--tag=") => {
977
- const refs = rewriteTagValue(value, tagPrefix);
978
- for (const ref of refs) execArgs.push(`${flag}${ref}`);
979
- summary.tags.push(...refs);
980
- };
981
-
982
- for (let i = 0; i < argv.length; i++) {
983
- const arg = argv[i]!;
984
- let v: string;
985
- if (arg === "-t") {
986
- [v, i] = nextValue(argv, i, "-t");
987
- addTags(v, "-t");
988
- } else if (arg === "--tag") {
989
- [v, i] = nextValue(argv, i, "--tag");
990
- addTags(v, "--tag=");
991
- } else if (arg.startsWith("-t") && arg.length > 2) {
992
- addTags(arg.slice(2), "-t");
993
- } else if (arg.startsWith("--tag=")) {
994
- addTags(arg.slice("--tag=".length), "--tag=");
995
- } else if (arg === "--cache-from") {
996
- [v, i] = nextValue(argv, i, "--cache-from");
997
- rawCacheFrom.push(v);
998
- } else if (arg === "--cache-to") {
999
- [v, i] = nextValue(argv, i, "--cache-to");
1000
- rawCacheTo.push(v);
1001
- } else if (arg.startsWith("--cache-from=")) {
1002
- rawCacheFrom.push(arg.slice("--cache-from=".length));
1003
- } else if (arg.startsWith("--cache-to=")) {
1004
- rawCacheTo.push(arg.slice("--cache-to=".length));
1005
- } else if (arg === "--cache") {
1006
- [v, i] = nextValue(argv, i, "--cache");
1007
- rawCacheFrom.push(v);
1008
- rawCacheTo.push(v);
1009
- } else if (arg.startsWith("--cache=")) {
1010
- v = arg.slice("--cache=".length);
1011
- rawCacheFrom.push(v);
1012
- rawCacheTo.push(v);
1013
- } else if (arg === "-f" || arg === "--file") {
1014
- [v, i] = nextValue(argv, i, arg);
1015
- execArgs.push(arg, v);
1016
- summary.dockerfile = v;
1017
- } else if (arg.startsWith("-f") && arg.length > 2) {
1018
- execArgs.push(arg);
1019
- summary.dockerfile = arg.slice(2);
1020
- } else if (arg.startsWith("--file=")) {
1021
- execArgs.push(arg);
1022
- summary.dockerfile = arg.slice("--file=".length);
1023
- } else if (arg === "--target") {
1024
- [v, i] = nextValue(argv, i, "--target");
1025
- execArgs.push(arg, v);
1026
- summary.target = v;
1027
- } else if (arg.startsWith("--target=")) {
1028
- execArgs.push(arg);
1029
- summary.target = arg.slice("--target=".length);
1030
- } else if (arg === "--platform") {
1031
- [v, i] = nextValue(argv, i, "--platform");
1032
- execArgs.push(arg, v);
1033
- summary.platform = v;
1034
- } else if (arg.startsWith("--platform=")) {
1035
- execArgs.push(arg);
1036
- summary.platform = arg.slice("--platform=".length);
1037
- } else if (arg === "--build-arg") {
1038
- [v, i] = nextValue(argv, i, "--build-arg");
1039
- execArgs.push(arg, v);
1040
- summary.build_args.push(v);
1041
- } else if (arg.startsWith("--build-arg=")) {
1042
- execArgs.push(arg);
1043
- summary.build_args.push(arg.slice("--build-arg=".length));
1044
- } else if (arg.startsWith("-")) {
1045
- execArgs.push(arg);
1046
- } else {
1047
- execArgs.push(arg);
1048
- summary.context = arg; // last non-flag positional wins
1049
- }
1050
- }
1051
-
1052
- // Scopes matching a cache-to get the cache prefix applied so reads and
1053
- // writes target the same namespaced scope. Unmatched cache-from values pass
1054
- // through unchanged so they can read from a shared cache.
1055
- const toNames = new Set(rawCacheTo.map(cacheScopeName));
1056
- for (const c of rawCacheFrom) {
1057
- const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
1058
- execArgs.push(`--cache-from=${rewritten}`);
1059
- summary.cache_from.push(rewritten);
1060
- }
1061
- for (const c of rawCacheTo) {
1062
- const rewritten = normalizeCacheScope(c, cachePrefix);
1063
- execArgs.push(`--cache-to=${rewritten}`);
1064
- summary.cache_to.push(rewritten);
1065
- }
1066
-
1067
- return [execArgs, summary];
1068
- }
1069
-
1070
- /** Print a build/bud summary to stderr. Only for build-like subcommands. */
1071
- export function formatBuildSummary(engine: string, execArgs: string[], summary: BuildSummary): void {
1072
- // Skip leading flags and an optional "buildx" to find a build|bud
1073
- // subcommand; do not summarize other subcommands (e.g. push, pull).
1074
- let subcommand = "";
1075
- for (const arg of execArgs) {
1076
- if (arg.startsWith("-") || arg === "buildx") continue;
1077
- if (arg === "build" || arg === "bud") subcommand = arg;
1078
- break;
1079
- }
1080
- if (!subcommand) return;
1081
-
1082
- const out = [`[${SELF}] build summary:`, ` engine: ${engine}`, ` subcommand: ${subcommand}`];
1083
- const list = (title: string, items: string[]) => {
1084
- if (items.length) out.push(` ${title}:`, ...items.map((item) => ` - ${item}`));
1085
- };
1086
- list("tags", summary.tags);
1087
- list("cache-from", summary.cache_from);
1088
- list("cache-to", summary.cache_to);
1089
- if (summary.dockerfile) out.push(` dockerfile: ${summary.dockerfile}`);
1090
- if (summary.target) out.push(` target: ${summary.target}`);
1091
- if (summary.platform) out.push(` platform: ${summary.platform}`);
1092
- list("build args", summary.build_args);
1093
- if (summary.context) out.push(` context: ${summary.context}`);
1094
- out.push("");
1095
- sys.stderr.write(`${out.join("\n")}\n`);
1096
- }
1097
-
1098
- /**
1099
- * Rewrite args and exec the engine in place.
1100
- *
1101
- * Tag refs and cache scopes are normalized, then the wrapper is replaced by
1102
- * the engine via execve so it inherits PID/exit code/signals.
1103
- */
1104
- export function enginePassthrough(engine: string, argv: string[], tagPrefix: string, cachePrefix: string): number {
1105
- // Validate/rewrite args first so input errors (bad tag, --cache with no
1106
- // value, non-ASCII) surface as exit 2 even when the engine binary is not
1107
- // installed.
1108
- const [execArgs, summary] = parseBuildArgs(argv, tagPrefix, cachePrefix);
1109
-
1110
- ensureHelperOnPath();
1111
- if (!os.which(engine)) {
1112
- print(`Engine not found: ${engine}`, sys.stderr);
1113
- return 1;
1114
- }
1115
- if (!doWait([engine])) return 1;
1116
-
1117
- formatBuildSummary(engine, execArgs, summary);
1118
-
1119
- if (engine === "docker") os.execvp("docker", ["docker", ...execArgs]);
1120
- // podman / buildah: prefer gosu if present so we drop privileges first.
1121
- if (os.which("gosu")) {
1122
- const user = process.env.USER ?? "";
1123
- if (!user) {
1124
- print(
1125
- "ce: gosu is available but USER is not set; cannot drop privileges. Set USER or remove gosu from PATH.",
1126
- sys.stderr,
1127
- );
1128
- return 1;
1129
- }
1130
- os.execvp("gosu", ["gosu", user, engine, ...execArgs]);
1131
- }
1132
- return os.execvp(engine, [engine, ...execArgs]);
1133
- }
1134
-
1135
- export async function main(argv: string[]): Promise<number> {
1136
- if (argv.length === 0) {
1137
- sys.stderr.write(helpText());
1138
- return 0;
1139
- }
1140
-
1141
- let tagPrefix = process.env.TAG_PREFIX ?? "";
1142
- // Honor an explicitly empty CACHE_PREFIX rather than falling back to TAG_PREFIX.
1143
- let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
1144
-
1145
- while (argv.length) {
1146
- const a = argv[0]!;
1147
- if (a === "--tag-prefix") {
1148
- [tagPrefix, argv] = consumeValue(argv.slice(1), "--tag-prefix");
1149
- } else if (a.startsWith("--tag-prefix=")) {
1150
- tagPrefix = a.slice("--tag-prefix=".length);
1151
- argv = argv.slice(1);
1152
- } else if (a === "--cache-prefix") {
1153
- [cachePrefix, argv] = consumeValue(argv.slice(1), "--cache-prefix");
1154
- } else if (a.startsWith("--cache-prefix=")) {
1155
- cachePrefix = a.slice("--cache-prefix=".length);
1156
- argv = argv.slice(1);
1157
- } else {
1158
- break;
1159
- }
1160
- }
1161
-
1162
- if (argv.length === 0) {
1163
- sys.stderr.write(helpText());
1164
- return 0;
1165
- }
1166
-
1167
- const [command, ...rest] = argv as [string, ...string[]];
1168
- if (command === "login") return cmdLogin();
1169
- if (command === "credential-helper") return cmdCredentialHelper(rest);
1170
- if (command === "docker" || command === "podman" || command === "buildah") {
1171
- return enginePassthrough(command, rest, tagPrefix, cachePrefix);
1172
- }
1173
-
1174
- sys.stderr.write(helpText());
1175
- return 1;
1176
- }
1177
-
1178
- /** Entry point for the bin scripts: run `main` and exit with its status. */
1179
- export async function entry(argv: string[]): Promise<never> {
1180
- try {
1181
- process.exit(await main(argv));
1182
- } catch (exc) {
1183
- // ValueErrors carry a clear message; surface it cleanly instead of as a
1184
- // stack trace.
1185
- if (!(exc instanceof ValueError)) throw exc;
1186
- print(exc.message, sys.stderr);
1187
- process.exit(2);
1188
- }
1189
- }