@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.20

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 (54) hide show
  1. package/README.md +125 -62
  2. package/manifest.schema.json +208 -11
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +121 -51
  6. package/src/build/cli.ts +29 -22
  7. package/src/build/elf.ts +8 -7
  8. package/src/build/hashes.ts +82 -20
  9. package/src/build/project.ts +95 -28
  10. package/src/builder/pipeline.ts +344 -88
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +7 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +61 -15
  17. package/src/core/models.ts +32 -15
  18. package/src/core/origin.ts +23 -0
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +89 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +111 -40
  24. package/src/ctx/index.ts +84 -31
  25. package/src/ctx/sh.ts +105 -44
  26. package/src/ctx/types.ts +12 -4
  27. package/src/describe/describe.ts +701 -133
  28. package/src/describe/manifest-schema.ts +258 -61
  29. package/src/describe/mapper-source.ts +249 -104
  30. package/src/describe/record.ts +67 -37
  31. package/src/dev/cli.ts +72 -35
  32. package/src/harness/env.ts +45 -25
  33. package/src/harness/execute.ts +58 -29
  34. package/src/harness/index.ts +22 -9
  35. package/src/harness/main.ts +239 -78
  36. package/src/harness/result.ts +38 -22
  37. package/src/harness/validate.ts +77 -15
  38. package/src/index.ts +124 -11
  39. package/src/library/checks.ts +28 -0
  40. package/src/library/compose-deploy.ts +113 -0
  41. package/src/library/github-source.ts +100 -47
  42. package/src/library/helm-deploy-ssh.ts +452 -108
  43. package/src/library/helm-deploy.ts +449 -0
  44. package/src/library/http-health.ts +7 -5
  45. package/src/library/image-build.ts +499 -105
  46. package/src/library/index.ts +48 -12
  47. package/src/library/models.ts +33 -9
  48. package/src/library/ssh.ts +67 -0
  49. package/src/testing/index.ts +74 -36
  50. package/src/util/canonical.ts +7 -4
  51. package/src/util/handler-hash.ts +17 -7
  52. package/src/util/hash.ts +5 -5
  53. package/src/util/tar.ts +86 -36
  54. package/src/library/script.ts +0 -85
package/src/core/steps.ts CHANGED
@@ -1,29 +1,46 @@
1
- import type { z } from "zod";
2
- import { STEP, type StepBrand } from "./brands.ts";
3
- import type { AnyModel, Infer, Optional } from "./models.ts";
4
- import type { Secret } from "./secrets.ts";
5
- import type { Simplify } from "./types.ts";
1
+ import type { z } from 'zod';
2
+ import { ORIGIN, STEP, type StepBrand } from './brands.ts';
3
+ import { callerFile } from './origin.ts';
4
+ import type { AnyModel, Infer, Optional } from './models.ts';
5
+ import type { External } from './external.ts';
6
+ import type { AnyConfigEntry } from './config.ts';
7
+ import { configRefsIn } from './config-refs.ts';
8
+ import type { Secret } from './secrets.ts';
9
+ import type { Simplify } from './types.ts';
6
10
 
7
- /** A step input entry: a model (matchable by model), `optional(model)`, or a plain zod schema (mapper required). */
8
- export type InputEntry = AnyModel | Optional | z.ZodType;
11
+ /**
12
+ * A step input entry: a model (matchable by model), `optional(model)`, a plain zod schema (mapper required),
13
+ * or `external(model, …)`, answered from outside the run.
14
+ */
15
+ export type InputEntry = AnyModel | Optional | External | z.ZodType;
9
16
  export type InputSpec = { readonly [key: string]: InputEntry };
10
17
 
11
18
  export type InferEntry<E> =
12
- E extends Optional<infer M> ? Infer<M> | undefined
13
- : E extends AnyModel ? Infer<E>
14
- : E extends z.ZodType ? z.output<E>
15
- : never;
19
+ E extends Optional<infer M>
20
+ ? Infer<M> | undefined
21
+ : E extends External<infer M>
22
+ ? Infer<M>
23
+ : E extends AnyModel
24
+ ? Infer<E>
25
+ : E extends z.ZodType
26
+ ? z.output<E>
27
+ : never;
16
28
  /** What a handler receives: the output view of every input entry. */
