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