Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
f6e215a
feat(coolify): set up a Coolify server over SSH
RyanGroch Aug 20, 2026
6a520b7
fix(coolify): address review feedback on the SSH setup flow
RyanGroch Aug 20, 2026
3286851
fix(coolify): refuse reserved subdomains and reset the setup singleton
RyanGroch Aug 20, 2026
22df869
fix(coolify): narrow the API token and stop waiting on impossible cer…
RyanGroch Aug 21, 2026
c1159c7
fix(coolify): mint the scopes Dyad asks users for, and tidy the HTTPS…
RyanGroch Aug 21, 2026
78b7878
test(coolify): pin the scope list, and say when the domain is coming off
RyanGroch Aug 21, 2026
a1f7865
test(coolify): pin both spellings of the scope list
RyanGroch Aug 21, 2026
aaacc14
fix(coolify): refuse to install onto a server nobody looked at
RyanGroch Aug 21, 2026
19c2536
fix(coolify): show a running install first, and gate on a check that …
RyanGroch Aug 21, 2026
f02927a
test(coolify): pin the mark a refusal carries, and reunite a doc with…
RyanGroch Aug 21, 2026
f734715
fix(coolify): show a setup error unless the machine already has it
RyanGroch Aug 21, 2026
6a65068
test(coolify): hold the placement that keeps a refusal audible
RyanGroch Aug 21, 2026
3e7d899
fix(coolify): remember a server by its port as well as its address
RyanGroch Aug 21, 2026
cfdd662
fix(coolify): decide what a check saw only once it has finished
RyanGroch Aug 22, 2026
a16ff7b
test(coolify): hold the panel's half of the re-check rule
RyanGroch Aug 22, 2026
c76978e
feat(coolify): forget the instance when signing out of it
RyanGroch Aug 23, 2026
ef8a034
fix(coolify): hear about a run that ends while the panel is mounting
RyanGroch Aug 23, 2026
9edcc36
refactor(coolify): keep the admin account as one thing in settings
RyanGroch Aug 23, 2026
ec6bdf8
fix(coolify): stop a locked admin password from breaking every settin…
RyanGroch Aug 23, 2026
48c3b54
fix(coolify): actually forget a token that will not decrypt
RyanGroch Aug 23, 2026
b8092ef
fix(coolify): keep the overtaken-read count from belonging to one caller
RyanGroch Aug 23, 2026
197d6bf
fix(coolify): keep each Coolify address with what it opens
RyanGroch Aug 23, 2026
c602dc7
fix(coolify): do not ask for an acknowledgement over an empty panel
RyanGroch Aug 24, 2026
98a2371
feat(coolify): treat a server Dyad set up as a Coolify it is holding
RyanGroch Aug 24, 2026
38eb0c8
fix(coolify): keep a server that is installed when its seeder gives up
RyanGroch Aug 24, 2026
32e17e2
fix(coolify): say when the credentials could not be read
RyanGroch Aug 24, 2026
2286ea5
fix(coolify): do not take a custom domain on trust when DNS went unan…
RyanGroch Aug 24, 2026
55c2080
fix(coolify): stop reporting a lost connection as an unsupported Coolify
RyanGroch Aug 24, 2026
0a2be44
fix(coolify): give each credential block its own field ids
RyanGroch Aug 24, 2026
2160a90
fix(coolify): tell apart the answers a domain lookup cannot give
RyanGroch Aug 24, 2026
c5178e0
fix(coolify): give each refusal the remedy that fits it
RyanGroch Aug 24, 2026
9a5efeb
fix(coolify): stop the family refusal claiming what it did not establish
RyanGroch Aug 24, 2026
f179121
fix(coolify): stop a server that will not answer reporting itself as …
RyanGroch Aug 24, 2026
e2eadd9
fix(coolify): put the password down before the installer uses it
RyanGroch Aug 24, 2026
0d895f4
fix(coolify): stop accepting a domain there was nothing to compare
RyanGroch Aug 24, 2026
591f38a
feat(coolify): ask before keeping a token for an unencrypted address
RyanGroch Aug 24, 2026
270d00d
fix(coolify): do not take an earlier server's account down with a fai…
RyanGroch Aug 24, 2026
e5c97d9
fix(coolify): know this run's own record when its password went unrea…
RyanGroch Aug 24, 2026
d9c244e
fix(coolify): name the password when putting an account back
RyanGroch Aug 25, 2026
e1e396c
fix(coolify): hold a token for an unencrypted address rather than und…
RyanGroch Aug 26, 2026
fb92f3a
test(coolify): prove the agreement is what stores the token
RyanGroch Aug 26, 2026
9c6a2e3
fix(coolify): do not install over an account Dyad is holding
RyanGroch Aug 26, 2026
9ea9aed
fix(coolify): tell a token Dyad will drop from one it could not make
RyanGroch Aug 26, 2026
9215eb8
fix(coolify): let the refusal to install again be acted on
RyanGroch Aug 26, 2026
a9d7f27
refactor(coolify): ask the machine whether a run may start
RyanGroch Aug 26, 2026
7bc4e03
fix(coolify): refresh what a run wrote once it settles
RyanGroch Aug 26, 2026
d57c9ac
fix(coolify): do not read "cannot tell" as "nothing was installed"
RyanGroch Aug 26, 2026
d58d5dc
fix(coolify): say what failed before saying what it means
RyanGroch Aug 26, 2026
1ccbfe1
test(coolify): make these observe what they are named for
RyanGroch Aug 26, 2026
8f2124c
test(coolify): wait a turn, and let the shared helper do the comparing
RyanGroch Aug 27, 2026
fe85335
fix(coolify): do not ask for a step Dyad already took
RyanGroch Aug 27, 2026
481ef6d
fix(coolify): ask whether the API was opened, not whether a token was…
RyanGroch Aug 27, 2026
2290357
test(coolify): give the fixture a shape the app can produce
RyanGroch Aug 27, 2026
c4f1fe8
test(coolify): hold the answer to the shape the channel promises
RyanGroch Aug 27, 2026
cc41f5e
fix(coolify): blame the step that actually stopped answering
RyanGroch Aug 27, 2026
fd9c26f
fix(coolify): point at the password, not away from it
RyanGroch Aug 27, 2026
5a939e7
fix(coolify): say when a password is held but unreadable
RyanGroch Aug 27, 2026
aadd2e2
docs(coolify): the panel says this now
RyanGroch Aug 28, 2026
599ae98
test(coolify): check the sentence the handler writes, not the one the…
RyanGroch Aug 28, 2026
0a24525
fix(coolify): keep ssh2 off the app's startup path
RyanGroch Aug 28, 2026
4a448bf
fix(coolify): say when the temporary domain will not come back off
RyanGroch Aug 28, 2026
c16e127
fix(coolify): do not call a slow answer an old Coolify
RyanGroch Aug 28, 2026
a3a65b5
feat(coolify): show that Install was pressed
RyanGroch Aug 28, 2026
2a9a77d
fix(coolify): load ssh2 when connecting, not when the app starts
RyanGroch Aug 28, 2026
e349b0e
fix(coolify): trim the sentence, not its last clause
RyanGroch Aug 28, 2026
0ca1f95
fix(coolify): tell other windows when a held token is kept
RyanGroch Aug 28, 2026
aa71944
fix(coolify): say what a stopped run left behind
RyanGroch Aug 28, 2026
71993a6
fix(coolify): confirm the API flag was written, not just assigned
RyanGroch Aug 28, 2026
7cc32c5
fix(coolify): make the leftover-domain warning actually reach the user
RyanGroch Aug 28, 2026
dc2c813
fix(coolify): check the failure is one of ours before saying it is
RyanGroch Aug 28, 2026
9edadde
fix(coolify): only say the domain was applied if it was
RyanGroch Aug 28, 2026
874c9ad
fix(coolify): do not call a failed negotiation a changed identity
RyanGroch Aug 28, 2026
8718a1b
fix(coolify): stop offering an install that is always refused
RyanGroch Aug 28, 2026
46ea488
fix(coolify): say it on every screen the run can be watched from
RyanGroch Aug 28, 2026
d360e13
fix(coolify): ask whether the domain save was taken
RyanGroch Aug 28, 2026
504f059
fix(coolify): do not require the prompt psysh happens to print
RyanGroch Aug 28, 2026
271cfa9
fix(coolify): say when a stopped install left something on the server
RyanGroch Aug 28, 2026
f77d9eb
docs(coolify): the prompt is modelled, not required
RyanGroch Aug 28, 2026
2b94c98
fix(coolify): do not start an install it cannot record the password for
RyanGroch Aug 29, 2026
0548ea8
fix(coolify): read unreadable memory as unknown, not as enough
RyanGroch Aug 29, 2026
bce43e1
test(coolify): pin the domain rule on its own
RyanGroch Aug 29, 2026
5f18892
fix(coolify): do not call a version it could not read an old one
RyanGroch Aug 29, 2026
f88aae5
fix(coolify): say the details are coming while they are read
RyanGroch Aug 29, 2026
fa34bd7
docs(coolify): put each comment back over what it describes
RyanGroch Aug 29, 2026
5295706
fix(coolify): read every tinker answer the same way
RyanGroch Aug 29, 2026
255cd07
docs(coolify): move four comments back to what they describe
RyanGroch Aug 29, 2026
bb38a4c
fix(coolify): the fifth reader, and the two beside the token
RyanGroch Aug 29, 2026
8e4ce58
docs(coolify): the token does not follow that rule any more
RyanGroch Aug 29, 2026
7cf1fc0
test(coolify): pin the transcript shape against a second real Coolify
RyanGroch Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions e2e-tests/coolify_deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ async function connectGithubFromPublishPanel(po: any) {
*/
async function connectCoolify(po: any, fakeLlmPort: number) {
await po.page.getByRole("tab", { name: "Your Own Server" }).click();
// The tab opens on the installer, so switch to the paste-a-token form.
await po.page.getByTestId("coolify-setup-use-existing").click();

await po.page
.getByTestId("coolify-instance-url")
Expand Down
168 changes: 168 additions & 0 deletions e2e-tests/coolify_setup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect } from "@playwright/test";
import { testWithConfig, type ElectronConfig } from "./helpers/test_helper";
import { Timeout } from "./helpers/constants";
import {
startFakeSshServer,
type FakeSshServer,
} from "./helpers/fake_ssh_server";

/**
* Installing Coolify onto a server, through the packaged app.
*
* Two tests, both of which need real Chromium and the shipped build: the whole
* chain through to a stored token, and a server Dyad refuses. Everything that
* does not look at the screen belongs in
* src/coolify_setup/setup_flow.integration.test.ts, which drives the same SSH
* server in a fraction of the time.
*
* Not covered anywhere: whether Coolify's installer seeds an account, whether
* its seeder accepts an address, whether a certificate is issued. Those need a
* real machine.
*/

/**
* One server per test, started before the app is.
*
* The app launches during fixture setup, so anything the app must see in its
* environment has to exist by then — which is why this runs in the pre-launch
* hook rather than in the test body. What each test wants the server to do is
* set afterwards by adjusting `state`, which the fake reads on every command.
*/
let sshServer: FakeSshServer | null = null;

const electronConfig: ElectronConfig = {
preLaunchHook: async ({ userDataDir, fakeLlmPort }) => {
await fs.mkdir(userDataDir, { recursive: true });
// The feature is behind an experiment, off unless the user turns it on.
await fs.writeFile(
path.join(userDataDir, "user-settings.json"),
JSON.stringify({ enableOwnServerDeployment: true }),
"utf8",
);

sshServer = await startFakeSshServer();
// The form asks for an address, not a port, so both of these travel
// through the same e2e seam as every other test-only behaviour here.
process.env.DYAD_E2E_SSH_PORT = String(sshServer.port);
process.env.DYAD_E2E_DASHBOARD_PORT = String(fakeLlmPort);
// Whatever another spec left in the shared fake is not this test's setup.
await resetCoolify(fakeLlmPort);
},
};

const test = testWithConfig(electronConfig);

/** The server this test is talking to, adjustable before anything is done. */
function server(): FakeSshServer {
if (!sshServer) throw new Error("The fake server was never started.");
return sshServer;
}

test.afterEach(async () => {
delete process.env.DYAD_E2E_SSH_PORT;
delete process.env.DYAD_E2E_DASHBOARD_PORT;
await sshServer?.close();
sshServer = null;
});

/** The fake Coolify is shared per worker, so each test says what it expects. */
async function resetCoolify(port: number) {
await fetch(`http://localhost:${port}/coolify/test/reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
}

async function openInstaller(po: any) {
// The publish panel needs an app to be about, and the deployment section it
// carries needs a repository — Coolify deploys from one, so the tab that
// holds the installer only appears once GitHub is connected.
await po.sendPrompt("hi");
await po.previewPanel.selectPreviewMode("publish");
await po.githubConnector.connect();
await po.githubConnector.createRepo(`coolify-setup-e2e-${Date.now()}`);
await po.page.getByRole("tab", { name: "Your Own Server" }).click();
await expect(po.page.getByTestId("coolify-server-setup")).toBeVisible();
}

async function fillAndInstall(po: any) {
await po.page.getByTestId("coolify-setup-host").fill("127.0.0.1");
await po.page.getByTestId("coolify-setup-email").fill("me@gmail.com");
// Install is offered only for a server Dyad has looked at, so this is the
// ordinary path rather than an extra step for the test.
await po.page.getByTestId("coolify-setup-inspect").click();
await expect(po.page.getByTestId("coolify-setup-inspection")).toBeVisible({
timeout: Timeout.MEDIUM,
});
await po.page.getByTestId("coolify-setup-install").click();
}

test("installs Coolify onto a server and connects to it", async ({ po }) => {
await po.setUp({ autoApprove: true });
await openInstaller(po);

// The key is the one manual step: nothing can reach the server without it.
await expect(po.page.getByTestId("coolify-setup-public-key")).toContainText(
"ssh-ed25519",
);

await fillAndInstall(po);

// A loopback address can never be given a certificate, so the install ends
// on plain HTTP — and the screen stays up to ask, because the token it
// would keep travels over that address on every deploy.
await expect(po.page.getByTestId("coolify-setup-done")).toBeVisible({
timeout: Timeout.LONG,
});
await expect(po.page.getByTestId("coolify-setup-insecure")).toBeVisible();
await expect(po.page.getByTestId("coolify-setup-done")).toContainText(
"It is not kept unless you say so",
);

// Saying so is what stores it. Ticking here is the whole of the difference
// between the picker below and the token form, which is what makes this the
// one place the agreement is proved end to end.
await po.page.getByTestId("coolify-setup-accept-insecure").click();
await po.page.getByTestId("coolify-setup-continue").click();
await expect(po.page.getByTestId("coolify-server-select")).toBeVisible({
timeout: Timeout.MEDIUM,
});
// The picker being present says only that a token was stored. This says the
// address stored with it is one Dyad can actually talk to: the servers came
// back from the instance the install pointed it at.
await po.page.getByTestId("coolify-server-select").click();
// Named, not "the first option": while discovery is in flight the picker
// renders a "Loading servers..." row that is also an option, which would
// satisfy a looser assertion without a server ever arriving.
await expect(po.page.getByRole("option", { name: "production" })).toBeVisible(
{ timeout: Timeout.MEDIUM },
);

// Dyad offered a key rather than a password, and the installer really ran.
expect(server().state.keyOffered).toBe(true);
expect(server().state.commands.some((c) => c.includes("install.sh"))).toBe(
true,
);
expect(server().state.commands.some((c) => c.includes("tinker"))).toBe(true);
});

test("refuses a server that already has Coolify on it", async ({ po }) => {
server().state.probe = "mem=1967\ncontainer=coolify\nbusy=no\n";
await po.setUp({ autoApprove: true });
await openInstaller(po);
await po.page.getByTestId("coolify-setup-host").fill("127.0.0.1");
// Filled, so that a disabled Install button after the check means the
// server was refused rather than that the form is incomplete.
await po.page.getByTestId("coolify-setup-email").fill("me@gmail.com");
await po.page.getByTestId("coolify-setup-inspect").click();

await expect(po.page.getByTestId("coolify-setup-inspection")).toContainText(
"already has Coolify",
{ timeout: Timeout.MEDIUM },
);
// And it really refuses: the button that would install over it is disabled.
await expect(po.page.getByTestId("coolify-setup-install")).toBeDisabled();
});
Loading
Loading