17
29
  export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
18
30
 
19
- /** `custom` steps have a handler file; the others are library steps implemented by the SDK. */
20
- export type StepKind = "custom" | "script" | "gitHubSource" | "imageBuild" | "helmDeployOverSsh";
31
+ /**
32
+ * A label for what a step is, shown in the UI. `custom` (the default) is a step whose handler is in
33
+ * `handlers/`; a step that carries its implementation (`implement(step, …)` staged directly) names its
34
+ * kind, e.g. `gitHubSource`. Packages prefix theirs (`acme/deploy`).
35
+ */
36
+ export type StepKind = string;
21
37
 
22
38
  export interface Step<
23
39
  N extends string = string,
24
40
  I extends InputSpec = InputSpec,
25
41
  O extends AnyModel | null = AnyModel | null,
26
42
  Sec extends string = string,
43
+ Cfg extends AnyConfigEntry = never,
27
44
  > {
28
45
  readonly [StepBrand]: true;
29
46
  readonly kind: StepKind;
@@ -33,35 +50,41 @@ export interface Step<
33
50
  readonly input: I;
34
51
  readonly output: O;
35
52
  readonly secrets: readonly Secret<Sec>[];
53
+ /** Config entries the step receives as `ctx.config`. */
54
+ readonly config: readonly Cfg[];
36
55
  readonly timeoutSeconds?: number;
37
56
  readonly retries?: number;
38
- /** Library steps carry their own implementation and config. */
39
- readonly builtin?: LibraryImplementation;
57
+ /** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
58
+ readonly clusterAccess?: boolean;
59
+ /** How the step is set up, as written in the definition (may hold config references). */
60
+ readonly settings?: Readonly<Record<string, unknown>>;
40
61
  }
41
- /** Runtime part of a library step (not part of the public contract). */
42
- export interface LibraryImplementation {
43
- /** Config as emitted in the manifest (JSON only; must not contain paths). */
44
- readonly manifestConfig: () => Record<string, unknown>;
45
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
- readonly run: (input: any, ctx: any) => Promise<unknown>;
47
- }
48
- export type LibraryStep = { readonly builtin: LibraryImplementation };
49
62
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
- export type AnyStep = Step<string, any, AnyModel | null, any>;
63
+ export type AnyStep = Step<string, any, AnyModel | null, any, any>;
51
64
 
52
65
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
- export type SecretsOf<S> = S extends Step<any, any, any, infer Sec> ? Sec : never;
54
- export type OutputValue<S extends AnyStep> = S["output"] extends AnyModel ? Infer<S["output"]> : void;
66
+ export type SecretsOf<S> = S extends Step<any, any, any, infer Sec, any> ? Sec : never;
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ export type ConfigOf<S> = S extends Step<any, any, any, any, infer C> ? C : never;
69
+ export type OutputValue<S extends AnyStep> = S['output'] extends AnyModel
70
+ ? Infer<S['output']>
71
+ : void;
55
72
 
56
73
  /** Step names must be literals (identity, duplicate detection and mapper keys depend on it). */
57
- export type LiteralName<N extends string> = N & (string extends N ? "❌ step name must be a string literal" : unknown);
74
+ export type LiteralName<N extends string> = N &
75
+ (string extends N ? '❌ step name must be a string literal' : unknown);
58
76
 
59
- /** Step names: lower-case kebab, ≤ 63 chars (usable as K8s label values and URL segments). */
60
- export const STEP_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
77
+ export { STEP_NAME } from './models.ts';
61
78
  /** Names that cannot be steps: `input` is the pipeline input's root in mappings. */
62
- export const RESERVED_STEP_NAMES: readonly string[] = ["input"];
79
+ export const RESERVED_STEP_NAMES: readonly string[] = ['input'];
63
80
 
