@intentius/chant-lexicon-sql 0.124.52 → 0.124.55

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 (61) hide show
  1. package/dist/capability-plugin.d.ts +160 -0
  2. package/dist/capability-plugin.d.ts.map +1 -0
  3. package/dist/capability-plugin.js +370 -0
  4. package/dist/change-set.d.ts +35 -0
  5. package/dist/change-set.d.ts.map +1 -1
  6. package/dist/change-set.js +43 -0
  7. package/dist/ci-setup.d.ts +34 -0
  8. package/dist/ci-setup.d.ts.map +1 -0
  9. package/dist/ci-setup.js +106 -0
  10. package/dist/clickhouse/access.d.ts +20 -0
  11. package/dist/clickhouse/access.d.ts.map +1 -1
  12. package/dist/clickhouse/access.js +42 -1
  13. package/dist/clickhouse/live/describe-resources.d.ts +3 -1
  14. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  15. package/dist/clickhouse/live/describe-resources.js +31 -3
  16. package/dist/clickhouse/plan/deep.d.ts +42 -11
  17. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  18. package/dist/clickhouse/plan/deep.js +104 -33
  19. package/dist/exit-codes.d.ts +69 -0
  20. package/dist/exit-codes.d.ts.map +1 -0
  21. package/dist/exit-codes.js +105 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +12 -0
  25. package/dist/integrity.json +2 -2
  26. package/dist/lock-grain.d.ts +27 -0
  27. package/dist/lock-grain.d.ts.map +1 -0
  28. package/dist/lock-grain.js +13 -0
  29. package/dist/manifest.json +1 -1
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/plugin.js +8 -0
  32. package/dist/postgres/testing/migration.d.ts +2 -0
  33. package/dist/postgres/testing/migration.d.ts.map +1 -1
  34. package/dist/token-source.d.ts +28 -0
  35. package/dist/token-source.d.ts.map +1 -1
  36. package/dist/token-source.js +70 -1
  37. package/dist/yodel-run.d.ts +58 -0
  38. package/dist/yodel-run.d.ts.map +1 -0
  39. package/dist/yodel-run.js +145 -0
  40. package/package.json +2 -2
  41. package/src/capability-plugin.test.ts +317 -0
  42. package/src/capability-plugin.ts +515 -0
  43. package/src/change-set.ts +74 -0
  44. package/src/ci-setup.test.ts +67 -0
  45. package/src/ci-setup.ts +116 -0
  46. package/src/clickhouse/access.ts +49 -2
  47. package/src/clickhouse/live/access.e2e.test.ts +8 -7
  48. package/src/clickhouse/live/describe-resources.ts +27 -3
  49. package/src/clickhouse/plan/deep-grants-drift.test.ts +151 -0
  50. package/src/clickhouse/plan/deep-grants.test.ts +10 -5
  51. package/src/clickhouse/plan/deep.ts +103 -32
  52. package/src/exit-codes.test.ts +46 -0
  53. package/src/exit-codes.ts +140 -0
  54. package/src/index.ts +28 -0
  55. package/src/lock-grain.test.ts +57 -0
  56. package/src/lock-grain.ts +37 -0
  57. package/src/plugin.ts +9 -0
  58. package/src/testdata/fake-yodel.mjs +58 -0
  59. package/src/token-source-traded.test.ts +75 -0
  60. package/src/token-source.ts +74 -1
  61. package/src/yodel-run.ts +175 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * #4572: in a CI job that traded its OIDC token through chant's
