@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/ctx/index.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { mkdirSync, readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { skipped } from "../core/handlers.ts";
4
- import type { Secret } from "../core/secrets.ts";
5
- import { createFetch, createOutput, redactUrl } from "./files.ts";
6
- import { createSh } from "./sh.ts";
7
- import type { Ctx, Http, RetryOptions, RunInfo } from "./types.ts";
1
+ import { mkdirSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { skipped } from '../core/handlers.ts';
4
+ import type { Secret } from '../core/secrets.ts';
5
+ import { createFetch, createOutput, redactUrl } from './files.ts';
6
+ import { createSh } from './sh.ts';
7
+ import type { Ctx, Http, RetryOptions, RunInfo } from './types.ts';
8
8
 
9
9
  export interface CtxOptions {
10
10
  run: RunInfo;
@@ -12,25 +12,38 @@ export interface CtxOptions {
12
12
  secrets: readonly Secret[];
13
13
  /** Secret values that take precedence over the environment (tests). */
14
14
  secretValues?: Readonly<Record<string, string>>;
15
+ /** Parsed config entries of the step (`ctx.config`). */
16
+ config?: Readonly<Record<string, unknown>>;
15
17
  paths: { root: string; workFiles: string; tmp: string };
16
18
  signal: AbortSignal;
17
19
  /** Where log lines go (default stderr). */
18
20
  logSink?: (line: string) => void;
21
+ /** `<step>/<input>` → callback URL (`ctx.inputUrl`). */
22
+ callbackUrls?: Readonly<Record<string, string>>;
19
23
  }
20
24
 
21
25
  export function formatLog(message: string, fields?: Record<string, unknown>): string {
22
- const parts = Object.entries(fields ?? {}).map(([k, v]) => `${k}=${typeof v === "string" && !/\s|"/.test(v) ? v : JSON.stringify(v)}`);
23
- return parts.length ? `${message} ${parts.join(" ")}` : message;
26
+ const parts = Object.entries(fields ?? {}).map(
27
+ ([k, v]) =>
28
+ `${k}=${typeof v === 'string' && !/\s|"/.test(v) ? v : JSON.stringify(v)}`,
29
+ );
30
+ return parts.length ? `${message} ${parts.join(' ')}` : message;
24
31
  }
25
32
 
26
- export function createSecrets(declared: readonly Secret[], values?: Readonly<Record<string, string>>): Record<string, string> {
33
+ export function createSecrets(
34
+ declared: readonly Secret[],
35
+ values?: Readonly<Record<string, string>>,
36
+ ): Record<string, string> {
27
37
  const obj: Record<string, string> = {};
28
38
  for (const s of declared) {
29
39
  Object.defineProperty(obj, s.name, {
30
40
  enumerable: true,
31
41
  get() {
32
42
  const v = values?.[s.name] ?? process.env[s.name];
33
- if (v === undefined) throw new Error(`secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`);
43
+ if (v === undefined)
44
+ throw new Error(
45
+ `secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`,
46
+ );
34
47
  return v;
35
48
  },
36
49
  });
@@ -39,52 +52,74 @@ export function createSecrets(declared: readonly Secret[], values?: Readonly<Rec
39
52
  }
40
53
 
41
54
  async function httpError(res: Response, url: string): Promise<Error> {
42
- let body = "";
55
+ let body = '';
43
56
  try {
44
57
  body = (await res.text()).slice(0, 500);
45
58
  } catch {
46
59
  /* ignore */
47
60
  }
48
- return new Error(`HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ""}`);
61
+ return new Error(
62
+ `HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ''}`,
63
+ );
49
64
  }
50
65
 
51
66
  export function createHttp(signal: AbortSignal): Http {
52
- const f = (url: string, init?: RequestInit) => fetch(url, { ...init, signal: init?.signal ?? signal });
67
+ const f = (url: string, init?: RequestInit) =>
68
+ fetch(url, { ...init, signal: init?.signal ?? signal });
53
69
  return {
54
70
  fetch: f,
55
- async getJson<T>(url: string, init?: { headers?: Record<string, string> }): Promise<T> {
56
- const res = await f(url, { headers: { accept: "application/json", ...init?.headers } });
71
+ async getJson<T>(
72
+ url: string,
73
+ init?: { headers?: Record<string, string> },
74
+ ): Promise<T> {
75
+ const res = await f(url, {
76
+ headers: { accept: 'application/json', ...init?.headers },
77
+ });
57
78
  if (!res.ok) throw await httpError(res, url);
58
79
  return (await res.json()) as T;
59
80
  },
60
- async expectOk(url: string, init?: { headers?: Record<string, string>; method?: string }): Promise<Response> {
61
- const res = await f(url, { method: init?.method ?? "GET", headers: init?.headers });
81
+ async expectOk(
82
+ url: string,
83
+ init?: { headers?: Record<string, string>; method?: string },
84
+ ): Promise<Response> {
85
+ const res = await f(url, { method: init?.method ?? 'GET', headers: init?.headers });
62
86
  if (!res.ok) throw await httpError(res, url);
63
87
  return res;
64
88
  },
65
89
  };
66
90
  }
67
91
 
68
- export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<string, unknown>) => void) {
69
- return async <T>(opts: RetryOptions, fn: (attempt: number) => Promise<T>): Promise<T> => {
70
- if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1)) throw new Error("retry: attempts must be a positive integer");
92
+ export function createRetry(
93
+ signal: AbortSignal,
94
+ log: (m: string, f?: Record<string, unknown>) => void,
95
+ ) {
96
+ return async <T>(
97
+ opts: RetryOptions,
98
+ fn: (attempt: number) => Promise<T>,
99
+ ): Promise<T> => {
100
+ if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1))
101
+ throw new Error('retry: attempts must be a positive integer');
71
102
  let delay = Math.max(0, opts.delaySeconds) * 1000;
72
103
  for (let attempt = 1; ; attempt++) {
73
104
  try {
74
105
  return await fn(attempt);
75
106
  } catch (e) {
76
107
  if (attempt >= opts.attempts || signal.aborted) throw e;
77
- log("retrying", { attempt, of: opts.attempts, error: (e as Error)?.message ?? String(e) });
108
+ log('retrying', {
109
+ attempt,
110
+ of: opts.attempts,
111
+ error: (e as Error)?.message ?? String(e),
112
+ });
78
113
  await new Promise<void>((res, rej) => {
79
114
  const t = setTimeout(() => {
80
- signal.removeEventListener("abort", onAbort);
115
+ signal.removeEventListener('abort', onAbort);
81
116
  res();
82
117
  }, delay);
83
118
  const onAbort = () => {
84
119
  clearTimeout(t);
85
- rej(new Error("retry cancelled: the step was cancelled"));
120
+ rej(new Error('retry cancelled: the step was cancelled'));
86
121
  };
87
- signal.addEventListener("abort", onAbort, { once: true });
122
+ signal.addEventListener('abort', onAbort, { once: true });
88
123
  });
89
124
  delay *= opts.backoff ?? 1;
90
125
  }
@@ -96,16 +131,25 @@ export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<str
96
131
  export function createCtx(o: CtxOptions): Ctx {
97
132
  mkdirSync(o.paths.tmp, { recursive: true });
98
133
  mkdirSync(o.paths.workFiles, { recursive: true });
99
- const sink = o.logSink ?? ((line: string) => process.stderr.write(line + "\n"));
100
- const log = (message: string, fields?: Record<string, unknown>) => sink(formatLog(message, fields));
134
+ const sink = o.logSink ?? ((line: string) => process.stderr.write(line + '\n'));
135
+ const log = (message: string, fields?: Record<string, unknown>) =>
136
+ sink(formatLog(message, fields));
101
137
  const { sh, exec } = createSh({ tmpDir: o.paths.tmp, signal: o.signal });
102
- const filesDeps = { run: o.run, workFiles: o.paths.workFiles, tmpDir: o.paths.tmp, root: o.paths.root, signal: o.signal, log };
138
+ const filesDeps = {
139
+ run: o.run,
140
+ workFiles: o.paths.workFiles,
141
+ tmpDir: o.paths.tmp,
142
+ root: o.paths.root,
143
+ signal: o.signal,
144
+ log,
145
+ };
103
146
  return {
104
- secrets: createSecrets(o.secrets, o.secretValues) as Ctx["secrets"],
147
+ secrets: createSecrets(o.secrets, o.secretValues) as Ctx['secrets'],
148
+ config: Object.freeze({ ...o.config }) as Ctx['config'],
105
149
  sh,
106
150
  exec,
107
- readText: async (p) => readFileSync(resolve(p), "utf8"),
108
- readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), "utf8")) as T,
151
+ readText: async (p) => readFileSync(resolve(p), 'utf8'),
152
+ readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), 'utf8')) as T,
109
153
  files: { fetch: createFetch(filesDeps) },
110
154
  output: createOutput(filesDeps),
111
155
  http: createHttp(o.signal),
@@ -115,5 +159,14 @@ export function createCtx(o: CtxOptions): Ctx {
115
159
  run: Object.freeze({ ...o.run }),
116
160
  tmpDir: o.paths.tmp,
117
161
  skip: skipped,
162
+ inputUrl: (step, input) => {
163
+ const name = typeof step === 'string' ? step : step.name;
164
+ const url = o.callbackUrls?.[`${name}/${input}`];
165
+ if (!url)
166
+ throw new Error(
167
+ `no callback URL for input '${input}' of step '${name}': it must list callback() in from, and the instance needs a public base URL`,
168
+ );
169
+ return url;
170
+ },
118
171
  };
119
172
  }
package/src/ctx/sh.ts CHANGED
@@ -1,32 +1,49 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
- import { basename, isAbsolute, join } from "node:path";
3
- import type { ExecOptions, Sh, ShOptions } from "./types.ts";
4
- import { PRIVATE_ENV } from "../harness/env.ts";
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { basename, isAbsolute, join } from 'node:path';
10
+ import type { ExecOptions, Sh, ShOptions } from './types.ts';
11
+ import { PRIVATE_ENV } from '../harness/env.ts';
5
12
 
6
13
  /** Shells looked for, in order. kaniko only has /busybox/sh; distroless has none. */
7
- export const SHELL_CANDIDATES = ["/bin/sh", "/busybox/sh", "/bin/bash"] as const;
14
+ export const SHELL_CANDIDATES = ['/bin/sh', '/busybox/sh', '/bin/bash'] as const;
8
15
 
9
16
  export class ShellNotFoundError extends Error {
10
- override name = "ShellNotFoundError";
17
+ override name = 'ShellNotFoundError';
11
18
  constructor() {
12
19
  super(
13
- `no shell found in this image (looked for ${SHELL_CANDIDATES.join(", ")}). ` +
20
+ `no shell found in this image (looked for ${SHELL_CANDIDATES.join(', ')}). ` +
14
21
  `ctx.sh needs a shell: use an image that has one, or run binaries directly with ctx.exec([...]).`,
15
22
  );
16
23
  }
17
24
  }
18
25
 
19
26
  export class CommandError extends Error {
20
- override name = "CommandError";
21
- constructor(readonly what: string, readonly exitCode: number | null, readonly signalCode: string | null) {
22
- super(`${what} failed with ${signalCode ? `signal ${signalCode}` : `exit code ${exitCode}`}`);
27
+ override name = 'CommandError';
28
+ constructor(
29
+ readonly what: string,
30
+ readonly exitCode: number | null,
31
+ readonly signalCode: string | null,
32
+ ) {
33
+ super(
34
+ `${what} failed with ${signalCode ? `signal ${signalCode}` : `exit code ${exitCode}`}`,
35
+ );
23
36
  }
24
37
  }
25
38
 
26
39
  /** The command went quiet for longer than its `idleTimeoutSeconds` and was killed. */
27
40
  export class CommandStalledError extends CommandError {
28
- override name = "CommandStalledError";
29
- constructor(what: string, readonly idleTimeoutSeconds: number, signalCode: string | null) {
41
+ override name = 'CommandStalledError';
42
+ constructor(
43
+ what: string,
44
+ readonly idleTimeoutSeconds: number,
45
+ signalCode: string | null,
46
+ ) {
30
47
  super(what, null, signalCode);
31
48
  this.message = `${what} produced no output for ${idleTimeoutSeconds} s and was killed`;
32
49
  }
@@ -42,7 +59,7 @@ export function detectShell(candidates: readonly string[] = SHELL_CANDIDATES): s
42
59
 
43
60
  /** True for the value of `import x from "./x.sh" with { type: "file" }` (compiled or from source). */
44
61
  export function isEmbeddedScript(s: string): boolean {
45
- if (s.startsWith("/$bunfs/") || s.startsWith("B:/~BUN/")) return true;
62
+ if (s.startsWith('/$bunfs/') || s.startsWith('B:/~BUN/')) return true;
46
63
  if (!isAbsolute(s) || !/\.(sh|bash)$/.test(s) || /\s/.test(s)) return false;
47
64
  try {
48
65
  return statSync(s).isFile();
@@ -54,7 +71,8 @@ export function isEmbeddedScript(s: string): boolean {
54
71
  /** Environment for child processes: the harness env minus harness-only credentials, plus extras. */
55
72
  export function childEnv(extra?: Record<string, string>): Record<string, string> {
56
73
  const env: Record<string, string> = {};
57
- for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !PRIVATE_ENV.includes(k)) env[k] = v;
74
+ for (const [k, v] of Object.entries(process.env))
75
+ if (v !== undefined && !PRIVATE_ENV.includes(k)) env[k] = v;
58
76
  return { ...env, ...extra };
59
77
  }
60
78
 
@@ -64,7 +82,10 @@ export interface ShDeps {
64
82
  shellCandidates?: readonly string[];
65
83
  }
66
84
 
67
- export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[], opts?: ExecOptions) => Promise<void> } {
85
+ export function createSh(deps: ShDeps): {
86
+ sh: Sh;
87
+ exec: (argv: readonly string[], opts?: ExecOptions) => Promise<void>;
88
+ } {
68
89
  const copied = new Map<string, string>();
69
90
  let counter = 0;
70
91
 
@@ -72,7 +93,7 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
72
93
  const materialize = (script: string): string => {
73
94
  const hit = copied.get(script);
74
95
  if (hit) return hit;
75
- const dir = join(deps.tmpDir, "scripts");
96
+ const dir = join(deps.tmpDir, 'scripts');
76
97
  mkdirSync(dir, { recursive: true });
77
98
  const dest = join(dir, `${++counter}-${basename(script)}`);
78
99
  writeFileSync(dest, readFileSync(script));
@@ -81,20 +102,34 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
81
102
  return dest;
82
103
  };
83
104
 
84
- const argvFor = (cmd: string | { script: string }, args: readonly string[]): { argv: string[]; what: string } => {
85
- const scriptPath = typeof cmd === "object" ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
105
+ const argvFor = (
106
+ cmd: string | { script: string },
107
+ args: readonly string[],
108
+ ): { argv: string[]; what: string } => {
109
+ const scriptPath =
110
+ typeof cmd === 'object' ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
86
111
  if (scriptPath === undefined) {
87
112
  const shell = detectShell(deps.shellCandidates);
88
- return { argv: [shell, "-c", cmd as string], what: `command \`${truncate(cmd as string)}\`` };
113
+ // `sh -c cmd sh a b`: the args are $1, $2… of the command.
114
+ return {
115
+ argv: [shell, '-c', cmd as string, 'sh', ...args],
116
+ what: `command \`${truncate(cmd as string)}\``,
117
+ };
89
118
  }
90
119
  const file = materialize(scriptPath);
91
- const first = readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
120
+ const first = readFileSync(file, 'utf8').split('\n', 1)[0] ?? '';
92
121
  const shebang = /^#!\s*(\S+)(?:\s+(.*))?$/.exec(first);
93
122
  if (shebang && existsSync(shebang[1]!)) {
94
123
  const interpArgs = shebang[2] ? [shebang[2].trim()] : [];
95
- return { argv: [shebang[1]!, ...interpArgs, file, ...args], what: `script ${basename(scriptPath)}` };
124
+ return {
125
+ argv: [shebang[1]!, ...interpArgs, file, ...args],
126
+ what: `script ${basename(scriptPath)}`,
127
+ };
96
128
  }
97
- return { argv: [detectShell(deps.shellCandidates), file, ...args], what: `script ${basename(scriptPath)}` };
129
+ return {
130
+ argv: [detectShell(deps.shellCandidates), file, ...args],
131
+ what: `script ${basename(scriptPath)}`,
132
+ };
98
133
  };
99
134
 
100
135
  const run = async (
@@ -103,22 +138,26 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
103
138
  opts: { env?: Record<string, string>; cwd?: string; idleTimeoutSeconds?: number },
104
139
  capture: boolean,
105
140
  ): Promise<string> => {
106
- if (deps.signal.aborted) throw new Error(`${what} not started: the step was cancelled`);
107
- const idleSeconds = opts.idleTimeoutSeconds !== undefined && opts.idleTimeoutSeconds > 0 ? opts.idleTimeoutSeconds : undefined;
108
- let proc: Bun.Subprocess<"ignore", "pipe" | "inherit", "pipe" | "inherit">;
141
+ if (deps.signal.aborted)
142
+ throw new Error(`${what} not started: the step was cancelled`);
143
+ const idleSeconds =
144
+ opts.idleTimeoutSeconds !== undefined && opts.idleTimeoutSeconds > 0
145
+ ? opts.idleTimeoutSeconds
146
+ : undefined;
147
+ let proc: Bun.Subprocess<'ignore', 'pipe' | 'inherit', 'pipe' | 'inherit'>;
109
148
  try {
110
149
  proc = Bun.spawn(argv, {
111
150
  cwd: opts.cwd,
112
151
  env: childEnv(opts.env),
113
- stdin: "ignore",
114
- stdout: capture || idleSeconds ? "pipe" : "inherit",
115
- stderr: idleSeconds ? "pipe" : "inherit",
152
+ stdin: 'ignore',
153
+ stdout: capture || idleSeconds ? 'pipe' : 'inherit',
154
+ stderr: idleSeconds ? 'pipe' : 'inherit',
116
155
  });
117
156
  } catch (e) {
118
157
  throw new Error(`cannot start ${what} (${argv[0]}): ${(e as Error).message}`);
119
158
  }
120
- const onAbort = () => proc.kill("SIGTERM");
121
- deps.signal.addEventListener("abort", onAbort, { once: true });
159
+ const onAbort = () => proc.kill('SIGTERM');
160
+ deps.signal.addEventListener('abort', onAbort, { once: true });
122
161
 
123
162
  // Watchdog: every chunk of output re-arms the timer; on expiry TERM, then KILL after a grace.
124
163
  let stalled = false;
@@ -129,13 +168,16 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
129
168
  clearTimeout(idleTimer);
130
169
  idleTimer = setTimeout(() => {
131
170
  stalled = true;
132
- proc.kill("SIGTERM");
133
- killTimer = setTimeout(() => proc.kill("SIGKILL"), STALL_KILL_GRACE_MS);
171
+ proc.kill('SIGTERM');
172
+ killTimer = setTimeout(() => proc.kill('SIGKILL'), STALL_KILL_GRACE_MS);
134
173
  }, idleSeconds * 1000);
135
174
  };
136
175
  const captured: Uint8Array[] = [];
137
176
  const readers: { cancel(): Promise<void> }[] = [];
138
- const pump = async (stream: ReadableStream<Uint8Array>, sink: (chunk: Uint8Array) => void) => {
177
+ const pump = async (
178
+ stream: ReadableStream<Uint8Array>,
179
+ sink: (chunk: Uint8Array) => void,
180
+ ) => {
139
181
  const reader = stream.getReader();
140
182
  readers.push(reader);
141
183
  try {
@@ -150,9 +192,18 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
150
192
  }
151
193
  };
152
194
  const pumps: Promise<void>[] = [];
153
- if (capture) pumps.push(pump(proc.stdout as ReadableStream<Uint8Array>, (c) => captured.push(c)));
154
- else if (idleSeconds) pumps.push(pump(proc.stdout as ReadableStream<Uint8Array>, (c) => process.stdout.write(c)));
155
- if (idleSeconds) pumps.push(pump(proc.stderr as ReadableStream<Uint8Array>, (c) => process.stderr.write(c)));
195
+ if (capture)
196
+ pumps.push(
197
+ pump(proc.stdout as ReadableStream<Uint8Array>, (c) => captured.push(c)),
198
+ );
199
+ else if (idleSeconds)
200
+ pumps.push(
201
+ pump(proc.stdout as ReadableStream<Uint8Array>, (c) => process.stdout.write(c)),
202
+ );
203
+ if (idleSeconds)
204
+ pumps.push(
205
+ pump(proc.stderr as ReadableStream<Uint8Array>, (c) => process.stderr.write(c)),
206
+ );
156
207
  arm();
157
208
 
158
209
  try {
@@ -160,13 +211,23 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
160
211
  // A killed command's children may still hold the pipes open: don't wait for them after a stall.
161
212
  if (stalled) await Promise.all(readers.map((r) => r.cancel().catch(() => {})));
162
213
  else await Promise.all(pumps);
163
- if (stalled) throw new CommandStalledError(what, idleSeconds!, proc.signalCode ? String(proc.signalCode) : null);
164
- if (code !== 0) throw new CommandError(what, proc.signalCode ? null : code, proc.signalCode ? String(proc.signalCode) : null);
165
- return capture ? Buffer.concat(captured).toString("utf8") : "";
214
+ if (stalled)
215
+ throw new CommandStalledError(
216
+ what,
217
+ idleSeconds!,
218
+ proc.signalCode ? String(proc.signalCode) : null,
219
+ );
220
+ if (code !== 0)
221
+ throw new CommandError(
222
+ what,
223
+ proc.signalCode ? null : code,
224
+ proc.signalCode ? String(proc.signalCode) : null,
225
+ );
226
+ return capture ? Buffer.concat(captured).toString('utf8') : '';
166
227
  } finally {
167
228
  clearTimeout(idleTimer);
168
229
  clearTimeout(killTimer);
169
- deps.signal.removeEventListener("abort", onAbort);
230
+ deps.signal.removeEventListener('abort', onAbort);
170
231
  }
171
232
  };
172
233
 
@@ -184,13 +245,13 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
184
245
  ) as Sh;
185
246
 
186
247
  const exec = async (argv: readonly string[], opts: ExecOptions = {}) => {
187
- if (argv.length === 0) throw new Error("ctx.exec needs at least the program");
188
- await run([...argv], `\`${truncate(argv.join(" "))}\``, opts, false);
248
+ if (argv.length === 0) throw new Error('ctx.exec needs at least the program');
249
+ await run([...argv], `\`${truncate(argv.join(' '))}\``, opts, false);
189
250
  };
190
251
  return { sh, exec };
191
252
  }
192
253
 
193
254
  function truncate(s: string, n = 120): string {
194
- const one = s.replace(/\s+/g, " ").trim();
195
- return one.length > n ? one.slice(0, n - 1) + "…" : one;
255
+ const one = s.replace(/\s+/g, ' ').trim();
256
+ return one.length > n ? one.slice(0, n - 1) + '…' : one;
196
257
  }
package/src/ctx/types.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { Skipped } from "../core/handlers.ts";
2
- import type { FileRef } from "../core/models.ts";
3
- import type { DeclaredSecrets } from "../core/secrets.ts";
1
+ import type { Skipped } from '../core/handlers.ts';
2
+ import type { FileRef } from '../core/models.ts';
3
+ import type { AnyConfigEntry, ConfigValues } from '../core/config.ts';
4
+ import type { DeclaredSecrets } from '../core/secrets.ts';
4
5
 
5
6
  /** Facts about the current execution, from the service (see README "Environment"). */
6
7
  export interface RunInfo {
@@ -79,9 +80,11 @@ export interface OutputFileOptions {
79
80
  name?: string;
80
81
  }
81
82
 
82
- export interface Ctx<Sec extends string = string> {
83
+ export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = never> {
83
84
  /** Exactly the secrets the step declares, read from the environment. */
84
85
  readonly secrets: DeclaredSecrets<Sec>;
86
+ /** Exactly the config entries the step declares, from the run's config snapshot (defaults applied). */
87
+ readonly config: ConfigValues<Cfg>;
85
88
  readonly sh: Sh;
86
89
  /** Run a binary directly, without a shell (works in shell-less images). Rejects on non-zero exit. */
87
90
  exec(argv: readonly string[], opts?: ExecOptions): Promise<void>;
@@ -110,4 +113,9 @@ export interface Ctx<Sec extends string = string> {
110
113
  readonly tmpDir: string;
111
114
  /** Runtime escape hatch: produce no output. Prefer `when` blocks. */
112
115
  skip(reason: string): Skipped;
116
+ /**
117
+ * This run's callback URL for an external input with `from: [callback()]` (of any step of the pipeline):
118
+ * hand it to the system that will answer. POST the value as JSON, or `{"$decline": "reason"}`.
119
+ */
120
+ inputUrl(step: string | { readonly name: string }, input: string): string;
113
121
  }