@mastra/docker 0.9.4-alpha.0 → 0.10.0-alpha.1

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/dist/index.cjs CHANGED
@@ -2174,6 +2174,8 @@ function isImageNotFoundError(error) {
2174
2174
  }
2175
2175
  //#endregion
2176
2176
  //#region ../../packages/_internals/workspace/dist/index.js
2177
+ /** Directory, relative to the template's build cwd, that holds every marker below. */
2178
+ const SETUP_MARKER_DIR = ".mastra-sandbox";
2177
2179
  /**
2178
2180
  * Setup completion marker shared by repo templates and their consumers.
2179
2181
  *
@@ -2183,8 +2185,29 @@ function isImageNotFoundError(error) {
2183
2185
  * booted from the image tell whether the setup it is about to run already
2184
2186
  * happened. Relative to the template's build cwd, which is also the runtime
2185
2187
  * working directory the repo was cloned into.
2188
+ *
2189
+ * This is the single-repository marker. Templates built from a repository
2190
+ * list write one marker per repository instead ({@link repoSetupMarkerPath}).
2186
2191
  */
2187
2192
  const SETUP_MARKER_PATH = ".mastra-sandbox/setup";
2193
+ /**
2194
+ * Marker for the workspace-level setup of a multi-repository template: the
2195
+ * commands that ran at the working directory after every repository was set
2196
+ * up. Written even when there were no such commands, so a consumer can tell
2197
+ * "ran nothing" from "never built".
2198
+ */
2199
+ const WORKSPACE_SETUP_MARKER_PATH = ".mastra-sandbox/workspace-setup";
2200
+ /**
2201
+ * One line per repository directory whose setup failed while the build was
2202
+ * told to continue. Absent when nothing failed. Consumers check this before
2203
+ * trusting a per-repository marker: that marker means every step ran, not
2204
+ * that every step succeeded.
2205
+ */
2206
+ const SETUP_FAILED_MARKER_PATH = ".mastra-sandbox/setup-failed";
2207
+ /** Per-repository marker of a multi-repository template, keyed by the clone directory name. */
2208
+ function repoSetupMarkerPath(repoDir) {
2209
+ return `${SETUP_MARKER_DIR}/repos/${repoDir}`;
2210
+ }
2188
2211
  /** Blank entries never become build steps, so they never count toward the digest either. */
