Skip to content

Commit 3f0e0fe

Browse files
NicolappsConvex, Inc.
authored andcommitted
cli: support --deployment local + create/select local (#49514)
This allows local deployments to work with `npx convex deployment create` and `npx convex deployment select`, in a way that’s consistent with how cloud deployments work. GitOrigin-RevId: 674f06ff234fe4185f136ad3184c4c6aa3c40e06
1 parent 4c03bec commit 3f0e0fe

File tree

11 files changed

+602
-18
lines changed

11 files changed

+602
-18
lines changed

src/cli/deploymentCreate.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ import {
1515
} from "./lib/deploymentSelection.js";
1616
import { saveSelectedDeployment } from "./deploymentSelect.js";
1717
import { deploymentCreate, resolveRegionDetails } from "./deploymentCreate.js";
18+
import { ensureBackendBinaryDownloaded } from "./lib/localDeployment/download.js";
19+
import {
20+
loadProjectLocalConfig,
21+
saveDeploymentConfig,
22+
} from "./lib/localDeployment/filePaths.js";
23+
import {
24+
chooseLocalBackendPorts,
25+
LOCAL_BACKEND_INSTANCE_SECRET,
26+
} from "./lib/localDeployment/utils.js";
27+
import { bigBrainStart } from "./lib/localDeployment/bigBrain.js";
1828

1929
vi.mock("@sentry/node", () => ({
2030
captureException: vi.fn(),
@@ -39,6 +49,24 @@ vi.mock("./deploymentSelect.js", () => ({
3949
saveSelectedDeployment: vi.fn(),
4050
}));
4151

52+
vi.mock("./lib/localDeployment/download.js", () => ({
53+
ensureBackendBinaryDownloaded: vi.fn(),
54+
}));
55+
56+
vi.mock("./lib/localDeployment/filePaths.js", () => ({
57+
loadProjectLocalConfig: vi.fn(),
58+
saveDeploymentConfig: vi.fn(),
59+
}));
60+
61+
vi.mock("./lib/localDeployment/utils.js", () => ({
62+
chooseLocalBackendPorts: vi.fn(),
63+
LOCAL_BACKEND_INSTANCE_SECRET: "MockSecret123",
64+
}));
65+
66+
vi.mock("./lib/localDeployment/bigBrain.js", () => ({
67+
bigBrainStart: vi.fn(),
68+
}));
69+
4270
const mockRegions = [
4371
{
4472
name: "aws-us-east-1" as const,
@@ -132,6 +160,165 @@ describe("non-interactive create flow", () => {
132160
expect.stringContaining("--type is required"),
133161
);
134162
});
163+
164+
test("creates a local deployment: downloads binary, chooses ports, registers with Big Brain, saves config", async () => {
165+
vi.mocked(getDeploymentSelection).mockResolvedValue({
166+
kind: "existingDeployment",
167+
deploymentToActOn: {
168+
url: "https://joyful-capybara-123.convex.cloud",
169+
adminKey: "admin-key",
170+
deploymentFields: {
171+
deploymentName: "joyful-capybara-123",
172+
deploymentType: "dev",
173+
teamSlug: "my-team",
174+
projectSlug: "my-project",
175+
},
176+
source: "deployKey" as const,
177+
},
178+
});
179+
vi.mocked(getProjectDetails).mockResolvedValue(fakeProject);
180+
vi.mocked(loadProjectLocalConfig).mockReturnValue(null);
181+
vi.mocked(ensureBackendBinaryDownloaded).mockResolvedValue({
182+
binaryPath: "/path",
183+
version: "1.0.0",
184+
});
185+
vi.mocked(chooseLocalBackendPorts).mockResolvedValue({
186+
cloudPort: 3210,
187+
sitePort: 3211,
188+
});
189+
vi.mocked(bigBrainStart).mockResolvedValue({
190+
deploymentName: "local-test-123",
191+
adminKey: "test-key",
192+
});
193+
194+
await deploymentCreate.parseAsync(["local"], { from: "user" });
195+
196+
expect(saveDeploymentConfig).toHaveBeenCalledWith(
197+
expect.anything(),
198+
"local",
199+
"local-test-123",
200+
{
201+
backendVersion: "1.0.0",
202+
ports: { cloud: 3210, site: 3211 },
203+
adminKey: "test-key",
204+
instanceSecret: LOCAL_BACKEND_INSTANCE_SECRET,
205+
},
206+
);
207+
expect(mockPlatformPost).not.toHaveBeenCalled();
208+
});
209+
210+
test("creates a local deployment with --select and selects it", async () => {
211+
vi.mocked(getDeploymentSelection).mockResolvedValue({
212+
kind: "existingDeployment",
213+
deploymentToActOn: {
214+
url: "https://joyful-capybara-123.convex.cloud",
215+
adminKey: "admin-key",
216+
deploymentFields: {
217+
deploymentName: "joyful-capybara-123",
218+
deploymentType: "dev",
219+
teamSlug: "my-team",
220+
projectSlug: "my-project",
221+
},
222+
source: "deployKey" as const,
223+
},
224+
});
225+
vi.mocked(getProjectDetails).mockResolvedValue(fakeProject);
226+
vi.mocked(loadProjectLocalConfig).mockReturnValue(null);
227+
vi.mocked(ensureBackendBinaryDownloaded).mockResolvedValue({
228+
binaryPath: "/path",
229+
version: "1.0.0",
230+
});
231+
vi.mocked(chooseLocalBackendPorts).mockResolvedValue({
232+
cloudPort: 3210,
233+
sitePort: 3211,
234+
});
235+
vi.mocked(bigBrainStart).mockResolvedValue({
236+
deploymentName: "local-test-123",
237+
adminKey: "test-key",
238+
});
239+
240+
await deploymentCreate.parseAsync(["local", "--select"], {
241+
from: "user",
242+
});
243+
244+
expect(saveSelectedDeployment).toHaveBeenCalledWith(
245+
expect.anything(),
246+
"local",
247+
{
248+
kind: "deploymentWithinProject",
249+
targetProject: {
250+
kind: "deploymentName",
251+
deploymentName: "local-test-123",
252+
deploymentType: "local",
253+
},
254+
selectionWithinProject: {
255+
kind: "deploymentSelector",
256+
selector: "local",
257+
},
258+
},
259+
null,
260+
);
261+
});
262+
263+
test("crashes when creating a local deployment with --type", async () => {
264+
await expect(
265+
deploymentCreate.parseAsync(["local", "--type", "dev"], {
266+
from: "user",
267+
}),
268+
).rejects.toThrow();
269+
expect(process.stderr.write).toHaveBeenCalledWith(
270+
expect.stringContaining(
271+
"--type cannot be used when creating a local deployment",
272+
),
273+
);
274+
expect(mockPlatformPost).not.toHaveBeenCalled();
275+
});
276+
277+
test("errors when local deployment already exists", async () => {
278+
vi.mocked(getDeploymentSelection).mockResolvedValue({
279+
kind: "existingDeployment",
280+
deploymentToActOn: {
281+
url: "https://joyful-capybara-123.convex.cloud",
282+
adminKey: "admin-key",
283+
deploymentFields: {
284+
deploymentName: "joyful-capybara-123",
285+
deploymentType: "dev",
286+
teamSlug: "my-team",
287+
projectSlug: "my-project",
288+
},
289+
source: "deployKey" as const,
290+
},
291+
});
292+
vi.mocked(loadProjectLocalConfig).mockReturnValue({
293+
deploymentName: "existing-local-123",
294+
config: {} as any,
295+
});
296+
297+
await expect(
298+
deploymentCreate.parseAsync(["local"], { from: "user" }),
299+
).rejects.toThrow();
300+
expect(process.stderr.write).toHaveBeenCalledWith(
301+
expect.stringContaining("A local deployment already exists"),
302+
);
303+
});
304+
305+
test.each(["region", "default", "expiration"] as const)(
306+
"rejects --%s with local",
307+
async (flag) => {
308+
const args = ["local", `--${flag}`];
309+
if (flag === "region") args.push("us");
310+
if (flag === "expiration") args.push("none");
311+
312+
await expect(
313+
deploymentCreate.parseAsync(args, { from: "user" }),
314+
).rejects.toThrow();
315+
expect(process.stderr.write).toHaveBeenCalledWith(
316+
expect.stringContaining(
317+
`--${flag} cannot be used when creating a local deployment`,
318+
),
319+
);
320+
},
321+
);
135322
});
136323

137324
describe("with project configured", () => {
@@ -662,6 +849,41 @@ describe("interactive create flow", () => {
662849
);
663850
});
664851

852+
test("interactive ref prompt rejects 'local' as a deployment reference", async () => {
853+
setupDefaultRoutes();
854+
855+
const promise = deploymentCreate.parseAsync(["--type", "dev"], {
856+
from: "user",
857+
});
858+
859+
// Ref prompt — enter "local"
860+
await screen.next();
861+
expect(screen.getScreen()).toContain("How to name this deployment?");
862+
screen.type("local");
863+
screen.keypress("enter");
864+
865+
// Inline validation error
866+
await screen.next();
867+
expect(screen.getScreen()).toContain(
868+
'"local" is not a valid deployment reference',
869+
);
870+
871+
// Fix the input
872+
screen.type("ization-improvements");
873+
screen.keypress("enter");
874+
875+
await promise;
876+
877+
expect(mockPlatformPost).toHaveBeenCalledWith(
878+
"/projects/{project_id}/create_deployment",
879+
expect.objectContaining({
880+
body: expect.objectContaining({
881+
reference: "localization-improvements",
882+
}),
883+
}),
884+
);
885+
});
886+
665887
test("--region invalid crashes", async () => {
666888
setupDefaultRoutes();
667889

0 commit comments

Comments
 (0)