64
- export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]> {
81
+ export interface StepDef<
82
+ N extends string,
83
+ I extends InputSpec,
84
+ O extends AnyModel | null,
85
+ Sec extends readonly Secret[],
86
+ Cfg extends readonly AnyConfigEntry[] = [],
87
+ > {
65
88
  name: LiteralName<N>;
66
89
  /** What the step does, shown in the UI. Required; may be empty. */
67
90
  description: string;
@@ -70,38 +93,66 @@ export interface StepDef<N extends string, I extends InputSpec, O extends AnyMod
70
93
  input?: I;
71
94
  output?: O;
72
95
  secrets?: Sec;
96
+ /** Config entries delivered as `ctx.config` (typed to exactly these). */
97
+ config?: Cfg;
73
98
  timeoutSeconds?: number;
74
99
  /** Automatic retries (an idempotency claim). Default 0. */
75
100
  retries?: number;
101
+ /**
102
+ * Ask for a Kubernetes API token in the step pod: the instance's step ServiceAccount
103
+ * (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
104
+ */
105
+ clusterAccess?: boolean;
106
+ /** A label for the step's kind (default `custom`). */
107
+ kind?: StepKind;
108
+ /**
109
+ * How the step is set up, shown in the manifest and part of the step's identity: the parameters of
110
+ * a reusable step. JSON values and config references (`target.prod.host`); every config entry it
111
+ * references is delivered to the step, and `resolveConfigRefs(value, ctx.config)` resolves them.
112
+ */
113
+ settings?: Readonly<Record<string, unknown>>;
76
114
  }
77
115
 
78
- export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?: LibraryImplementation): AnyStep {
116
+ function makeStep(def: Record<string, unknown>, origin: string | undefined): AnyStep {
117
+ const config = new Map<string, AnyConfigEntry>();
118
+ for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? [])
119
+ if (!config.has(e.name)) config.set(e.name, e);
120
+ configRefsIn(def.settings, config);
79
121
  const s = {
80
122
  [STEP]: true,
81
- kind,
123
+ kind: def.kind ?? 'custom',
82
124
  name: def.name,
83
125
  description: def.description,
84
126
  image: def.image,
85
127
  input: Object.freeze({ ...((def.input as object | undefined) ?? {}) }),
86
128
  output: def.output ?? null,
87
129
  secrets: Object.freeze([...((def.secrets as readonly Secret[] | undefined) ?? [])]),
130
+ config: Object.freeze([...config.values()]),
88
131
  ...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
89
132
  ...(def.retries !== undefined ? { retries: def.retries } : {}),
90
- ...(builtin ? { builtin } : {}),
133
+ ...(def.clusterAccess === true ? { clusterAccess: true } : {}),
134
+ ...(def.settings !== undefined ? { settings: def.settings } : {}),
135
+ [ORIGIN]: origin === undefined ? [] : [origin],
91
136
  };
92
137
  return Object.freeze(s) as unknown as AnyStep;
93
138
  }
94
139
 
95
140
  export function isStep(v: unknown): v is AnyStep {
96
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[STEP] === true;
141
+ return (
142
+ typeof v === 'object' && v !== null && (v as Record<symbol, unknown>)[STEP] === true
143
+ );
97
144
  }
98
145
 
99
- /** Define a custom step: a contract (name, image, inputs, output, secrets). Its handler lives in `handlers/<name>.ts`. */
146
+ /**
147
+ * Define a step: a contract (name, image, inputs, output, secrets). Its handler is a `handlers/*.ts`
148
+ * default-exporting `implement(step, …)`, or, for a reusable step, `implement(step, …)` staged directly.
149
+ */
100
150
  export function step<
101
151
  const N extends string,
102
152
  const I extends InputSpec = {},
103
153
  O extends AnyModel | null = null,
104
154
  const Sec extends readonly Secret[] = [],
