@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.10
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/README.md +121 -61
- package/manifest.schema.json +161 -11
- package/package.json +1 -1
- package/src/build/build.ts +19 -17
- package/src/build/cli.ts +1 -1
- package/src/build/hashes.ts +32 -3
- package/src/build/project.ts +33 -5
- package/src/builder/pipeline.ts +92 -10
- package/src/core/brands.ts +2 -0
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +16 -10
- package/src/core/origin.ts +20 -0
- package/src/core/steps.ts +53 -22
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/index.ts +3 -0
- package/src/ctx/sh.ts +2 -1
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +213 -27
- package/src/describe/manifest-schema.ts +53 -5
- package/src/dev/cli.ts +16 -9
- package/src/harness/execute.ts +7 -4
- package/src/harness/index.ts +1 -1
- package/src/harness/main.ts +68 -17
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +7 -3
- package/src/library/checks.ts +23 -0
- package/src/library/compose-deploy.ts +96 -0
- package/src/library/github-source.ts +61 -38
- package/src/library/helm-deploy-ssh.ts +306 -99
- package/src/library/helm-deploy.ts +271 -0
- package/src/library/image-build.ts +350 -66
- package/src/library/index.ts +11 -7
- package/src/library/models.ts +17 -1
- package/src/library/ssh.ts +50 -0
- package/src/testing/index.ts +11 -15
- package/src/util/handler-hash.ts +4 -4
- package/src/util/tar.ts +13 -0
- package/src/library/script.ts +0 -85
package/src/builder/pipeline.ts
CHANGED
|
@@ -3,10 +3,12 @@ import type { AnyModel, Infer, InferInput, Plain } from "../core/models.ts";
|
|
|
3
3
|
import type { Secret } from "../core/secrets.ts";
|
|
4
4
|
import type { AnyStep, OutputValue, Step } from "../core/steps.ts";
|
|
5
5
|
import { isStep } from "../core/steps.ts";
|
|
6
|
+
import type { WebhookEvent, WebhookVerify } from "../core/webhooks.ts";
|
|
6
7
|
import type { IsUnion, OrError, Simplify } from "../core/types.ts";
|
|
7
8
|
import type { Optional } from "../core/models.ts";
|
|
8
9
|
import type { FailureContext } from "../library/models.ts";
|
|
9
10
|
import type { MapperResult, Ref } from "./refs.ts";
|
|
11
|
+
import { isConfigRef, narrowConfigRef, unionAt, type ConfigNode, type ConfigRef, type DiscriminatorOf, type VariantOf } from "../core/config.ts";
|
|
10
12
|
|
|
11
13
|
// ─── Availability (builder state, type level) ────────────────────────────────────────────────
|
|
12
14
|
|
|
@@ -70,7 +72,7 @@ type SiblingNames<E extends readonly unknown[], K> = {
|
|
|
70
72
|
}[number];
|
|
71
73
|
|
|
72
74
|
/**
|
|
73
|
-
* Per-entry check. Inference constraints
|
|
75
|
+
* Per-entry check. Inference constraints:
|
|
74
76
|
* - the [step, mapper] branch must be exactly `readonly [S, (from) => …]` — wrapping S or the whole
|
|
75
77
|
* mapper type in a conditional/intersection breaks inference of E or contextual typing of `from`;
|
|
76
78
|
* so name errors of tuple entries are reported on the mapper's return type instead;
|
|
@@ -95,6 +97,29 @@ type OutputsOf<E extends readonly unknown[], Maybe extends boolean> = {
|
|
|
95
97
|
};
|
|
96
98
|
type MakeMaybe<X extends AvailMap> = { [K in keyof X]: Slot<X[K]["model"], X[K]["value"], true> };
|
|
97
99
|
|
|
100
|
+
// ─── select (branching on config) ────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
type KeysOfUnion<T> = T extends unknown ? keyof T : never;
|
|
103
|
+
type SlotIn<B, K extends PropertyKey> = B extends { [P in K]: infer X extends Slot } ? X : never;
|
|
104
|
+
/** A case function: the pipeline so far, and the select's reference narrowed to the case's variant. */
|
|
105
|
+
export type SelectCase<In extends AnyModel, A extends AvailMap, R> = (p: Pipeline<In, A>, ref: R) => Pipeline<In, AvailMap>;
|
|
106
|
+
export type SelectCases<In extends AnyModel, A extends AvailMap, U, D extends string> = {
|
|
107
|
+
readonly [V in Extract<U[D & keyof U], string>]: SelectCase<In, A, ConfigNode<VariantOf<U, D, V>>>;
|
|
108
|
+
};
|
|
109
|
+
type CaseAvail<C> = { [K in keyof C]: C[K] extends (...args: never[]) => Pipeline<AnyModel, infer B> ? B : never }[keyof C];
|
|
110
|
+
/**
|
|
111
|
+
* After a select: a step every case has is as available as in its cases (one case always runs);
|
|
112
|
+
* a step only some cases have is maybe-available.
|
|
113
|
+
*/
|
|
114
|
+
export type AfterSelect<A extends AvailMap, B> = Simplify<
|
|
115
|
+
A &
|
|
116
|
+
{ [K in Exclude<keyof B, keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], SlotIn<B, K>["maybe"]> } &
|
|
117
|
+
{ [K in Exclude<KeysOfUnion<B>, keyof B | keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], true> }
|
|
118
|
+
>;
|
|
119
|
+
type SelectOnError<U> = [DiscriminatorOf<U>] extends [never]
|
|
120
|
+
? "❌ select needs a reference to a discriminated union in config (z.discriminatedUnion)"
|
|
121
|
+
: IsUnion<DiscriminatorOf<U>> extends true ? "❌ the union has more than one literal field; select can't tell which is the discriminator" : never;
|
|
122
|
+
|
|
98
123
|
// ─── Conditions / triggers ───────────────────────────────────────────────────────────────────
|
|
99
124
|
|
|
100
125
|
type IsLiteralLike<T> = [T] extends [string | number | boolean]
|
|
@@ -133,7 +158,8 @@ export interface StageStepNode {
|
|
|
133
158
|
}
|
|
134
159
|
export type StageNode =
|
|
135
160
|
| { readonly kind: "stage"; readonly steps: readonly StageStepNode[] }
|
|
136
|
-
| { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] }
|
|
161
|
+
| { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] }
|
|
162
|
+
| { readonly kind: "select"; readonly on: ConfigRef; readonly cases: Readonly<Record<string, readonly StageNode[]>> };
|
|
137
163
|
|
|
138
164
|
export type TriggerNode =
|
|
139
165
|
| { readonly kind: "push"; readonly name: string; readonly map: (event: unknown) => unknown }
|
|
@@ -146,7 +172,8 @@ export type TriggerNode =
|
|
|
146
172
|
readonly valuePath: string;
|
|
147
173
|
readonly intervalSeconds: number;
|
|
148
174
|
readonly map: (value: string) => unknown;
|
|
149
|
-
}
|
|
175
|
+
}
|
|
176
|
+
| { readonly kind: "webhook"; readonly name: string; readonly verify: WebhookVerify; readonly map: (event: WebhookEvent) => unknown };
|
|
150
177
|
|
|
151
178
|
export interface FailureHandlerNode {
|
|
152
179
|
readonly step: AnyStep;
|
|
@@ -176,6 +203,11 @@ export function isPipeline(p: unknown): boolean {
|
|
|
176
203
|
|
|
177
204
|
// ─── Pipeline builder ────────────────────────────────────────────────────────────────────────
|
|
178
205
|
|
|
206
|
+
// Builder calls record into shared data and hand back a fresh handle; a handle that was already
|
|
207
|
+
// extended no longer matches its type (it would claim fewer stages than the data has), so reuse is a problem.
|
|
208
|
+
const extended = new WeakSet<object>();
|
|
209
|
+
const ReusedProblem = "a pipeline value was extended twice: chain the calls (pipeline({...}).stage(a).stage(b)) instead of reusing an earlier value";
|
|
210
|
+
|
|
179
211
|
export interface PipelineDef<In extends AnyModel> {
|
|
180
212
|
/** Display name. */
|
|
181
213
|
name: string;
|
|
@@ -201,10 +233,19 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
201
233
|
};
|
|
202
234
|
}
|
|
203
235
|
|
|
236
|
+
private next(): never {
|
|
237
|
+
const data = this[PIPELINE];
|
|
238
|
+
if (extended.has(this) && !data.problems.includes(ReusedProblem)) data.problems.push(ReusedProblem);
|
|
239
|
+
extended.add(this);
|
|
240
|
+
const handle = Object.create(Object.getPrototypeOf(this)) as Pipeline<In, A>;
|
|
241
|
+
(handle as { [PIPELINE]: PipelineData })[PIPELINE] = data;
|
|
242
|
+
return handle as never;
|
|
243
|
+
}
|
|
244
|
+
|
|
204
245
|
/** Map a GitHub push to the pipeline input (`z.input` view). Runs as code in a `map-trigger` Job per event. */
|
|
205
246
|
onPush<R extends InferInput<In>>(map: (event: PushEvent) => Exact<R, InferInput<In>>): this {
|
|
206
247
|
this[PIPELINE].triggers.push({ kind: "push", name: "push", map: map as (event: unknown) => unknown });
|
|
207
|
-
return this;
|
|
248
|
+
return this.next();
|
|
208
249
|
}
|
|
209
250
|
|
|
210
251
|
/**
|
|
@@ -233,7 +274,19 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
233
274
|
intervalSeconds: cfg.intervalSeconds,
|
|
234
275
|
map: map as (value: string) => unknown,
|
|
235
276
|
});
|
|
236
|
-
return this;
|
|
277
|
+
return this.next();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Start a run from a delivery to `/api/hooks/<pipeline id>/<name>`, authenticated by `verify`. Map it to the
|
|
282
|
+
* pipeline input (`z.input` view), or return `null` to ignore it. Runs as code in a `map-trigger` Job per delivery.
|
|
283
|
+
*/
|
|
284
|
+
onWebhook<R extends InferInput<In>>(
|
|
285
|
+
cfg: { name: string; verify: WebhookVerify },
|
|
286
|
+
map: (event: WebhookEvent) => Exact<R, InferInput<In>> | null,
|
|
287
|
+
): this {
|
|
288
|
+
this[PIPELINE].triggers.push({ kind: "webhook", name: cfg.name, verify: cfg.verify, map: map as (event: WebhookEvent) => unknown });
|
|
289
|
+
return this.next();
|
|
237
290
|
}
|
|
238
291
|
|
|
239
292
|
/**
|
|
@@ -258,7 +311,7 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
258
311
|
});
|
|
259
312
|
if (entries.length === 0) data.problems.push("empty stage");
|
|
260
313
|
data.nodes.push({ kind: "stage", steps });
|
|
261
|
-
return this;
|
|
314
|
+
return this.next();
|
|
262
315
|
}
|
|
263
316
|
|
|
264
317
|
/** Declarative condition on literal-like pipeline-input fields. Outputs produced inside become maybe-available after. */
|
|
@@ -266,9 +319,38 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
266
319
|
const data = this[PIPELINE];
|
|
267
320
|
const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
|
|
268
321
|
const returned = block(inner);
|
|
269
|
-
if (returned
|
|
322
|
+
if (!isPipeline(returned) || pipelineData(returned) !== inner[PIPELINE])
|
|
323
|
+
data.problems.push("a when block must return the pipeline it was given (p => p.stage(...))");
|
|
270
324
|
data.nodes.push({ kind: "when", cond: { ...(cond as object) }, nodes: inner[PIPELINE].nodes });
|
|
271
|
-
return this
|
|
325
|
+
return this.next();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Branch on config: `on` references a discriminated union in a config entry, and each case gets the
|
|
330
|
+
* pipeline and `on` narrowed to its variant. Exactly one case runs, chosen by the run's config
|
|
331
|
+
* snapshot (not by run input; that is `when`). The same step name may appear in several cases.
|
|
332
|
+
*/
|
|
333
|
+
select<U, const C extends SelectCases<In, A, U, DiscriminatorOf<U>>>(
|
|
334
|
+
on: ConfigRef<U> & ([SelectOnError<U>] extends [never] ? unknown : SelectOnError<U>),
|
|
335
|
+
cases: C & { readonly [K in Exclude<keyof C, keyof SelectCases<In, A, U, DiscriminatorOf<U>>>]: `❌ '${K & string}' is not a case of this select` },
|
|
336
|
+
): Pipeline<In, AfterSelect<A, CaseAvail<C>>> {
|
|
337
|
+
const data = this[PIPELINE];
|
|
338
|
+
const recorded: Record<string, StageNode[]> = {};
|
|
339
|
+
const union = isConfigRef(on) ? unionAt(on) : undefined;
|
|
340
|
+
if (!union) data.problems.push("select: the first argument must reference a z.discriminatedUnion in a config entry");
|
|
341
|
+
for (const [value, fn] of Object.entries(cases as Record<string, unknown>)) {
|
|
342
|
+
if (typeof fn !== "function") {
|
|
343
|
+
data.problems.push(`select: case '${value}' is not a function`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
|
|
347
|
+
const returned = (fn as (p: unknown, ref: unknown) => unknown)(inner, union ? narrowConfigRef(on, union.discriminator, value) : on);
|
|
348
|
+
if (!isPipeline(returned) || pipelineData(returned) !== inner[PIPELINE])
|
|
349
|
+
data.problems.push(`select: case '${value}' must return the pipeline it was given ((p, ref) => p.stage(...))`);
|
|
350
|
+
recorded[value] = inner[PIPELINE].nodes;
|
|
351
|
+
}
|
|
352
|
+
data.nodes.push({ kind: "select", on, cases: recorded });
|
|
353
|
+
return this.next();
|
|
272
354
|
}
|
|
273
355
|
|
|
274
356
|
/** Run `handler` (a step whose inputs are all FailureContext) when one of `steps` (default: any) fails. Untracked, best-effort. */
|
|
@@ -278,10 +360,10 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
278
360
|
? unknown
|
|
279
361
|
: "❌ a failure handler's inputs must all be FailureContext"),
|
|
280
362
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
281
|
-
opts?: { steps?: readonly Step<StepNames<A>, any, any, any>[] },
|
|
363
|
+
opts?: { steps?: readonly Step<StepNames<A>, any, any, any, any>[] },
|
|
282
364
|
): this {
|
|
283
365
|
this[PIPELINE].failureHandlers.push({ step: handler as AnyStep, steps: opts?.steps ? [...(opts.steps as readonly AnyStep[])] : null });
|
|
284
|
-
return this;
|
|
366
|
+
return this.next();
|
|
285
367
|
}
|
|
286
368
|
}
|
|
287
369
|
|
package/src/core/brands.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// a dev runner and the user's installed package — still recognise each other's objects.
|
|
3
3
|
export const STEP = Symbol.for("olve.pipelines.step");
|
|
4
4
|
export const IMPLEMENTATION = Symbol.for("olve.pipelines.implementation");
|
|
5
|
+
export const ORIGIN = Symbol.for("olve.pipelines.origin");
|
|
5
6
|
export const SKIPPED = Symbol.for("olve.pipelines.skipped");
|
|
6
7
|
export const PIPELINE = Symbol.for("olve.pipelines.pipeline");
|
|
7
8
|
export const REF_PATH = Symbol.for("olve.pipelines.refPath");
|
|
@@ -11,3 +12,4 @@ export declare const ModelBrand: unique symbol;
|
|
|
11
12
|
export declare const RefBrand: unique symbol;
|
|
12
13
|
export declare const StepBrand: unique symbol;
|
|
13
14
|
export declare const SkippedBrand: unique symbol;
|
|
15
|
+
export declare const ImplementationBrand: unique symbol;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Config references inside library-step definitions: collected (the step receives their entries),
|
|
2
|
+
// recorded in the manifest as `{"$config": "<entry>.<path>"}`, and resolved from ctx.config at run time.
|
|
3
|
+
import { configRefPath, configRefTarget, isConfigRef, type AnyConfigEntry, type ConfigRef } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
/** `T` with every config reference replaced by the type of its value. */
|
|
6
|
+
export type Resolved<T> = T extends ConfigRef<infer U>
|
|
7
|
+
? U
|
|
8
|
+
: T extends readonly (infer E)[]
|
|
9
|
+
? Resolved<E>[]
|
|
10
|
+
: T extends (...args: never[]) => unknown
|
|
11
|
+
? T
|
|
12
|
+
: T extends object
|
|
13
|
+
? { [K in keyof T]: Resolved<T[K]> }
|
|
14
|
+
: T;
|
|
15
|
+
|
|
16
|
+
/** Manifest key of a recorded config reference. */
|
|
17
|
+
export const CONFIG_REF_KEY = "$config";
|
|
18
|
+
/** Beside it on a reference narrowed by a `select` case: the case's condition, `<ref>.<discriminator>=<value>`. */
|
|
19
|
+
export const CONFIG_CASE_KEY = "$case";
|
|
20
|
+
|
|
21
|
+
function isPlain(v: unknown): v is Record<string, unknown> {
|
|
22
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
23
|
+
const proto = Object.getPrototypeOf(v);
|
|
24
|
+
return proto === Object.prototype || proto === null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every config entry referenced anywhere in `v` (plain objects and arrays only), by name. */
|
|
28
|
+
export function configRefsIn(v: unknown, into = new Map<string, AnyConfigEntry>()): AnyConfigEntry[] {
|
|
29
|
+
if (isConfigRef(v)) {
|
|
30
|
+
const { entry } = configRefTarget(v);
|
|
31
|
+
if (!into.has(entry.name)) into.set(entry.name, entry);
|
|
32
|
+
} else if (Array.isArray(v)) for (const x of v) configRefsIn(x, into);
|
|
33
|
+
else if (isPlain(v)) for (const x of Object.values(v)) configRefsIn(x, into);
|
|
34
|
+
return [...into.values()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `v` as manifest JSON: references become `{"$config": "target.beta.host"}`; literal keys starting with `$` are reserved. */
|
|
38
|
+
export function recordConfigRefs(v: unknown): unknown {
|
|
39
|
+
if (isConfigRef(v)) {
|
|
40
|
+
const { path, case: kase } = configRefTarget(v);
|
|
41
|
+
for (const seg of path) if (seg === "" || seg.includes(".")) throw new Error(`config reference '${configRefPath(v)}': field names must not be empty or contain '.'`);
|
|
42
|
+
return { [CONFIG_REF_KEY]: configRefPath(v), ...(kase ? { [CONFIG_CASE_KEY]: kase } : {}) };
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(v)) return v.map(recordConfigRefs);
|
|
45
|
+
if (isPlain(v)) {
|
|
46
|
+
const out: Record<string, unknown> = {};
|
|
47
|
+
for (const [k, x] of Object.entries(v)) {
|
|
48
|
+
if (k.startsWith("$")) throw new Error(`key '${k}': keys starting with '$' are reserved`);
|
|
49
|
+
out[k] = recordConfigRefs(x);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `v` with every reference replaced by its value in `config` (the step's parsed config entries). */
|
|
57
|
+
export function resolveConfigRefs<T>(v: T, config: Readonly<Record<string, unknown>>): Resolved<T> {
|
|
58
|
+
if (isConfigRef(v)) {
|
|
59
|
+
const { entry, path } = configRefTarget(v);
|
|
60
|
+
if (!Object.hasOwn(config, entry.name)) throw new Error(`config entry '${entry.name}' was not delivered to the step`);
|
|
61
|
+
let cur: unknown = config[entry.name];
|
|
62
|
+
for (const seg of path) cur = cur !== null && typeof cur === "object" ? (cur as Record<string, unknown>)[seg] : undefined;
|
|
63
|
+
return cur as Resolved<T>;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(v)) return v.map((x) => resolveConfigRefs(x, config)) as Resolved<T>;
|
|
66
|
+
if (isPlain(v)) {
|
|
67
|
+
const out: Record<string, unknown> = {};
|
|
68
|
+
for (const [k, x] of Object.entries(v)) out[k] = resolveConfigRefs(x, config);
|
|
69
|
+
return out as Resolved<T>;
|
|
70
|
+
}
|
|
71
|
+
return v as Resolved<T>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Literal `v` (no reference inside), so define-time checks can run on it. */
|
|
75
|
+
export function isLiteral(v: unknown): boolean {
|
|
76
|
+
return configRefsIn(v).length === 0;
|
|
77
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Secret } from "./secrets.ts";
|
|
3
|
+
|
|
4
|
+
const CONFIG_REF = Symbol.for("olve.configRef");
|
|
5
|
+
declare const VALUE: unique symbol;
|
|
6
|
+
|
|
7
|
+
interface RefTarget {
|
|
8
|
+
readonly entry: AnyConfigEntry;
|
|
9
|
+
readonly path: readonly string[];
|
|
10
|
+
/** Set on a reference narrowed by a `select` case: `<ref>.<discriminator>=<value>`. */
|
|
11
|
+
readonly case?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A typed reference to a config value (`TARGET.beta.host`), accepted in a step's settings in place of a
|
|
16
|
+
* literal. It is recorded in the manifest as `{"$config": "target.beta.host"}` and resolved from the
|
|
17
|
+
* run's config when the step runs; the step receives the entry automatically.
|
|
18
|
+
*/
|
|
19
|
+
export interface ConfigRef<T = unknown> {
|
|
20
|
+
readonly [CONFIG_REF]: RefTarget;
|
|
21
|
+
readonly [VALUE]?: T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A literal or a reference to a config value of the same type. */
|
|
25
|
+
export type Configurable<T> = T | ConfigRef<T>;
|
|
26
|
+
|
|
27
|
+
type Fields<T> = T extends readonly unknown[] ? never : T extends object ? T : never;
|
|
28
|
+
type Children<T> = [Fields<NonNullable<T>>] extends [never]
|
|
29
|
+
? {}
|
|
30
|
+
: { readonly [K in keyof Fields<NonNullable<T>> & string]-?: ConfigNode<Fields<NonNullable<T>>[K] | (undefined extends T ? undefined : never)> };
|
|
31
|
+
/** A reference to a config value, and to each of its fields. */
|
|
32
|
+
export type ConfigNode<T> = ConfigRef<T> & Children<T>;
|
|
33
|
+
|
|
34
|
+
/** Fields of an entry that are its own properties; reach config fields with these names through `$`. */
|
|
35
|
+
export type ReservedConfigField = "kind" | "name" | "description" | "schema" | "$";
|
|
36
|
+
|
|
37
|
+
/** A named, typed value stored on the instance (one YAML document per pipeline), declared by the steps that use it. */
|
|
38
|
+
export interface ConfigEntryBase<N extends string = string, S extends z.ZodType = z.ZodType> {
|
|
39
|
+
readonly kind: "config";
|
|
40
|
+
readonly name: N;
|
|
41
|
+
readonly description: string;
|
|
42
|
+
readonly schema: S;
|
|
43
|
+
}
|
|
44
|
+
export type ConfigEntry<N extends string = string, S extends z.ZodType = z.ZodType> = ConfigEntryBase<N, S> &
|
|
45
|
+
ConfigRef<z.output<S>> &
|
|
46
|
+
Omit<Children<z.output<S>>, ReservedConfigField> & { readonly $: ConfigNode<z.output<S>> };
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
export type AnyConfigEntry = ConfigEntryBase<string, any>;
|
|
49
|
+
|
|
50
|
+
/** Config names are YAML keys: camelCase identifiers. */
|
|
51
|
+
export const CONFIG_NAME = /^[a-z][a-zA-Z0-9]*$/;
|
|
52
|
+
|
|
53
|
+
const NOT_REFS = new Set<PropertyKey>(["then", "toJSON", "constructor", "valueOf", "toString", "asymmetricMatch", "$$typeof", "nodeType"]);
|
|
54
|
+
|
|
55
|
+
function node(entry: AnyConfigEntry, path: readonly string[], kase?: string): unknown {
|
|
56
|
+
const target = Object.freeze({ [CONFIG_REF]: Object.freeze({ entry, path, ...(kase ? { case: kase } : {}) }) });
|
|
57
|
+
return new Proxy(target, {
|
|
58
|
+
get(t, prop) {
|
|
59
|
+
if (prop === CONFIG_REF) return t[CONFIG_REF];
|
|
60
|
+
if (typeof prop === "symbol" || NOT_REFS.has(prop)) return undefined;
|
|
61
|
+
return node(entry, [...path, prop], kase);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `ref` narrowed to the union member whose `discriminator` is `value` (a `select` case). */
|
|
67
|
+
export function narrowConfigRef(ref: ConfigRef, discriminator: string, value: string): unknown {
|
|
68
|
+
const { entry, path } = ref[CONFIG_REF];
|
|
69
|
+
return node(entry, path, `${[entry.name, ...path].join(".")}.${discriminator}=${value}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function config<const N extends string, S extends z.ZodType>(name: N, description: string, schema: S): ConfigEntry<N, S> {
|
|
73
|
+
const ref = { entry: undefined as unknown as AnyConfigEntry, path: [] as readonly string[] };
|
|
74
|
+
const target = Object.freeze({ kind: "config" as const, name, description, schema, [CONFIG_REF]: ref });
|
|
75
|
+
const entry: unknown = new Proxy(target, {
|
|
76
|
+
get(t, prop) {
|
|
77
|
+
if (prop in t) return t[prop as keyof typeof t];
|
|
78
|
+
if (typeof prop === "symbol" || NOT_REFS.has(prop)) return undefined;
|
|
79
|
+
if (prop === "$") return node(entry as AnyConfigEntry, []);
|
|
80
|
+
return node(entry as AnyConfigEntry, [prop]);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
ref.entry = entry as AnyConfigEntry;
|
|
84
|
+
Object.freeze(ref);
|
|
85
|
+
return entry as ConfigEntry<N, S>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isConfigEntry(v: unknown): v is AnyConfigEntry {
|
|
89
|
+
return typeof v === "object" && v !== null && (v as { kind?: unknown }).kind === "config" && (v as { schema?: unknown }).schema instanceof z.ZodType;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isConfigRef(v: unknown): v is ConfigRef {
|
|
93
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[CONFIG_REF] !== undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The entry and field path a reference points at, and the `select` case it was narrowed by. */
|
|
97
|
+
export function configRefTarget(ref: ConfigRef): { entry: AnyConfigEntry; path: readonly string[]; case?: string } {
|
|
98
|
+
return ref[CONFIG_REF];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `target.beta.host` */
|
|
102
|
+
export function configRefPath(ref: ConfigRef): string {
|
|
103
|
+
const { entry, path } = configRefTarget(ref);
|
|
104
|
+
return [entry.name, ...path].join(".");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const SECRET_REF_KIND = "secretRef";
|
|
108
|
+
|
|
109
|
+
/** A config field that names a secret: `{ secret: NAME }` in YAML. The value never enters the config. */
|
|
110
|
+
export function secretRef<const N extends string>(s: Secret<N>) {
|
|
111
|
+
return z.strictObject({ secret: z.literal(s.name) }).meta({ "x-olve-kind": SECRET_REF_KIND });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `ctx.config`: exactly the entries a step declares, after parsing (defaults applied). */
|
|
115
|
+
export type ConfigValues<C extends AnyConfigEntry> = { readonly [E in C as E["name"]]: z.output<E["schema"]> };
|
|
116
|
+
|
|
117
|
+
// ─── select: branching on a config union ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
type IsLiteralString<T> = [T] extends [string] ? (string extends T ? false : true) : false;
|
|
120
|
+
/** The fields of union `U` whose type is a string literal in every member: its discriminator(s). */
|
|
121
|
+
export type DiscriminatorOf<U> = { [K in keyof U & string]-?: IsLiteralString<U[K]> extends true ? K : never }[keyof U & string];
|
|
122
|
+
/** The member of `U` whose discriminator `D` is `V`. */
|
|
123
|
+
export type VariantOf<U, D extends string, V> = Extract<U, { readonly [K in D]: V }>;
|
|
124
|
+
|
|
125
|
+
function unwrap(schema: z.ZodType): z.ZodType {
|
|
126
|
+
let s = schema as unknown as { _zod: { def: { innerType?: z.ZodType } } };
|
|
127
|
+
for (let i = 0; i < 16 && s._zod.def.innerType; i++) s = s._zod.def.innerType as never;
|
|
128
|
+
return s as unknown as z.ZodType;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The discriminated union a reference points at: its discriminator and, per value, nothing else.
|
|
133
|
+
* Undefined when the referenced value is not a `z.discriminatedUnion`.
|
|
134
|
+
*/
|
|
135
|
+
export function unionAt(ref: ConfigRef): { discriminator: string; values: string[] } | undefined {
|
|
136
|
+
const { entry, path } = configRefTarget(ref);
|
|
137
|
+
let s = unwrap(entry.schema as z.ZodType) as unknown as { _zod: { def: Record<string, unknown> } };
|
|
138
|
+
for (const seg of path) {
|
|
139
|
+
const shape = s._zod.def.shape as Record<string, z.ZodType> | undefined;
|
|
140
|
+
if (!shape || !(seg in shape)) return undefined;
|
|
141
|
+
s = unwrap(shape[seg]!) as never;
|
|
142
|
+
}
|
|
143
|
+
const def = s._zod.def as { discriminator?: string; options?: { _zod: { propValues?: Record<string, Set<unknown>> } }[] };
|
|
144
|
+
if (typeof def.discriminator !== "string" || !Array.isArray(def.options)) return undefined;
|
|
145
|
+
const values = def.options.flatMap((o) => [...(o._zod.propValues?.[def.discriminator!] ?? [])]).filter((v): v is string => typeof v === "string");
|
|
146
|
+
return { discriminator: def.discriminator, values };
|
|
147
|
+
}
|
package/src/core/handlers.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { IMPLEMENTATION, SKIPPED, type SkippedBrand } from "./brands.ts";
|
|
1
|
+
import { IMPLEMENTATION, ORIGIN, SKIPPED, type ImplementationBrand, type SkippedBrand } from "./brands.ts";
|
|
2
2
|
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
-
import
|
|
3
|
+
import { callerFile, originsOf } from "./origin.ts";
|
|
4
|
+
import type { AnyStep, ConfigOf, OutputValue, SecretsOf, StepInput } from "./steps.ts";
|
|
4
5
|
import type { Awaitable } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
/** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
|
|
@@ -16,12 +17,15 @@ export function isSkipped(v: unknown): v is Skipped {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
|
|
19
|
-
export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<HandlerResult<S>>;
|
|
20
|
+
export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<HandlerResult<S>>;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/** A step with its handler. It is itself a step: stage it directly, or default-export it from `handlers/*.ts`. */
|
|
23
|
+
export type Implementation<S extends AnyStep = AnyStep> = S & {
|
|
24
|
+
readonly [ImplementationBrand]: true;
|
|
25
|
+
/** The step as defined. */
|
|
22
26
|
readonly step: S;
|
|
23
27
|
readonly handler: Handler<S>;
|
|
24
|
-
}
|
|
28
|
+
};
|
|
25
29
|
|
|
26
30
|
/** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
|
|
27
31
|
type NoExtraFields<R, T> = R extends Skipped | void
|
|
@@ -29,14 +33,16 @@ type NoExtraFields<R, T> = R extends Skipped | void
|
|
|
29
33
|
: { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the output model` };
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
* Implement a
|
|
33
|
-
*
|
|
36
|
+
* Implement a step. Input, ctx and return types come from the step definition alone. Default-export
|
|
37
|
+
* the result from a `handlers/*.ts` file, or stage it directly (a reusable step: give it a `kind`).
|
|
34
38
|
*/
|
|
35
39
|
export function implement<S extends AnyStep, R extends HandlerResult<S>>(
|
|
36
|
-
step: S & (S extends
|
|
37
|
-
handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
40
|
+
step: S & (S extends { readonly [ImplementationBrand]: true } ? "❌ this step already carries its implementation" : unknown),
|
|
41
|
+
handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
38
42
|
): Implementation<S> {
|
|
39
|
-
|
|
43
|
+
if (isImplementation(step)) throw new TypeError(`step '${(step as AnyStep).name}' already carries its implementation`);
|
|
44
|
+
const origins = [...new Set([...originsOf(step), callerFile()].filter((f): f is string => f !== undefined))];
|
|
45
|
+
return Object.freeze({ ...step, [IMPLEMENTATION]: true, [ORIGIN]: origins, step, handler: handler as unknown as Handler<S> }) as unknown as Implementation<S>;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export function isImplementation(v: unknown): v is Implementation {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ORIGIN } from "./brands.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The file that called the function calling this one, from the stack. A tail call drops its caller's
|
|
5
|
+
* frame, so this can name a module further up; the build only uses it to choose what to hash.
|
|
6
|
+
*/
|
|
7
|
+
export function callerFile(): string | undefined {
|
|
8
|
+
const files = (new Error().stack ?? "").split("\n").slice(1).map((f) => f.match(FRAME)?.[1]);
|
|
9
|
+
const own = files[0];
|
|
10
|
+
const callee = files[1];
|
|
11
|
+
const file = files.slice(2).find((f) => f && f !== own && f !== callee) ?? undefined;
|
|
12
|
+
return file?.startsWith("file://") ? new URL(file).pathname : file;
|
|
13
|
+
}
|
|
14
|
+
const FRAME = /\(?((?:file:\/\/)?\/[^()]*?):\d+(?::\d+)?\)?\s*$/;
|
|
15
|
+
|
|
16
|
+
/** The modules that defined and implemented a step (where `step` and `implement` were called). */
|
|
17
|
+
export function originsOf(v: unknown): string[] {
|
|
18
|
+
const o = (v as Record<symbol, unknown>)[ORIGIN];
|
|
19
|
+
return Array.isArray(o) ? o.filter((x): x is string => typeof x === "string") : [];
|
|
20
|
+
}
|
package/src/core/steps.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
-
import { STEP, type StepBrand } from "./brands.ts";
|
|
2
|
+
import { ORIGIN, STEP, type StepBrand } from "./brands.ts";
|
|
3
|
+
import { callerFile } from "./origin.ts";
|
|
3
4
|
import type { AnyModel, Infer, Optional } from "./models.ts";
|
|
5
|
+
import type { AnyConfigEntry } from "./config.ts";
|
|
6
|
+
import { configRefsIn } from "./config-refs.ts";
|
|
4
7
|
import type { Secret } from "./secrets.ts";
|
|
5
8
|
import type { Simplify } from "./types.ts";
|
|
6
9
|
|
|
@@ -16,14 +19,19 @@ export type InferEntry<E> =
|
|
|
16
19
|
/** What a handler receives: the output view of every input entry. */
|
|
17
20
|
export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
|
|
18
21
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
22
|
+
/**
|
|
23
|
+
* A label for what a step is, shown in the UI. `custom` (the default) is a step whose handler is in
|
|
24
|
+
* `handlers/`; a step that carries its implementation (`implement(step, …)` staged directly) names its
|
|
25
|
+
* kind, e.g. `gitHubSource`. Packages prefix theirs (`acme/deploy`).
|
|
26
|
+
*/
|
|
27
|
+
export type StepKind = string;
|
|
21
28
|
|
|
22
29
|
export interface Step<
|
|
23
30
|
N extends string = string,
|
|
24
31
|
I extends InputSpec = InputSpec,
|
|
25
32
|
O extends AnyModel | null = AnyModel | null,
|
|
26
33
|
Sec extends string = string,
|
|
34
|
+
Cfg extends AnyConfigEntry = never,
|
|
27
35
|
> {
|
|
28
36
|
readonly [StepBrand]: true;
|
|
29
37
|
readonly kind: StepKind;
|
|
@@ -33,24 +41,22 @@ export interface Step<
|
|
|
33
41
|
readonly input: I;
|
|
34
42
|
readonly output: O;
|
|
35
43
|
readonly secrets: readonly Secret<Sec>[];
|
|
44
|
+
/** Config entries the step receives as `ctx.config`. */
|
|
45
|
+
readonly config: readonly Cfg[];
|
|
36
46
|
readonly timeoutSeconds?: number;
|
|
37
47
|
readonly retries?: number;
|
|
38
|
-
/**
|
|
39
|
-
readonly
|
|
48
|
+
/** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
|
|
49
|
+
readonly clusterAccess?: boolean;
|
|
50
|
+
/** How the step is set up, as written in the definition (may hold config references). */
|
|
51
|
+
readonly settings?: Readonly<Record<string, unknown>>;
|
|
40
52
|
}
|
|
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
53
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
-
export type AnyStep = Step<string, any, AnyModel | null, any>;
|
|
54
|
+
export type AnyStep = Step<string, any, AnyModel | null, any, any>;
|
|
51
55
|
|
|
52
56
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
-
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec> ? Sec : never;
|
|
57
|
+
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec, any> ? Sec : never;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
export type ConfigOf<S> = S extends Step<any, any, any, any, infer C> ? C : never;
|
|
54
60
|
export type OutputValue<S extends AnyStep> = S["output"] extends AnyModel ? Infer<S["output"]> : void;
|
|
55
61
|
|
|
56
62
|
/** Step names must be literals (identity, duplicate detection and mapper keys depend on it). */
|
|
@@ -61,7 +67,7 @@ export const STEP_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
|
61
67
|
/** Names that cannot be steps: `input` is the pipeline input's root in mappings. */
|
|
62
68
|
export const RESERVED_STEP_NAMES: readonly string[] = ["input"];
|
|
63
69
|
|
|
64
|
-
export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]> {
|
|
70
|
+
export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[], Cfg extends readonly AnyConfigEntry[] = []> {
|
|
65
71
|
name: LiteralName<N>;
|
|
66
72
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
67
73
|
description: string;
|
|
@@ -70,24 +76,45 @@ export interface StepDef<N extends string, I extends InputSpec, O extends AnyMod
|
|
|
70
76
|
input?: I;
|
|
71
77
|
output?: O;
|
|
72
78
|
secrets?: Sec;
|
|
79
|
+
/** Config entries delivered as `ctx.config` (typed to exactly these). */
|
|
80
|
+
config?: Cfg;
|
|
73
81
|
timeoutSeconds?: number;
|
|
74
82
|
/** Automatic retries (an idempotency claim). Default 0. */
|
|
75
83
|
retries?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Ask for a Kubernetes API token in the step pod: the instance's step ServiceAccount
|
|
86
|
+
* (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
|
|
87
|
+
*/
|
|
88
|
+
clusterAccess?: boolean;
|
|
89
|
+
/** A label for the step's kind (default `custom`). */
|
|
90
|
+
kind?: StepKind;
|
|
91
|
+
/**
|
|
92
|
+
* How the step is set up, shown in the manifest and part of the step's identity: the parameters of
|
|
93
|
+
* a reusable step. JSON values and config references (`target.prod.host`); every config entry it
|
|
94
|
+
* references is delivered to the step, and `resolveConfigRefs(value, ctx.config)` resolves them.
|
|
95
|
+
*/
|
|
96
|
+
settings?: Readonly<Record<string, unknown>>;
|
|
76
97
|
}
|
|
77
98
|
|
|
78
|
-
|
|
99
|
+
function makeStep(def: Record<string, unknown>, origin: string | undefined): AnyStep {
|
|
100
|
+
const config = new Map<string, AnyConfigEntry>();
|
|
101
|
+
for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? []) if (!config.has(e.name)) config.set(e.name, e);
|
|
102
|
+
configRefsIn(def.settings, config);
|
|
79
103
|
const s = {
|
|
80
104
|
[STEP]: true,
|
|
81
|
-
kind,
|
|
105
|
+
kind: def.kind ?? "custom",
|
|
82
106
|
name: def.name,
|
|
83
107
|
description: def.description,
|
|
84
108
|
image: def.image,
|
|
85
109
|
input: Object.freeze({ ...((def.input as object | undefined) ?? {}) }),
|
|
86
110
|
output: def.output ?? null,
|
|
87
111
|
secrets: Object.freeze([...((def.secrets as readonly Secret[] | undefined) ?? [])]),
|
|
112
|
+
config: Object.freeze([...config.values()]),
|
|
88
113
|
...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
|
|
89
114
|
...(def.retries !== undefined ? { retries: def.retries } : {}),
|
|
90
|
-
...(
|
|
115
|
+
...(def.clusterAccess === true ? { clusterAccess: true } : {}),
|
|
116
|
+
...(def.settings !== undefined ? { settings: def.settings } : {}),
|
|
117
|
+
[ORIGIN]: origin === undefined ? [] : [origin],
|
|
91
118
|
};
|
|
92
119
|
return Object.freeze(s) as unknown as AnyStep;
|
|
93
120
|
}
|
|
@@ -96,12 +123,16 @@ export function isStep(v: unknown): v is AnyStep {
|
|
|
96
123
|
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[STEP] === true;
|
|
97
124
|
}
|
|
98
125
|
|
|
99
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Define a step: a contract (name, image, inputs, output, secrets). Its handler is a `handlers/*.ts`
|
|
128
|
+
* default-exporting `implement(step, …)`, or, for a reusable step, `implement(step, …)` staged directly.
|
|
129
|
+
*/
|
|
100
130
|
export function step<
|
|
101
131
|
const N extends string,
|
|
102
132
|
const I extends InputSpec = {},
|
|
103
133
|
O extends AnyModel | null = null,
|
|
104
134
|
const Sec extends readonly Secret[] = [],
|
|
105
|
-
|
|
106
|
-
|
|
135
|
+
const Cfg extends readonly AnyConfigEntry[] = [],
|
|
136
|
+
>(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]["name"], Cfg[number]> {
|
|
137
|
+
return makeStep(def as never, callerFile()) as never;
|
|
107
138
|
}
|