3
+ * ci.identity, the cloud token sources mint from what the trade left, and
4
+ * run no cloud command line. Test values only; nothing reaches a cloud.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { AZURE_DATABASE_TOKEN_ENV as CORE_AZURE_DATABASE_TOKEN_ENV } from "@intentius/chant/ci-identity";
9
+ import { AZURE_DATABASE_TOKEN_ENV, credentialSource, tradedToken, TokenSourceError, type RunCommand } from "./token-source";
10
+
11
+ const RDS_URL = "postgres://db.abc123.eu-west-1.rds.amazonaws.com:5433/shop";
12
+ const AWS = { AWS_ACCESS_KEY_ID: "AKIDEXAMPLE", AWS_SECRET_ACCESS_KEY: "secret", AWS_SESSION_TOKEN: "session", AWS_REGION: "eu-west-1" };
13
+ const NOW = new Date("2026-10-27T12:00:00Z");
14
+
15
+ describe("tradedToken", () => {
16
+ test("rds-iam presigns connect for the host, port and user with the traded keys", () => {
17
+ const token = tradedToken({ token: "rds-iam" }, { url: RDS_URL, user: "app", env: AWS }, NOW)!;
18
+ expect(token.startsWith("db.abc123.eu-west-1.rds.amazonaws.com:5433/?")).toBe(true);
19
+ const q = new URLSearchParams(token.slice(token.indexOf("?") + 1));
20
+ expect(q.get("Action")).toBe("connect");
21
+ expect(q.get("DBUser")).toBe("app");
22
+ expect(q.get("X-Amz-Credential")).toBe("AKIDEXAMPLE/20261027/eu-west-1/rds-db/aws4_request");
23
+ expect(q.get("X-Amz-Expires")).toBe("900");
24
+ expect(q.get("X-Amz-Security-Token")).toBe("session");
25
+ expect(q.get("X-Amz-Signature")).toMatch(/^[0-9a-f]{64}$/);
26
+ });
27
+
28
+ test("rds-iam without traded keys falls through to the command line", () => {
29
+ expect(tradedToken({ token: "rds-iam" }, { url: RDS_URL, user: "app", env: {} }, NOW)).toBeUndefined();
30
+ });
31
+
32
+ test("rds-iam with keys still needs a user and a region", () => {
33
+ expect(() => tradedToken({ token: "rds-iam" }, { url: RDS_URL, env: AWS }, NOW)).toThrow(TokenSourceError);
34
+ const { AWS_REGION: _r, ...noRegion } = AWS;
35
+ expect(() => tradedToken({ token: "rds-iam" }, { url: "postgres://db.internal/shop", user: "app", env: noRegion }, NOW)).toThrow(/region/);
36
+ });
37
+
38
+ test("cloud-sql-iam is the traded Google access token", () => {
39
+ expect(tradedToken({ token: "cloud-sql-iam" }, { url: "postgres://10.0.0.3/shop", env: { GOOGLE_OAUTH_ACCESS_TOKEN: "ya29.x" } })).toBe("ya29.x");
40
+ expect(tradedToken({ token: "cloud-sql-iam" }, { url: "postgres://10.0.0.3/shop", env: { CLOUDSDK_AUTH_ACCESS_TOKEN: "ya29.y" } })).toBe("ya29.y");
41
+ expect(tradedToken({ token: "cloud-sql-iam" }, { url: "postgres://10.0.0.3/shop", env: {} })).toBeUndefined();
42
+ });
43
+
44
+ test("entra is the database token, never the ARM one", () => {
45
+ expect(tradedToken({ token: "entra" }, { url: "postgres://x.postgres.database.azure.com/shop", env: { [AZURE_DATABASE_TOKEN_ENV]: "eyJdb" } })).toBe("eyJdb");
46
+ expect(tradedToken({ token: "entra" }, { url: "postgres://x.postgres.database.azure.com/shop", env: { AZURE_ACCESS_TOKEN: "eyJarm" } })).toBeUndefined();
47
+ });
48
+
49
+ test("command never takes a traded token", () => {
50
+ expect(tradedToken({ token: "command", command: ["./mint.sh"] }, { url: "postgres://x/shop", env: AWS })).toBeUndefined();
51
+ });
52
+
53
+ test("the variable is the one core's trade sets", () => {
54
+ expect(AZURE_DATABASE_TOKEN_ENV).toBe(CORE_AZURE_DATABASE_TOKEN_ENV);
55
+ });
56
+ });
57
+
58
+ describe("credentialSource with a traded identity", () => {
59
+ test("mints without running a command, and caches", async () => {
60
+ let ran = 0;
61
+ const run: RunCommand = async () => {
62
+ ran += 1;
63
+ return "from-cli";
64
+ };
65
+ const src = credentialSource({ token: "cloud-sql-iam" }, { url: "postgres://10.0.0.3/shop", env: { GOOGLE_OAUTH_ACCESS_TOKEN: "ya29.x" }, source: "sql.profiles.prod.password", run });
66
+ expect(await src.get()).toBe("ya29.x");
67
+ expect(await src.get()).toBe("ya29.x");
68
+ expect(ran).toBe(0);
69
+ });
70
+
71
+ test("runs the command line when the job traded nothing", async () => {
72
+ const src = credentialSource({ token: "entra" }, { url: "postgres://x/shop", env: {}, source: "sql.profiles.prod.password", run: async () => "from-az\n" });
73
+ expect(await src.get()).toBe("from-az");
74
+ });
75
+ });
@@ -21,6 +21,22 @@
21
21
  * - `entra`: `az account get-access-token` for Azure Database for PostgreSQL.