105
- >(def: StepDef<N, I, O, Sec>): Step<N, I, O, Sec[number]["name"]> {
106
- return makeStep("custom", def as never) as never;
155
+ const Cfg extends readonly AnyConfigEntry[] = [],
156
+ >(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]['name'], Cfg[number]> {
157
+ return makeStep(def as never, callerFile()) as never;
107
158
  }
package/src/core/types.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export type Primitive = string | number | boolean | bigint | null | undefined;
2
2
  export type Simplify<T> = { [K in keyof T]: T[K] } & {};
3
3
  export type Awaitable<T> = T | Promise<T>;
4
- export type IsUnion<T, U = T> = T extends unknown ? ([U] extends [T] ? false : true) : never;
4
+ export type IsUnion<T, U = T> = T extends unknown
5
+ ? [U] extends [T]
6
+ ? false
7
+ : true
8
+ : never;
5
9
  /** `Ok` when there is no error, else the error (a readable string-literal type). */
6
10
  export type OrError<Err, Ok> = [Err] extends [never] ? Ok : Err;
@@ -1,23 +1,37 @@
1
- import type { Secret } from "./secrets.ts";
1
+ import type { Secret } from './secrets.ts';
2
2
 
3
3
  /** How a webhook trigger authenticates deliveries. Build one with `hmacSha256`, `githubSignature` or `bearer`. */
4
4
  export type WebhookVerify =
5
- | { readonly scheme: "hmac-sha256"; readonly secret: Secret; readonly header: string; readonly prefix: string }
6
- | { readonly scheme: "bearer"; readonly secret: Secret };
5
+ | {
6
+ readonly scheme: 'hmac-sha256';
7
+ readonly secret: Secret;
8
+ readonly header: string;
9
+ readonly prefix: string;
10
+ }
11
+ | { readonly scheme: 'bearer'; readonly secret: Secret };
7
12
 
8
13
  /** HMAC-SHA256 of the raw body keyed by `secret`, hex, in `header` after `prefix`. */
9
- export function hmacSha256(o: { secret: Secret; header: string; prefix?: string }): WebhookVerify {
10
- return Object.freeze({ scheme: "hmac-sha256", secret: o.secret, header: o.header, prefix: o.prefix ?? "" });
14
+ export function hmacSha256(o: {
15
+ secret: Secret;
16
+ header: string;
17
+ prefix?: string;
18
+ }): WebhookVerify {
19
+ return Object.freeze({
20
+ scheme: 'hmac-sha256',
21
+ secret: o.secret,
22
+ header: o.header,
23
+ prefix: o.prefix ?? '',
24
+ });
11
25
  }
12
26
 
13
27
  /** GitHub's delivery signature: `X-Hub-Signature-256: sha256=<hex>`. */
14
28
  export function githubSignature(secret: Secret): WebhookVerify {
15
- return hmacSha256({ secret, header: "X-Hub-Signature-256", prefix: "sha256=" });
29
+ return hmacSha256({ secret, header: 'X-Hub-Signature-256', prefix: 'sha256=' });
16
30
  }
17
31
 
18
32
  /** `Authorization: Bearer <secret>`. */
19
33
  export function bearer(secret: Secret): WebhookVerify {
20
- return Object.freeze({ scheme: "bearer", secret });
34
+ return Object.freeze({ scheme: 'bearer', secret });
21
35
  }
22
36
 
23
37
  /**
package/src/ctx/files.ts CHANGED
@@ -4,19 +4,31 @@
4
4
  // `path` minus the trailing "/" plus ".tar" for directories (a deterministic tar). The step writes
5
5
  // objects to `$OLVE_DIR/work/files/<object path>`; the service stores them under
6
6
  // `runs/<p>/<run>/<step>/<attempt>/files/<object path>`.
7
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
- import { basename, dirname, join, resolve } from "node:path";
9
- import { fileRefSchema, FILE_PATH, type FileRef } from "../core/models.ts";
10
- import { ENV } from "../harness/env.ts";
11
- import { sha256File } from "../util/hash.ts";
12
- import { dirEntries, extractTar, writeTar } from "../util/tar.ts";
13
- import type { OutputFileOptions, RunInfo } from "./types.ts";
7
+ import {
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { basename, dirname, join, resolve } from 'node:path';
18
+ import { fileRefSchema, FILE_PATH, type FileRef } from '../core/models.ts';
19
+ import { ENV } from '../harness/env.ts';
20
+ import { sha256File } from '../util/hash.ts';
21
+ import { dirEntries, extractTar, writeTar } from '../util/tar.ts';
22
+ import type { OutputFileOptions, RunInfo } from './types.ts';
14
23
 
15
- export const objectPath = (refPath: string): string => (refPath.endsWith("/") ? refPath.slice(0, -1) + ".tar" : refPath);
16
- export const fileKey = (ref: Pick<FileRef, "run" | "step" | "attempt" | "path">): string => `${ref.run}/${ref.step}/${ref.attempt}/${ref.path}`;
24
+ export const objectPath = (refPath: string): string =>
25
+ refPath.endsWith('/') ? refPath.slice(0, -1) + '.tar' : refPath;
26
+ export const fileKey = (
27
+ ref: Pick<FileRef, 'run' | 'step' | 'attempt' | 'path'>,
28
+ ): string => `${ref.run}/${ref.step}/${ref.attempt}/${ref.path}`;
17
29
 
18
30
  /** Strips everything from `?` on (pre-signed credentials). */
19
- export const redactUrl = (url: string): string => url.replace(/[?#].*$/, "?…");
31
+ export const redactUrl = (url: string): string => url.replace(/[?#].*$/, '?…');
20
32
 
21
33
  export interface FilesDeps {
22
34
  run: RunInfo;
@@ -28,42 +40,54 @@ export interface FilesDeps {
28
40
  }
29
41
 
30
42
  function checkName(name: string, what: string): void {
31
- if (!FILE_PATH.test(name) || name.endsWith("/")) throw new Error(`${what}: '${name}' is not a valid relative file name (no leading '/', no '..', no trailing '/')`);
43
+ if (!FILE_PATH.test(name) || name.endsWith('/'))
44
+ throw new Error(
45
+ `${what}: '${name}' is not a valid relative file name (no leading '/', no '..', no trailing '/')`,
46
+ );
32
47
  }
33
48
 
34
49
  export function createOutput(deps: FilesDeps) {
35
50
  const used = new Set<string>();
36
51
  const claim = (obj: string) => {
37
- if (used.has(obj) || existsSync(join(deps.workFiles, obj))) throw new Error(`output file '${obj}' already exists`);
52
+ if (used.has(obj) || existsSync(join(deps.workFiles, obj)))
53
+ throw new Error(`output file '${obj}' already exists`);
38
54
  used.add(obj);
39
55
  const dest = join(deps.workFiles, obj);
40
56
  mkdirSync(dirname(dest), { recursive: true });
41
57
  return dest;
42
58
  };
43
59
  const ref = (path: string, dest: string): FileRef =>
44
- ({ run: deps.run.number, step: deps.run.step, attempt: deps.run.attempt, path, sha256: sha256File(dest) }) as FileRef;
60
+ ({
61
+ run: deps.run.number,
62
+ step: deps.run.step,
63
+ attempt: deps.run.attempt,
64
+ path,
65
+ sha256: sha256File(dest),
66
+ }) as FileRef;
45
67
 
46
68
  return {
47
69
  async file(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
48
70
  const src = resolve(path);
49
- if (!statSync(src).isFile()) throw new Error(`ctx.output.file: '${path}' is not a file`);
71
+ if (!statSync(src).isFile())
72
+ throw new Error(`ctx.output.file: '${path}' is not a file`);
50
73
  const name = opts.name ?? basename(src);
51
- checkName(name, "ctx.output.file");
74
+ checkName(name, 'ctx.output.file');
52
75
  const dest = claim(name);
53
76
  copyFileSync(src, dest);
54
77
  return ref(name, dest);
55
78
  },
56
79
  async dir(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
57
80
  const src = resolve(path);
58
- if (!statSync(src).isDirectory()) throw new Error(`ctx.output.dir: '${path}' is not a directory`);
81
+ if (!statSync(src).isDirectory())
82
+ throw new Error(`ctx.output.dir: '${path}' is not a directory`);
59
83
  const name = opts.name ?? basename(src);
60
- checkName(name, "ctx.output.dir");
61
- const dest = claim(objectPath(name + "/"));
84
+ checkName(name, 'ctx.output.dir');
85
+ const dest = claim(objectPath(name + '/'));
62
86
  writeTar(dest, dirEntries(src));
63
- return ref(name + "/", dest);
87
+ return ref(name + '/', dest);
64
88
  },
65
89
  async write(name: string, content: string | Uint8Array): Promise<FileRef> {
66
- checkName(name, "ctx.output.write");
90
+ checkName(name, 'ctx.output.write');
67
91
  const dest = claim(name);
68
92
  writeFileSync(dest, content);
69
93
  return ref(name, dest);
@@ -72,20 +96,33 @@ export function createOutput(deps: FilesDeps) {
72
96
  }
73
97
 
74
98
  function urlMap(root: string): Record<string, string> {
75
- const raw = process.env[ENV.fileUrls] ?? (existsSync(join(root, "files.json")) ? readFileSync(join(root, "files.json"), "utf8") : undefined);
99
+ const raw =
100
+ process.env[ENV.fileUrls] ??
101
+ (existsSync(join(root, 'files.json'))
102
+ ? readFileSync(join(root, 'files.json'), 'utf8')
103
+ : undefined);
76
104
  if (!raw) return {};
77
105
  try {
78
106
  const m = JSON.parse(raw) as unknown;
79
- if (typeof m !== "object" || m === null || Array.isArray(m)) throw new Error("not an object");
107
+ if (typeof m !== 'object' || m === null || Array.isArray(m))
108
+ throw new Error('not an object');
80
109
  return m as Record<string, string>;
81
110
  } catch (e) {
82
- throw new Error(`${ENV.fileUrls} / files.json is not a JSON object of file key → URL (${(e as Error).message})`);
111
+ throw new Error(
112
+ `${ENV.fileUrls} / files.json is not a JSON object of file key → URL (${(e as Error).message})`,
113
+ );
83
114
  }
84
115
  }
85
116
 
86
117
  const TRANSIENT = (status: number) => status >= 500 || status === 408 || status === 429;
87
118
 
88
- async function download(url: string, dest: string, headers: Record<string, string>, signal: AbortSignal, log: FilesDeps["log"]): Promise<void> {
119
+ async function download(
120
+ url: string,
121
+ dest: string,
122
+ headers: Record<string, string>,
123
+ signal: AbortSignal,
124
+ log: FilesDeps['log'],
125
+ ): Promise<void> {
89
126
  const attempts = 4;
90
127
  for (let i = 1; ; i++) {
91
128
  let status = 0;
@@ -93,14 +130,19 @@ async function download(url: string, dest: string, headers: Record<string, strin
93
130
  const res = await fetch(url, { headers, signal });
94
131
  status = res.status;
95
132
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
96
- await Bun.write(dest + ".partial", res);
97
- renameSync(dest + ".partial", dest);
133
+ await Bun.write(dest + '.partial', res);
134
+ renameSync(dest + '.partial', dest);
98
135
  return;
99
136
  } catch (e) {
100
- rmSync(dest + ".partial", { force: true });
137
+ rmSync(dest + '.partial', { force: true });
101
138
  const transient = status === 0 || TRANSIENT(status);
102
- if (signal.aborted || !transient || i >= attempts) throw new Error(`download of ${redactUrl(url)} failed: ${(e as Error).message}`);
103
- log("download failed, retrying", { url: redactUrl(url), attempt: i, error: (e as Error).message });
139
+ if (signal.aborted || !transient || i >= attempts)
140
+ throw new Error(`download of ${redactUrl(url)} failed: ${(e as Error).message}`);
141
+ log('download failed, retrying', {
142
+ url: redactUrl(url),
143
+ attempt: i,
144
+ error: (e as Error).message,
145
+ });
104
146
  await Bun.sleep(1000 * 2 ** (i - 1));
105
147
  }
106
148
  }
@@ -110,29 +152,56 @@ export function createFetch(deps: FilesDeps) {
110
152
  const cache = new Map<string, string>();
111
153
  return async (input: FileRef): Promise<string> => {
112
154
  const parsed = fileRefSchema.safeParse(input);
113
- if (!parsed.success) throw new Error(`ctx.files.fetch: not a valid file reference: ${JSON.stringify(input)}`);
155
+ if (!parsed.success)
156
+ throw new Error(
157
+ `ctx.files.fetch: not a valid file reference: ${JSON.stringify(input)}`,
158
+ );
114
159
  const ref = parsed.data;
115
160
  const key = fileKey(ref);
116
161
  const hit = cache.get(key);
117
162
  if (hit) return hit;
118
163
 
119
164
  const obj = objectPath(ref.path);
120
- const base = join(deps.tmpDir, "files", String(ref.run), ref.step, String(ref.attempt));
121
- const objFile = join(base, ".objects", obj);
165
+ const base = join(
166
+ deps.tmpDir,
167
+ 'files',
168
+ String(ref.run),
169
+ ref.step,
170
+ String(ref.attempt),
171
+ );
172
+ const objFile = join(base, '.objects', obj);
122
173
  mkdirSync(dirname(objFile), { recursive: true });
123
174
 
124
- const own = ref.run === deps.run.number && ref.step === deps.run.step && ref.attempt === deps.run.attempt;
175
+ const own =
176
+ ref.run === deps.run.number &&
177
+ ref.step === deps.run.step &&
178
+ ref.attempt === deps.run.attempt;
125
179
  const urls = urlMap(deps.root);
126
180
  const localDir = process.env[ENV.filesDir];
127
181
  const baseUrl = process.env[ENV.filesBaseUrl];
128
- if (own && existsSync(join(deps.workFiles, obj))) copyFileSync(join(deps.workFiles, obj), objFile);
182
+ if (own && existsSync(join(deps.workFiles, obj)))
183
+ copyFileSync(join(deps.workFiles, obj), objFile);
129
184
  else if (urls[key]) await download(urls[key], objFile, {}, deps.signal, deps.log);
130
- else if (localDir && existsSync(join(localDir, String(ref.run), ref.step, String(ref.attempt), "files", obj)))
131
- copyFileSync(join(localDir, String(ref.run), ref.step, String(ref.attempt), "files", obj), objFile);
185
+ else if (
186
+ localDir &&
187
+ existsSync(
188
+ join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
189
+ )
190
+ )
191
+ copyFileSync(
192
+ join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
193
+ objFile,
194
+ );
132
195
  else if (baseUrl) {
133
196
  const token = process.env[ENV.filesToken];
134
- const url = `${baseUrl.replace(/\/+$/, "")}/${ref.run}/${encodeURIComponent(ref.step)}/${ref.attempt}/${obj.split("/").map(encodeURIComponent).join("/")}`;
135
- await download(url, objFile, token ? { authorization: `Bearer ${token}` } : {}, deps.signal, deps.log);
197
+ const url = `${baseUrl.replace(/\/+$/, '')}/${ref.run}/${encodeURIComponent(ref.step)}/${ref.attempt}/${obj.split('/').map(encodeURIComponent).join('/')}`;
198
+ await download(
199
+ url,
200
+ objFile,
201
+ token ? { authorization: `Bearer ${token}` } : {},
202
+ deps.signal,
203
+ deps.log,
204
+ );
136
205
  } else {
137
206
  throw new Error(
138
207
  `ctx.files.fetch: no source for ${key} (set ${ENV.fileUrls} / $OLVE_DIR/files.json, ${ENV.filesBaseUrl} or ${ENV.filesDir})`,
@@ -142,10 +211,12 @@ export function createFetch(deps: FilesDeps) {
142
211
  const actual = sha256File(objFile);
143
212
  if (actual !== ref.sha256) {
144
213
  rmSync(objFile, { force: true });
145
- throw new Error(`ctx.files.fetch: ${key} has sha256 ${actual}, expected ${ref.sha256}`);
214
+ throw new Error(
215
+ `ctx.files.fetch: ${key} has sha256 ${actual}, expected ${ref.sha256}`,
216
+ );
146
217
  }
147
218
  let result = objFile;
148
- if (ref.path.endsWith("/")) {
219
+ if (ref.path.endsWith('/')) {
149
220
  result = join(base, ref.path.slice(0, -1));
150
221
  rmSync(result, { recursive: true, force: true });
151
222
  mkdirSync(result, { recursive: true });