diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index f9ea930f8..1915ed70f 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -1,10 +1,10 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { ProjectStateError } from "../../../errors/errors"; -import type { Project, ProjectEvent } from "../../../handlers/project/types"; +import { NotImplementedError, ProjectStateError } from "../../../errors/errors"; +import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; import { requireTool, runProcess, type ProcessRunner } from "../../../io"; import type { Logger } from "../../../logging"; -import type { ProjectBackend } from "./types"; +import type { DeployBackendInput, ProjectBackend } from "./types"; export type CdkBackendConfig = { logger: Logger; @@ -41,4 +41,12 @@ export class CdkBackend implements ProjectBackend { onOutput: (chunk) => this.logger.debug(chunk), }); } + + public async *deploy( + _project: Project, + _input: DeployBackendInput, + ): AsyncGenerator { + yield* []; + throw new NotImplementedError("CDK project deployment is not implemented yet"); + } } diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index 1e327cf49..eebeccf29 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -1,6 +1,13 @@ -import type { Project, ProjectEvent } from "../../../handlers/project/types"; +import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; + +export type DeployBackendInput = { + /** Fully resolved account and region selected from aws-targets.json. */ + target: AwsDeploymentTarget; +}; /** Builds the deployable artifacts owned by a project's selected backend. */ export interface ProjectBackend { build(project: Project): AsyncGenerator; + deploy(project: Project, input: DeployBackendInput): AsyncGenerator; } diff --git a/src/core/project/index.tsx b/src/core/project/index.tsx index b4b61cbe0..830a3455d 100644 --- a/src/core/project/index.tsx +++ b/src/core/project/index.tsx @@ -1,3 +1,3 @@ export { FsProjectManager } from "./manager"; export { CdkBackend, type CdkBackendConfig } from "./backends/cdk"; -export type { ProjectBackend } from "./backends/types"; +export type { DeployBackendInput, ProjectBackend } from "./backends/types"; diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 4a426ef1f..c086e5afc 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -4,6 +4,8 @@ import { join, relative } from "node:path"; import type { AddResourceInput, CreateProjectInput, + DeployProjectInput, + DeployResult, ResolveProjectInput, Project, ProjectManager, @@ -33,6 +35,9 @@ import type { HarnessSpecSchema } from "../../projectSchemas/harness"; import z from "zod"; import { CdkBackend } from "./backends/cdk"; import type { ProjectBackend } from "./backends/types"; +import { AwsDeploymentTargetsSchema } from "../../projectSchemas/aws-targets"; + +const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; type ProjectManagerConfig = { logger: Logger; @@ -282,6 +287,41 @@ export class FsProjectManager implements ProjectManager { yield* this.backendFor(project).build(project); } + // Resolves the named target from aws-targets.json before handing off, so the + // backend receives a fully resolved account and region and never has to know + // how targets are stored. The backend owns everything after that point. + public async *deploy( + project: Project, + input: DeployProjectInput, + ): AsyncGenerator { + const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); + if (!existsSync(targetsPath)) { + throw new ProjectStateError( + `No deployment targets are configured for project '${project.name}'. ` + + `Add ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, + ); + } + + const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); + + if (targets.length === 0) { + throw new ProjectStateError( + `No deployment targets are configured for project '${project.name}'. ` + + `Add at least one to ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, + ); + } + + const target = targets.find((candidate) => candidate.name === input.target); + if (!target) { + throw new ProjectStateError( + `Project '${project.name}' has no deployment target named '${input.target}'. ` + + `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, + ); + } + + return yield* this.backendFor(project).deploy(project, { target }); + } + private backendFor(project: Project): ProjectBackend { const backend = this.backends[project.spec.managedBy]; if (!backend) { diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index 3f7523267..858c497f3 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -107,17 +107,21 @@ export class SourceResolutionError extends InputValidationError { } } +type DeserializationErrorOptions = Omit & { + /** + * Why the file could not be read. Required because the root handler prints + * only `error.message`: a detail left in `cause` never reaches the user, and + * these files are hand-edited, so naming the file without naming the bad + * field leaves them nothing to act on. + */ + details: string; +}; + export class DeserializationError extends AgentCoreCLIError { - constructor( - path: string, - options?: Omit & { - /** Rendered reasons the payload was rejected, appended so the user sees which field to fix. */ - detail?: string; - }, - ) { - const detail = options?.detail ? `\n${options.detail}` : ""; - super(`Failed to deserialize file at "${path}"${detail}`, { - ...options, + constructor(path: string, options: DeserializationErrorOptions) { + const { details, ...errorOptions } = options; + super(`Failed to deserialize file at "${path}":\n\n${details}`, { + ...errorOptions, source: ERROR_SOURCE.USER, }); this.name = "DeserializationError"; diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts new file mode 100644 index 000000000..6384195b7 --- /dev/null +++ b/src/handlers/project/deploy/index.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import type { DeployBackendInput, ProjectBackend } from "../../../core/project"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import type { DeployResult, Project, ProjectEvent } from "../types"; + +const DEFAULT_TARGET: AwsDeploymentTarget = { + name: "default", + account: "111122223333", + region: "us-east-1", +}; +const STAGING_TARGET: AwsDeploymentTarget = { + name: "staging", + account: "444455556666", + region: "eu-west-1", +}; +const TARGETS = [DEFAULT_TARGET, STAGING_TARGET]; + +/** + * A ProjectBackend that deploys successfully, which CdkBackend cannot do until + * CDK deployment is implemented. Stubbing the backend rather than the whole + * manager keeps the real FsProjectManager in the path, so target resolution and + * withProject run for real. + */ +function fakeBackend(result: DeployResult, events: ProjectEvent[] = []) { + const calls: { project: Project; input: DeployBackendInput }[] = []; + const backend: ProjectBackend = { + async *build() {}, + async *deploy(project, input) { + calls.push({ project, input }); + yield* events; + return result; + }, + }; + return { calls, backend }; +} + +function testDeployCommand(result: DeployResult, events: ProjectEvent[] = []) { + const io = testIO(); + const fake = fakeBackend(result, events); + const core = new TestCoreClient({ backends: { CDK: fake.backend } }); + const root = createRootHandler(core, { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + + return { + ...fake, + io, + run: (args: string[] = []) => root.route(["node", "agentcore", "project", "deploy", ...args]), + create: (args: string[]) => root.route(["node", "agentcore", "project", ...args]), + }; +} + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-deploy-")); + tempDirectories.push(directory); + process.chdir(directory); + // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var + // symlink), matching the paths the manager derives from process.cwd(). + return process.cwd(); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +/** Scaffolds a project whose aws-targets.json holds exactly `contents`, and cds into it. */ +async function inProjectWithTargets( + subject: ReturnType, + contents: string = JSON.stringify(TARGETS), +): Promise { + const directory = await inTempDirectory(); + await subject.create(["create", "--name", "orders", "--skip-install", "--skip-git"]); + const projectRoot = join(directory, "orders"); + await writeFile(join(projectRoot, "agentcore", "aws-targets.json"), contents); + process.chdir(projectRoot); + return projectRoot; +} + +describe("project deploy handler", () => { + test("defaults to the default target and keeps progress off stdout", async () => { + const subject = testDeployCommand( + { outputs: { ZetaUrl: "https://zeta.example", AlphaArn: "arn:alpha" } }, + [{ message: "Preparing deployment" }, { message: "Deploying stack" }], + ); + await inProjectWithTargets(subject); + + await subject.run(); + + expect(subject.calls.map(({ input }) => input)).toEqual([{ target: DEFAULT_TARGET }]); + expect(subject.io.stderr()).toContain("Preparing deployment\nDeploying stack"); + expect(subject.io.stderr()).toContain("Deployed project 'orders' to target 'default'"); + expect(subject.io.stdout()).toBe("AlphaArn: arn:alpha\nZetaUrl: https://zeta.example"); + }); + + test("passes an explicit target and renders the result as JSON", async () => { + const result = { outputs: { ServiceUrl: "https://service.example" } }; + const subject = testDeployCommand(result); + await inProjectWithTargets(subject); + + await subject.run(["--target", "staging", "--json"]); + + expect(subject.calls.map(({ input }) => input)).toEqual([{ target: STAGING_TARGET }]); + expect(JSON.parse(subject.io.stdout())).toEqual(result); + }); + + test("rejects an unknown target without invoking the backend", async () => { + const subject = testDeployCommand({ outputs: {} }); + await inProjectWithTargets(subject); + + await expect(subject.run(["--target", "nope"])).rejects.toThrow( + /no deployment target named 'nope'/, + ); + expect(subject.calls).toEqual([]); + }); + + test("requires deployment targets to be configured", async () => { + const subject = testDeployCommand({ outputs: {} }); + await inProjectWithTargets(subject, JSON.stringify([])); + + await expect(subject.run()).rejects.toThrow(/No deployment targets are configured/); + expect(subject.calls).toEqual([]); + }); +}); + +/** The message the user would see on stderr, since the reporter prints only that. */ +async function messageFrom(command: Promise): Promise { + try { + await command; + } catch (error) { + return (error as Error).message; + } + throw new Error("expected the command to fail"); +} + +describe("project deploy reports which field of aws-targets.json is wrong", () => { + test("names the offending field for an unsupported region", async () => { + const subject = testDeployCommand({ outputs: {} }); + await inProjectWithTargets( + subject, + JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-11" }]), + ); + + const message = await messageFrom(subject.run()); + + expect(message).toContain("aws-targets.json"); + expect(message).toContain("at [0].region"); + expect(message).toContain('"us-east-1"'); + expect(subject.calls).toEqual([]); + }); + + test("surfaces the duplicate target name", async () => { + const subject = testDeployCommand({ outputs: {} }); + await inProjectWithTargets(subject, JSON.stringify([DEFAULT_TARGET, DEFAULT_TARGET])); + + await expect(subject.run()).rejects.toThrow(/Duplicate deployment target name: default/); + expect(subject.calls).toEqual([]); + }); + + test("surfaces the account id rule", async () => { + const subject = testDeployCommand({ outputs: {} }); + await inProjectWithTargets( + subject, + JSON.stringify([{ name: "default", account: "123", region: "us-east-1" }]), + ); + + await expect(subject.run()).rejects.toThrow(/AWS account ID must be exactly 12 digits/); + expect(subject.calls).toEqual([]); + }); + + test("surfaces the parse error for malformed json", async () => { + const subject = testDeployCommand({ outputs: {} }); + await inProjectWithTargets(subject, '[{ "name": "default", }]'); + + await expect(subject.run()).rejects.toThrow(/JSON Parse error/); + expect(subject.calls).toEqual([]); + }); +}); diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts index f73a75565..50fda3346 100644 --- a/src/handlers/project/deploy/index.ts +++ b/src/handlers/project/deploy/index.ts @@ -1,11 +1,46 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import z from "zod"; +import type { AppIO } from "../../../io"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import { JsonKey } from "../../keys"; +import type { ProjectManager } from "../types"; -export const createDeployProjectHandler = () => +type DeployProjectHandlerConfig = { + projectManager: ProjectManager; + io: AppIO; +}; + +export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) => createHandler({ name: "deploy", description: "deploy the project to AWS", - handle: async () => { - throw new NotImplementedError("agentcore project deploy is not implemented yet"); + flags: [ + flag("target", "name of the aws-targets.json entry to deploy", z.string().default("default")), + ], + handle: async (ctx, flags) => { + // withProject has already resolved the enclosing project. + const project = ctx.require(ProjectKey); + + // Progress goes to stderr, keeping stdout for machine output. Driven by + // hand rather than `for await` because the outputs we render below are the + // generator's return value, which `for await` discards. + const deployment = config.projectManager.deploy(project, { target: flags.target }); + let next = await deployment.next(); + while (!next.done) { + config.io.stderr.write(`${next.value.message}\n`); + next = await deployment.next(); + } + const result = next.value; + + config.io.stderr.write(`Deployed project '${project.name}' to target '${flags.target}'\n`); + if (ctx.require(JsonKey)) { + ctx.require(JsonRendererKey).renderJson(result); + return; + } + for (const [key, value] of Object.entries(result.outputs).sort(([a], [b]) => + a.localeCompare(b), + )) { + config.io.stdout.write(`${key}: ${value}\n`); + } }, }); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 232879425..9581d650c 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -43,7 +43,11 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { }), ), ); - project.handler(createDeployProjectHandler()); + project.handler( + withProject({ projectManager: config.projectManager })( + createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }), + ), + ); project.handler(createStatusProjectHandler()); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ced2d9277..7d0036e85 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,6 +1,6 @@ import { afterEach, test, expect, describe } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; @@ -24,7 +24,7 @@ async function run(args: string[], opts?: { core?: TestCoreClient }) { return { io, core }; } -describe.each(["deploy", "status"])("project %s", (command) => { +describe.each(["status"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); @@ -346,3 +346,27 @@ describe("project build", () => { await expect(run(["build"])).rejects.toThrow(/npm install/); }); }); + +describe("project deploy", () => { + test("requires an AgentCore project", async () => { + await inTempDirectory(); + await expect(run(["deploy"])).rejects.toThrow(/No AgentCore project found/); + }); + + test("reports that a freshly scaffolded project has no deployment targets", async () => { + await inProject(); + await expect(run(["deploy"])).rejects.toThrow(/No deployment targets are configured/); + }); + + // Proves the manager reaches CdkBackend.deploy once a target resolves; the + // backend is what remains unimplemented until the CDK deployment PR. + test("remains nonfunctional until deployment support is implemented", async () => { + const projectRoot = await inProject(); + await writeFile( + join(projectRoot, "agentcore", "aws-targets.json"), + JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-1" }]), + ); + + await expect(run(["deploy"])).rejects.toThrow(/not implemented/); + }); +}); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index e804d10f9..669d3f399 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -29,6 +29,22 @@ export type ProjectEvent = { message: string; }; +export type DeployProjectInput = { + /** Name of the aws-targets.json entry to deploy. */ + target: string; +}; + +export type DeployResult = { + /** + * Named outputs the deployment produced, e.g. a runtime ARN or a gateway URL. + * Each backend maps its own notion of outputs into this shape (CDK reads + * CloudFormation stack outputs; a terraform backend would read `terraform + * output`), so no individual key is part of the contract — callers render the + * map rather than indexing into it. + */ + outputs: Record; +}; + export type ResolveProjectInput = { /** A path to search from when locating the project root. */ filePath: string; @@ -82,6 +98,9 @@ export interface ProjectManager { /** Compile the project's CDK app and synthesize its CloudFormation templates. */ build(project: Project): AsyncGenerator; + /** Deploy the project to one of its configured AWS targets. */ + deploy(project: Project, input: DeployProjectInput): AsyncGenerator; + /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; diff --git a/src/io/json.ts b/src/io/json.ts index 064b1481c..d1c610b9f 100644 --- a/src/io/json.ts +++ b/src/io/json.ts @@ -1,8 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import type z from "zod"; -import { prettifyError } from "zod"; +import z from "zod"; import { DeserializationError } from "../errors"; import type { Logger } from "../logging"; @@ -34,7 +33,7 @@ export class FsReadWriteJson implements ReadWriteJson { this.logger .child({ filePath, errorName: error.name, errorMessage: error.message }) .error(`failed to parse json file`); - throw new DeserializationError(filePath, { cause: e }); + throw new DeserializationError(filePath, { cause: e, details: error.message }); } } @@ -55,7 +54,7 @@ export class FsReadWriteJson implements ReadWriteJson { .error(`failed to validate parsed json file`); throw new DeserializationError(filePath, { cause: parseResult.error, - detail: prettifyError(parseResult.error), + details: z.prettifyError(parseResult.error), }); } diff --git a/src/projectSchemas/aws-targets.test.ts b/src/projectSchemas/aws-targets.test.ts new file mode 100644 index 000000000..3e0d695b8 --- /dev/null +++ b/src/projectSchemas/aws-targets.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { + AgentCoreRegionSchema, + AwsAccountIdSchema, + AwsDeploymentTargetSchema, + AwsDeploymentTargetsSchema, + DeploymentTargetNameSchema, +} from "./aws-targets"; + +const target = { + name: "default", + description: "Default deployment target", + account: "111122223333", + region: "us-east-1", +} as const; + +describe("AWS deployment targets", () => { + test("accepts the scaffolded target shape", () => { + expect(AwsDeploymentTargetSchema.parse(target)).toEqual(target); + expect(AwsDeploymentTargetsSchema.parse([target])).toEqual([target]); + }); + + test.each([ + ["a digit short", "11112222333"], + ["a digit long", "1111222233334"], + ["punctuated", "1111-2222-3333"], + ["an account alias", "my-account"], + ])("rejects an account that is %s", (_label, account) => { + expect(AwsAccountIdSchema.safeParse(account).success).toBe(false); + }); + + test.each(["", "1default", "-default", "_default", "has_underscore", "has spaces", "has.dots"])( + "rejects invalid target name %j", + (name) => { + expect(DeploymentTargetNameSchema.safeParse(name).success).toBe(false); + }, + ); + + test("enforces target name and description lengths", () => { + expect(DeploymentTargetNameSchema.safeParse("a".repeat(64)).success).toBe(true); + expect(DeploymentTargetNameSchema.safeParse("a".repeat(65)).success).toBe(false); + expect( + AwsDeploymentTargetSchema.safeParse({ ...target, description: "a".repeat(257) }).success, + ).toBe(false); + }); + + test.each(["ap-southeast-5", "ap-southeast-7", "eu-south-1", "eu-south-2", "us-gov-west-1"])( + "accepts supported region %s", + (region) => { + expect(AgentCoreRegionSchema.safeParse(region).success).toBe(true); + }, + ); + + test("rejects an AWS region where AgentCore is unavailable", () => { + expect(AgentCoreRegionSchema.safeParse("us-west-1").success).toBe(false); + }); + + test("rejects duplicate target names", () => { + const result = AwsDeploymentTargetsSchema.safeParse([target, target]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("Duplicate deployment target name: default"); + } + }); +}); diff --git a/src/projectSchemas/aws-targets.ts b/src/projectSchemas/aws-targets.ts new file mode 100644 index 000000000..6d847db2d --- /dev/null +++ b/src/projectSchemas/aws-targets.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { uniqueBy } from "./zod-util"; + +// Keep in sync with https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-regions.html +export const AgentCoreRegionSchema = z.enum([ + "ap-northeast-1", + "ap-northeast-2", + "ap-south-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", + "ca-central-1", + "eu-central-1", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-west-2", + "us-gov-west-1", +]); + +export const DeploymentTargetNameSchema = z + .string() + .min(1) + .max(64) + // Underscores are rejected up front even though the CDK normalizes them to + // hyphens, so a target name means the same thing everywhere it appears. + .regex( + /^[a-zA-Z][a-zA-Z0-9-]*$/, + "Name must start with a letter and contain only alphanumeric characters and hyphens", + ) + .describe("Unique identifier for the deployment target"); + +export const AwsAccountIdSchema = z + .string() + .regex(/^[0-9]{12}$/, "AWS account ID must be exactly 12 digits") + .describe("AWS account ID"); + +export const AwsDeploymentTargetSchema = z.object({ + name: DeploymentTargetNameSchema, + description: z.string().max(256).optional(), + account: AwsAccountIdSchema, + region: AgentCoreRegionSchema, +}); + +export type AwsDeploymentTarget = z.infer; + +export const AwsDeploymentTargetsSchema = z.array(AwsDeploymentTargetSchema).superRefine( + uniqueBy( + (target) => target.name, + (name) => `Duplicate deployment target name: ${name}`, + ), +); + +export type AwsDeploymentTargets = z.infer; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 50047db83..b97c8594f 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -148,7 +148,8 @@ import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; -import { FsProjectManager } from "../core/project"; +import { FsProjectManager, type ProjectBackend } from "../core/project"; +import type { ManagedBy } from "../projectSchemas/project"; // TestCoreClient is a hand-controllable `Core` for tests. It implements the same // interface the real CoreClient satisfies, so it drops straight into @@ -1179,6 +1180,7 @@ export class TestGatewayClient implements CoreGatewayClient { type TestCoreClientOptions = { logger?: Logger; json?: ReadWriteJson; + backends?: Partial>; }; export class TestIdentityClient implements CoreIdentityClient { @@ -1951,6 +1953,7 @@ export class TestCoreClient implements Core { this.projectManager = new FsProjectManager({ logger: options?.logger ?? createSilentLogger(), json: options?.json, + backends: options?.backends, runner: async (command, { cwd }) => { this.projectCommands.push({ command, cwd }); },