2189
2212
  function normalizeSetupCommands(setupCommand) {
2190
2213
  return (setupCommand === void 0 ? [] : Array.isArray(setupCommand) ? setupCommand : [setupCommand]).filter((command) => command.trim() !== "");
@@ -2194,8 +2217,26 @@ function setupMarkerContent(setupCommand) {
2194
2217
  return `sha256:${(0, crypto.createHash)("sha256").update(normalizeSetupCommands(setupCommand).join("\n")).digest("hex")}`;
2195
2218
  }
2196
2219
  /** Shell step that writes the marker relative to the cwd. `content` is a digest, so it is shell-safe. */
2197
- function setupMarkerCommand(content) {
2198
- return `mkdir -p "$(dirname "${SETUP_MARKER_PATH}")" && printf '%s' '${content}' > "${SETUP_MARKER_PATH}"`;
2220
+ function setupMarkerCommand(content, markerPath = SETUP_MARKER_PATH) {
2221
+ return `mkdir -p "$(dirname "${markerPath}")" && printf '%s' '${content}' > "${markerPath}"`;
2222
+ }
2223
+ /** A clone directory name is one path segment of word characters, dots and dashes, so it never needs quoting. */
2224
+ const REPO_DIR_PATTERN = /^[\w.-]+$/;
2225
+ /**
2226
+ * Shell step that runs one setup command inside a repository clone.
2227
+ *
2228
+ * Without the guard this is the plain `cd "<dir>" && <cmd>` step. With it,
2229
+ * the `cd` sits inside the parenthesized group so that the failure branch
2230
+ * runs back at the build cwd and the failure list lands at the workspace
2231
+ * level, never inside the repository.
2232
+ */
2233
+ function guardedSetupCommand({ repoDir, command, continueOnFailure }) {
2234
+ if (!REPO_DIR_PATTERN.test(repoDir)) throw new TypeError(`Repository directory "${repoDir}" must match ${REPO_DIR_PATTERN}.`);
2235
+ if (!continueOnFailure) return `cd "${repoDir}" && ${command}`;
2236
+ return `( cd "${repoDir}" && sh -c ${shellQuote$1(command)} ) || { mkdir -p "${SETUP_MARKER_DIR}" && grep -qxF -- '${repoDir}' "${SETUP_FAILED_MARKER_PATH}" 2>/dev/null || printf '%s\\n' '${repoDir}' >> "${SETUP_FAILED_MARKER_PATH}"; }`;
2237
+ }
2238
+ function shellQuote$1(value) {
2239
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2199
2240
  }
2200
2241
  //#endregion
2201
2242
  //#region src/template/repo-template.ts
@@ -2211,7 +2252,11 @@ function setupMarkerCommand(content) {
2211
2252
  * the template identity. A moved branch therefore yields a fresh image on the
2212
2253
  * next new sandbox, and an unmoved one reuses the cached image. When the head
2213
2254
  * cannot be resolved the resolver rejects: an unpinned clone cached under a
2214
- * stable tag would otherwise serve stale repository state forever.
2255
+ * stable tag would otherwise serve stale repository state forever. This holds
2256
+ * for every entry of the `repos` form too, where the platform and E2B
2257
+ * templates clone such a repository unpinned instead: those use a shallow
2258
+ * clone whose tip the session fetches past, while this template clones full
2259
+ * history and pins by `checkout`, so there is no cheap unpinned mode here.
2215
2260
  *
2216
2261
  * The clone runs in a throwaway build stage; the credential is passed by value
2217
2262
  * to that stage only and never enters the template identity or the image.
@@ -2227,38 +2272,67 @@ function setupMarkerCommand(content) {
2227
2272
  * ```
2228
2273
  */
2229
2274
  const execFileAsync = (0, util.promisify)(child_process.execFile);
2230
- /** Env var the build's clone reads the credential from (see `cloneFull`). */
2275
+ /** Env var the build's clone reads the credential from (see `cloneAndPin`). */
2231
2276
  const BUILD_TOKEN_ENV = "GH_TOKEN";
2232
2277
  const DEFAULT_BASE_IMAGE = "node:22-slim";
2233
2278
  const DEFAULT_WORKING_DIRECTORY = "/workspace";
2234
2279
  const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
2235
- const SHA_PATTERN = /^[0-9a-f]{40}$/i;
2236
2280
  const CLONE_URL_ALLOWED_CHARS = /^[a-z0-9:/._-]+$/i;
2237
2281
  const CLONE_URL_HOST_PATTERN = /^[a-z0-9.-]+$/i;
2238
2282
  const CLONE_URL_SEGMENT_PATTERN = /^[\w.-]+$/;
2239
2283
  /** Refs interpolate into shell too; git ref names are already restricted, so allowlist tightly. */
2240
2284
  const REF_PATTERN = /^[\w./-]+$/;
2241
2285
  function createDockerRepoTemplate(options) {
2242
- if (!options.getRepositoryAccess) return void 0;
2243
- if (options.ref !== void 0 && !REF_PATTERN.test(options.ref)) throw new Error(`Invalid ref '${options.ref}': expected a git ref name`);
2286
+ if (options.getRepositoryAccess && options.repos) throw new TypeError("createDockerRepoTemplate: pass either getRepositoryAccess or repos, not both");
2287
+ if (!options.repos && (options.workspaceSetupCommand !== void 0 || options.continueOnSetupFailure !== void 0)) throw new TypeError("createDockerRepoTemplate: workspaceSetupCommand and continueOnSetupFailure require repos");
2288
+ if (!options.getRepositoryAccess && !options.repos?.length) return void 0;
2289
+ const entries = options.repos ?? [{
2290
+ getRepositoryAccess: options.getRepositoryAccess,
2291
+ ref: options.ref,
2292
+ setupCommand: options.setupCommand
2293
+ }];
2294
+ for (const entry of entries) {
2295
+ if (entry.ref !== void 0 && !REF_PATTERN.test(entry.ref)) throw new Error(`Invalid ref '${entry.ref}': expected a git ref name`);
2296
+ assertSingleLineCommands(entry.setupCommand, "setupCommand");
2297
+ }
2298
+ assertSingleLineCommands(options.workspaceSetupCommand, "workspaceSetupCommand");
2244
2299
  const workingDirectory = trimTrailingSlashes(options.workingDirectory ?? DEFAULT_WORKING_DIRECTORY);
2245
2300
  if (!workingDirectory.startsWith("/")) throw new Error(`workingDirectory must be an absolute path, got '${options.workingDirectory}'`);
2246
- return (resolveOptions) => resolveRepoTemplate(options, workingDirectory, resolveOptions);
2301
+ return (resolveOptions) => resolveRepoTemplate(options, entries, workingDirectory, resolveOptions);
2247
2302
  }
2248
- async function resolveRepoTemplate(options, workingDirectory, resolveOptions = {}) {
2303
+ async function resolveRepoTemplate(options, entries, workingDirectory, resolveOptions = {}) {
2249
2304
  const { abortSignal } = resolveOptions;
2305
+ const isList = options.repos !== void 0;
2250
2306
  throwIfAborted(abortSignal, "resolve Docker repository template");
2251
- let access;
2252
- try {
2253
- access = await options.getRepositoryAccess(resolveOptions);
2254
- } catch (error) {
2255
- throw normalizeAbortError(error, "resolve Docker repository template");
2307
+ const repos = [];
2308
+ const seenDirs = /* @__PURE__ */ new Map();
2309
+ for (const [index, entry] of entries.entries()) {
2310
+ let access;
2311
+ try {
2312
+ access = await entry.getRepositoryAccess(resolveOptions);
2313
+ } catch (error) {
2314
+ throw normalizeAbortError(error, "resolve Docker repository template");
2315
+ }
2316
+ throwIfAborted(abortSignal, "resolve Docker repository template");
2317
+ const cloneUrl = access?.cloneUrl;
2318
+ if (!cloneUrl) throw new Error(`Repo template has no clone URL: repository access returned none${isList ? ` (repos[${index}])` : ""}.`);
2319
+ assertCloneUrl(cloneUrl);
2320
+ const repoDir = repoDirName(cloneUrl);
2321
+ const previous = seenDirs.get(repoDir);
2322
+ if (previous) throw new Error(`createDockerRepoTemplate: repositories ${previous} and ${cloneUrl} would both clone into "${repoDir}"`);
2323
+ seenDirs.set(repoDir, cloneUrl);
2324
+ const token = access?.authorization?.token;
2325
+ const sha = await resolveHead(cloneUrl, entry.ref, token, abortSignal);
2326
+ throwIfAborted(abortSignal, "resolve Docker repository template");
2327
+ if (!sha) throw new Error(`Could not resolve ${entry.ref ?? "HEAD"} of ${cloneUrl} with git ls-remote; check the ref, the credential and network access`);
2328
+ repos.push({
2329
+ cloneUrl,
2330
+ sha,
2331
+ token,
2332
+ tokenEnv: token ? isList ? `${BUILD_TOKEN_ENV}_${index}` : BUILD_TOKEN_ENV : void 0,
2333
+ setupCommand: entry.setupCommand
2334
+ });
2256
2335
  }
2257
- throwIfAborted(abortSignal, "resolve Docker repository template");
2258
- const cloneUrl = access?.cloneUrl;
2259
- if (!cloneUrl) throw new Error("Repo template has no clone URL: repository access returned none.");
2260
- assertCloneUrl(cloneUrl);
2261
- const token = access?.authorization?.token;
2262
2336
  let buildEnv;
2263
2337
  try {
2264
2338
  buildEnv = typeof options.buildEnv === "function" ? await options.buildEnv(resolveOptions) : options.buildEnv;
@@ -2266,19 +2340,28 @@ async function resolveRepoTemplate(options, workingDirectory, resolveOptions = {
2266
2340
  throw normalizeAbortError(error, "resolve Docker repository template");
2267
2341
  }
2268
2342
  throwIfAborted(abortSignal, "resolve Docker repository template");
2269
- const sha = await resolveHead(cloneUrl, options.ref, token, abortSignal);
2270
- throwIfAborted(abortSignal, "resolve Docker repository template");
2271
- if (!sha) throw new Error(`Could not resolve ${options.ref ?? "HEAD"} of ${cloneUrl} with git ls-remote; check the ref, the credential and network access`);
2272
- return buildRepoTemplate({
2273
- cloneUrl,
2274
- sha,
2275
- token,
2343
+ const shared = {
2276
2344
  buildEnv,
2277
- setupCommand: options.setupCommand,
2278
2345
  workingDirectory,
2279
2346
  baseImage: options.baseImage,
2280
2347
  owner: options.owner,
2281
2348
  dockerOptions: options.dockerOptions
2349
+ };
2350
+ if (!isList) {
2351
+ const [repo] = repos;
2352
+ return buildRepoTemplate({
2353
+ ...shared,
2354
+ cloneUrl: repo.cloneUrl,
2355
+ sha: repo.sha,
2356
+ token: repo.token,
2357
+ setupCommand: repo.setupCommand
2358
+ });
2359
+ }
2360
+ return buildMultiRepoTemplate({
2361
+ ...shared,
2362
+ repos,
2363
+ workspaceSetupCommand: options.workspaceSetupCommand,
2364
+ continueOnSetupFailure: options.continueOnSetupFailure ?? false
2282
2365
  });
2283
2366
  }
2284
2367
  /**
@@ -2297,12 +2380,12 @@ function buildRepoTemplate(inputs) {
2297
2380
  if (inputs.baseImage === void 0) template = template.aptInstall(["git", "ca-certificates"]);
2298
2381
  if (buildEnv && Object.keys(buildEnv).length > 0) template = template.setEnvs(buildEnv);
2299
2382
  const tokenEnv = token ? BUILD_TOKEN_ENV : void 0;
2300
- const clone = [cloneFull({
2383
+ template = template.runWithSecrets(cloneAndPin({
2301
2384
  cloneUrl,
2302
2385
  destination,
2303
- tokenEnv
2304
- }), `git -C ${shellQuote(destination)} checkout --detach ${shellQuote(sha)}`];
2305
- template = template.runWithSecrets(clone, {
2386
+ tokenEnv,
2387
+ sha
2388
+ }), {
2306
2389
  secrets: tokenEnv ? [tokenEnv] : [],
2307
2390
  output: destination,
2308
2391
  ...inputs.owner !== void 0 ? { owner: inputs.owner } : {}
@@ -2313,6 +2396,60 @@ function buildRepoTemplate(inputs) {
2313
2396
  return template;
2314
2397
  }
2315
2398
  /**
2399
+ * List-form assembly: the working directory is the cwd, every repository is
2400
+ * laid out under it (public first) around its own pin, then the workspace
2401
+ * steps and marker. Exported for tests.
2402
+ * @internal
2403
+ */
2404
+ function buildMultiRepoTemplate(inputs) {
2405
+ const { repos, buildEnv, continueOnSetupFailure } = inputs;
2406
+ const workingDirectory = trimTrailingSlashes(inputs.workingDirectory);
2407
+ const secretValues = {};
2408
+ for (const repo of repos) if (repo.tokenEnv && repo.token) secretValues[repo.tokenEnv] = repo.token;
2409
+ let template = new DockerTemplate({
2410
+ baseImage: inputs.baseImage ?? DEFAULT_BASE_IMAGE,
2411
+ dockerOptions: inputs.dockerOptions,
2412
+ ...Object.keys(secretValues).length > 0 ? { secrets: secretValues } : {}
2413
+ });
2414
+ if (inputs.baseImage === void 0) template = template.aptInstall(["git", "ca-certificates"]);
2415
+ if (buildEnv && Object.keys(buildEnv).length > 0) template = template.setEnvs(buildEnv);
2416
+ template = template.setWorkdir(workingDirectory);
2417
+ const ordered = [...repos.filter((repo) => !repo.token), ...repos.filter((repo) => repo.token)];
2418
+ for (const repo of ordered) {
2419
+ const repoDir = repoDirName(repo.cloneUrl);
2420
+ const destination = `${workingDirectory}/${repoDir}`;
2421
+ const secrets = repo.tokenEnv ? [repo.tokenEnv] : [];
2422
+ const setupCommands = normalizeSetupCommands(repo.setupCommand);
2423
+ template = template.runWithSecrets(cloneAndPin({
2424
+ cloneUrl: repo.cloneUrl,
2425
+ destination,
2426
+ tokenEnv: repo.tokenEnv,
2427
+ sha: repo.sha
2428
+ }), {
2429
+ secrets,
2430
+ output: destination,
2431
+ ...inputs.owner !== void 0 ? { owner: inputs.owner } : {}
2432
+ });
2433
+ for (const command of setupCommands) template = template.runCmd(guardedSetupCommand({
2434
+ repoDir,
2435
+ command,
2436
+ continueOnFailure: continueOnSetupFailure
2437
+ }));
2438
+ template = template.runCmd(setupMarkerCommand(setupMarkerContent(setupCommands), repoSetupMarkerPath(repoDir)));
2439
+ }
2440
+ const workspaceCommands = normalizeSetupCommands(inputs.workspaceSetupCommand);
2441
+ for (const command of workspaceCommands) template = template.runCmd(command);
2442
+ template = template.runCmd(setupMarkerCommand(setupMarkerContent(workspaceCommands), WORKSPACE_SETUP_MARKER_PATH));
2443
+ return template;
2444
+ }
2445
+ /**
2446
+ * A Dockerfile `RUN` only spans lines through `\\` continuation, which the
2447
+ * parser folds away; a bare newline would start a new instruction.
2448
+ */
2449
+ function assertSingleLineCommands(commands, option) {
2450
+ for (const command of normalizeSetupCommands(commands)) if (/(?<!\\)\n|\r/.test(command)) throw new Error(`createDockerRepoTemplate: ${option} entries cannot contain a bare newline; end the line with \\ or pass separate commands`);
2451
+ }
2452
+ /**
2316
2453
  * Resolve `ref` (or the default branch) to a commit sha with `git ls-remote`
2317
2454
  * on the host, without cloning. A full sha is returned as is. Any failure
2318
2455
  * yields undefined; the caller decides how to surface it.
@@ -2343,14 +2480,14 @@ async function resolveHead(cloneUrl, ref, token, abortSignal) {
2343
2480
  });
2344
2481
  const lines = stdout.split("\n").filter(Boolean).map((line) => line.split(" "));
2345
2482
  const sha = (lines.find(([, name]) => name?.endsWith("^{}")) ?? lines[0])?.[0]?.trim();
2346
- return sha && SHA_PATTERN.test(sha) ? sha.toLowerCase() : void 0;
2483
+ return sha && FULL_SHA_PATTERN.test(sha) ? sha.toLowerCase() : void 0;
2347
2484
  } catch (error) {
2348
2485
  if (abortSignal?.aborted) throw normalizeAbortError(error, "resolve Docker repository template");
2349
2486
  return;
2350
2487
  }
2351
2488
  }
2352
- function cloneFull({ cloneUrl, destination, tokenEnv }) {
2353
- return `git ${tokenEnv ? `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)" ` : ""}clone ${shellQuote(cloneUrl)} ${shellQuote(destination)}`;
2489
+ function cloneAndPin({ cloneUrl, destination, tokenEnv, sha }) {
2490
+ return [`git ${tokenEnv ? `-c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$${tokenEnv}" | base64 -w0)" ` : ""}clone ${shellQuote(cloneUrl)} ${shellQuote(destination)}`, `git -C ${shellQuote(destination)} checkout --detach ${shellQuote(sha)}`];
2354
2491
  }
2355
2492
  function assertCloneUrl(cloneUrl) {
2356
2493
  if (cloneUrl.length > 2048 || !CLONE_URL_ALLOWED_CHARS.test(cloneUrl)) throw new Error(`Invalid cloneUrl '${cloneUrl}': expected an https URL with a plain host and path`);