22
22
  * - `command`: any command that prints a password on its standard output.
23
23
  *
24
+ * In a CI job that traded its OIDC token through chant's `ci.identity`
25
+ * (`@intentius/chant/ci-identity`, #4296, #4572), the cloud sources mint
26
+ * from what the trade left instead, with no cloud command line in the
27
+ * image ({@link tradedToken}):
28
+ *
29
+ * - `rds-iam` presigns the token with the traded AWS keys
30
+ * (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`),
31
+ * through chant's SigV4 signer, as `aws rds generate-db-auth-token` does.
32
+ * - `cloud-sql-iam` is the traded Google access token
33
+ * (`GOOGLE_OAUTH_ACCESS_TOKEN`), which Cloud SQL's IAM sign-in takes.
34
+ * - `entra` is `AZURE_DATABASE_ACCESS_TOKEN`, which the trade sets with
35
+ * `ci.identity.cloud.azure.database`. The ARM token it always sets is
36
+ * for another scope, so without `database` `entra` still runs `az`.
37
+ *
38
+ * Outside CI none of these variables is set and the command lines run.
39
+ *
24
40
  * A token is reused until most of its lifetime has passed and minted again
25
41
  * after, so a long apply, whose ClickHouse requests each carry the password
26
42
  * and whose Postgres connections each authenticate when opened, never sends
@@ -29,6 +45,7 @@
29
45
 
30
46
  import { execFile } from "node:child_process";
31
47
  import { z } from "zod";
48
+ import { presignV4 } from "@intentius/chant/cloud-lister/sigv4";
32
49
 
33
50
  const ttl = z.number().int().positive().optional();
34
51
 
@@ -104,6 +121,56 @@ export interface CredentialSource {
104
121
  invalidate(): void;
105
122
  }
106
123
 
124
+ /** The variable chant's trade sets to an Azure Database for PostgreSQL token (`AZURE_DATABASE_TOKEN_ENV` in `@intentius/chant/ci-identity`, #4572). */
125
+ export const AZURE_DATABASE_TOKEN_ENV = "AZURE_DATABASE_ACCESS_TOKEN";
126
+
127
+ /** The region an `rds-iam` token is signed for: the config's, `AWS_REGION`, `AWS_DEFAULT_REGION`, else the RDS host name's. */
128
+ function rdsRegion(config: { region?: string }, hostname: string, env: Record<string, string | undefined>): string | undefined {
129
+ return config.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? /\.([a-z]{2}(?:-[a-z]+)+-\d+)\.rds\.amazonaws\.com$/.exec(hostname)?.[1];
130
+ }
131
+
132
+ /**
133
+ * The token a cloud source takes from chant's traded identity (#4572), when
134
+ * the job's environment holds it; undefined otherwise, and for `command`.
135
+ * Pure but for `now`, for tests.
136
+ */
137
+ export function tradedToken(
138
+ config: TokenSourceConfig,
139
+ input: { url: string; user?: string; env: Record<string, string | undefined> },
140
+ now: Date = new Date(),
141
+ ): string | undefined {
142
+ const env = input.env;
143
+ switch (config.token) {
144
+ case "command":
145
+ return undefined;
146
+ case "cloud-sql-iam":
147
+ return env.GOOGLE_OAUTH_ACCESS_TOKEN || env.CLOUDSDK_AUTH_ACCESS_TOKEN || undefined;
148
+ case "entra":
149
+ return env[AZURE_DATABASE_TOKEN_ENV] || undefined;
150
+ case "rds-iam": {
151
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
152
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
153
+ if (!accessKeyId || !secretAccessKey) return undefined;
154
+ if (input.user === undefined) throw new TokenSourceError("rds-iam mints a token for one database user; the profile names no user");
155
+ const url = new URL(input.url);
156
+ const region = rdsRegion(config, url.hostname, env);
157
+ if (region === undefined) throw new TokenSourceError(`rds-iam signs its token for a region; set the token source's region or AWS_REGION for ${url.hostname}`);
158
+ const target = new URL(`https://${url.hostname}:${url.port || "5432"}/`);
159
+ target.searchParams.set("Action", "connect");
160
+ target.searchParams.set("DBUser", input.user);
161
+ const signed = presignV4({
162
+ url: target.toString(),
163
+ region,
164
+ service: "rds-db",
165
+ credentials: { accessKeyId, secretAccessKey, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}) },
166
+ seconds: DEFAULT_TTL_SECONDS["rds-iam"],
167
+ now,
168
+ });
169
+ return signed.replace(/^https:\/\//, "");
170
+ }
171
+ }
172
+ }
173
+
107
174
  /** The program a source runs. Pure, for tests. */
108
175
  export function tokenCommand(
109
176
  config: TokenSourceConfig,
@@ -119,7 +186,7 @@ export function tokenCommand(
119
186
  case "rds-iam": {
120
187
  const url = new URL(input.url);
121
188
  if (input.user === undefined) throw new TokenSourceError("rds-iam mints a token for one database user; the profile names no user");
122
- const region = config.region ?? input.env.AWS_REGION ?? input.env.AWS_DEFAULT_REGION ?? /\.([a-z]{2}(?:-[a-z]+)+-\d+)\.rds\.amazonaws\.com$/.exec(url.hostname)?.[1];
189
+ const region = rdsRegion(config, url.hostname, input.env);
123
190
  return [
124
191
  "aws",
125
192
  "rds",
@@ -158,6 +225,12 @@ export function credentialSource(
158
225
  let cached: { token: string; until: number } | undefined;
159
226
  let pending: Promise<string> | undefined;
160
227
  const mint = async (): Promise<string> => {
228
+ // #4572: a CI job that traded its OIDC token through chant mints from what the trade left.
229
+ const traded = tradedToken(config, input, new Date(now()));
230
+ if (traded !== undefined) {
231
+ cached = { token: traded, until: now() + lifetimeMs };
232
+ return traded;
233
+ }
161
234
  const argv = tokenCommand(config, input);
162
235
  let out: string;
163
236
  try {
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Running sql-yodeler's versioned migration engine (`yodel`) for the sql
3
+ * lexicon's `sql-migrate` capability (#4569). The ruling on
4
+ * sql-yodeler#285 (A) keeps the engine in sql-yodeler and has chant run it
5
+ * through a fixed argv list, the way the pulumi lexicon runs plunge
6
+ * (`lexicons/pulumi/src/run.ts`). {@link YODEL_COMMANDS} is that list, and
7
+ * {@link runAllowedYodel} refuses any argv off it before it reaches the
8
+ * runner, so no later caller can make this module run another yodel verb.
9
+ *
10
+ * The three commands, each the engine's own entry point:
11
+ *
12
+ * - `yodel plan <env> --part <file>`: the pending migrations as a chant
13
+ * change-set part (sql-yodeler#237, built by this lexicon's
14
+ * `sqlVersionedPlanPart`), one entry per object with its change classes.
15
+ * Reads only.
16
+ * - `yodel apply <env> --wave-plan <file> --spec <spec>`: the wave plan the
17
+ * op wave's gate binds. Its digest covers the migrations' digest, the
18
+ * environment's gate policy at the base commit, whether a pending step is
19
+ * destructive, and the promotion evidence. Reads only.
20
+ * - `yodel apply <env> --wave-apply <file> --spec <spec>`: applies that
21
+ * plan under the engine's lock. The engine plans again under the lock and
22
+ * refuses a plan that moved, and it checks chant's decision for the wave
23
+ * and the gate ledger's approval itself (ruling A on sql-yodeler#300).
24
+ *
25
+ * Each may carry, in this order and no other, `--dir <project>`, `--op
26
+ * <name>` and `--allow-out-of-order`. A value must not start with a dash.
27
+ *
28
+ * ## Credentials
29
+ *
30
+ * The two planning commands run with the writer taken out of their
31
+ * environment ({@link readerEnv}): no `YODEL_ROLE` or `YODEL_CREDENTIALS`,
32
+ * which pick the writer, and no `<ENV>_<DIALECT>_WRITER_USER` or
33
+ * `..._WRITER_PASSWORD`, the writer's default variables. Only the apply runs
34
+ * with the job's environment as given, so the writer reaches the apply
35
+ * alone even when one job plans and applies.
36
+ *
37
+ * The runner is injectable, so the tests run no binary.
38
+ */
39
+ import { execFile } from "node:child_process";
40
+ import { existsSync } from "node:fs";
41
+ import { join } from "node:path";
42
+
43
+ /** What one yodel run gave. Never thrown: a non-zero exit is a `code`. */
44
+ export interface YodelRun {
45
+ /** The exit status; null when the process did not exit by itself or never started. */
46
+ code: number | null;
47
+ stdout: string;
48
+ stderr: string;
49
+ /** No yodel binary was found. */
50
+ missingBinary?: boolean;
51
+ }
52
+
53
+ /** Runs yodel with these arguments in `cwd`, with `env`. Injectable so tests need no binary. */
54
+ export type YodelRunner = (args: string[], opts: { cwd: string; env: NodeJS.ProcessEnv; dir?: string }) => Promise<YodelRun>;
55
+
56
+ /**
57
+ * The only yodel commands chant runs (ruling A on sql-yodeler#285). Each
58
+ * may be followed by `--dir <project>`, `--op <name>` and
59
+ * `--allow-out-of-order`, in that order ({@link YODEL_OPTIONAL_FLAGS}).
60
+ */
61
+ export const YODEL_COMMANDS: readonly string[] = [
62
+ "yodel plan <env> --part <file>",
63
+ "yodel apply <env> --wave-plan <file> --spec <spec>",
64
+ "yodel apply <env> --wave-apply <file> --spec <spec>",
65
+ ];
66
+
67
+ /** The flags any of {@link YODEL_COMMANDS} may end with, in this order. */
68
+ export const YODEL_OPTIONAL_FLAGS: readonly string[] = ["--dir <project>", "--op <name>", "--allow-out-of-order"];
69
+
70
+ /** The options every command takes. */
71
+ export interface YodelCommonOptions {
72
+ /** The yodel project directory, passed as `--dir`. */
73
+ dir?: string;
74
+ /** The migrations Op, when more than one applies the environment. */
75
+ op?: string;
76
+ allowOutOfOrder?: boolean;
77
+ }
78
+
79
+ function common(opts: YodelCommonOptions): string[] {
80
+ return [...(opts.dir ? ["--dir", opts.dir] : []), ...(opts.op ? ["--op", opts.op] : []), ...(opts.allowOutOfOrder ? ["--allow-out-of-order"] : [])];
81
+ }
82
+
83
+ /** `yodel plan <env> --part <file>`, without the binary. */
84
+ export function yodelPlanArgs(env: string, part: string, opts: YodelCommonOptions = {}): string[] {
85
+ return ["plan", env, "--part", part, ...common(opts)];
86
+ }
87
+
88
+ /** `yodel apply <env> --wave-plan <file> --spec <spec>`, without the binary. */
89
+ export function yodelWavePlanArgs(env: string, file: string, spec: string, opts: YodelCommonOptions = {}): string[] {
90
+ return ["apply", env, "--wave-plan", file, "--spec", spec, ...common(opts)];
91
+ }
92
+
93
+ /** `yodel apply <env> --wave-apply <file> --spec <spec>`, without the binary. */
94
+ export function yodelWaveApplyArgs(env: string, file: string, spec: string, opts: YodelCommonOptions = {}): string[] {
95
+ return ["apply", env, "--wave-apply", file, "--spec", spec, ...common(opts)];
96
+ }
97
+
98
+ /** A value after a flag: present, and not itself a flag. */
99
+ const value = (v: string | undefined): boolean => typeof v === "string" && v.length > 0 && !v.startsWith("-");
100
+
101
+ /** The optional tail: `--dir <d>`, `--op <n>`, `--allow-out-of-order`, each at most once, in that order. */
102
+ function allowedTail(rest: readonly string[]): boolean {
103
+ let i = 0;
104
+ if (rest[i] === "--dir") {
105
+ if (!value(rest[i + 1])) return false;
106
+ i += 2;
107
+ }
108
+ if (rest[i] === "--op") {
109
+ if (!value(rest[i + 1])) return false;
110
+ i += 2;
111
+ }
112
+ if (rest[i] === "--allow-out-of-order") i += 1;
113
+ return i === rest.length;
114
+ }
115
+
116
+ /** Whether `args` is one of {@link YODEL_COMMANDS} with an allowed tail. */
117
+ export function allowedYodelArgv(args: readonly string[]): boolean {
118
+ if (!value(args[1])) return false;
119
+ if (args[0] === "plan") return args[2] === "--part" && value(args[3]) && allowedTail(args.slice(4));
120
+ if (args[0] === "apply") {
121
+ return (args[2] === "--wave-plan" || args[2] === "--wave-apply") && value(args[3]) && args[4] === "--spec" && value(args[5]) && allowedTail(args.slice(6));
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /** Whether `args` is the apply, the one command that writes. */
127
+ export function isYodelApplyArgv(args: readonly string[]): boolean {
128
+ return args[0] === "apply" && args[2] === "--wave-apply";
129
+ }
130
+
131
+ /** Run `args` through `run`, refusing any argv off {@link YODEL_COMMANDS}. */
132
+ export async function runAllowedYodel(run: YodelRunner, args: string[], opts: { cwd: string; env: NodeJS.ProcessEnv; dir?: string }): Promise<YodelRun> {
133
+ if (!allowedYodelArgv(args)) {
134
+ throw new Error(`chant runs only ${YODEL_COMMANDS.join(", ")} (each optionally with ${YODEL_OPTIONAL_FLAGS.join(", ")}) for a SQL member, not yodel ${args.join(" ")}`);
135
+ }
136
+ return run(args, opts);
137
+ }
138
+
139
+ /** The variables that pick the writer (sql-yodeler `credentials.ts`). */
140
+ export const YODEL_ROLE_VARIABLES: readonly string[] = ["YODEL_ROLE", "YODEL_CREDENTIALS"];
141
+
142
+ /** A writer's default user and password variables, `<ENV>_<DIALECT>_WRITER_USER` and `..._WRITER_PASSWORD`. */
143
+ const WRITER_VARIABLE = /^[A-Z0-9_]+_(CLICKHOUSE|POSTGRES)_WRITER_(USER|PASSWORD)$/;
144
+
145
+ /** `env` with the writer taken out: what the planning commands run with. */
146
+ export function readerEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
147
+ const out: NodeJS.ProcessEnv = {};
148
+ for (const [k, v] of Object.entries(env)) {
149
+ if (YODEL_ROLE_VARIABLES.includes(k) || WRITER_VARIABLE.test(k)) continue;
150
+ out[k] = v;
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * The yodel binary: the project's `node_modules/.bin/yodel`, else the
157
+ * working directory's, else `yodel` on PATH. Always a file named `yodel`.
158
+ */
159
+ export function yodelBinary(cwd: string, dir?: string): string {
160
+ for (const base of [...(dir ? [join(cwd, dir)] : []), cwd]) {
161
+ const bin = join(base, "node_modules", ".bin", "yodel");
162
+ if (existsSync(bin)) return bin;
163
+ }
164
+ return "yodel";
165
+ }
166
+
167
+ export const defaultYodelRunner: YodelRunner = (args, opts) =>
168
+ new Promise((resolve) => {
169
+ execFile(yodelBinary(opts.cwd, opts.dir), args, { cwd: opts.cwd, env: opts.env, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
170
+ if (!err) return resolve({ code: 0, stdout, stderr });
171
+ const e = err as NodeJS.ErrnoException & { code?: unknown };
172
+ if (e.code === "ENOENT") return resolve({ code: null, stdout, stderr: stderr || e.message, missingBinary: true });
173
+ resolve({ code: typeof e.code === "number" ? e.code : null, stdout, stderr: stderr || e.message });
174
+ });
175
+ });