diff --git a/e2e-tests/coolify_deploy.spec.ts b/e2e-tests/coolify_deploy.spec.ts
index 0a112662d3..f249c9c7b5 100644
--- a/e2e-tests/coolify_deploy.spec.ts
+++ b/e2e-tests/coolify_deploy.spec.ts
@@ -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")
diff --git a/e2e-tests/coolify_setup.spec.ts b/e2e-tests/coolify_setup.spec.ts
new file mode 100644
index 0000000000..23821b883b
--- /dev/null
+++ b/e2e-tests/coolify_setup.spec.ts
@@ -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 "../src/testing/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();
+});
diff --git a/forge.config.ts b/forge.config.ts
index fde761d71f..b522d64a7d 100644
--- a/forge.config.ts
+++ b/forge.config.ts
@@ -44,8 +44,27 @@ const pgRuntimeDependencies = [
"xtend",
] as const;
-function isPgRuntimeDependency(file: string): boolean {
- return pgRuntimeDependencies.some((dependency) => {
+/**
+ * What ssh2 needs at runtime, and only that.
+ *
+ * Its optional native helpers — cpu-features, nan, buildcheck — are left out
+ * on purpose. ssh2 guards those requires and falls back to pure JavaScript,
+ * so leaving them behind costs some speed and avoids shipping a binding
+ * compiled against whatever Node the build machine happened to have.
+ */
+const ssh2RuntimeDependencies = [
+ "ssh2",
+ "asn1",
+ "safer-buffer",
+ "bcrypt-pbkdf",
+ "tweetnacl",
+] as const;
+
+function isRuntimeDependency(
+ file: string,
+ packages: readonly string[],
+): boolean {
+ return packages.some((dependency) => {
const modulePath = `/node_modules/${dependency}`;
return file === modulePath || file.startsWith(`${modulePath}/`);
});
@@ -87,6 +106,9 @@ const ignore = (file: string) => {
if (file.startsWith("/node_modules/node-pty")) {
return false;
}
+ if (isRuntimeDependency(file, ssh2RuntimeDependencies)) {
+ return false;
+ }
if (file.startsWith("/node_modules/mustardscript")) {
return false;
}
@@ -111,7 +133,7 @@ const ignore = (file: string) => {
if (file.startsWith("/node_modules/@typescript/old")) {
return false;
}
- if (isPgRuntimeDependency(file)) {
+ if (isRuntimeDependency(file, pgRuntimeDependencies)) {
return false;
}
if (file === "/node_modules/ws" || file.startsWith("/node_modules/ws/")) {
diff --git a/package-lock.json b/package-lock.json
index f2974c80a2..39ed643c92 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -92,6 +92,7 @@
"shell-env": "^4.0.1",
"shiki": "^3.2.1",
"sonner": "^2.0.3",
+ "ssh2": "^1.17.0",
"stacktrace-js": "^2.0.2",
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.3",
@@ -132,6 +133,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
+ "@types/ssh2": "^1.15.5",
"@types/use-sync-external-store": "^0.0.6",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^5.62.0",
@@ -8121,6 +8123,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/ssh2": {
+ "version": "1.15.5",
+ "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
+ "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^18.11.18"
+ }
+ },
+ "node_modules/@types/ssh2/node_modules/@types/node": {
+ "version": "18.19.130",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@types/ssh2/node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -9389,6 +9418,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asn1": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+ "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -9572,6 +9610,15 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bcrypt-pbkdf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+ "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tweetnacl": "^0.14.3"
+ }
+ },
"node_modules/before-after-hook": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz",
@@ -9843,6 +9890,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/buildcheck": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
+ "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -10636,6 +10692,20 @@
"node": ">= 0.10"
}
},
+ "node_modules/cpu-features": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
+ "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "buildcheck": "~0.0.6",
+ "nan": "^2.19.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -18289,6 +18359,13 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/nan": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
+ "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -21804,6 +21881,23 @@
"license": "BSD-3-Clause",
"optional": true
},
+ "node_modules/ssh2": {
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
+ "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "asn1": "^0.2.6",
+ "bcrypt-pbkdf": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ },
+ "optionalDependencies": {
+ "cpu-features": "~0.0.10",
+ "nan": "^2.23.0"
+ }
+ },
"node_modules/ssri": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz",
@@ -22870,6 +22964,12 @@
"url": "https://github.com/sponsors/Wombosvideo"
}
},
+ "node_modules/tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+ "license": "Unlicense"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index d893e4bcb5..408b5d38ce 100644
--- a/package.json
+++ b/package.json
@@ -147,6 +147,7 @@
"shell-env": "^4.0.1",
"shiki": "^3.2.1",
"sonner": "^2.0.3",
+ "ssh2": "^1.17.0",
"stacktrace-js": "^2.0.2",
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.3",
@@ -190,6 +191,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
+ "@types/ssh2": "^1.15.5",
"@types/use-sync-external-store": "^0.0.6",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^5.62.0",
diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx
index 7276bd6f52..fd2a3306f4 100644
--- a/src/components/CoolifyConnector.test.tsx
+++ b/src/components/CoolifyConnector.test.tsx
@@ -1,3 +1,6 @@
+import React, { useState } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { queryKeys } from "@/lib/queryKeys";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -9,6 +12,63 @@ const toastMock = vi.hoisted(() => ({
}));
vi.mock("sonner", () => ({ toast: toastMock }));
+// Stubbed so these tests stay about the connector. The real one fetches
+// secrets through its own mutation, which would drag a query client into every
+// case here for something none of them are checking.
+vi.mock("@/components/CoolifyCredentials", () => ({
+ CoolifyCredentials: ({ showTitle }: { showTitle?: boolean }) => (
+
+ {showTitle ? "Your Coolify server" : null}
+
+ ),
+}));
+
+// Stubbed down to its two edges — that it is open, and the way to confirm —
+// so these cases can check the wiring without the checkbox gating, which is
+// the dialog's own test's job.
+vi.mock("@/components/CoolifySignOutDialog", () => ({
+ CoolifySignOutDialog: ({
+ open,
+ onConfirm,
+ }: {
+ open: boolean;
+ onConfirm: () => void;
+ }) =>
+ open ? (
+
+ ) : null,
+}));
+
+// The real installer fetches a key and runs mutations of its own, none of
+// which these cases are about.
+vi.mock("@/components/CoolifyServerSetup", () => ({
+ CoolifyServerSetup: ({
+ children,
+ onUseExisting,
+ heldServerUrl,
+ }: {
+ children?: React.ReactNode;
+ onUseExisting?: (url?: string) => void;
+ heldServerUrl?: string | null;
+ }) => (
+
+ {/* Shown so the wiring is observable. The panel refuses to install
+ while this is set, and nothing else here would notice it being
+ dropped on the way in. */}
+ {heldServerUrl ?? ""}
+
+ {children}
+
+ ),
+}));
+
/**
* What the panel shows before it has an answer.
*
@@ -55,11 +115,76 @@ const loadedApp = vi.hoisted(() => ({
vi.mock("@/hooks/useLoadApp", () => ({
useLoadApp: () => ({ app: loadedApp.value, loading: false }),
}));
+const setup = vi.hoisted(() => ({ state: { type: "idle" } as unknown }));
+const dismissMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("@/ipc/types", () => ({
- ipc: { system: { openExternalUrl: vi.fn() } },
+ ipc: {
+ system: { openExternalUrl: vi.fn() },
+ coolifySetup: {
+ snapshot: () => Promise.resolve(setup.state),
+ dismiss: dismissMock,
+ },
+ events: { coolifySetup: { onChanged: () => () => {} } },
+ },
}));
-const { CoolifyConnector } = await import("./CoolifyConnector");
+const { CoolifyConnector: Panel } = await import("./CoolifyConnector");
+
+// The installer's state is the main process's, shared by every window — so
+// each case has to say what it is, or it inherits the last one's.
+beforeEach(() => {
+ setup.state = { type: "idle" };
+ dismissMock.mockClear();
+});
+
+function CoolifyConnector(props: { appId: number | null }) {
+ // One client for the life of the test, so a rerender keeps the cache it
+ // built — a fresh one per render would throw away anything a test invalidated
+ // or cached and quietly pass on the re-seed below.
+ const [client] = useState(
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ );
+ // Seeded rather than fetched: what the main process is doing is present on
+ // the first render in the app too, once any window has asked. Waiting for
+ // it here would make an "it is not shown" assertion pass before the answer
+ // arrived, which is no assertion at all.
+ client.setQueryData(queryKeys.coolify.setup, setup.state);
+ return (
+
+
+
+ );
+}
+
+describe("an install that is going on", () => {
+ it("is shown even when this app's own status cannot be read", async () => {
+ // The install belongs to the machine, not to the app being looked at. Put
+ // behind that read, a multi-minute run is replaced by an error about
+ // something else, with its Cancel button out of reach.
+ deploy.value = {
+ status: undefined,
+ statusError: new Error("could not read the app"),
+ };
+ setup.state = {
+ type: "running",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ step: "installing",
+ log: "",
+ stopping: false,
+ };
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-status-error")).toBeNull();
+ });
+});
describe("before the status query has answered", () => {
it("waits rather than claiming a failure when it is merely paused", () => {
@@ -85,6 +210,630 @@ describe("before the status query has answered", () => {
});
});
+/**
+ * Two things share this panel and they are not the same thing.
+ *
+ * The Coolify instance is the user's and outlives any app; where an app
+ * deploys is per app. Kept apart so the instance — and the way back into it —
+ * is readable without opening one app's settings.
+ */
+describe("the instance and the app are separate sections", () => {
+ function connected(connection: Record | null) {
+ deploy.value = {
+ status: {
+ hasToken: true,
+ instanceUrl: "https://coolify.test",
+ connection,
+ appUrl: null,
+ lastDeployedAt: null,
+ },
+ discovery: { servers: [], projects: [] },
+ };
+ }
+
+ it("shows the instance section while picking where an app deploys", async () => {
+ connected(null);
+ render();
+
+ expect(screen.getByTestId("coolify-instance-section")).toBeTruthy();
+ expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy();
+ expect(screen.getByText("Where this app deploys")).toBeTruthy();
+ });
+
+ it("shows it once the app has somewhere to deploy too", async () => {
+ // Previously it appeared only while editing, so the instance details were
+ // reachable only by opening one app's settings.
+ connected({
+ instanceUrl: "https://coolify.test",
+ serverUuid: "srv-1",
+ projectUuid: "prj-1",
+ environmentName: "production",
+ domain: null,
+ });
+ render();
+
+ expect(screen.getByTestId("coolify-instance-section")).toBeTruthy();
+ expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy();
+ });
+
+ it("offers signing out from the instance section, not the app one", async () => {
+ connected(null);
+ render();
+
+ const section = screen.getByTestId("coolify-instance-section");
+ expect(section.textContent).toContain("Sign out of Coolify");
+ });
+
+ /** The default mock builds a fresh one per render, so nothing can watch it. */
+ function watchClearToken() {
+ const mutateAsync = vi.fn();
+ deploy.value.clearToken = { mutateAsync, isPending: false };
+ return mutateAsync;
+ }
+
+ it("asks before forgetting anything", async () => {
+ // Signing out throws away the password Dyad invented, and pressing the
+ // button is not the same as having read that.
+ connected(null);
+ const clearToken = watchClearToken();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Sign out of Coolify" }),
+ );
+
+ expect(clearToken).not.toHaveBeenCalled();
+ expect(screen.getByTestId("confirm-sign-out")).toBeTruthy();
+ });
+
+ it("forgets the instance once that is confirmed", async () => {
+ connected(null);
+ const clearToken = watchClearToken();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Sign out of Coolify" }),
+ );
+ await user.click(screen.getByTestId("confirm-sign-out"));
+
+ expect(clearToken).toHaveBeenCalled();
+ expect(toastMock.success).toHaveBeenCalled();
+ });
+});
+
+/** With no token, installing is the landing screen; this is the other route. */
+async function openTokenForm(user: ReturnType) {
+ await user.click(
+ screen.getByRole("button", { name: "I already have Coolify installed" }),
+ );
+}
+
+const NO_TOKEN = {
+ status: {
+ hasToken: false,
+ tokenId: null,
+ instanceUrl: null,
+ serverUrl: null,
+ connection: null,
+ appUrl: null,
+ lastDeployedAt: null,
+ },
+};
+
+/** Installed, and its API token could not be minted — so no token, but a
+ server whose account Dyad is the only holder of. */
+const SERVER_NO_TOKEN = {
+ status: { ...NO_TOKEN.status, serverUrl: "http://203.0.113.5:8000" },
+};
+
+describe("a server Dyad set up but has no token for", () => {
+ it("will not set up another over the top of it", async () => {
+ // Installing again replaces the only copy of this one's password, so it
+ // is not something a screen offers on the way past.
+ deploy.value = SERVER_NO_TOKEN;
+ render();
+
+ expect(screen.getByTestId("coolify-already-has-server")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull();
+ });
+
+ it("offers signing out as the way to a different Coolify", async () => {
+ // The only state where Dyad holds a Coolify and has no token to give up,
+ // so without this there is nothing here that reaches the account.
+ deploy.value = SERVER_NO_TOKEN;
+ render();
+
+ expect(
+ screen.getByRole("button", { name: "Sign out of Coolify" }),
+ ).toBeTruthy();
+ });
+
+ it("pins the address to that server", async () => {
+ // A token typed against another address would leave the account naming
+ // one machine and the token another.
+ deploy.value = SERVER_NO_TOKEN;
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Enter an API token" }),
+ );
+ const field = screen.getByTestId(
+ "coolify-instance-url",
+ ) as HTMLInputElement;
+ expect(field.readOnly).toBe(true);
+ await user.type(field, "https://somewhere-else.example.com");
+ expect(field.value).toBe("http://203.0.113.5:8000");
+ });
+
+ it("still refuses a new install after one was cancelled", async () => {
+ // Cancelling rests the machine in failed with nothing to report and no
+ // way to clear it, so treating that as a failure to make room for would
+ // hand the installer back for as long as the app is open.
+ deploy.value = SERVER_NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ message: "Cancelled",
+ cancelled: true,
+ log: "",
+ };
+ render();
+
+ expect(screen.getByTestId("coolify-already-has-server")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull();
+ });
+
+ it("does not stand in front of an install that failed", async () => {
+ // The account is written partway through, so a failure after that point
+ // has one stored. The message, the log and the way to clear it are the
+ // installer's, and retrying is the ordinary thing to do next.
+ deploy.value = SERVER_NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ message: "boom",
+ cancelled: false,
+ log: "",
+ };
+ render();
+ expect({
+ failureVisible: Boolean(
+ screen.queryByTestId("coolify-server-setup-stub"),
+ ),
+ refusalCard: Boolean(screen.queryByTestId("coolify-already-has-server")),
+ // Installing again is refused while the account is stored, and the
+ // refusal says to sign out first — which has to be doable from here.
+ signOut: Boolean(
+ screen.queryByRole("button", { name: "Sign out of Coolify" }),
+ ),
+ }).toEqual({ failureVisible: true, refusalCard: false, signOut: true });
+ });
+
+ it("says a cancelled install may have left something behind", async () => {
+ // Cancel reads as an undo, and it is not one: the installer may already
+ // have put Docker and Coolify on the server, which is why checking it
+ // again can answer that Coolify is already there. Saying nothing leaves
+ // that refusal looking like it came from nowhere.
+ deploy.value = NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "3/6 Pulling coolify...",
+ cancelled: true,
+ };
+ render();
+
+ expect(screen.getByTestId("coolify-setup-warning").textContent).toContain(
+ "Docker and Coolify may be on the server",
+ );
+ });
+
+ it("says nothing about a cancel that never got started", async () => {
+ // No output means the installer never ran, so the server is as it was.
+ // Telling that user Docker might be on it would be a new untruth.
+ deploy.value = NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "",
+ cancelled: true,
+ };
+ render();
+
+ expect(screen.queryByTestId("coolify-setup-warning")).toBeNull();
+ });
+
+ it("keeps a terminal run's notice when this app's status cannot be read", async () => {
+ // The run belongs to the machine. A status query that fails is about one
+ // app, and must not take the only note about what the run left behind.
+ deploy.value = {
+ status: undefined,
+ statusError: new Error("could not read the app"),
+ };
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "3/6 Pulling coolify...",
+ cancelled: true,
+ };
+ render();
+
+ expect(screen.getByTestId("coolify-status-error")).toBeTruthy();
+ expect(screen.getByTestId("coolify-setup-warning")).toBeTruthy();
+ });
+
+ it("says what a cancelled run left on the server", async () => {
+ // A cancel hands the screen back to the card below rather than to the
+ // installer, so the panel that would otherwise carry this is not on
+ // screen at all. The domain is still pointing at the server either way.
+ deploy.value = SERVER_NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "",
+ cancelled: true,
+ warning: "Coolify may still be configured for 203.0.113.5.sslip.io.",
+ };
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-warning").textContent).toContain(
+ "may still be configured",
+ ),
+ );
+ // The card a cancel lands on, not the installer panel.
+ expect(screen.getByTestId("coolify-already-has-server")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull();
+ });
+
+ it("says it on the token form too, where another window may be sitting", async () => {
+ // The run belongs to the machine, not to a window. One that had already
+ // moved on to entering a token still needs to know what was left behind.
+ deploy.value = NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "",
+ cancelled: true,
+ warning: "Coolify may still be configured for 203.0.113.5.sslip.io.",
+ };
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "I already have Coolify installed" }),
+ );
+ expect(screen.getByTestId("coolify-setup-warning").textContent).toContain(
+ "may still be configured",
+ );
+ });
+
+ it("gives a cancelled run's warning a way off the screen", async () => {
+ // Nothing else dismisses a cancelled run — the panel's own Dismiss went
+ // with the panel — so without this it would sit there for good.
+ deploy.value = SERVER_NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "",
+ cancelled: true,
+ warning: "Coolify may still be configured for 203.0.113.5.sslip.io.",
+ };
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-dismiss-warning")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-setup-dismiss-warning"));
+ expect(dismissMock).toHaveBeenCalled();
+ });
+
+ it("tells the installer which server it is already holding", async () => {
+ // The panel cannot ask: it never receives coolify status. Without this
+ // it offers an install the handler answers only with "sign out first".
+ deploy.value = SERVER_NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ message: "boom",
+ cancelled: false,
+ log: "",
+ };
+ render();
+
+ expect(screen.getByTestId("stub-held-server").textContent).toBe(
+ "http://203.0.113.5:8000",
+ );
+ });
+
+ it("has nothing to sign out of when the run never got that far", async () => {
+ // A failure before the account was written leaves Dyad holding nothing,
+ // so the way to forget it is an offer to forget what does not exist.
+ deploy.value = NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ message: "boom",
+ cancelled: false,
+ log: "",
+ };
+ render();
+
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy();
+ expect(
+ screen.queryByRole("button", { name: "Sign out of Coolify" }),
+ ).toBeNull();
+ });
+
+ it("can be connected to from the card that refuses a new install", async () => {
+ // The address cannot be typed into here, so a form that never filled it
+ // in would leave signing out — which forgets the password Dyad is the
+ // only holder of — as the only way on from that card.
+ deploy.value = SERVER_NO_TOKEN;
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Enter an API token" }),
+ );
+ await user.type(screen.getByTestId("coolify-token"), "1|abc");
+ // A freshly installed server answers on http until it has a certificate,
+ // so this is the ordinary way through rather than an unusual one.
+ await user.click(screen.getByTestId("coolify-acknowledge-insecure"));
+
+ expect(
+ (screen.getByTestId("coolify-save-token") as HTMLButtonElement).disabled,
+ ).toBe(false);
+ });
+
+ it("keeps the way back to a failure the installer is reporting", async () => {
+ // That screen carries the message, the output and the only control that
+ // clears the run, so the token form must not be a one-way door into it.
+ deploy.value = SERVER_NO_TOKEN;
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ message: "boom",
+ cancelled: false,
+ log: "",
+ };
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "I already have Coolify installed" }),
+ );
+ // Named for where it goes. Dyad set this server up, so offering to set
+ // one up "yet" describes somebody else's situation.
+ expect(screen.getByTestId("coolify-no-instance").textContent).toBe(
+ "Back to the installer",
+ );
+ expect(screen.queryByText(/No Coolify server yet/i)).toBeNull();
+ });
+
+ it("does not offer the installer as a way out of the token form", async () => {
+ deploy.value = SERVER_NO_TOKEN;
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Enter an API token" }),
+ );
+ expect(screen.queryByTestId("coolify-no-instance")).toBeNull();
+ });
+});
+
+describe("where someone with no Coolify lands", () => {
+ it("offers to install one rather than asking for a token", async () => {
+ // The token form asks about a Coolify that already exists. Landing on it
+ // tells everyone else they are in the wrong place.
+ deploy.value = NO_TOKEN;
+ render();
+
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-instance-url")).toBeNull();
+ });
+
+ it("reaches the token form from there, and back again", async () => {
+ deploy.value = NO_TOKEN;
+ const user = userEvent.setup();
+ render();
+
+ await openTokenForm(user);
+ expect(screen.getByTestId("coolify-instance-url")).toBeTruthy();
+
+ await user.click(screen.getByTestId("coolify-no-instance"));
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy();
+ });
+
+ it("puts the way out under what it knows, not against Install", async () => {
+ deploy.value = NO_TOKEN;
+ render();
+
+ const text = screen.getByTestId("coolify-server-setup-stub").textContent;
+ expect(text?.indexOf("Your Coolify server")).toBeLessThan(
+ text?.indexOf("I already have Coolify installed") ?? -1,
+ );
+ });
+
+ it("still shows what it knows about a server with no token yet", async () => {
+ // Installing a server whose token could not be minted lands here, and the
+ // account Dyad made is the only way into it.
+ deploy.value = NO_TOKEN;
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByTestId("coolify-credentials-stub").textContent).toBe(
+ "Your Coolify server",
+ );
+ await openTokenForm(user);
+ expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy();
+ });
+});
+
+describe("what the installer leaves on screen", () => {
+ const DONE = {
+ type: "done",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ result: {
+ dashboardUrl: "http://203.0.113.5:8000",
+ secure: false,
+ insecureReason: "No certificate arrived.",
+ adminEmail: "me@gmail.com",
+ adminPassword: "Abc123@xyz",
+ tokenStored: true,
+ apiEnabled: true,
+ tokenUnavailableReason: null,
+ version: "4.3.2",
+ },
+ };
+
+ const CONNECTED = {
+ status: {
+ hasToken: true,
+ instanceUrl: "http://203.0.113.5:8000",
+ connection: null,
+ appUrl: null,
+ lastDeployedAt: null,
+ },
+ discovery: { servers: [], projects: [] },
+ };
+
+ it("keeps a finished install up even once a token exists", async () => {
+ // The install stores a token, so the panel would otherwise be replaced by
+ // the connected view — taking with it the only notice that the server
+ // ended up unencrypted.
+ setup.state = DONE;
+ deploy.value = CONNECTED;
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(),
+ );
+ });
+
+ it("moves on once the user has read it", async () => {
+ // Dismissing is what ends it, and that is recorded where the install is:
+ // in the main process, not in a flag this panel could lose.
+ setup.state = { type: "idle" };
+ deploy.value = CONNECTED;
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByText("Where this app deploys")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull();
+ });
+
+ it("shows a running install rather than the form", async () => {
+ setup.state = {
+ type: "running",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ step: "installing",
+ log: "",
+ stopping: false,
+ };
+ deploy.value = CONNECTED;
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(),
+ );
+ });
+
+ it("does not hold the panel open after a failure", async () => {
+ // A failure leaves the form up with the installer's output under it, and
+ // holding the view there made the token form unreachable.
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Installing Coolify failed (exit 1).",
+ log: "dpkg: error",
+ cancelled: false,
+ };
+ deploy.value = CONNECTED;
+ render();
+
+ // Waited for something positive: an absence is true before the answer
+ // arrives, so asserting only that would pass without proving anything.
+ await waitFor(() =>
+ expect(screen.getByText("Where this app deploys")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull();
+ });
+
+ it("is about the server, so it does not change with the app", async () => {
+ // Deliberate: the install is about a machine, not about one app, and it
+ // stays until the user has read it and moved on.
+ setup.state = DONE;
+ deploy.value = CONNECTED;
+ const { rerender } = render();
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(),
+ );
+
+ rerender();
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(),
+ );
+ });
+});
+
describe("consent to an unencrypted address", () => {
it("does not carry over to an address it was not given for", async () => {
// Ticked for one plain-HTTP host, then the host is edited. The token would
@@ -101,6 +850,7 @@ describe("consent to an unencrypted address", () => {
};
const user = userEvent.setup();
render();
+ await openTokenForm(user);
const url = screen.getByTestId("coolify-instance-url");
await user.type(url, "http://box.local:8000");
@@ -132,6 +882,7 @@ describe("consent to an unencrypted address", () => {
};
const user = userEvent.setup();
const { rerender } = render();
+ await openTokenForm(user);
const url = screen.getByTestId("coolify-instance-url");
await user.clear(url);
@@ -250,6 +1001,37 @@ describe("naming the target in the connected view", () => {
lastDeployedAt: 1,
};
+ it("still says what a cancelled run left behind", () => {
+ // The run belongs to the machine, not to a window or to one app. An app
+ // that already has somewhere to deploy is the easiest tab to be sitting
+ // on when a cancel lands, and it is the same server underneath.
+ setup.state = {
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "",
+ cancelled: true,
+ warning: "Coolify may still be configured for 203.0.113.5.sslip.io.",
+ };
+ deploy.value = {
+ status: CONNECTED,
+ discovery: {
+ servers: [{ uuid: "srv-1", name: "production-box" }],
+ projects: [{ uuid: "prj-1", name: "storefront" }],
+ },
+ };
+ render();
+
+ expect(screen.getByTestId("coolify-setup-warning").textContent).toContain(
+ "may still be configured",
+ );
+ });
+
it("names the server and project once discovery has answered", () => {
deploy.value = {
status: CONNECTED,
@@ -378,6 +1160,19 @@ describe("editing while the saved connection changes", () => {
).toBe("typed.example.com");
});
+ it("keeps Cancel with Save, since they answer the same form", async () => {
+ // Up by the refresh control it read as cancelling something in progress.
+ deploy.value = statusWith(CONNECTION);
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+ const save = screen.getByTestId("coolify-save-connection");
+ const cancel = screen.getByRole("button", { name: "Cancel" });
+
+ expect(save.parentElement).toBe(cancel.parentElement);
+ });
+
it("puts the saved values back when the edit is abandoned", async () => {
// Cancel only drops the flag; refilling is the effect's job.
deploy.value = statusWith(CONNECTION);
diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx
index 7dac5bc12c..f8c1aab39c 100644
--- a/src/components/CoolifyConnector.tsx
+++ b/src/components/CoolifyConnector.tsx
@@ -24,6 +24,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ipc } from "@/ipc/types";
+import type { SetupSnapshot } from "@/ipc/types";
+import { CoolifyServerSetup } from "@/components/CoolifyServerSetup";
+import { useCoolifySetupSnapshot } from "@/hooks/useCoolifySetupSnapshot";
+import { CoolifyCredentials } from "@/components/CoolifyCredentials";
+import { CoolifySignOutDialog } from "@/components/CoolifySignOutDialog";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useCoolifyDeploy } from "@/hooks/useCoolifyDeploy";
import { selectCoolifyDeployCapabilities } from "@/coolify_deploy/capabilities";
@@ -94,6 +99,11 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
disconnect,
} = useCoolifyDeploy(appId);
+ // What the main process is doing with a server, if anything. Asked for
+ // rather than remembered: an install outlives this panel, and the panel
+ // being replaced — by a spinner, by a status error — must not lose it.
+ const { data: setupSnapshot } = useCoolifySetupSnapshot();
+
const serverSelectId = useId();
const projectSelectId = useId();
const instanceUrlId = useId();
@@ -104,6 +114,10 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
// A saved connection otherwise hides the only form that can change the
// server, project or domain — and the only way to sign out.
const [isEditingConnection, setIsEditingConnection] = useState(false);
+ // Installing is where this lands, so the flag is the other way round: it
+ // records having said "I already have Coolify".
+ const [isEnteringToken, setIsEnteringToken] = useState(false);
+ const [isConfirmingSignOut, setIsConfirmingSignOut] = useState(false);
const [token, setToken] = useState("");
const [serverUuid, setServerUuid] = useState("");
const [projectUuid, setProjectUuid] = useState("");
@@ -121,7 +135,13 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
// Leaving the edit refills, since this keys on that too.
if (isEditingConnection) return;
const connection = status?.connection;
- setInstanceUrl(connection?.instanceUrl ?? status?.instanceUrl ?? "");
+ // The server Dyad set up wins, because that is the case where the field is
+ // pinned and nothing else can fill it in. It is also the one address that
+ // is right there: a stored token names the instance it opens, which is not
+ // necessarily the machine whose account Dyad is holding.
+ setInstanceUrl(
+ status?.serverUrl ?? connection?.instanceUrl ?? status?.instanceUrl ?? "",
+ );
setServerUuid(connection?.serverUuid ?? "");
setProjectUuid(connection?.projectUuid ?? "");
setDomain(connection?.domain ?? "");
@@ -129,7 +149,13 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
// only on keystrokes left a tick given for a typed address sitting over the
// remembered one this puts back.
setAcknowledgedInsecure(false);
- }, [appId, status?.connection, status?.instanceUrl, isEditingConnection]);
+ }, [
+ appId,
+ status?.connection,
+ status?.instanceUrl,
+ status?.serverUrl,
+ isEditingConnection,
+ ]);
// Only on an app change. Keying this to the connection would close the form
// under the user whenever a background refetch handed back a new object.
@@ -137,13 +163,135 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
setIsEditingConnection(false);
}, [appId, status?.hasToken]);
+ // On both screens below. A server Dyad installed but could not mint a token
+ // for is named by its admin account and nothing else, and these are the two
+ // states that leaves the user in.
+ const newServerCredentials = ;
+
+ // One element, not one per branch: rendering a second copy elsewhere in the
+ // tree would remount the panel and lose the result it is being kept for.
+ const setupState: SetupSnapshot = setupSnapshot ?? { type: "idle" };
+ /**
+ * A failure the installer has something to say about.
+ *
+ * Cancelling is the user's own decision rather than a fault, so it carries
+ * no message and no way to clear itself — the machine simply rests in
+ * `failed` until the next run. Treating that as something to make room for
+ * would hand the installer back for good.
+ */
+ const isReportingFailure =
+ setupState.type === "failed" && !setupState.cancelled;
+
+ /**
+ * What a terminal run left for the user to know about.
+ *
+ * Two things, and the panel would otherwise say neither. A domain the run
+ * put on and could not take back off is theirs to clear; and a cancel that
+ * got as far as running the installer may have left Docker and Coolify on
+ * the server, which is why checking it again can answer that Coolify is
+ * already there.
+ *
+ * Here rather than in the installer panel because a cancelled run hands the
+ * screen back to the card below instead of to that panel, and above the
+ * status guards because this belongs to the machine rather than to one
+ * app's view of it. It carries its own way out: the panel's Dismiss goes
+ * with the panel, and nothing else clears a cancelled run.
+ */
+ const stoppedAfterInstalling =
+ setupState.type === "failed" &&
+ setupState.cancelled &&
+ // The log only takes output from the installer onwards, so an empty one
+ // is a run that stopped before it could change anything. Saying Docker
+ // might be on the server would be a new untruth.
+ setupState.log.trim() !== "";
+ const terminalNotice =
+ setupState.type === "failed" &&
+ (setupState.warning || stoppedAfterInstalling) ? (
+
+
+ {stoppedAfterInstalling && (
+
+ Setting up Coolify was stopped. The installer had already started,
+ so Docker and Coolify may be on the server — if checking it again
+ says Coolify is already there, that is why.
+
+ )}
+ {setupState.warning &&
{setupState.warning}
}
+
+
+
+ ) : null;
+
+ const serverSetup = (
+ {
+ if (url) setInstanceUrl(url);
+ setIsEnteringToken(true);
+ }}
+ >
+ {newServerCredentials}
+
+ {/* Last, under anything Dyad already knows about a Coolify: this is the
+ exit for the people the installer does not apply to, not another
+ control on it. */}
+
+
+
+
+ );
+
+ // Before the token check: an install that is going on, or has something to
+ // say about how it went, outranks anything else this panel could show. Read
+ // from the main process rather than remembered here, so leaving the screen
+ // and coming back finds it again.
+ // A failure is not one of them: it leaves the form on screen with the
+ // installer's output under it, which is the panel the user would land on
+ // anyway, and holding the view there puts the token form out of reach.
+ if (setupState.type === "running" || setupState.type === "done") {
+ return (
+
+ {serverSetup}
+
+ );
+ }
+
// A query react-query has paused — the renderer is offline — is pending
// with no data and no error, which is not a failure and must not read as
// one. It is waiting, so it shows as waiting.
if (appId === null || isStatusLoading || (!status && !statusError)) {
return (
-
- Loading...
+
+ {/* Above the two guards below, not inside the branches after them: a
+ run that ended belongs to the machine, and this app's status
+ being slow or unreadable is no reason to take back what it said. */}
+ {terminalNotice}
+
+ Loading...
+
);
}
@@ -153,20 +301,23 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
// so the panel stayed empty until a remount. The discovery query beside
// it has said so properly all along.
return (
-
-
Could not read this app's Coolify setup
-
{getErrorMessage(statusError)}
-
);
- // --- Step 1: instance URL + API token ---
+ // --- Step 1: get a Coolify, or connect to one ---
+ // The one control that makes Dyad forget a Coolify: the address, the token,
+ // and the admin account for a server it set up. Available wherever Dyad
+ // holds any of those, because holding an account without a token is still
+ // holding a Coolify — and it is what has to be given up to reach a
+ // different one.
+ const signOut = (
+ <>
+ setIsConfirmingSignOut(true)}
+ >
+ Sign out of Coolify
+
+ {
+ try {
+ await clearToken.mutateAsync();
+ // The component is not remounted when the token goes, so without
+ // this the form comes back holding the credential just forgotten —
+ // and pressing Connect would silently store it again, which is
+ // the opposite of what signing out to rotate a token is for.
+ setToken("");
+ setAcknowledgedInsecure(false);
+ toast.success(
+ "Signed out of Coolify. Your server keeps running and your apps keep their settings.",
+ );
+ } catch (error) {
+ toast.error(getErrorMessage(error));
+ }
+ }}
+ />
+ >
+ );
+
if (!status.hasToken) {
+ // Installing comes first: the token form asks for an address and a token,
+ // which is a question about a Coolify that already exists. Landing on it
+ // told everyone else they were in the wrong place.
+ if (!isEnteringToken) {
+ // Dyad already has a server. Setting up another would replace the only
+ // copy of its password, so the way to one is through giving this one up
+ // deliberately rather than through starting again over the top of it.
+ //
+ // Not over a failure the installer is reporting. Its message, output and
+ // the way to clear it are all on that screen, and standing in front of
+ // it leaves the run both unexplained and impossible to dismiss. Retrying
+ // is also the ordinary thing to do next, and it lands on the same server.
+ if (status.serverUrl && !isReportingFailure) {
+ return (
+
+ {terminalNotice}
+
+
Dyad already set up a server
+
+ Its details are below. Finish connecting to it, or sign out to
+ set up a different one — signing out forgets these.
+
+
+ setIsEnteringToken(true)}>
+ Enter an API token
+
+ {newServerCredentials}
+ {signOut}
+
+ );
+ }
+ return (
+
+ {terminalNotice}
+ {serverSetup}
+ {/* Installing again is refused while Dyad still holds an admin
+ password, and that refusal says to sign out first. Behind the
+ failure report is the one place it could be said without the way
+ to do it being on screen. */}
+ {status.serverUrl && signOut}
+
+ );
+ }
+
const trimmedUrl = instanceUrl.trim();
// A stock Coolify serves plain HTTP until it has a domain and certificate,
// so this is the common case rather than an unusual one.
@@ -281,6 +517,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
const isInsecure = hasUsableScheme && !isSecureInstanceUrl(trimmedUrl);
return (
+ {terminalNotice}
Deploy this app to a Coolify instance you run. In Coolify, enable the
API under Settings → Advanced → API Access, then create a token under
@@ -295,6 +532,11 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
data-testid="coolify-instance-url"
placeholder="https://coolify.example.com"
value={instanceUrl}
+ // Fixed to the server Dyad set up while it holds that server's
+ // account. A token typed against another address would leave the
+ // account describing one machine and the token another, which is
+ // a pairing nothing downstream can tell apart from a way in.
+ readOnly={Boolean(status.serverUrl)}
onChange={(e) => {
setInstanceUrl(e.target.value);
// The consent was given for the address that was on screen at
@@ -303,6 +545,12 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
setAcknowledgedInsecure(false);
}}
/>
+ {status.serverUrl && (
+
+ The server Dyad set up. Sign out to connect to a different
+ Coolify.
+
+ )}
@@ -335,6 +583,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
onCheckedChange={(checked) =>
setAcknowledgedInsecure(checked === true)
}
+ data-testid="coolify-acknowledge-insecure"
/>
Connect anyway
@@ -369,10 +618,55 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
)}
Connect
+
+ {newServerCredentials}
+
+ {/* Back to the installer, for someone who came here by mistake, and
+ the only way back to a failure it is reporting. Not offered while
+ Dyad holds a server's account and has nothing to report: that
+ screen refuses to set up another anyway, and signing out is the
+ way there. */}
+ {status.serverUrl && !isReportingFailure ? (
+
{signOut}
+ ) : (
+
+
+ {/* The other way in here is a run that failed on a server Dyad
+ did set up. Offering to set one up "yet" over the top of it
+ describes somebody else's situation. */}
+ {status.serverUrl
+ ? "Dyad set up a server here and the run has something to say about it. "
+ : "No Coolify server yet? "}
+ setIsEnteringToken(false)}
+ data-testid="coolify-no-instance"
+ >
+ {status.serverUrl ? "Back to the installer" : "Set one up"}
+
+ {status.serverUrl ? "." : " on a server you already have."}
+
+
+ )}
);
}
+ // Two things are on this screen and they are not the same thing: the
+ // Coolify the user connected to, which is theirs and outlives any app, and
+ // where this one app deploys. Kept apart so the instance and its way back
+ // in are readable without opening the app's settings.
+ const coolifySection = (
+
+
+
Coolify
+ {signOut}
+
+
+
+ );
+
// --- Step 2: server, project, domain ---
if (!status.connection || isEditingConnection) {
const projects = discovery?.projects ?? [];
@@ -380,39 +674,18 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
// is isFetching and so also covers background refetches over a list the
// user is already reading.
const awaitingDiscovery = isDiscoveryPending;
- // The only control that removes the stored instance URL and token. It used
- // to live solely inside the discovery-error card, so rotating a token or
- // moving to another instance meant first breaking discovery on purpose.
- const disconnectEverything = (
- {
- try {
- await clearToken.mutateAsync();
- // The component is not remounted when the token goes, so without
- // this the form comes back holding the credential just revoked —
- // and pressing Connect would silently store it again, which is
- // the opposite of what signing out to rotate a token is for.
- setToken("");
- setAcknowledgedInsecure(false);
- toast.success(
- "Signed out of Coolify. Your apps keep their settings; enter a token to use them again.",
- );
- } catch (error) {
- toast.error(getErrorMessage(error));
- }
- }}
- >
- Sign out of Coolify
-
- );
const duplicateProjectName = projects.some(
(p) => p.name.toLowerCase() === newProjectName.trim().toLowerCase(),
);
return (
+ {terminalNotice}
+ {coolifySection}
+
+
+ Where this app deploys
+
+
{discoveryError && (
Could not load servers and projects
@@ -452,23 +725,6 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
-
- {isEditingConnection && (
- {
- // Only the flag. The prefill effect waits on it and puts
- // every field back, the address and its consent included.
- setIsEditingConnection(false);
- }}
- >
- Cancel
-
- )}
- {disconnectEverything}
-
-
{/* A project belongs to the Coolify instance, not to this app, so it
is named and created on its own before anything is picked. */}
{isEditingConnection && movingHost && (
@@ -636,100 +892,117 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
{insecureWarningBlock}
- {
- try {
- const trimmed = domain.trim();
- // Whether a domain may be cleared is the handler's to answer:
- // it turns on whether an application exists in Coolify, which
- // the status contract does not carry. A copy here was wrong
- // twice — first missing the moving case, then missing the
- // never-provisioned one — so there is no copy. The catch below
- // surfaces the handler's message.
- // Checked before saving, because a save that succeeds should not
- // then be followed by a lookup the user waits on. Reported only
- // after, because every one of these warnings says the connection
- // was saved anyway — and the save can still be refused, by an
- // invalid domain or by a deploy that started in another window.
- // Announcing "saved anyway" and then "not saved" leaves the user
- // to work out which of the two toasts to believe.
- let warn: (() => void) | null = null;
- if (trimmed) {
- // Advisory only: if the check itself fails, say so and still
- // let the user save rather than trapping them behind it.
- try {
- const dns = await checkDomain.mutateAsync({
- serverUuid,
- domain: trimmed,
- });
- // Only speak up about something actually detected: an
- // "unknown" verdict means the check could not run, and a
- // warning that appears when nothing is wrong gets ignored.
- //
- // The deploy itself succeeds; it is HTTPS that breaks, so
- // say that rather than implying the build fails.
- const consequence =
- "The app will still deploy, but HTTPS will not work.";
- if (dns.verdict === "points-elsewhere") {
- // A proxied domain deliberately resolves to the proxy
- // rather than the origin, so the mismatch is what a
- // correct setup looks like there. We cannot tell one from
- // the other — a CDN address is public like any other — so
- // the reading is offered rather than the instruction.
+
+ {
+ try {
+ const trimmed = domain.trim();
+ // Whether a domain may be cleared is the handler's to answer:
+ // it turns on whether an application exists in Coolify, which
+ // the status contract does not carry. A copy here was wrong
+ // twice — first missing the moving case, then missing the
+ // never-provisioned one — so there is no copy. The catch below
+ // surfaces the handler's message.
+ // Checked before saving, because a save that succeeds should not
+ // then be followed by a lookup the user waits on. Reported only
+ // after, because every one of these warnings says the connection
+ // was saved anyway — and the save can still be refused, by an
+ // invalid domain or by a deploy that started in another window.
+ // Announcing "saved anyway" and then "not saved" leaves the user
+ // to work out which of the two toasts to believe.
+ let warn: (() => void) | null = null;
+ if (trimmed) {
+ // Advisory only: if the check itself fails, say so and still
+ // let the user save rather than trapping them behind it.
+ try {
+ const dns = await checkDomain.mutateAsync({
+ serverUuid,
+ domain: trimmed,
+ });
+ // Only speak up about something actually detected: an
+ // "unknown" verdict means the check could not run, and a
+ // warning that appears when nothing is wrong gets ignored.
+ //
+ // The deploy itself succeeds; it is HTTPS that breaks, so
+ // say that rather than implying the build fails.
+ const consequence =
+ "The app will still deploy, but HTTPS will not work.";
+ if (dns.verdict === "points-elsewhere") {
+ // A proxied domain deliberately resolves to the proxy
+ // rather than the origin, so the mismatch is what a
+ // correct setup looks like there. We cannot tell one from
+ // the other — a CDN address is public like any other — so
+ // the reading is offered rather than the instruction.
+ warn = () =>
+ toast.warning(
+ `${dns.hostname} points at ${dns.actualIps.join(", ")}, not your server at ${dns.expectedIp}.`,
+ {
+ description: `Saved anyway. If your domain is behind a proxy or CDN, this is expected. Otherwise the app will still deploy, but HTTPS will not work — point it at ${dns.expectedIp} and redeploy.`,
+ },
+ );
+ } else if (dns.verdict === "no-records") {
+ // Where to point it is only named when it is known. A
+ // server on a private address has no address a public
+ // record could use, but the domain still has no records.
+ const where = dns.expectedIp
+ ? ` point a DNS record at ${dns.expectedIp} and redeploy.`
+ : " point a DNS record at your server and redeploy.";
+ warn = () =>
+ toast.warning(
+ `${dns.hostname} has no DNS record yet.`,
+ {
+ description: `${consequence} Saved anyway;${where}`,
+ },
+ );
+ }
+ } catch {
warn = () =>
toast.warning(
- `${dns.hostname} points at ${dns.actualIps.join(", ")}, not your server at ${dns.expectedIp}.`,
- {
- description: `Saved anyway. If your domain is behind a proxy or CDN, this is expected. Otherwise the app will still deploy, but HTTPS will not work — point it at ${dns.expectedIp} and redeploy.`,
- },
+ `Could not check where ${trimmed} points; saved anyway.`,
);
- } else if (dns.verdict === "no-records") {
- // Where to point it is only named when it is known. A
- // server on a private address has no address a public
- // record could use, but the domain still has no records.
- const where = dns.expectedIp
- ? ` point a DNS record at ${dns.expectedIp} and redeploy.`
- : " point a DNS record at your server and redeploy.";
- warn = () =>
- toast.warning(`${dns.hostname} has no DNS record yet.`, {
- description: `${consequence} Saved anyway;${where}`,
- });
}
- } catch {
- warn = () =>
- toast.warning(
- `Could not check where ${trimmed} points; saved anyway.`,
- );
}
+ await saveConnection.mutateAsync({
+ serverUuid,
+ projectUuid,
+ environmentName,
+ domain: trimmed || null,
+ });
+ warn?.();
+ // Only now: a failed save has to leave the form open with the
+ // user's edits still in it.
+ setIsEditingConnection(false);
+ } catch (error) {
+ toast.error(getErrorMessage(error));
}
- await saveConnection.mutateAsync({
- serverUuid,
- projectUuid,
- environmentName,
- domain: trimmed || null,
- });
- warn?.();
- // Only now: a failed save has to leave the form open with the
- // user's edits still in it.
- setIsEditingConnection(false);
- } catch (error) {
- toast.error(getErrorMessage(error));
- }
- }}
- >
- {(saveConnection.isPending || checkDomain.isPending) && (
-
+ }}
+ >
+ {(saveConnection.isPending || checkDomain.isPending) && (
+
+ )}
+ Save
+
+ {isEditingConnection && (
+ {
+ // Only the flag. The prefill effect waits on it and puts every
+ // field back, the address and its consent included.
+ setIsEditingConnection(false);
+ }}
+ >
+ Cancel
+
)}
- Save
-
+
);
}
@@ -763,6 +1036,13 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
return (
+ {terminalNotice}
+ {coolifySection}
+
+
+ Where this app deploys
+
+
{belongsElsewhere && (
diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx
new file mode 100644
index 0000000000..412628a649
--- /dev/null
+++ b/src/components/CoolifyCredentials.test.tsx
@@ -0,0 +1,351 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/toast", () => ({ showError: vi.fn() }));
+
+const h = vi.hoisted(() => ({ revealCredentials: vi.fn() }));
+vi.mock("@/ipc/types", () => ({
+ ipc: { coolifySetup: { revealCredentials: h.revealCredentials } },
+}));
+
+const { queryKeys } = await import("@/lib/queryKeys");
+const { CoolifyCredentials: Panel } = await import("./CoolifyCredentials");
+
+function CoolifyCredentials(props: { showTitle?: boolean }) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return (
+
+
+
+ );
+}
+
+const FULL = {
+ instance: {
+ url: "https://203.0.113.5.sslip.io",
+ apiToken: "1|abcdefghijklmnop",
+ },
+ server: {
+ url: "https://203.0.113.5.sslip.io",
+ email: "me@gmail.com",
+ password: "Abc123@xyzAbc123@xyz",
+ },
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ h.revealCredentials.mockResolvedValue(FULL);
+});
+
+/**
+ * Waits for the read to have answered, not merely to have been asked.
+ *
+ * This panel renders nothing while the question is in flight and nothing
+ * when the answer is empty, so asserting on the first of those proves only
+ * that a promise had not resolved yet — it holds just as well with the guard
+ * for the second one taken out.
+ */
+async function settle() {
+ await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled());
+ // A turn of the event loop, not just the microtask queue: react-query
+ // carries a resolved read through to a render on a macrotask, so flushing
+ // microtasks alone lands back here with the panel still pending — which
+ // looks exactly like the empty answer these assertions are about.
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+async function renderAndSettle() {
+ render();
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-credentials")).toBeTruthy(),
+ );
+}
+
+describe("what is on screen without asking", () => {
+ it("shows the details rather than hiding them behind a control", async () => {
+ // Made to click to discover Dyad even has these, most people never find
+ // out — and then signing out locks them out of their own server.
+ await renderAndSettle();
+
+ expect(screen.getByTestId("coolify-field-address").textContent).toBe(
+ FULL.instance.url,
+ );
+ expect(screen.getByTestId("coolify-field-email").textContent).toBe(
+ FULL.server.email,
+ );
+ });
+
+ it("includes the API token, not only the sign-in details", async () => {
+ // Signing out of Coolify in Dyad clears it, so without this the token is
+ // gone for good and the instance has to be set up again.
+ await renderAndSettle();
+ expect(screen.getByTestId("coolify-field-api-token")).toBeTruthy();
+ });
+
+ it("keeps the secrets masked until they are asked for", async () => {
+ // Showing the details is not the same as showing the secrets: these sit
+ // in a panel someone may have open while screen sharing.
+ await renderAndSettle();
+
+ expect(screen.getByTestId("coolify-field-password").textContent).toMatch(
+ /^•+$/,
+ );
+ expect(screen.getByTestId("coolify-field-api-token").textContent).toMatch(
+ /^•+$/,
+ );
+ });
+
+ it("does not mask what is not secret", async () => {
+ await renderAndSettle();
+ expect(screen.queryByRole("button", { name: "Show Address" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Show Email" })).toBeNull();
+ });
+});
+
+describe("revealing one value", () => {
+ it("shows the password when asked, and hides it again", async () => {
+ const user = userEvent.setup();
+ await renderAndSettle();
+
+ await user.click(screen.getByRole("button", { name: "Show Password" }));
+ expect(screen.getByTestId("coolify-field-password").textContent).toBe(
+ FULL.server.password,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Hide Password" }));
+ expect(screen.getByTestId("coolify-field-password").textContent).toMatch(
+ /^•+$/,
+ );
+ });
+
+ it("reveals each value on its own", async () => {
+ // Showing the password should not put the token on screen too.
+ const user = userEvent.setup();
+ await renderAndSettle();
+
+ await user.click(screen.getByRole("button", { name: "Show Password" }));
+
+ expect(screen.getByTestId("coolify-field-api-token").textContent).toMatch(
+ /^•+$/,
+ );
+ });
+});
+
+describe("naming the section", () => {
+ it("names a server Dyad installed but has no token for", async () => {
+ // Reached by installing a server whose API token could not be minted, so
+ // the account is all Dyad has for it.
+ h.revealCredentials.mockResolvedValue({ ...FULL, instance: null });
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByText("Your Coolify server")).toBeTruthy(),
+ );
+ });
+
+ it("leaves no heading over nothing", async () => {
+ // The caller cannot know there is anything to show until this has asked.
+ h.revealCredentials.mockResolvedValue({ instance: null, server: null });
+ render();
+
+ await settle();
+ expect(screen.queryByText("Your Coolify server")).toBeNull();
+ });
+});
+
+describe("while the read is still going", () => {
+ it("says so rather than leaving the caller's heading over nothing", async () => {
+ // Every caller introduces this panel as the details it is about to show.
+ // Rendering nothing until the answer lands leaves "Its details are below"
+ // with nothing below it, which reads as Dyad holding nothing at all.
+ h.revealCredentials.mockReturnValue(new Promise(() => {}));
+ render();
+
+ expect(
+ await screen.findByTestId("coolify-credentials-loading"),
+ ).toBeTruthy();
+ expect(screen.queryByTestId("coolify-credentials")).toBeNull();
+ });
+
+ it("gives way once the answer arrives", async () => {
+ render();
+
+ await settle();
+ expect(screen.queryByTestId("coolify-credentials-loading")).toBeNull();
+ expect(screen.getByTestId("coolify-credentials")).toBeTruthy();
+ });
+});
+
+describe("a password Dyad holds but cannot read", () => {
+ it("says so rather than showing a server that never had one", async () => {
+ // readSettings drops a password it cannot decrypt and keeps the account.
+ // Left as a missing row, that reads as there never having been one — and
+ // the obvious next move from there is the sign-out that discards it.
+ h.revealCredentials.mockResolvedValue({
+ instance: null,
+ server: {
+ url: "http://203.0.113.5:8000",
+ email: "me@gmail.com",
+ password: null,
+ },
+ });
+ render();
+
+ await settle();
+ expect(
+ screen.getByTestId("coolify-credentials-locked-password").textContent,
+ ).toContain("cannot read it on this machine");
+ expect(screen.queryByTestId("coolify-field-password")).toBeNull();
+ });
+
+ it("says it on the merged panel too", async () => {
+ // One address for both, so the fields are merged — the same gap, on the
+ // layout the connected view uses.
+ h.revealCredentials.mockResolvedValue({
+ instance: { url: "http://203.0.113.5:8000", apiToken: "1|abc" },
+ server: {
+ url: "http://203.0.113.5:8000",
+ email: "me@gmail.com",
+ password: null,
+ },
+ });
+ render();
+
+ await settle();
+ expect(
+ screen.getByTestId("coolify-credentials-locked-password"),
+ ).toBeTruthy();
+ expect(screen.queryByTestId("coolify-field-password")).toBeNull();
+ });
+});
+
+describe("two servers that are not the same server", () => {
+ it("keeps each address with what it opens", async () => {
+ // Installed a server whose token could not be minted, then connected to a
+ // different Coolify. One address over both would read as a way into the
+ // connected one that is not one.
+ h.revealCredentials.mockResolvedValue({
+ instance: {
+ url: "https://someone-elses.example.com",
+ apiToken: "1|other",
+ },
+ server: {
+ url: "http://203.0.113.5:8000",
+ email: "me@gmail.com",
+ password: "Abc123@xyz",
+ },
+ });
+ await renderAndSettle();
+
+ // One id each, so a lookup for the address reaches one thing rather than
+ // two — both blocks carry one.
+ expect(screen.getByTestId("coolify-field-server-address")).toBeTruthy();
+ expect(screen.getByTestId("coolify-field-instance-address")).toBeTruthy();
+
+ const forServer = screen.getByTestId("coolify-credentials-server");
+ const forInstance = screen.getByTestId("coolify-credentials-instance");
+ expect(forServer.textContent).toContain("http://203.0.113.5:8000");
+ expect(forServer.textContent).not.toContain("someone-elses");
+ expect(forInstance.textContent).toContain("someone-elses.example.com");
+ // The password belongs to the machine Dyad built, and stays with it.
+ // Asserted on the field rather than on the text: a secret renders as
+ // bullets until it is revealed, so looking for the value itself passes
+ // wherever the row is put.
+ expect(screen.getByTestId("coolify-field-server-password")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-field-instance-password")).toBeNull();
+ });
+
+ it("shows one block when both describe the same address", async () => {
+ await renderAndSettle();
+
+ expect(screen.queryByTestId("coolify-credentials-server")).toBeNull();
+ expect(screen.queryByTestId("coolify-credentials-instance")).toBeNull();
+ expect(screen.getAllByTestId(/^coolify-field-address$/)).toHaveLength(1);
+ });
+});
+
+describe("a read that did not answer", () => {
+ it("keeps details it already has when a later read fails", async () => {
+ // The panel refetches on window focus once its data is a minute old, and
+ // production does not retry. Standing that failure in front of a password
+ // Dyad holds the only copy of takes it off screen with nothing to copy —
+ // and in the sign-out dialog it goes just as the user is asked to confirm
+ // they have saved it.
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-field-password")).toBeTruthy(),
+ );
+
+ h.revealCredentials.mockRejectedValue(new Error("keychain locked"));
+ await client.refetchQueries({ queryKey: queryKeys.coolify.credentials });
+ // The refetch settling is not the panel having re-rendered on it, and
+ // reading the screen in between shows what was there before either way.
+ await waitFor(() => expect(h.revealCredentials).toHaveBeenCalledTimes(2));
+
+ expect(screen.getByTestId("coolify-field-password")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-credentials-unreadable")).toBeNull();
+ });
+
+ it("says so rather than rendering nothing", async () => {
+ // Callers introduce this panel as the details they are about to show, so
+ // a blank space where they should be reads as Dyad holding nothing.
+ h.revealCredentials.mockRejectedValue(new Error("keychain locked"));
+ render();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-credentials-unreadable")).toBeTruthy(),
+ );
+ });
+
+ it("stays silent when there is genuinely nothing stored", async () => {
+ // Signed out, or connected by pasting a token. Saying a read failed here
+ // would report a problem that did not happen.
+ h.revealCredentials.mockResolvedValue({ instance: null, server: null });
+ const { container } = render();
+
+ // Waiting for the call is not waiting for the answer, and a pending query
+ // renders nothing whatever this branch does. Settling first is what makes
+ // a wrong branch here visible.
+ await waitFor(() => expect(h.revealCredentials).toHaveBeenCalledTimes(1));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(screen.queryByTestId("coolify-credentials-unreadable")).toBeNull();
+ expect(container.textContent).toBe("");
+ });
+});
+
+describe("an instance Dyad did not set up", () => {
+ it("renders nothing rather than an empty heading", async () => {
+ // Connected by pasting a token: no account Dyad created, no address it
+ // chose. A panel of blanks would read as something having failed.
+ h.revealCredentials.mockResolvedValue({ instance: null, server: null });
+ const { container } = render();
+
+ await settle();
+ expect(screen.queryByTestId("coolify-credentials")).toBeNull();
+ expect(container.textContent).toBe("");
+ });
+
+ it("still shows a token the user pasted themselves", async () => {
+ h.revealCredentials.mockResolvedValue({
+ instance: { url: "https://coolify.example.com", apiToken: "1|theirs" },
+ server: null,
+ });
+ await renderAndSettle();
+
+ expect(screen.getByTestId("coolify-field-api-token")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-field-password")).toBeNull();
+ });
+});
diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx
new file mode 100644
index 0000000000..2e23bd3097
--- /dev/null
+++ b/src/components/CoolifyCredentials.tsx
@@ -0,0 +1,264 @@
+import { useEffect, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Eye, EyeOff, Copy, Check } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { ipc } from "@/ipc/types";
+import { showError } from "@/lib/toast";
+import { queryKeys } from "@/lib/queryKeys";
+
+/**
+ * The way into a server Dyad set up.
+ *
+ * Dyad invents the admin password and mints the API token, so it is the only
+ * thing that knows either. Without somewhere to read them, a machine the user
+ * owns has no way in they can see.
+ *
+ * Shown rather than hidden behind a control: these belong to the user, and
+ * making them click to discover that Dyad even has them means most people
+ * never find out. The values themselves stay masked until asked for, which is
+ * the part worth a click.
+ */
+
+function Field({
+ label,
+ value,
+ secret,
+ idPrefix,
+}: {
+ label: string;
+ value: string;
+ secret?: boolean;
+ /**
+ * Told apart from the same label in the other block, and only when both
+ * are on screen. Both carry an address, so an id naming just the label
+ * would be two things at once — which any lookup for it reaches
+ * ambiguously. One block alone has nothing to be confused with.
+ */
+ idPrefix?: string;
+}) {
+ const [shown, setShown] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const resetTimer = useRef>(undefined);
+ useEffect(() => () => clearTimeout(resetTimer.current), []);
+ const id = `${idPrefix ? `${idPrefix}-` : ""}${label
+ .toLowerCase()
+ .replace(/\s+/g, "-")}`;
+ return (
+
+ );
+}
+
+/**
+ * Said rather than left blank.
+ *
+ * readSettings drops a password it cannot decrypt and keeps the account, so
+ * the absence is Dyad holding one it cannot open — not a server that never
+ * had one. Left to the row simply not appearing, the two look identical, and
+ * only one of them makes signing out a safe thing to do next.
+ */
+function LockedPassword() {
+ return (
+
+ Dyad is holding an admin password for this server but cannot read it on
+ this machine.
+
+ );
+}
+
+export function CoolifyCredentials({
+ showTitle,
+}: { showTitle?: boolean } = {}) {
+ // Not held after this leaves the screen: these are the keys to the user's
+ // server, and there is no reason for them to sit in a cache once nothing is
+ // showing them.
+ const {
+ data: credentials,
+ isError,
+ isPending,
+ } = useQuery({
+ queryKey: queryKeys.coolify.credentials,
+ queryFn: () => ipc.coolifySetup.revealCredentials(),
+ gcTime: 0,
+ });
+
+ // Said rather than left blank, the same reason the failure below is said:
+ // callers introduce this panel as the details they are about to show, so a
+ // blank where those belong reads as Dyad holding nothing rather than as a
+ // read still going. Local and quick, which is why this is a line and not a
+ // skeleton — but quick is not instant on a cold start.
+ if (isPending && !isError) {
+ return (
+
+ Looking up what Dyad has stored…
+
+ );
+ }
+
+ // Only when there is nothing to show. A read that fails over details
+ // already in hand — the refetch on window focus, which production does not
+ // retry — still leaves them readable, and taking a password Dyad holds the
+ // only copy of off the screen to report the refresh would be the worse
+ // trade.
+ if (isError && !credentials) {
+ return (
+
+ Dyad could not read what it has stored for this Coolify.
+
+ );
+ }
+ if (!credentials) return null;
+
+ const { instance, server } = credentials;
+ // Nothing stored at all — signed out, or never connected. A panel of blanks
+ // would read as something having failed.
+ if (!instance && !server) return null;
+ // The usual case: Dyad set the server up and is connected to it. Merged on
+ // the address matching exactly, never on a guess at two spellings of one
+ // machine — guessing wrong the other way shows two blocks with two correct
+ // addresses, which is a moment's confusion rather than a wrong password.
+ const isOneServer =
+ instance !== null && server !== null && instance.url === server.url;
+ // Only when there are two blocks to tell apart. Over a lone block they name
+ // something nothing is being distinguished from.
+ const showsBoth = instance !== null && server !== null;
+
+ return (
+
+ {/* Kept inside so a caller cannot leave a heading over nothing when
+ there is nothing to show. */}
+ {showTitle && (
+
Your Coolify server
+ )}
+
+ {/* A password Dyad holds but cannot decrypt comes back absent, which
+ renders as a server that never had one — and the way that reads,
+ the obvious next move is the sign-out that discards it for good.
+ The dialog says this; nowhere else did. */}
+ {isOneServer ? (
+ <>
+
+
+ {server.password ? (
+
+ ) : (
+
+ )}
+ {instance.apiToken && (
+
+ )}
+ >
+ ) : (
+ <>
+ {server && (
+
+ );
+}
diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx
new file mode 100644
index 0000000000..98b418f61b
--- /dev/null
+++ b/src/components/CoolifyServerSetup.test.tsx
@@ -0,0 +1,1084 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { queryKeys } from "@/lib/queryKeys";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup";
+
+const toastMock = vi.hoisted(() => ({
+ warning: vi.fn(),
+ error: vi.fn(),
+ success: vi.fn(),
+ info: vi.fn(),
+}));
+vi.mock("sonner", () => ({ toast: toastMock }));
+vi.mock("@/lib/toast", () => ({ showError: h.showError }));
+
+const h = vi.hoisted(() => ({
+ showError: vi.fn(),
+ getServerKey: vi.fn(),
+ snapshot: vi.fn(),
+ dismiss: vi.fn(),
+ acceptInsecureToken: vi.fn(),
+ changedListeners: [] as Array<(state: unknown) => void>,
+ inspect: vi.fn(),
+ run: vi.fn(),
+ cancel: vi.fn(),
+}));
+
+/** What the main process says is going on, pushed as it would be in the app. */
+const IDLE = { type: "idle" } as const;
+function push(state: unknown) {
+ h.changedListeners.forEach((fn) => fn(state));
+}
+const runningState = (over: Record = {}) => ({
+ type: "running",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ step: "installing",
+ log: "",
+ stopping: false,
+ ...over,
+});
+
+vi.mock("@/ipc/types", () => ({
+ ipc: {
+ coolifySetup: {
+ getServerKey: h.getServerKey,
+ inspect: h.inspect,
+ run: h.run,
+ cancel: h.cancel,
+ snapshot: h.snapshot,
+ dismiss: h.dismiss,
+ acceptInsecureToken: h.acceptInsecureToken,
+ },
+ events: {
+ coolifySetup: {
+ onChanged: (listener: (state: unknown) => void) => {
+ h.changedListeners.push(listener);
+ return () => {};
+ },
+ },
+ },
+ },
+}));
+
+const { CoolifyServerSetup } = await import("./CoolifyServerSetup");
+
+function renderPanel(
+ onUseExisting = vi.fn(),
+ props: { heldServerUrl?: string | null } = {},
+) {
+ const client = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+ return {
+ invalidate,
+ ...render(
+
+
+
+
+ ,
+ ),
+ };
+}
+
+const PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA dyad-server-access";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ h.changedListeners.length = 0;
+ h.snapshot.mockResolvedValue(IDLE);
+ h.dismiss.mockResolvedValue(undefined);
+ h.acceptInsecureToken.mockResolvedValue(undefined);
+ h.getServerKey.mockResolvedValue({ publicKey: PUBLIC_KEY });
+ h.cancel.mockResolvedValue(undefined);
+ h.inspect.mockResolvedValue({
+ ready: true,
+ reason: null,
+ alreadyInstalled: false,
+ memoryMb: 1967,
+ hostFingerprint: "SHA256:3FQS9D0B0DVizoYtw1hNV09EClubwWqRUXoFnRTu6nA",
+ });
+});
+
+/** Install is offered only for a server Dyad has looked at. */
+async function checkServer(user: ReturnType) {
+ await user.click(screen.getByTestId("coolify-setup-inspect"));
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-inspection")).toBeTruthy(),
+ );
+}
+
+describe("the key the user has to install", () => {
+ it("shows it first, because nothing else can happen until it is added", async () => {
+ renderPanel();
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-public-key").textContent).toBe(
+ PUBLIC_KEY,
+ ),
+ );
+ });
+});
+
+describe("the admin address", () => {
+ it("warns about a domain Coolify will refuse, while it is being typed", async () => {
+ // Coolify resolves the domain when it seeds the account, so an address on
+ // a reserved domain fails after a multi-minute install rather than before.
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(
+ screen.getByTestId("coolify-setup-email"),
+ "admin@dyad.test",
+ );
+
+ expect(screen.getByText(/receive mail at/)).toBeTruthy();
+ });
+
+ it("says nothing about an ordinary address", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+
+ expect(screen.queryByText(/receive mail at/)).toBeNull();
+ });
+
+ it("will not start an install it knows the address fails", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(
+ screen.getByTestId("coolify-setup-email"),
+ "admin@dyad.test",
+ );
+ // Checked, so what refuses below is the address rather than the check the
+ // button is otherwise waiting for.
+ await checkServer(user);
+
+ expect(
+ screen.getByTestId("coolify-setup-install").hasAttribute("disabled"),
+ ).toBe(true);
+ });
+});
+
+describe("checking the server first", () => {
+ it("shows the fingerprint, so it can be compared before anything is sent", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.click(screen.getByTestId("coolify-setup-inspect"));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-inspection")).toBeTruthy(),
+ );
+ expect(screen.getByText(/SHA256:3FQS9D0B/)).toBeTruthy();
+ });
+
+ it("blocks the install when the server cannot take one", async () => {
+ h.inspect.mockResolvedValue({
+ ready: false,
+ reason: "This server already has Coolify on it.",
+ alreadyInstalled: true,
+ memoryMb: 1967,
+ hostFingerprint: null,
+ });
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await user.click(screen.getByTestId("coolify-setup-inspect"));
+
+ await waitFor(() =>
+ expect(screen.getByText(/already has Coolify/)).toBeTruthy(),
+ );
+ expect(
+ screen.getByTestId("coolify-setup-install").hasAttribute("disabled"),
+ ).toBe(true);
+ });
+});
+
+describe("while it runs", () => {
+ // The panel is a view of what the main process says is going on. It keeps
+ // none of this itself, so leaving the screen and coming back finds it again.
+
+ it("says which step it is on rather than only spinning", async () => {
+ // The install takes minutes; a spinner with no label is indistinguishable
+ // from a hang, and this is the screen people stare at.
+ h.snapshot.mockResolvedValue(runningState({ step: "installing" }));
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByText("Installing Coolify")).toBeTruthy(),
+ );
+ });
+
+ it("shows the installer's own output", async () => {
+ h.snapshot.mockResolvedValue(
+ runningState({ log: "3/6 Pulling Docker images..." }),
+ );
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-log").textContent).toContain(
+ "3/6 Pulling Docker images...",
+ ),
+ );
+ });
+
+ it("offers a way to stop", async () => {
+ h.snapshot.mockResolvedValue(runningState());
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-cancel")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-setup-cancel"));
+ expect(h.cancel).toHaveBeenCalled();
+ });
+
+ it("says it is stopping once asked, rather than offering again", async () => {
+ h.snapshot.mockResolvedValue(runningState({ stopping: true }));
+ renderPanel();
+
+ await waitFor(() =>
+ expect(
+ (screen.getByTestId("coolify-setup-cancel") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true),
+ );
+ });
+
+ it("shows a run this window did not start", async () => {
+ // The whole point of holding this in the main process: the install was
+ // started by another window, or by this one before it was replaced.
+ h.snapshot.mockResolvedValue(runningState({ step: "securing" }));
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-running")).toBeTruthy(),
+ );
+ expect(screen.getByText("Setting up HTTPS")).toBeTruthy();
+ });
+
+ it("keeps up as the run moves on", async () => {
+ renderPanel();
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-server-setup")).toBeTruthy(),
+ );
+
+ push(runningState({ step: "waiting-for-dashboard" }));
+
+ await waitFor(() =>
+ expect(screen.getByText("Waiting for Coolify to start")).toBeTruthy(),
+ );
+ });
+});
+
+describe("what it refuses before starting", () => {
+ it("says so while a domain is being typed, not minutes later", async () => {
+ // The same shape the installer refuses. Left to the guard, it arrives
+ // after the install as the reason HTTPS did not happen.
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await user.type(
+ screen.getByTestId("coolify-setup-domain"),
+ "coolify.example.com:8000",
+ );
+
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true);
+ });
+
+ it("will not install onto a server it has not looked at", async () => {
+ // The check is what puts the fingerprint in front of the user. Installing
+ // without it means trusting whatever answers the address with the admin
+ // password and a token, and never showing them what they trusted.
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true);
+
+ await checkServer(user);
+
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(false);
+ });
+
+ it("accepts an ordinary domain", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await user.type(
+ screen.getByTestId("coolify-setup-domain"),
+ "coolify.example.com",
+ );
+ await checkServer(user);
+
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(false);
+ });
+});
+
+describe("a re-check that does not finish", () => {
+ it("leaves no verdict behind, and no install to press", async () => {
+ // The main process keeps the last finished check's pass on purpose, so
+ // this is the only thing between a check that never came back and an
+ // install that sends the admin password to whatever answered.
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(false);
+
+ h.inspect.mockRejectedValueOnce(new Error("connection reset"));
+ await user.click(screen.getByTestId("coolify-setup-inspect"));
+
+ await waitFor(() =>
+ expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull(),
+ );
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true);
+ });
+});
+
+describe("an answer about a server the user has moved on from", () => {
+ it("does not show one machine's check against another's address", async () => {
+ // The answer arrives after a round trip. By then the address in the field
+ // may be a different machine, whose Install button this verdict would
+ // otherwise disable.
+ let answer!: (checks: unknown) => void;
+ h.inspect.mockReturnValue(
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+ const host = screen.getByTestId("coolify-setup-host");
+ await user.type(host, "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await user.click(screen.getByTestId("coolify-setup-inspect"));
+
+ await user.clear(host);
+ await user.type(host, "198.51.100.9");
+ answer({
+ ready: false,
+ reason: "This server already has Coolify on it.",
+ alreadyInstalled: true,
+ memoryMb: 1967,
+ hostFingerprint: "SHA256:aaa",
+ });
+
+ await waitFor(() => expect(h.inspect).toHaveBeenCalled());
+ // The verdict belonged to the address it was asked about. Shown here it
+ // would say this machine already has Coolify on it, which nobody checked.
+ expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull();
+ });
+});
+
+describe("catching up with a run this window did not start", () => {
+ it("is listening before it asks what is going on", async () => {
+ // The answer takes a round trip. A run that finishes inside it is only
+ // heard about if the listener was already there when the question left.
+ let listenersWhenAsked = -1;
+ h.snapshot.mockImplementation(async () => {
+ listenersWhenAsked = h.changedListeners.length;
+ return IDLE;
+ });
+ renderPanel();
+
+ await waitFor(() => expect(h.snapshot).toHaveBeenCalled());
+ expect(listenersWhenAsked).toBeGreaterThan(0);
+ });
+
+ it("does not let a late answer undo what it was told meanwhile", async () => {
+ // The read says "installing" because that was true when it was asked. By
+ // the time it lands the run is over, and letting it win would put the
+ // panel back on a step the run has left, under a Cancel button for
+ // something that is no longer going.
+ let answer: (state: unknown) => void = () => {};
+ h.snapshot.mockReturnValue(
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ renderPanel();
+
+ await waitFor(() => expect(h.changedListeners.length).toBeGreaterThan(0));
+ push({
+ type: "done",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ result: {
+ dashboardUrl: "https://203.0.113.5.sslip.io",
+ secure: true,
+ insecureReason: null,
+ adminEmail: "me@gmail.com",
+ adminPassword: "Abc123@xyz",
+ tokenStored: true,
+ apiEnabled: true,
+ tokenUnavailableReason: null,
+ version: "4.3.2",
+ },
+ });
+ answer(runningState());
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-setup-running")).toBeNull();
+ });
+});
+
+describe("when the panel cannot tell what is going on", () => {
+ it("says so, and asking again clears it", async () => {
+ // Install is disabled while this is unknown, and a disabled control with
+ // no explanation reads as broken.
+ h.snapshot.mockRejectedValue(new Error("no answer"));
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-snapshot-error")).toBeTruthy(),
+ );
+ expect(screen.getByTestId("coolify-setup-install")).toHaveProperty(
+ "disabled",
+ true,
+ );
+
+ // The way out has to work, not just be on screen.
+ h.snapshot.mockResolvedValue({ type: "idle" });
+ await user.click(screen.getByRole("button", { name: "Try again" }));
+
+ await waitFor(() =>
+ expect(screen.queryByTestId("coolify-setup-snapshot-error")).toBeNull(),
+ );
+ });
+});
+
+describe("a failure with nothing to show for it", () => {
+ it("can still be dismissed", async () => {
+ // Connection and preflight refusals never reach the installer, so they
+ // carry no output. Dismiss has to sit outside the log, or these failures
+ // would have no way to clear them.
+ h.snapshot.mockResolvedValue({
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Could not reach the server (ECONNREFUSED).",
+ log: "",
+ cancelled: false,
+ });
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-failed-message")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-setup-failed-log")).toBeNull();
+
+ await user.click(screen.getByTestId("coolify-setup-dismiss-failure"));
+ expect(h.dismiss).toHaveBeenCalled();
+ });
+});
+
+describe("a failure that will not go away", () => {
+ it("can be dismissed, since it follows the user everywhere", async () => {
+ // The state is the main process's, so the last failure shows in every
+ // app's panel and every window until something clears it.
+ h.snapshot.mockResolvedValue({
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Installing Coolify failed (exit 1).",
+ log: "dpkg: error",
+ cancelled: false,
+ });
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-dismiss-failure")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-setup-dismiss-failure"));
+ expect(h.dismiss).toHaveBeenCalled();
+ });
+});
+
+describe("pressing Install", () => {
+ it("does not offer a second press while the first is in flight", async () => {
+ // The panel still shows the form until the broadcast lands, and a second
+ // request is refused as a second setup.
+ h.run.mockReturnValue(new Promise(() => {}));
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ await user.click(screen.getByTestId("coolify-setup-install"));
+ // The press has to have landed, or what is disabled below is the button
+ // waiting for a check rather than the run it started.
+ expect(h.run).toHaveBeenCalledTimes(1);
+
+ await waitFor(() =>
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true),
+ );
+ });
+
+ it("does not offer an install the handler will refuse", async () => {
+ // A run that failed after the account was written leaves this panel up,
+ // and a fresh check would otherwise re-enable Install — for a press that
+ // can only come back as "sign out first".
+ const user = userEvent.setup();
+ renderPanel(vi.fn(), { heldServerUrl: "http://203.0.113.5:8000" });
+ await user.type(screen.getByTestId("coolify-setup-host"), "198.51.100.9");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true);
+ // And says why, next to the way out of it.
+ expect(
+ screen.getByTestId("coolify-setup-holds-account").textContent,
+ ).toContain("Sign out of Coolify");
+ });
+
+ it("will not install against a verdict a new check has replaced", async () => {
+ // Checking again is how the user reacts to changing the address or
+ // suspecting the machine moved. Until the new answer lands there is no
+ // fingerprint on screen to have agreed to, and installing would hand
+ // root and a fresh password to whatever now answers.
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(false);
+
+ // A second check that has not answered yet.
+ h.inspect.mockReturnValue(new Promise(() => {}));
+ await user.click(screen.getByTestId("coolify-setup-inspect"));
+
+ await waitFor(() =>
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true),
+ );
+ expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull();
+ });
+
+ it("says the press landed while the first connect is still going", async () => {
+ // Disabled on its own reads as the button having refused. The connect
+ // behind it can take seconds with nothing else on screen moving, and
+ // Check server already says so for the same wait.
+ h.run.mockReturnValue(new Promise(() => {}));
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ await user.click(screen.getByTestId("coolify-setup-install"));
+
+ await waitFor(() =>
+ expect(
+ screen
+ .getByTestId("coolify-setup-install")
+ .querySelector(".animate-spin"),
+ ).toBeTruthy(),
+ );
+ });
+
+ it("does not start when the key could not be read", async () => {
+ // The key is what the server trusts; without it the install cannot work,
+ // and the panel already says so.
+ h.getServerKey.mockRejectedValue(new Error("key file is corrupt"));
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ // Checked, so what is disabled below is the missing key rather than the
+ // check the button is otherwise waiting for.
+ await checkServer(user);
+
+ await waitFor(() =>
+ expect(
+ (screen.getByTestId("coolify-setup-install") as HTMLButtonElement)
+ .disabled,
+ ).toBe(true),
+ );
+ });
+});
+
+describe("the key the server needs", () => {
+ it("offers a way to try again when it cannot be read", async () => {
+ // Left as "Generating…" forever, there is nothing to read and nothing to
+ // press, and the whole screen depends on it.
+ h.getServerKey.mockRejectedValue(new Error("key file is corrupt"));
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy(),
+ );
+ });
+});
+
+describe("the way out of the installer", () => {
+ it("carries what the screen below adds, under the form", async () => {
+ // The way out for someone who already has Coolify lives there.
+ renderPanel();
+ expect(screen.getByTestId("beneath")).toBeTruthy();
+ });
+
+ it("drops it once the install is running", async () => {
+ // That screen has one thing left to say, and a link away from it is not
+ // it. Nothing here is a next step while the work is going on.
+ h.snapshot.mockResolvedValue(runningState());
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-running")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("beneath")).toBeNull();
+ });
+
+ it("asks for the admin email before the optional domain", async () => {
+ // A required field under an optional one reads as optional too.
+ renderPanel();
+ const text = screen.getByTestId("coolify-server-setup").textContent ?? "";
+ expect(text.indexOf("Email for the Coolify admin account")).toBeLessThan(
+ text.indexOf("Domain (optional)"),
+ );
+ });
+});
+
+describe("when the user stops it", () => {
+ it("does not report cancelling as a failure", async () => {
+ // Cancelling is the user asking for the work to stop. The installer's
+ // output under "What the server reported" reads as a fault.
+ h.snapshot.mockResolvedValue({
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Cancelled.",
+ log: "3/6 Pulling...",
+ cancelled: true,
+ });
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-server-setup")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-setup-failed-log")).toBeNull();
+ });
+
+ it("does not raise a red error for a cancel", async () => {
+ // The flow rethrows the cancellation, so it arrives here as a rejection —
+ // and reporting it says something went wrong while the screen says
+ // nothing did.
+ h.run.mockRejectedValue(
+ Object.assign(new DyadError("Cancelled.", DyadErrorKind.UserCancelled), {
+ code: SETUP_MACHINE_REPORTED,
+ }),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ await user.click(screen.getByTestId("coolify-setup-install"));
+
+ await waitFor(() => expect(h.run).toHaveBeenCalled());
+ expect(h.showError).not.toHaveBeenCalled();
+ });
+
+ it("says what the machine never saw, rather than swallowing it", async () => {
+ // The IPC layer turns down bad input before the handler is reached, so
+ // that error carries no mark and the machine has no state for it. Left
+ // unsaid, pressing Install would do nothing at all.
+ h.run.mockRejectedValue(
+ new DyadError(
+ "[coolify-setup:run] Invalid input",
+ DyadErrorKind.Validation,
+ ),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ await user.click(screen.getByTestId("coolify-setup-install"));
+
+ await waitFor(() => expect(h.showError).toHaveBeenCalled());
+ });
+
+ it("still reports a refusal to start", async () => {
+ // The shape the handler actually refuses with: nothing reached the
+ // machine, so the error carries no mark and the panel says it out loud.
+ h.run.mockRejectedValue(
+ new DyadError(
+ "A server is already being set up.",
+ DyadErrorKind.Precondition,
+ ),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ await user.click(screen.getByTestId("coolify-setup-install"));
+
+ await waitFor(() => expect(h.showError).toHaveBeenCalled());
+ });
+
+ it("leaves a failed install to the panel rather than saying it twice", async () => {
+ // The failure block carries the installer's own output; a toast beside it
+ // repeats the same event with less to show.
+ h.run.mockRejectedValue(
+ Object.assign(
+ new DyadError(
+ "Installing Coolify failed (exit 1).",
+ DyadErrorKind.External,
+ ),
+ { code: SETUP_MACHINE_REPORTED },
+ ),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+ await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5");
+ await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com");
+ await checkServer(user);
+ await user.click(screen.getByTestId("coolify-setup-install"));
+
+ await waitFor(() => expect(h.run).toHaveBeenCalled());
+ expect(h.showError).not.toHaveBeenCalled();
+ });
+
+ it("still shows what the server said about a real failure", async () => {
+ h.snapshot.mockResolvedValue({
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ message: "Installing Coolify failed (exit 1).",
+ log: "dpkg: error processing",
+ cancelled: false,
+ });
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-failed-log")).toBeTruthy(),
+ );
+ expect(
+ screen.getByTestId("coolify-setup-failed-log").textContent,
+ ).toContain("dpkg: error processing");
+ // And what went wrong, which the installer's output does not always say.
+ expect(
+ screen.getByTestId("coolify-setup-failed-message").textContent,
+ ).toContain("Installing Coolify failed");
+ });
+});
+
+describe("when it finishes", () => {
+ const DONE_RESULT = {
+ dashboardUrl: "https://203.0.113.5.sslip.io",
+ secure: true,
+ insecureReason: null,
+ adminEmail: "me@gmail.com",
+ adminPassword: "Abc123@xyz",
+ tokenStored: true,
+ // A token comes from a mint, and Dyad enables the API to reach one.
+ apiEnabled: true,
+ tokenUnavailableReason: null,
+ version: "4.3.2",
+ };
+
+ const doneState = (over: Record = {}) => ({
+ type: "done" as const,
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ result: { ...DONE_RESULT, ...over },
+ });
+
+ it("does not keep a token for an unencrypted address unless asked to", async () => {
+ // The token is held rather than written when the run ends, so continuing
+ // past the warning without a word leaves it unkept.
+ h.snapshot.mockResolvedValue(
+ doneState({ secure: false, insecureReason: "No certificate arrived." }),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-setup-continue"));
+
+ expect(h.acceptInsecureToken).not.toHaveBeenCalled();
+ });
+
+ it("stays put when the token could not be kept", async () => {
+ // Dismissing here would put the screen away having agreed to something
+ // that was never stored, and say nothing about why.
+ h.snapshot.mockResolvedValue(
+ doneState({ secure: false, insecureReason: "No certificate arrived." }),
+ );
+ h.acceptInsecureToken.mockRejectedValue(new Error("keychain locked"));
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-accept-insecure")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-setup-accept-insecure"));
+ await user.click(screen.getByTestId("coolify-setup-continue"));
+
+ expect(h.showError).toHaveBeenCalled();
+ expect(h.dismiss).not.toHaveBeenCalled();
+ });
+
+ it("says how to make a token by hand when the offered one is not kept", async () => {
+ // Leaving the box unticked drops the token, and this is the only place
+ // that says how to make one instead.
+ h.snapshot.mockResolvedValue(
+ doneState({ secure: false, insecureReason: "No certificate arrived." }),
+ );
+ renderPanel();
+
+ const panel = await waitFor(() =>
+ screen.getByTestId("coolify-setup-manual-token"),
+ );
+ expect(panel.textContent).toContain("Security → API Tokens");
+ // A token Dyad made and will drop is not one it could not make. Saying
+ // the latter here would contradict the offer to keep it, directly above.
+ expect(panel.textContent).toContain("Unless you tick the box above");
+ expect(panel.textContent).not.toContain("could not create");
+ // Minting the token turned the API on, so this is not still to do.
+ expect(panel.textContent).not.toContain("enable the API");
+ expect(screen.getByTestId("coolify-setup-done").textContent).toContain(
+ "It is not kept unless you say so",
+ );
+ });
+
+ it("keeps it when the address is agreed to", async () => {
+ h.snapshot.mockResolvedValue(
+ doneState({ secure: false, insecureReason: "No certificate arrived." }),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-accept-insecure")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-setup-accept-insecure"));
+ await user.click(screen.getByTestId("coolify-setup-continue"));
+
+ expect(h.acceptInsecureToken).toHaveBeenCalled();
+ });
+
+ it("asks nothing when the address is encrypted", async () => {
+ // Nothing crosses the network in the clear, so there is no decision.
+ h.snapshot.mockResolvedValue(doneState());
+ const user = userEvent.setup();
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-setup-accept-insecure")).toBeNull();
+ await user.click(screen.getByTestId("coolify-setup-continue"));
+
+ expect(h.acceptInsecureToken).not.toHaveBeenCalled();
+ });
+
+ it("shows the details, since this is the moment they are needed", async () => {
+ h.snapshot.mockResolvedValue(doneState({ tokenStored: false }));
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(),
+ );
+ expect(screen.getByTestId("coolify-setup-password").textContent).toBe(
+ "Abc123@xyz",
+ );
+ });
+
+ it("says nothing about encryption when the server got a certificate", async () => {
+ // Dyad asks for one and usually gets it, so a standing warning would be
+ // noise — and noise is what makes a real warning easy to miss.
+ h.snapshot.mockResolvedValue(doneState({ tokenStored: false }));
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(),
+ );
+ expect(screen.queryByTestId("coolify-setup-insecure")).toBeNull();
+ });
+
+ it("warns when it had to settle for plain HTTP", async () => {
+ h.snapshot.mockResolvedValue(
+ doneState({ secure: false, insecureReason: "No certificate arrived." }),
+ );
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-insecure")).toBeTruthy(),
+ );
+ });
+
+ it("says what is left to do when only the token step failed", async () => {
+ h.snapshot.mockResolvedValue(
+ doneState({
+ tokenStored: false,
+ apiEnabled: false,
+ tokenUnavailableReason: "too old",
+ }),
+ );
+ renderPanel();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-manual-token")).toBeTruthy(),
+ );
+ const panel = screen.getByTestId("coolify-setup-manual-token");
+ expect(panel.textContent).toContain("too old");
+ // Nothing was switched on, so switching it on is still to do.
+ expect(panel.textContent).toContain("enable the API");
+ });
+
+ it("points at the password it is asking to be copied", async () => {
+ // The one path where this screen holds the only copy: the install stood
+ // and the write did not. Sending the user the wrong way past it is how
+ // that copy gets lost.
+ h.snapshot.mockResolvedValue(
+ doneState({
+ tokenStored: false,
+ apiEnabled: true,
+ tokenUnavailableReason:
+ "Dyad could not save these details on this computer. Copy the " +
+ "password above before leaving this screen.",
+ }),
+ );
+ renderPanel();
+
+ const done = await waitFor(() => screen.getByTestId("coolify-setup-done"));
+ const text = done.textContent ?? "";
+ expect(text).toContain("Copy the password above");
+ // Above means above: the card carrying it is rendered over this panel.
+ expect(text.indexOf("Abc123@xyz")).toBeGreaterThan(-1);
+ expect(text.indexOf("Abc123@xyz")).toBeLessThan(
+ text.indexOf("Copy the password above"),
+ );
+ });
+
+ it("does not ask for the API step when the mint was what failed", async () => {
+ // Dyad turns the API on and then mints, so an account with no team, or a
+ // link that drops, leaves the API on and no token. Saying to go and
+ // enable it sends the user after something already done.
+ h.snapshot.mockResolvedValue(
+ doneState({
+ tokenStored: false,
+ apiEnabled: true,
+ tokenUnavailableReason: "This Coolify account has no team yet.",
+ }),
+ );
+ renderPanel();
+
+ const panel = await waitFor(() =>
+ screen.getByTestId("coolify-setup-manual-token"),
+ );
+ expect(panel.textContent).toContain("has no team yet");
+ expect(panel.textContent).toContain("Security → API Tokens");
+ expect(panel.textContent).not.toContain("enable the API");
+ });
+
+ it("puts the screen away and refreshes when the user moves on", async () => {
+ // The address and token are already stored, but every window is still
+ // holding the answer from before they were.
+ h.snapshot.mockResolvedValue(doneState());
+ const onUseExisting = vi.fn();
+ const user = userEvent.setup();
+ const { invalidate } = renderPanel(onUseExisting);
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-setup-continue"));
+
+ await waitFor(() => expect(h.dismiss).toHaveBeenCalled());
+ expect(invalidate).toHaveBeenCalledWith({
+ queryKey: queryKeys.coolify.all,
+ });
+ // Refreshed before the screen is put away: the other order hands the
+ // panel back to a connector that still believes there is no token, and
+ // the empty install form flashes up.
+ expect(invalidate.mock.invocationCallOrder[0]).toBeLessThan(
+ h.dismiss.mock.invocationCallOrder[0],
+ );
+ expect(onUseExisting).toHaveBeenCalledWith("https://203.0.113.5.sslip.io");
+ // And before the screen is cleared: dismissing first puts the machine
+ // back to idle while the panel above still believes there is nothing to
+ // enter, so the empty install form appears in between.
+ expect(onUseExisting.mock.invocationCallOrder[0]).toBeLessThan(
+ h.dismiss.mock.invocationCallOrder[0],
+ );
+ });
+});
diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx
new file mode 100644
index 0000000000..ef953e974b
--- /dev/null
+++ b/src/components/CoolifyServerSetup.tsx
@@ -0,0 +1,635 @@
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, Copy, Check, ServerCog } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ipc } from "@/ipc/types";
+import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup";
+import type {
+ SetupPreflight,
+ SetupResult,
+ SetupSnapshot,
+ SetupStep,
+} from "@/ipc/types";
+import { showError } from "@/lib/toast";
+import { queryKeys } from "@/lib/queryKeys";
+import { useCoolifySetupSnapshot } from "@/hooks/useCoolifySetupSnapshot";
+import { adminEmailRefusal } from "@/shared/coolify_admin_email";
+import { isPlausibleInstanceDomain } from "@/shared/coolify_domain";
+import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities";
+
+/**
+ * Setting up a server that has nothing on it yet.
+ *
+ * The order on screen is the order the work has to happen in, and the first
+ * step is the only manual one: nothing can reach the server until Dyad's key is
+ * on it. Everything after that is Dyad's job, and the panel's remaining work is
+ * to make a multi-minute install look like progress rather than a hang.
+ */
+
+const STEP_LABELS: Record = {
+ connecting: "Connecting to the server",
+ "checking-server": "Checking the server",
+ installing: "Installing Coolify",
+ "waiting-for-dashboard": "Waiting for Coolify to start",
+ "verifying-account": "Checking the admin account",
+ securing: "Setting up HTTPS",
+ "creating-token": "Setting up API access",
+ done: "Done",
+};
+
+function CopyButton({ value, label }: { value: string; label: string }) {
+ const [copied, setCopied] = useState(false);
+ const resetTimer = useRef>(undefined);
+ useEffect(() => () => clearTimeout(resetTimer.current), []);
+ return (
+ {
+ // A refused clipboard is worth saying out loud. This is often the
+ // only copy of a password Dyad invented, and a button that quietly
+ // does nothing reads as one that worked.
+ navigator.clipboard
+ .writeText(value)
+ .then(() => {
+ setCopied(true);
+ clearTimeout(resetTimer.current);
+ resetTimer.current = setTimeout(() => setCopied(false), 2000);
+ })
+ .catch(showError);
+ }}
+ aria-label={label}
+ >
+ {copied ? : }
+
+ );
+}
+
+export function CoolifyServerSetup({
+ onUseExisting,
+ heldServerUrl,
+ children,
+}: {
+ /**
+ * Leaves the installer for the screen that connects a Coolify that exists.
+ *
+ * Given the address when there is one, because after an install that could
+ * not mint a token the next screen asks for the address of the server just
+ * built — and leaving it blank means typing from memory.
+ */
+ onUseExisting: (instanceUrl?: string) => void;
+ /**
+ * The server Dyad already holds an account for, if it holds one.
+ *
+ * Installing is refused outright while it does — Dyad has the only copy of
+ * that password — so without this the button is live for a press that can
+ * only come back as an error.
+ */
+ heldServerUrl?: string | null;
+ /** Sits under the form. Not under the run or the result, which have their
+ own next step and nothing to add to. */
+ children?: ReactNode;
+}) {
+ const queryClient = useQueryClient();
+ const hostId = useId();
+ const emailId = useId();
+ const domainId = useId();
+ const [host, setHost] = useState("");
+ const [adminEmail, setAdminEmail] = useState("");
+ const [customDomain, setCustomDomain] = useState("");
+ // Unticked every time the screen appears: it is about the address this run
+ // ended on, not a preference that outlives it.
+ const [acceptedInsecureToken, setAcceptedInsecureToken] = useState(false);
+
+ // Kept with the address it was asked about. The answer arrives after a
+ // round trip, and by then the address in the field may be a different
+ // machine — whose Install button this verdict would then disable.
+ const [inspection, setInspection] = useState<{
+ host: string;
+ checks: SetupPreflight;
+ } | null>(null);
+ const inspectionForHost =
+ inspection && inspection.host === host.trim() ? inspection.checks : null;
+ const logRef = useRef(null);
+
+ const serverKey = useQuery({
+ queryKey: queryKeys.coolify.serverKey,
+ queryFn: () => ipc.coolifySetup.getServerKey(),
+ });
+ const publicKey = serverKey.data?.publicKey ?? null;
+
+ const snapshot = useCoolifySetupSnapshot();
+ const setup: SetupSnapshot = snapshot.data ?? { type: "idle" };
+ // What the machine allows, asked once and answered the same way the
+ // transition would. What the form allows — a usable address, a key that
+ // could be read — stays below with the fields it is about.
+ const can = selectCoolifySetupCapabilities(setup);
+
+ useEffect(() => {
+ logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
+ }, [setup]);
+
+ const inspect = useMutation({
+ mutationFn: async () => {
+ const asked = host.trim();
+ // Cleared before asking, so a check that fails leaves no verdict behind.
+ // The screen's premise is that Dyad installs onto the machine that just
+ // answered, and a pass from a previous answer contradicts it.
+ setInspection(null);
+ const checks = await ipc.coolifySetup.inspect({
+ host: asked,
+ username: "root",
+ });
+ setInspection({ host: asked, checks });
+ return checks;
+ },
+ onError: (error) => showError(error),
+ });
+
+ const run = useMutation({
+ mutationFn: () =>
+ ipc.coolifySetup.run({
+ host: host.trim(),
+ username: "root",
+ adminEmail,
+ customDomain: customDomain.trim() || undefined,
+ }),
+ // What became of the run is read from the snapshot, which every window
+ // gets. Nothing is done with the answer here: this window may not be the
+ // one still watching by the time it arrives. Only a refusal to start —
+ // one setup at a time — belongs to the caller.
+ onError: (error) => {
+ // Anything the machine took on is on screen already — a failure with
+ // the installer's own words under it, or a cancel that correctly says
+ // nothing went wrong — and it says so on the error. Everything else is
+ // shown, because an error nobody reports is a button that does nothing:
+ // a refusal that never started, and whatever the IPC layer turns down
+ // before the handler is reached.
+ const machineReported =
+ (error as { code?: string }).code === SETUP_MACHINE_REPORTED;
+ if (machineReported) return;
+ showError(error);
+ },
+ });
+
+ /** Puts the finished screen away and lets the panel behind catch up. */
+ const leaveResult = async (
+ instanceUrl?: string,
+ { acceptToken = false }: { acceptToken?: boolean } = {},
+ ) => {
+ // Before the queries are refreshed, so the panel behind sees a token that
+ // has been agreed to rather than one on its way to being.
+ if (acceptToken) {
+ // Not swallowed. Dismissing over a failure here would put the screen
+ // away having agreed to something that was never stored, and the panel
+ // behind would read as unconnected with nothing said about why.
+ try {
+ await ipc.coolifySetup.acceptInsecureToken();
+ } catch (error) {
+ showError(error);
+ return;
+ }
+ }
+ // Refreshed before the screen is put away. Dismissing first hands the
+ // panel back to a connector that still believes there is no token, so the
+ // empty install form flashes up before the right screen arrives.
+ //
+ // All of it, including the setup key, unlike the narrower predicate in
+ // useCoolifySetupSnapshot — deliberately, not by oversight. There the
+ // snapshot has just been pushed and re-reading it is how a finished run
+ // gets put back on a step it has left; here the screen is going away and
+ // the connector behind it needs everything current. Keep them apart.
+ await queryClient.invalidateQueries({ queryKey: queryKeys.coolify.all });
+ // Told where to go before the screen is cleared. Dismissing first puts the
+ // machine back to idle while the panel above still believes there is
+ // nothing to enter, so the empty install form appears in between.
+ onUseExisting(instanceUrl);
+ await ipc.coolifySetup.dismiss().catch(showError);
+ };
+
+ const emailRefusal = adminEmail ? adminEmailRefusal(adminEmail) : null;
+ const emailLooksUsable = !emailRefusal;
+ const domainLooksUsable =
+ !customDomain.trim() || isPlausibleInstanceDomain(customDomain);
+ // --- Finished ---
+ if (setup.type === "done") {
+ const result = setup.result;
+ return (
+
+
+
+ Coolify is installed
+
+ {/* Dyad keeps these, so this is a copy rather than the only sight of
+ them. Put here anyway: this is the moment they are needed. */}
+
+
+ Save these now, or find them again under the server list.
+
+
+ Address
+
+ {result.dashboardUrl}
+
+
+
+
+ Email
+
+ {result.adminEmail}
+
+
+
+
+ Password
+
+
+ {result.adminPassword}
+
+
+
+
+
+ {/* Only when it is true. Dyad asks for a certificate and usually gets
+ one, so a standing warning would be noise — and a warning nobody
+ sees when it matters is worse than one that appears only then. The
+ token here carries root abilities and travels on every deploy, not
+ once at setup. */}
+ {!result.secure && (
+
+
This server is not encrypted
+
+ {result.insecureReason} Dyad will still work, but its access token
+ crosses your network unencrypted every time it deploys. Adding a
+ domain that points at this server fixes it.
+
+ {/* A decision rather than a notice, and only where there is one to
+ make: a token was created, and keeping it is what puts it on
+ the network. Unticked to start, so continuing without reading
+ this leaves Dyad unconnected rather than connected over a
+ address nobody agreed to. */}
+ {result.tokenStored && (
+
+ )}
+
+ )}
+ {result.tokenStored && (
+
+ {result.secure
+ ? "Dyad created its own API token, so you can pick a server and project next."
+ : "Dyad created an API token for this server. It is not kept unless you say so above, because this address is not encrypted."}
+
+ )}
+ {(!result.tokenStored ||
+ (!result.secure && !acceptedInsecureToken)) && (
+ // The install stands; only the last step did not. Saying so plainly
+ // beats implying the whole thing failed. A token Dyad made but will
+ // not keep is not a token it failed to make, so the two say so
+ // differently.
+
+
One step left, in Coolify
+
+ {result.tokenStored
+ ? "Unless you tick the box above, Dyad forgets the token it made."
+ : (result.tokenUnavailableReason ??
+ "Dyad could not create an API token automatically.")}{" "}
+ Open {result.dashboardUrl}, sign in with the details above,{" "}
+ {/* Dyad turns the API on before it mints, so this stays done
+ even when the mint is what failed. */}
+ {!result.apiEnabled &&
+ "enable the API under Settings → Advanced, then "}
+ create a token under Security → API Tokens and paste it in on the
+ next screen.
+
+ Dyad allows you to self-host an instance of Coolify to deploy your apps.
+ To install it you need a Linux server with root access and about 2GB of
+ memory. Easiest if you have not created the server yet, since the key
+ below can go in at that point.
+
+
+ {/* First because nothing else can happen until it is done. */}
+
+
+ {/* The provider's web form comes first because it is the path that
+ needs no terminal: most hosts take an SSH key when the server is
+ created, so this whole step can happen in a browser. Leading with
+ authorized_keys made the easy route look like the footnote. */}
+
+ Easiest when creating the server: most hosts — DigitalOcean, Hetzner
+ and others — have an SSH keys field on the create
+ page. Paste this in there and the server will trust Dyad from the
+ moment it starts.
+
+
+ For a server that already exists, add it as a new line in{" "}
+ /root/.ssh/authorized_keys on the server.
+
+
+ setAdminEmail(e.target.value)}
+ />
+ {/* Checked while typing, because neither reason is cheap to find out
+ later: a domain Coolify will not take costs the whole install, and
+ an address Dyad cannot put in a shell word costs a run that
+ connects, looks the server over, and then fails. */}
+ {emailRefusal && (
+
+ {emailRefusal}
+
+ )}
+
+ This is the account you would sign in to Coolify with.
+
+
+
+
+
+ {/* Dyad can get a certificate without this, using a free service that
+ turns an address into a name. Someone with their own domain is
+ better off using it: it is theirs, and that free service shares one
+ certificate allowance between everyone who uses it. */}
+ setCustomDomain(e.target.value)}
+ />
+ {!domainLooksUsable && (
+
+ Use just the domain, with no port or path — for example
+ coolify.yourdomain.com.
+
+ )}
+
+ Point it at this server first. Leave blank and Dyad will set up HTTPS
+ using the server's address.
+
+
+
+ {/* Kept after a failure, not only during the run. The failure message
+ points at the installer's own output, and replacing the log with the
+ form the moment it fails leaves nothing to point at. */}
+ {/* One block, so Dismiss sits beside the message rather than inside the
+ log — a connection or preflight refusal carries no output, and would
+ otherwise have nothing to clear it. */}
+ {setup.type === "failed" && !setup.cancelled && (
+
+ {inspectionForHost.hostFingerprint && (
+ // Shown so someone who cares can compare it against their
+ // provider's console before Dyad sends anything to the machine.
+
+ Server fingerprint
+
+ {inspectionForHost.hostFingerprint}
+
+
+ Could not read what Dyad is doing with servers right now.{" "}
+ void snapshot.refetch()}
+ >
+ Try again
+
+
+ )}
+
+
+ inspect.mutate()}
+ data-testid="coolify-setup-inspect"
+ >
+ {inspect.isPending && (
+
+ )}
+ Check server
+
+ run.mutate()}
+ data-testid="coolify-setup-install"
+ >
+ {/* The gap between pressing this and the machine's first broadcast
+ is a slow SSH connect, and nothing else on screen moves for it.
+ Check server already says so; this said nothing. */}
+ {run.isPending && }
+ Install Coolify
+
+
+ {heldServerUrl && (
+
+ Dyad is holding the admin password for {heldServerUrl}, and it has the
+ only copy. Sign out of Coolify to set up another — that shows the
+ password one last time before forgetting it.
+
+ )}
+ {!inspectionForHost && host.trim() && (
+
+ Check the server first. Dyad shows you its fingerprint, and installs
+ only onto the machine that answered.
+
+ )}
+
+ {children}
+
+ );
+}
diff --git a/src/components/CoolifySignOutDialog.test.tsx b/src/components/CoolifySignOutDialog.test.tsx
new file mode 100644
index 0000000000..c521affd75
--- /dev/null
+++ b/src/components/CoolifySignOutDialog.test.tsx
@@ -0,0 +1,237 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/toast", () => ({ showError: vi.fn() }));
+
+const h = vi.hoisted(() => ({ revealCredentials: vi.fn() }));
+vi.mock("@/ipc/types", () => ({
+ ipc: { coolifySetup: { revealCredentials: h.revealCredentials } },
+}));
+
+const { queryKeys } = await import("@/lib/queryKeys");
+const { CoolifySignOutDialog: Dialog } = await import("./CoolifySignOutDialog");
+
+const FULL = {
+ instance: {
+ url: "https://203.0.113.5.sslip.io",
+ apiToken: "1|abcdefghijklmnop",
+ },
+ server: {
+ url: "https://203.0.113.5.sslip.io",
+ email: "me@gmail.com",
+ password: "Abc123@xyzAbc123@xyz",
+ },
+};
+
+const onConfirm = vi.fn();
+const onOpenChange = vi.fn();
+
+function Harness({ open }: { open: boolean }) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return (
+
+
+
+ );
+}
+
+function open(props: { open?: boolean } = {}) {
+ return render();
+}
+
+async function openAndSettle() {
+ const result = open();
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-sign-out-dialog")).toBeTruthy(),
+ );
+ return result;
+}
+
+function signOutButton() {
+ return screen.getByRole("button", { name: "Sign out" }) as HTMLButtonElement;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ h.revealCredentials.mockResolvedValue(FULL);
+});
+
+describe("acknowledging the loss", () => {
+ it("will not sign out until the box is ticked", async () => {
+ // The whole point of the dialog: the password below is about to go and
+ // Dyad has the only copy, so confirming has to be a separate act.
+ await openAndSettle();
+
+ expect(signOutButton().disabled).toBe(true);
+ });
+
+ it("signs out once it is", async () => {
+ const user = userEvent.setup();
+ await openAndSettle();
+
+ await user.click(screen.getByTestId("coolify-sign-out-acknowledge"));
+ await user.click(signOutButton());
+
+ expect(onConfirm).toHaveBeenCalled();
+ });
+
+ it("starts unticked again the next time it opens", async () => {
+ // Otherwise a tick from an earlier sign-out arms this one, and the last
+ // look at the password is skipped.
+ const user = userEvent.setup();
+ const { rerender } = await openAndSettle();
+ await user.click(screen.getByTestId("coolify-sign-out-acknowledge"));
+ await waitFor(() => expect(signOutButton().disabled).toBe(false));
+
+ rerender();
+ rerender();
+
+ await waitFor(() => expect(signOutButton().disabled).toBe(true));
+ });
+});
+
+describe("nothing to look at yet", () => {
+ it("will not sign out while it is still finding out", async () => {
+ // Ticking a box that says the details above have been saved, over a panel
+ // that has not shown any, is not the acknowledgement this asks for.
+ h.revealCredentials.mockReturnValue(new Promise(() => {}));
+ const user = userEvent.setup();
+ open();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-sign-out-dialog")).toBeTruthy(),
+ );
+ await user.click(screen.getByTestId("coolify-sign-out-acknowledge"));
+
+ expect(signOutButton().disabled).toBe(true);
+ });
+
+ it("says so when it cannot read what it has stored", async () => {
+ // Signing out still forgets them, so staying silent would destroy
+ // credentials the user was never shown.
+ h.revealCredentials.mockRejectedValue(new Error("keychain locked"));
+ open();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-sign-out-unreadable")).toBeTruthy(),
+ );
+ // Said once. The panel below reports the read; this only adds what it
+ // means for signing out, so both saying it reads as a stutter.
+ expect(
+ screen.queryAllByText(/could not read what it has stored/i),
+ ).toHaveLength(1);
+ // And said after it. "Anyway" contrasts with a failure, so meeting it
+ // first leaves the user contrasting with nothing.
+ const cause = screen.getByText(/could not read what it has stored/i);
+ const addendum = screen.getByTestId("coolify-sign-out-unreadable");
+ expect(
+ cause.compareDocumentPosition(addendum) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("keeps the details on screen when only a later read failed", async () => {
+ // The panel still has them, so it still shows them. Saying a read failed
+ // over a panel full of credentials leaves a sentence hanging off nothing,
+ // and this is the moment the user is asked to confirm they saved them.
+ const user = userEvent.setup();
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-field-password")).toBeTruthy(),
+ );
+
+ h.revealCredentials.mockRejectedValue(new Error("keychain locked"));
+ await client.refetchQueries({ queryKey: queryKeys.coolify.credentials });
+ await waitFor(() => expect(h.revealCredentials).toHaveBeenCalledTimes(2));
+
+ expect(screen.getByTestId("coolify-field-password")).toBeTruthy();
+ expect(screen.queryByTestId("coolify-sign-out-unreadable")).toBeNull();
+ // And the acknowledgement still means what it says.
+ await user.click(screen.getByTestId("coolify-sign-out-acknowledge"));
+ expect(signOutButton().disabled).toBe(false);
+ });
+
+ it("says it is looking only once", async () => {
+ // The panel below says it now. Saying it here too put the same sentence
+ // on screen twice, which is what the read-failure line was moved for.
+ h.revealCredentials.mockReturnValue(new Promise(() => {}));
+ open();
+
+ expect(
+ await screen.findAllByText(/Looking up what Dyad has stored/),
+ ).toHaveLength(1);
+ });
+
+ it("says when it holds a password it cannot read", async () => {
+ // The panel cannot show a row for a value it does not have, so a missing
+ // password would otherwise read as there never having been one.
+ h.revealCredentials.mockResolvedValue({
+ ...FULL,
+ server: { ...FULL.server, password: null },
+ });
+ open();
+
+ const addendum = await waitFor(() =>
+ screen.getByTestId("coolify-sign-out-locked-password"),
+ );
+ // Said once. The panel below states what Dyad is holding; this only adds
+ // what signing out does to it, so both saying it reads as a stutter.
+ expect(screen.queryAllByText(/holding an admin password/i)).toHaveLength(1);
+ // And said after it, for the same reason the read failure is.
+ const cause = screen.getByTestId("coolify-credentials-locked-password");
+ expect(
+ cause.compareDocumentPosition(addendum) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+});
+
+describe("the last look", () => {
+ it("shows what is about to be forgotten", async () => {
+ await openAndSettle();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("coolify-credentials")).toBeTruthy(),
+ );
+ expect(screen.getByTestId("coolify-field-address")).toBeTruthy();
+ expect(screen.getByTestId("coolify-field-password")).toBeTruthy();
+ });
+
+ it("says the password cannot be got back when there is one", async () => {
+ // Coolify can mint another token; it cannot tell anyone this password.
+ await openAndSettle();
+
+ await waitFor(() =>
+ expect(
+ screen.getByText(/only thing holding it/, { exact: false }),
+ ).toBeTruthy(),
+ );
+ });
+
+ it("does not say it for an instance Dyad did not set up", async () => {
+ // Connected by pasting a token, so nothing here was invented by Dyad and
+ // a warning about losing it forever would be untrue.
+ h.revealCredentials.mockResolvedValue({ ...FULL, server: null });
+ await openAndSettle();
+
+ await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled());
+ expect(screen.queryByText(/only thing holding it/)).toBeNull();
+ });
+
+ it("asks for nothing while it is closed", async () => {
+ open({ open: false });
+
+ expect(h.revealCredentials).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx
new file mode 100644
index 0000000000..e30f98de3b
--- /dev/null
+++ b/src/components/CoolifySignOutDialog.tsx
@@ -0,0 +1,130 @@
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Checkbox } from "@/components/ui/checkbox";
+import { CoolifyCredentials } from "@/components/CoolifyCredentials";
+import { ipc } from "@/ipc/types";
+import { queryKeys } from "@/lib/queryKeys";
+
+/**
+ * The last look at credentials Dyad is about to forget.
+ *
+ * Signing out clears the instance outright rather than keeping a copy around
+ * for an instance nothing is connected to. That is only fair if the user gets
+ * to take the details with them first, so they are on screen here with their
+ * copy buttons, behind an acknowledgement rather than a plain confirm.
+ */
+export function CoolifySignOutDialog({
+ open,
+ onOpenChange,
+ onConfirm,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+}) {
+ const [acknowledged, setAcknowledged] = useState(false);
+
+ // Ticked once is not ticked forever: every open starts from unticked, so
+ // the acknowledgement is about this sign-out. Keyed to `open` rather than
+ // done on the way out, because closing is not always the user's doing.
+ useEffect(() => {
+ if (!open) setAcknowledged(false);
+ }, [open]);
+
+ // Shares a key with the fields below, so opening this asks once. Only read
+ // here to decide whether the password gets a warning of its own.
+ const {
+ data: credentials,
+ isPending,
+ isError,
+ } = useQuery({
+ queryKey: queryKeys.coolify.credentials,
+ queryFn: () => ipc.coolifySetup.revealCredentials(),
+ gcTime: 0,
+ enabled: open,
+ });
+ // A tick over an empty panel is not the acknowledgement this asks for, so
+ // nothing can be confirmed until the read has answered. `enabled` leaves the
+ // query pending while the dialog is closed, which reads the same here.
+ const answered = !isPending || isError;
+ // What the panel below reports: a read that failed with nothing already in
+ // hand. A failed refetch over details it can still show is not one, and the
+ // line below would then hang off nothing.
+ const readFailed = isError && !credentials;
+ // Held but unreadable. The panel below names it; this adds only what
+ // signing out does to it, so the two read as one thought rather than as
+ // the same sentence twice.
+ const passwordIsLocked =
+ credentials?.server !== null &&
+ credentials?.server !== undefined &&
+ credentials.server.password === null;
+
+ return (
+
+
+
+ Sign out of Coolify?
+
+ Dyad will forget the details below. Your server keeps running and
+ your apps keep their settings.
+ {credentials?.server?.password
+ ? " Dyad made this password up and is the only thing holding it — Coolify cannot show it to you again."
+ : ""}
+
+
+
+
+
+ {passwordIsLocked && (
+
+ It goes when you sign out, unread.
+
+ )}
+
+ {readFailed && (
+
+ Signing out forgets it anyway.
+
+ )}
+
+
+
+
+ Cancel
+ {/* Closes as it fires — it is a Close underneath — so there is no
+ pending state to show here. The button that opened this stays on
+ screen and carries that. */}
+
+ Sign out
+
+
+
+
+ );
+}
diff --git a/src/coolify_setup/admin_credentials.test.ts b/src/coolify_setup/admin_credentials.test.ts
new file mode 100644
index 0000000000..1332e8c390
--- /dev/null
+++ b/src/coolify_setup/admin_credentials.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildAdminCredentials,
+ generateAdminPassword,
+ isShellSafe,
+} from "./admin_credentials";
+
+describe("generateAdminPassword", () => {
+ it("meets every rule Coolify checks", () => {
+ // Its validator wants length, mixed case, a digit and a symbol. Failing any
+ // of them leaves the install finished with no account on it.
+ for (let i = 0; i < 200; i++) {
+ const pw = generateAdminPassword();
+ expect(pw.length).toBe(24);
+ expect(pw).toMatch(/[A-Z]/);
+ expect(pw).toMatch(/[a-z]/);
+ expect(pw).toMatch(/[0-9]/);
+ expect(pw).toMatch(/[@%^*_\-+=]/);
+ }
+ });
+
+ it("never produces a character that would break out of its quoting", () => {
+ // The value crosses a shell assignment and lands in a .env file. A quote or
+ // a backslash ends the quoting; # starts a comment and would truncate it.
+ for (let i = 0; i < 200; i++) {
+ expect(isShellSafe(generateAdminPassword())).toBe(true);
+ }
+ });
+
+ it("does not always put the required characters first", () => {
+ // They are appended before shuffling, so a missing shuffle would leave the
+ // first four positions perfectly predictable.
+ const firstFour = new Set(
+ Array.from({ length: 60 }, () => generateAdminPassword().slice(0, 4)),
+ );
+ expect(firstFour.size).toBeGreaterThan(1);
+ });
+});
+
+describe("buildAdminCredentials", () => {
+ it("keeps the address it was given rather than inventing one", () => {
+ const creds = buildAdminCredentials(" someone@gmail.com ");
+ expect(creds.email).toBe("someone@gmail.com");
+ expect(creds.username).toBe("dyad-admin");
+ expect(isShellSafe(creds.password)).toBe(true);
+ });
+});
diff --git a/src/coolify_setup/admin_credentials.ts b/src/coolify_setup/admin_credentials.ts
new file mode 100644
index 0000000000..b33f6bf211
--- /dev/null
+++ b/src/coolify_setup/admin_credentials.ts
@@ -0,0 +1,77 @@
+import { randomInt } from "crypto";
+
+/**
+ * The admin account Coolify seeds itself with during installation.
+ *
+ * Coolify creates its first user from environment variables the installer
+ * writes, which is what lets Dyad set a server up without the user ever opening
+ * the dashboard. The values only take effect while no admin exists, so this
+ * cannot take over an instance somebody is already using.
+ */
+
+/**
+ * Excludes quote, backslash, backtick and $ so the value survives a shell, and
+ * # and ! because these land in a .env file, where # starts a comment and would
+ * silently truncate the password.
+ */
+const UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ";
+const LOWER = "abcdefghijkmnopqrstuvwxyz";
+const DIGITS = "23456789";
+const SYMBOLS = "@%^*_-+=";
+
+function pick(alphabet: string): string {
+ return alphabet[randomInt(alphabet.length)];
+}
+
+/**
+ * A password Coolify will accept.
+ *
+ * Its rule is at least eight characters with an upper case letter, a lower case
+ * letter, a digit and a symbol, and it additionally rejects passwords found in
+ * known breaches. A generated 24-character value clears both, though the breach
+ * check is a network call made by the server, so seeding needs the server to
+ * have outbound access.
+ */
+export function generateAdminPassword(length = 24): string {
+ const required = [pick(UPPER), pick(LOWER), pick(DIGITS), pick(SYMBOLS)];
+ const all = UPPER + LOWER + DIGITS + SYMBOLS;
+ const rest = Array.from(
+ { length: Math.max(0, length - required.length) },
+ () => pick(all),
+ );
+ const chars = [...required, ...rest];
+ // Fisher-Yates, so the four required characters are not always at the front.
+ for (let i = chars.length - 1; i > 0; i--) {
+ const j = randomInt(i + 1);
+ [chars[i], chars[j]] = [chars[j], chars[i]];
+ }
+ return chars.join("");
+}
+
+export interface AdminCredentials {
+ username: string;
+ email: string;
+ password: string;
+}
+
+/**
+ * Whether a value can be carried to the server without being escaped.
+ *
+ * These are written into a shell assignment and then into a .env file. Rather
+ * than escape them for each, values containing a character that would end the
+ * quoting are rejected outright — getting that subtly wrong runs arbitrary text
+ * as a command on somebody's server.
+ */
+export function isShellSafe(value: string): boolean {
+ return !/['"\\`$\n\r#!]/.test(value);
+}
+
+export function buildAdminCredentials(email: string): AdminCredentials {
+ return {
+ username: "dyad-admin",
+ // Asked for rather than invented, because the domain has to resolve and
+ // because this is the address the user signs in with afterwards.
+ email: email.trim(),
+ password: generateAdminPassword(),
+ };
+}
diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts
new file mode 100644
index 0000000000..52d0395983
--- /dev/null
+++ b/src/coolify_setup/api_token.test.ts
@@ -0,0 +1,296 @@
+import { describe, expect, it, vi } from "vitest";
+import { COOLIFY_SCOPES_PHP_ARRAY } from "@/shared/coolify_scopes";
+import {
+ compareVersions,
+ enableApi,
+ mintApiToken,
+ readCoolifyVersion,
+ supportsAutomaticToken,
+ tryAutomaticAccess,
+} from "./api_token";
+import { DyadErrorKind } from "@/errors/dyad_error";
+import { SshError } from "@/ipc/utils/ssh_client";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+
+/** Wraps a value the way a real tinker transcript carries it. */
+function transcript(output: string): string {
+ return [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ output,
+ "__DYAD_OUT_END__",
+ ].join("\n");
+}
+
+/** A token of the shape Sanctum actually returns. */
+const REAL_TOKEN = "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95";
+
+function fakeSession(replies: string[]): SshSession & {
+ scripts: string[];
+ commands: string[];
+} {
+ const scripts: string[] = [];
+ const commands: string[] = [];
+ let call = 0;
+ return {
+ scripts,
+ commands,
+ run: vi.fn(async (command: string, options?: { input?: string }) => {
+ commands.push(command);
+ scripts.push(options?.input ?? "");
+ const reply = replies[Math.min(call, replies.length - 1)];
+ call += 1;
+ return { code: 0, stdout: transcript(reply), stderr: "" };
+ }) as unknown as SshSession["run"],
+ end: vi.fn(),
+ };
+}
+
+describe("compareVersions", () => {
+ it("orders by number, not by text", () => {
+ // Compared as strings, "4.10.0" sorts below "4.9.0" and the automated path
+ // would quietly switch itself off on newer instances.
+ expect(compareVersions("4.10.0", "4.9.0")).toBe(1);
+ expect(compareVersions("4.3.2", "4.3.2")).toBe(0);
+ expect(compareVersions("4.3", "4.3.0")).toBe(0);
+ expect(compareVersions("3.9.9", "4.0.0")).toBe(-1);
+ });
+});
+
+describe("supportsAutomaticToken", () => {
+ it.each([
+ ["4.3.2", true],
+ ["4.0.0", true],
+ ["4.10.0", true],
+ ["3.9.0", false],
+ [null, false],
+ ])("reads %s as %s", (version, expected) => {
+ expect(supportsAutomaticToken(version)).toBe(expected);
+ });
+});
+
+describe("readCoolifyVersion", () => {
+ it("asks the instance, because the installer picks the version", async () => {
+ const session = fakeSession(["4.3.2"]);
+ expect(await readCoolifyVersion(session)).toBe("4.3.2");
+ });
+
+ it("reads the version past a notice on either side of it", async () => {
+ // The first of the five readers, and the one the rest are behind: a
+ // notice here costs the whole automated-token path on a server that
+ // answered correctly. After it, the notice would have been glued onto
+ // the version and stored as it.
+ expect(
+ await readCoolifyVersion(fakeSession(["PHP Deprecated: x\n4.3.2"])),
+ ).toBe("4.3.2");
+ expect(
+ await readCoolifyVersion(fakeSession(["4.3.2\nPHP Deprecated: x"])),
+ ).toBe("4.3.2");
+ });
+
+ it("says it could not read the version rather than that it is old", async () => {
+ // The installer always fetches the newest Coolify, so "too old" is the
+ // least likely thing to be true here — and the key this reads is
+ // Coolify's own, free to be renamed, which is the ordinary way to get an
+ // answer that is not a version. Saying the version is unsupported sends
+ // the user looking for a problem with a server they just installed.
+ const session = fakeSession(["Command not found"]);
+ await expect(readCoolifyVersion(session)).rejects.toThrow(
+ /could not read which version/,
+ );
+ });
+
+ it("does not call a lost link an unreadable version", async () => {
+ // Answering null here sends the caller down the path that tells the user
+ // their freshly installed Coolify is too old to drive, for a question
+ // that never reached it.
+ const session = {
+ run: vi.fn(async () => {
+ throw new SshError(
+ // The connection stopped answering, which is the case this tells
+ // apart from a bound Dyad set on one command.
+ "timeout",
+ "the connection stopped answering",
+ DyadErrorKind.External,
+ );
+ }),
+ end: vi.fn(),
+ } as unknown as SshSession;
+
+ await expect(readCoolifyVersion(session)).rejects.toBeInstanceOf(SshError);
+ });
+
+ it("says a slow answer was slow rather than calling the version old", async () => {
+ // The instance is reachable and simply did not answer in time, which on
+ // a small server right after an install is ordinary. Answered as null,
+ // the user is told the Coolify they just installed is too old to drive.
+ const session = {
+ run: vi.fn(async () => {
+ throw new SshError(
+ "command-timeout",
+ "timed out",
+ DyadErrorKind.External,
+ );
+ }),
+ end: vi.fn(),
+ } as unknown as SshSession;
+
+ await expect(readCoolifyVersion(session)).rejects.toThrow(
+ /did not answer in time/,
+ );
+ });
+
+ it("does not call a container still starting an unreadable version", async () => {
+ // No markers back means the script never ran — a container still coming
+ // up. Its own message says to wait, which is the useful thing to say.
+ const session = {
+ run: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })),
+ end: vi.fn(),
+ } as unknown as SshSession;
+
+ await expect(readCoolifyVersion(session)).rejects.toThrow(
+ /may still be starting/,
+ );
+ });
+});
+
+describe("enableApi", () => {
+ it("confirms the setting took rather than assuming the write worked", async () => {
+ const session = fakeSession(["enabled"]);
+ await expect(enableApi(session)).resolves.toBeUndefined();
+ expect(session.scripts[0]).toContain("is_api_enabled = true");
+ // Read back from the database rather than off the property just set:
+ // tinker carries on after a statement throws, so a save that never
+ // happened would otherwise still answer "enabled".
+ expect(session.scripts[0]).toContain("$ok = $s->save()");
+ expect(session.scripts[0]).toContain(
+ "$ok && \\App\\Models\\InstanceSettings::get()->is_api_enabled",
+ );
+ });
+
+ it("reads the answer past a notice Coolify printed", async () => {
+ // Three of the four readers required the whole region to be the answer,
+ // so one deprecation notice would have reported a working server broken.
+ const session = fakeSession(["PHP Deprecated: something\nenabled"]);
+ await expect(enableApi(session)).resolves.toBeUndefined();
+ });
+
+ it("still names a missing team past a notice", async () => {
+ // Read as the whole answer, a notice beside the sentinel turned "no team
+ // yet" into "no usable token" — the same words a real failure gets, and
+ // none of the reason.
+ const session = fakeSession(["PHP Deprecated: x\nno-team"]);
+ await expect(mintApiToken(session, "me@gmail.com")).rejects.toThrow(
+ /no team yet/,
+ );
+ });
+
+ it("fails when the setting did not take", async () => {
+ const session = fakeSession(["still-disabled"]);
+ await expect(enableApi(session)).rejects.toMatchObject({
+ kind: "external",
+ });
+ });
+});
+
+describe("mintApiToken", () => {
+ it("seeds a team into the session before creating the token", async () => {
+ // Not defensive: Coolify's createToken override stamps the row with
+ // session('currentTeam')->id, and tinker has no session, so the insert
+ // fails on a not-null team_id without this.
+ const session = fakeSession([REAL_TOKEN]);
+ await mintApiToken(session, "admin@gmail.com");
+ expect(session.scripts[0]).toContain("session(['currentTeam' => $team])");
+ });
+
+ it("asks for the scopes Dyad tells users to tick", async () => {
+ // Narrower tokens hide a server's private key id, which the deploy path
+ // reads to tell a stale key from one it simply cannot see.
+ const session = fakeSession([REAL_TOKEN]);
+ await mintApiToken(session, "admin@gmail.com");
+ // The same list the panel tells a user to tick and the 403 message names,
+ // so a token Dyad mints and one made by hand behave alike. Not root, which
+ // Coolify treats as a bypass of the ability check rather than a scope.
+ expect(session.scripts[0]).toContain(COOLIFY_SCOPES_PHP_ARRAY);
+ expect(session.scripts[0]).not.toContain("root");
+ });
+
+ it("keeps the address out of the script", async () => {
+ const session = fakeSession([REAL_TOKEN]);
+ await mintApiToken(session, "admin@gmail.com");
+ expect(session.scripts[0]).not.toContain("admin@gmail.com");
+ expect(session.commands[0]).toContain(
+ "-e DYAD_ADMIN_EMAIL='admin@gmail.com'",
+ );
+ });
+
+ it("uses no early return, which tinker cannot parse", async () => {
+ // A `return` at top level is a parse error, and a script that does not
+ // parse prints nothing at all — so the branch it guarded never reports.
+ const session = fakeSession([REAL_TOKEN]);
+ await mintApiToken(session, "admin@gmail.com");
+ expect(session.scripts[0]).not.toMatch(/\breturn\s*;/);
+ });
+
+ it("says so when the account does not exist", async () => {
+ const session = fakeSession(["no-user"]);
+ await expect(
+ mintApiToken(session, "nobody@gmail.com"),
+ ).rejects.toMatchObject({
+ kind: "precondition",
+ });
+ });
+
+ it("says so when the account has no team", async () => {
+ const session = fakeSession(["no-team"]);
+ await expect(
+ mintApiToken(session, "admin@gmail.com"),
+ ).rejects.toMatchObject({
+ kind: "precondition",
+ });
+ });
+
+ it("refuses anything that is not a token", async () => {
+ // A warning line stored as a credential fails much later, somewhere that
+ // cannot explain what went wrong.
+ const session = fakeSession(["PHP Warning: something"]);
+ await expect(
+ mintApiToken(session, "admin@gmail.com"),
+ ).rejects.toMatchObject({
+ kind: "external",
+ });
+ });
+
+ it("refuses a token name that could break out of its quoting", async () => {
+ const session = fakeSession([REAL_TOKEN]);
+ await expect(
+ mintApiToken(session, "admin@gmail.com", {
+ tokenName: "a', ['root'], null); //",
+ }),
+ ).rejects.toMatchObject({ kind: "internal" });
+ });
+});
+
+describe("tryAutomaticAccess", () => {
+ it("returns a token when the instance can be driven", async () => {
+ const session = fakeSession(["4.3.2", "enabled", REAL_TOKEN]);
+ const access = await tryAutomaticAccess(session, "admin@gmail.com");
+ expect(access?.token).toBe(REAL_TOKEN);
+ expect(access?.version).toBe("4.3.2");
+ });
+
+ it("declines quietly on an instance it does not know", async () => {
+ // Not a failure: an instance Dyad did not install is the ordinary case,
+ // and the caller asks for a token by hand instead.
+ const session = fakeSession(["3.1.0"]);
+ expect(await tryAutomaticAccess(session, "admin@gmail.com")).toBeNull();
+ });
+
+ it("does not try to enable the API on an instance it declined", async () => {
+ const session = fakeSession(["3.1.0"]);
+ await tryAutomaticAccess(session, "admin@gmail.com");
+ expect(session.scripts.some((s) => s.includes("is_api_enabled"))).toBe(
+ false,
+ );
+ });
+});
diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts
new file mode 100644
index 0000000000..0aebf4c979
--- /dev/null
+++ b/src/coolify_setup/api_token.ts
@@ -0,0 +1,274 @@
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { COOLIFY_SCOPES_PHP_ARRAY } from "@/shared/coolify_scopes";
+import { SshError } from "@/ipc/utils/ssh_client";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+import { answerLine, runTinker } from "./tinker";
+
+/**
+ * How long one of these questions may take.
+ *
+ * Each is a tinker one-liner against a Coolify that is already up, so the
+ * honest answer arrives in seconds. Unbounded, a wedged docker leaves
+ * "Setting up API access" on screen forever with nothing behind it.
+ */
+const TINKER_TIMEOUT_MS = 30_000;
+
+/**
+ * Turning Coolify's API on and creating a token for it, without the dashboard.
+ *
+ * Coolify ships with its API off and offers no way to change that or to create
+ * a token except by hand in the browser. So both are done by driving Laravel
+ * and Sanctum directly, which is not an interface anybody promised to keep.
+ * Every workaround below is marked, and each says what would replace it.
+ *
+ * All of it is best-effort. When any part of it does not work the caller falls
+ * back to asking the user for a token, which is the path Dyad has always had.
+ */
+
+/**
+ * The last version this was checked against.
+ *
+ * Not a maximum. The path is attempted on anything at or above the version it
+ * was written for and simply reports failure otherwise — refusing outright
+ * would turn a working instance into a manual one for no reason, and the
+ * fallback already covers being wrong.
+ */
+export const VERIFIED_AGAINST = "4.3.2";
+
+/** Coolify's API was off by default when this was written. */
+const MINIMUM_SUPPORTED = "4.0.0";
+
+/**
+ * Compares dotted versions numerically.
+ *
+ * String comparison puts 4.10.0 before 4.9.0, which would silently disable the
+ * automated path on newer instances.
+ */
+export function compareVersions(a: string, b: string): number {
+ const parse = (v: string) =>
+ v
+ .trim()
+ .split(".")
+ .map((part) => Number.parseInt(part, 10) || 0);
+ const left = parse(a);
+ const right = parse(b);
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
+ if (diff !== 0) return diff > 0 ? 1 : -1;
+ }
+ return 0;
+}
+
+/**
+ * Reads the version of the Coolify that was just installed.
+ *
+ * Read rather than chosen: the installer resolves "latest" when it runs, so
+ * there is no version to pin at install time and the only way to know what
+ * landed is to ask it afterwards.
+ *
+ * WORKAROUND: the version endpoint needs the API, and whether the API can be
+ * turned on is what this answers, so it reads the config value directly.
+ *
+ * TODO: replace if a fresh Coolify gains a way to state its version before its
+ * API is reachable.
+ */
+export async function readCoolifyVersion(
+ session: SshSession,
+ { signal }: { signal?: AbortSignal } = {},
+): Promise {
+ try {
+ const output = await runTinker(
+ session,
+ `echo config('constants.coolify.version');`,
+ { signal, timeoutMs: TINKER_TIMEOUT_MS },
+ );
+ const said = answerLine(output, (line) => /^\d+\.\d+/.test(line));
+ if (said) return said;
+ // It answered, and what it said was not a version. Null would send the
+ // caller down the path that reports a Coolify too old to drive — and the
+ // installer always fetches the newest one, so that is the least likely
+ // thing to be true. The key this reads is Coolify's own and free to move,
+ // which is the ordinary way to arrive here.
+ throw new DyadError(
+ "Dyad could not read which version of Coolify this is, so it could not " +
+ "set up an API token by itself. The server is installed — open it " +
+ "and make a token there.",
+ DyadErrorKind.External,
+ );
+ } catch (error) {
+ // A cancelled setup is the user stopping, not an instance that cannot be
+ // driven. Swallowing it here would report a version problem for something
+ // they did on purpose, and carry on setting the server up.
+ if ((error as { kind?: string }).kind === "user_cancelled") throw error;
+ // Every way of not getting an answer says so as itself. Reported as a
+ // version this instance does not have, the user is sent looking for a
+ // problem with a server that is minutes old — so the only thing that
+ // returns from here is a version it actually read.
+ if (error instanceof SshError && error.failure === "command-timeout") {
+ // Reachable and simply slow, which on a small server right after an
+ // install is ordinary. Worth saying as itself: the version is not the
+ // problem, and there is nothing to fix by finding a newer Coolify.
+ throw new DyadError(
+ "Coolify did not answer in time when Dyad asked which version it " +
+ "is. It may still be starting up — open it and connect with a " +
+ "token once it does.",
+ DyadErrorKind.External,
+ );
+ }
+ throw error;
+ }
+}
+
+export function supportsAutomaticToken(version: string | null): boolean {
+ if (!version) return false;
+ return compareVersions(version, MINIMUM_SUPPORTED) >= 0;
+}
+
+/**
+ * Turns the API on.
+ *
+ * WORKAROUND: Coolify has a migration named disable_api_by_default and no
+ * setting, command or endpoint to reverse it, so this writes the model field
+ * the dashboard's own toggle writes.
+ *
+ * TODO: replace with whatever Coolify offers if it ever gains a way to enable
+ * the API at install time — an installer environment variable would be the
+ * natural shape, and would make this function unnecessary rather than smaller.
+ */
+export async function enableApi(
+ session: SshSession,
+ { signal }: { signal?: AbortSignal } = {},
+): Promise {
+ const output = await runTinker(
+ session,
+ [
+ `$s = \\App\\Models\\InstanceSettings::get();`,
+ `$s->is_api_enabled = true;`,
+ `$ok = $s->save();`,
+ // Read back rather than read off what was just assigned. Tinker keeps
+ // going after a statement throws, so echoing the property would say
+ // "enabled" for a save that never happened — and the screen would then
+ // leave out the one step the user still had to do by hand.
+ `echo $ok && \\App\\Models\\InstanceSettings::get()->is_api_enabled ? 'enabled' : 'still-disabled';`,
+ ].join("\n"),
+ { signal, timeoutMs: TINKER_TIMEOUT_MS },
+ );
+ // One line of the answer, not the whole of it: Coolify prints its own
+ // notices between the markers.
+ if (!answerLine(output, (line) => line === "enabled")) {
+ throw new DyadError(
+ "Could not turn Coolify's API on automatically.",
+ DyadErrorKind.External,
+ );
+ }
+}
+
+/**
+ * Creates an API token for Dyad.
+ *
+ * WORKAROUND, in two parts.
+ *
+ * There is no artisan command that mints a token, so this calls Sanctum's
+ * createToken directly. And Coolify overrides createToken to stamp the row with
+ * `session('currentTeam')->id`; tinker has no session, so without seeding one
+ * the insert fails on a not-null constraint against team_id. The session line
+ * is doing real work, not defensive coding.
+ *
+ * TODO: replace both halves if Coolify gains a token-minting command or an
+ * endpoint that does not need a browser session.
+ */
+
+export async function mintApiToken(
+ session: SshSession,
+ adminEmail: string,
+ {
+ signal,
+ tokenName = "dyad",
+ }: { signal?: AbortSignal; tokenName?: string } = {},
+): Promise {
+ if (!/^[A-Za-z0-9 _-]{1,40}$/.test(tokenName)) {
+ throw new DyadError(
+ `Unsafe token name: ${tokenName}`,
+ DyadErrorKind.Internal,
+ );
+ }
+ const output = await runTinker(
+ session,
+ // One statement per line and no early returns: tinker evaluates what it is
+ // fed as top-level code, where `return` is a parse error — and a script
+ // that does not parse produces no output at all rather than the branch it
+ // was meant to take.
+ [
+ `$u = \\App\\Models\\User::where('email', getenv('DYAD_ADMIN_EMAIL'))->first();`,
+ `$team = $u ? $u->teams()->first() : null;`,
+ // The session line is doing real work: Coolify's createToken override
+ // reads the team from a session tinker does not otherwise have, and the
+ // insert fails on team_id without it.
+ `if ($team) { session(['currentTeam' => $team]); }`,
+ `echo !$u ? 'no-user' : (!$team ? 'no-team' : $u->createToken('${tokenName}', ${COOLIFY_SCOPES_PHP_ARRAY}, null)->plainTextToken);`,
+ ].join("\n"),
+ {
+ env: { DYAD_ADMIN_EMAIL: adminEmail },
+ signal,
+ timeoutMs: TINKER_TIMEOUT_MS,
+ },
+ );
+
+ const token = output;
+ if (answerLine(token, (line) => line === "no-user")) {
+ throw new DyadError(
+ "Coolify has no account for this address, so no token could be created.",
+ DyadErrorKind.Precondition,
+ );
+ }
+ if (answerLine(token, (line) => line === "no-team")) {
+ throw new DyadError(
+ "Coolify's admin account has no team yet, so no token could be created.",
+ DyadErrorKind.Precondition,
+ );
+ }
+ // Sanctum's plain text token is `|<40+ characters>`. Read as one line
+ // among any others, so a notice beside it does not lose the token — and
+ // checked for shape, so a notice is never stored as one.
+ const minted = answerLine(token, (line) =>
+ /^\d+\|[A-Za-z0-9]{40,}$/.test(line),
+ );
+ if (!minted) {
+ throw new DyadError(
+ "Coolify did not return a usable API token.",
+ DyadErrorKind.External,
+ );
+ }
+ return minted;
+}
+
+export interface AutomaticAccess {
+ token: string;
+ version: string | null;
+}
+
+/**
+ * Enables the API and mints a token, or reports that it could not.
+ *
+ * Returns null rather than throwing when the instance is one this cannot drive,
+ * because that is not a failure — it is the ordinary case of an instance Dyad
+ * did not install, and the caller asks for a token by hand instead.
+ */
+export async function tryAutomaticAccess(
+ session: SshSession,
+ adminEmail: string,
+ {
+ signal,
+ onApiEnabled,
+ }: { signal?: AbortSignal; onApiEnabled?: () => void } = {},
+): Promise {
+ const version = await readCoolifyVersion(session, { signal });
+ if (!supportsAutomaticToken(version)) return null;
+ await enableApi(session, { signal });
+ // Said here rather than inferred from the token below, because minting is
+ // its own step and can fail on its own — an account with no team, a link
+ // that drops — long after this one has taken effect on the server.
+ onApiEnabled?.();
+ const token = await mintApiToken(session, adminEmail, { signal });
+ return { token, version };
+}
diff --git a/src/coolify_setup/capabilities.test.ts b/src/coolify_setup/capabilities.test.ts
new file mode 100644
index 0000000000..91ef778d2b
--- /dev/null
+++ b/src/coolify_setup/capabilities.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+import { assertCapabilityTransitionConsistency } from "@/state_machines/testing";
+import { selectCoolifySetupCapabilities } from "./capabilities";
+import { coolifySetupTransition } from "./transition";
+import { IDLE, type CoolifySetupState } from "./state";
+import type { SetupResult } from "@/ipc/types/coolify_setup";
+
+const REF = {
+ kind: "coolify-setup" as const,
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+};
+
+const OTHER = {
+ kind: "coolify-setup" as const,
+ entityKey: "198.51.100.9",
+ operationId: "op-9",
+};
+
+const running = (stopping = false): CoolifySetupState => ({
+ type: "running",
+ host: "203.0.113.5",
+ invocationRef: REF,
+ step: "installing",
+ log: "",
+ stopping,
+});
+
+const RESULT: SetupResult = {
+ dashboardUrl: "https://203.0.113.5.sslip.io",
+ secure: true,
+ insecureReason: null,
+ adminEmail: "me@gmail.com",
+ adminPassword: "Abc123@xyz",
+ tokenStored: true,
+ apiEnabled: true,
+ tokenUnavailableReason: null,
+ version: "4.3.2",
+};
+
+const done = (): CoolifySetupState => ({
+ type: "done",
+ host: "203.0.113.5",
+ invocationRef: REF,
+ result: RESULT,
+});
+
+const failed = (): CoolifySetupState => ({
+ type: "failed",
+ host: "203.0.113.5",
+ invocationRef: REF,
+ message: "boom",
+ log: "output",
+ cancelled: false,
+});
+
+describe("what the panel may offer", () => {
+ it("offers a start when nothing is going on", () => {
+ expect(selectCoolifySetupCapabilities(IDLE)).toMatchObject({
+ canStart: true,
+ canCancel: false,
+ });
+ });
+
+ it("refuses a second start, which is what the machine would say anyway", () => {
+ // Offering it means the answer to pressing it is an error rather than an
+ // install, and the transition already refuses with "already-running".
+ expect(selectCoolifySetupCapabilities(running()).canStart).toBe(false);
+ });
+
+ it("offers a cancel only while there is something to stop", () => {
+ expect(selectCoolifySetupCapabilities(running()).canCancel).toBe(true);
+ expect(selectCoolifySetupCapabilities(IDLE).canCancel).toBe(false);
+ });
+
+ it("stops offering a cancel once one has been asked for", () => {
+ expect(selectCoolifySetupCapabilities(running(true)).canCancel).toBe(false);
+ });
+});
+
+describe("against what the machine would actually do", () => {
+ it("keeps every enabled control consistent with the transition", () => {
+ // Two statements of one rule, in two files. Offering a control the
+ // machine refuses turns a button into an error message; refusing one it
+ // would take strands the user on a screen with nothing to press. The
+ // gate that guards installing again reads this selector too, so drift
+ // here is not only cosmetic.
+ expect(() =>
+ assertCapabilityTransitionConsistency({
+ states: [IDLE, running(), running(true), done(), failed()],
+ selectCapabilities: selectCoolifySetupCapabilities,
+ transition: coolifySetupTransition,
+ cases: {
+ canStart: {
+ representativeEvents: () => ({
+ valid: [
+ {
+ type: "start-requested" as const,
+ invocationRef: OTHER,
+ target: {
+ host: "198.51.100.9",
+ username: "root",
+ adminEmail: "me@gmail.com",
+ },
+ },
+ ],
+ }),
+ disabledReason: "already-running",
+ },
+ canCancel: {
+ representativeEvents: () => ({
+ valid: [{ type: "cancel-requested" as const }],
+ }),
+ },
+ },
+ }),
+ ).not.toThrow();
+ });
+});
diff --git a/src/coolify_setup/capabilities.ts b/src/coolify_setup/capabilities.ts
new file mode 100644
index 0000000000..a7cc03f5cd
--- /dev/null
+++ b/src/coolify_setup/capabilities.ts
@@ -0,0 +1,32 @@
+import type { CoolifySetupState } from "./state";
+
+export interface CoolifySetupCapabilities {
+ /**
+ * Whether an install may be started.
+ *
+ * False while one is going, because the machine refuses a second one —
+ * offering the button anyway means the answer to pressing it is an error
+ * message rather than an install.
+ */
+ readonly canStart: boolean;
+ /** Whether there is something to stop. */
+ readonly canCancel: boolean;
+}
+
+/**
+ * Pure domain policy for the controls on the setup panel.
+ *
+ * Only what the state decides. Whether the address looks like an address, or
+ * whether the key could be read, are questions about the form in front of the
+ * user and stay with it — this answers the questions the machine owns, and
+ * answers them the same way the transition does.
+ */
+export function selectCoolifySetupCapabilities(
+ state: CoolifySetupState,
+): CoolifySetupCapabilities {
+ const isRunning = state.type === "running";
+ return {
+ canStart: !isRunning,
+ canCancel: isRunning && !state.stopping,
+ };
+}
diff --git a/src/coolify_setup/controller.test.ts b/src/coolify_setup/controller.test.ts
new file mode 100644
index 0000000000..133ed1b4a9
--- /dev/null
+++ b/src/coolify_setup/controller.test.ts
@@ -0,0 +1,355 @@
+import { describe, expect, it, vi } from "vitest";
+import { CoolifySetupController } from "./controller";
+import type { CoolifySetupState } from "./state";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import type { SetupResult, SetupStep, SetupTarget } from "@/ipc/types";
+
+const TARGET: SetupTarget = {
+ host: "203.0.113.5",
+ username: "root",
+ adminEmail: "me@gmail.com",
+};
+
+const RESULT: SetupResult = {
+ dashboardUrl: "https://203.0.113.5.sslip.io",
+ secure: true,
+ insecureReason: null,
+ adminEmail: "me@gmail.com",
+ adminPassword: "Abc123@xyz",
+ tokenStored: true,
+ apiEnabled: true,
+ tokenUnavailableReason: null,
+ version: "4.3.2",
+};
+
+/** Deterministic, because a machine keyed on identity must be reproducible. */
+function ids() {
+ let n = 0;
+ return { next: () => `op-${++n}` };
+}
+
+function harness(
+ execute: CoolifySetupController extends never
+ ? never
+ : ConstructorParameters[0]["execute"],
+) {
+ const states: CoolifySetupState[] = [];
+ const controller = new CoolifySetupController({
+ execute,
+ ids: ids(),
+ onChanged: (state) => states.push(state),
+ });
+ return { controller, states };
+}
+
+/** A run the test finishes by hand. */
+function deferred() {
+ let resolve!: (value: SetupResult) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+describe("starting", () => {
+ it("reports what is happening, so a panel can be a view of it", async () => {
+ const gate = deferred();
+ let report!: (step: SetupStep, output?: string) => void;
+ const { controller } = harness(async (_t, hooks) => {
+ report = hooks.onProgress;
+ return gate.promise;
+ });
+
+ controller.start(TARGET);
+ expect(controller.getState()).toMatchObject({
+ type: "running",
+ host: "203.0.113.5",
+ step: "connecting",
+ });
+
+ report("installing", "3/6 Pulling...");
+ expect(controller.getState()).toMatchObject({
+ step: "installing",
+ log: "3/6 Pulling...",
+ });
+
+ gate.resolve(RESULT);
+ await controller.getState();
+ });
+
+ it("refuses a second setup while one is running", async () => {
+ const gate = deferred();
+ const { controller } = harness(async () => gate.promise);
+ controller.start(TARGET);
+
+ expect(() => controller.start({ ...TARGET, host: "198.51.100.9" })).toThrow(
+ /already being set up/,
+ );
+ gate.resolve(RESULT);
+ });
+
+ it("lets another start once the first has finished", async () => {
+ const first = deferred();
+ let calls = 0;
+ const { controller } = harness(async () => {
+ calls += 1;
+ return calls === 1 ? first.promise : RESULT;
+ });
+
+ const run = controller.start(TARGET);
+ first.resolve(RESULT);
+ await run.result;
+ controller.dismiss();
+
+ expect(() => controller.start(TARGET)).not.toThrow();
+ });
+});
+
+describe("finishing", () => {
+ it("keeps the result where a panel can find it after the fact", async () => {
+ const { controller } = harness(async () => RESULT);
+ const run = controller.start(TARGET);
+ await run.result;
+
+ expect(controller.getState()).toMatchObject({ type: "done" });
+ });
+
+ it("hands the caller what the work produced", async () => {
+ const { controller } = harness(async () => RESULT);
+ expect(await controller.start(TARGET).result).toEqual(RESULT);
+ });
+
+ it("keeps a failure and the output that explains it", async () => {
+ const { controller } = harness(async (_t, hooks) => {
+ hooks.onProgress("installing", "3/6 Pulling...");
+ throw new DyadError("exit 1", DyadErrorKind.External);
+ });
+
+ await expect(controller.start(TARGET).result).rejects.toThrow("exit 1");
+ expect(controller.getState()).toMatchObject({
+ type: "failed",
+ message: "exit 1",
+ log: "3/6 Pulling...",
+ cancelled: false,
+ });
+ });
+
+ it("marks a cancellation as one rather than as a fault", async () => {
+ const { controller } = harness(async (_t, hooks) => {
+ await new Promise((resolve) =>
+ hooks.signal.addEventListener("abort", () => resolve()),
+ );
+ throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled);
+ });
+
+ const run = controller.start(TARGET);
+ controller.cancel();
+ await expect(run.result).rejects.toThrow("Cancelled.");
+ expect(controller.getState()).toMatchObject({
+ type: "failed",
+ cancelled: true,
+ });
+ });
+});
+
+describe("cancelling", () => {
+ it("aborts the work rather than only saying so", async () => {
+ let aborted = false;
+ const gate = deferred();
+ const { controller } = harness(async (_t, hooks) => {
+ hooks.signal.addEventListener("abort", () => {
+ aborted = true;
+ });
+ return gate.promise;
+ });
+
+ controller.start(TARGET);
+ controller.cancel();
+
+ expect(aborted).toBe(true);
+ expect(controller.getState()).toMatchObject({ stopping: true });
+ gate.resolve(RESULT);
+ });
+
+ it("carries out a command from a transition that stays put", async () => {
+ // A second cancel does not change the state, and setState answers "no
+ // change" the same way it answers "disposed" — so the abort it asks for
+ // has to be run on the strength of the transition, not the state change.
+ const abort = vi.spyOn(AbortController.prototype, "abort");
+ const gate = deferred();
+ const { controller } = harness(async () => gate.promise);
+
+ controller.start(TARGET);
+ const before = abort.mock.calls.length;
+ controller.cancel();
+ controller.cancel();
+
+ expect(abort.mock.calls.length - before).toBe(2);
+ gate.resolve(RESULT);
+ abort.mockRestore();
+ });
+
+ it("is quiet when there is nothing to stop", () => {
+ const { controller } = harness(async () => RESULT);
+ expect(() => controller.cancel()).not.toThrow();
+ expect(controller.getState()).toEqual({ type: "idle" });
+ });
+});
+
+describe("answers from a run nobody is watching any more", () => {
+ it("does not put a superseded run's result back on screen", async () => {
+ // The reason the state is here and not in the panel: this run finishes
+ // after the user has moved on, and its answer must not revive anything.
+ const first = deferred();
+ let calls = 0;
+ const { controller } = harness(async () => {
+ calls += 1;
+ return calls === 1 ? first.promise : RESULT;
+ });
+
+ const stale = controller.start(TARGET);
+ controller.cancel();
+ first.reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled));
+ await expect(stale.result).rejects.toThrow();
+ controller.dismiss();
+
+ // A second run of the same server, then the first one's answer arrives.
+ const second = controller.start(TARGET);
+ await second.result;
+ expect(controller.getState()).toMatchObject({ type: "done" });
+ });
+});
+
+describe("what a run could not put back", () => {
+ it("reaches the state a panel reads, not just the error", async () => {
+ // The whole hop: an error carrying it, the dispatch that reads it off,
+ // and the transition that has to carry it onto the state. Asserting on
+ // either end alone leaves the middle free to drop it, which is what
+ // happened.
+ const { controller } = harness(async () => {
+ throw Object.assign(
+ new DyadError("Cancelled.", DyadErrorKind.UserCancelled),
+ { warning: "Coolify may still be configured for x.sslip.io." },
+ );
+ });
+
+ await controller.start(TARGET).result.catch(() => {});
+
+ expect(controller.getState()).toMatchObject({
+ type: "failed",
+ cancelled: true,
+ warning: "Coolify may still be configured for x.sslip.io.",
+ });
+ });
+
+ it("says nothing when a run had nothing to put back", async () => {
+ const { controller } = harness(async () => {
+ throw new DyadError("boom", DyadErrorKind.External);
+ });
+
+ await controller.start(TARGET).result.catch(() => {});
+
+ expect(
+ (controller.getState() as { warning?: string }).warning,
+ ).toBeUndefined();
+ });
+});
+
+describe("telling anyone who is listening", () => {
+ it("reports each change once, so windows can follow along", async () => {
+ const { controller, states } = harness(async (_t, hooks) => {
+ hooks.onProgress("installing");
+ return RESULT;
+ });
+
+ await controller.start(TARGET).result;
+
+ expect(states.map((s) => s.type)).toEqual(["running", "running", "done"]);
+ });
+
+ it("says nothing when the answer is the same as last time", async () => {
+ // Starting already puts it at "connecting". Reporting that again is not a
+ // change, and telling every window about it is a render for nothing.
+ const { controller, states } = harness(async (_t, hooks) => {
+ hooks.onProgress("connecting");
+ return RESULT;
+ });
+
+ await controller.start(TARGET).result;
+
+ expect(states.map((s) => s.type)).toEqual(["running", "done"]);
+ });
+
+ it("says nothing when it ignored the event", () => {
+ const { controller, states } = harness(async () => RESULT);
+ controller.cancel();
+ controller.dismiss();
+ expect(states).toHaveLength(0);
+ });
+});
+
+describe("an executor that throws before it starts", () => {
+ it("does not leave the machine running with nothing running", async () => {
+ // Reading the server key throws where it stands, not as a rejection, and
+ // handlers attached to a promise that was never made never run — so the
+ // machine would stay running and refuse every later setup.
+ const { controller } = harness((() => {
+ throw new Error("key file is corrupt");
+ }) as never);
+
+ const run = controller.start(TARGET);
+ await expect(run.result).rejects.toThrow("key file is corrupt");
+
+ expect(controller.getState()).toMatchObject({ type: "failed" });
+ controller.dismiss();
+ expect(() => controller.start(TARGET)).not.toThrow();
+ });
+});
+
+describe("after it is disposed", () => {
+ it("starts nothing new, rather than working where nobody can see it", async () => {
+ // A disposed store keeps nothing and tells nobody. Launching anyway would
+ // put an install on somebody's server that no window could show, cancel,
+ // or hear the end of.
+ let launched = 0;
+ const { controller } = harness(async () => {
+ launched += 1;
+ return RESULT;
+ });
+
+ controller.dispose();
+ expect(() => controller.start(TARGET)).toThrow();
+ expect(launched).toBe(0);
+ });
+
+ it("stops telling anyone, so a late answer cannot reach a closed window", async () => {
+ const gate = deferred();
+ const { controller, states } = harness(async () => gate.promise);
+ controller.start(TARGET);
+ const before = states.length;
+
+ controller.dispose();
+ gate.resolve(RESULT);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(states).toHaveLength(before);
+ });
+});
+
+describe("not taking the process down", () => {
+ it("survives a failure nobody awaited", async () => {
+ const rejection = vi.fn();
+ process.once("unhandledRejection", rejection);
+ const { controller } = harness(async () => {
+ throw new Error("nobody is listening");
+ });
+
+ controller.start(TARGET);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(rejection).not.toHaveBeenCalled();
+ process.off("unhandledRejection", rejection);
+ });
+});
diff --git a/src/coolify_setup/controller.ts b/src/coolify_setup/controller.ts
new file mode 100644
index 0000000000..3b6ab60cea
--- /dev/null
+++ b/src/coolify_setup/controller.ts
@@ -0,0 +1,217 @@
+import log from "electron-log";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { createInvocationRef } from "@/state_machines/invocation_ref";
+import type { IdSource } from "@/state_machines/clock";
+import type {
+ SetupResult,
+ SetupStep,
+ SetupTarget,
+} from "@/ipc/types/coolify_setup";
+import {
+ COOLIFY_SETUP_INVOCATION_KIND,
+ IDLE,
+ type CoolifySetupCommand,
+ type CoolifySetupEvent,
+ type CoolifySetupInvocationRef,
+ type CoolifySetupState,
+} from "./state";
+import { coolifySetupTransition } from "./transition";
+import { SnapshotStore } from "@/state_machines/snapshot_store";
+
+const logger = log.scope("coolify_setup_controller");
+
+/**
+ * Runs the setup machine and performs what it asks for.
+ *
+ * The state lives here, in the main process, because the work does. The panel
+ * asks for a snapshot and renders it; it keeps nothing of its own, so leaving
+ * the screen — which the running screen invites — cannot lose the install.
+ *
+ * Every launch mints an invocation ref, and every event echoes it back. An
+ * answer from a run that was cancelled or superseded is ignored by the
+ * transition rather than guarded against here.
+ */
+
+export interface SetupRun {
+ /** Resolves with what the flow produced, or rejects as the flow did. */
+ readonly result: Promise;
+ readonly invocationRef: CoolifySetupInvocationRef;
+}
+
+export interface CoolifySetupControllerOptions {
+ /** Does the work. Injected so the controller can be tested without SSH. */
+ execute: (
+ target: SetupTarget,
+ hooks: {
+ signal: AbortSignal;
+ onProgress: (step: SetupStep, output?: string) => void;
+ },
+ ) => Promise;
+ ids: IdSource;
+ /** Told after every applied transition, so windows can be updated. */
+ onChanged?: (state: CoolifySetupState) => void;
+}
+
+export class CoolifySetupController {
+ /**
+ * The snapshot everything else reads.
+ *
+ * The shared store rather than a field and a callback: it already refuses
+ * to notify when the reference has not moved, which is the difference
+ * between `stay` and `change`, and it can be disposed so a late answer
+ * cannot reach windows after teardown.
+ */
+ private readonly store = new SnapshotStore(IDLE);
+ private aborters = new Map();
+ private disposed = false;
+ private runs = new Map>();
+
+ constructor(private readonly options: CoolifySetupControllerOptions) {
+ if (options.onChanged) {
+ this.store.subscribe(() => options.onChanged?.(this.store.getSnapshot()));
+ }
+ }
+
+ getState(): CoolifySetupState {
+ return this.store.getSnapshot();
+ }
+
+ /** Stops telling anyone anything. Nothing in flight is cancelled by it. */
+ dispose(): void {
+ this.disposed = true;
+ this.store.dispose();
+ }
+
+ /**
+ * Starts a setup, or refuses because one is already going.
+ *
+ * The refusal is the machine's decision rather than a check here, so the
+ * rule is tested where it is made.
+ */
+ start(target: SetupTarget): SetupRun {
+ const invocationRef = createInvocationRef(
+ COOLIFY_SETUP_INVOCATION_KIND,
+ target.host.trim(),
+ this.options.ids,
+ );
+ const before = this.store.getSnapshot();
+ this.dispatch({ type: "start-requested", invocationRef, target });
+ if (this.store.getSnapshot() === before) {
+ // Nothing launched: the machine refused. Named for the user rather than
+ // for the log, because this is what the panel shows.
+ throw new DyadError(
+ "A server is already being set up. Wait for it to finish, or cancel it.",
+ DyadErrorKind.Precondition,
+ );
+ }
+ const result = this.runs.get(invocationRef.operationId);
+ if (!result) {
+ // Unreachable: a launch command is emitted with the state change.
+ throw new DyadError(
+ "The setup did not start.",
+ DyadErrorKind.Precondition,
+ );
+ }
+ return { result, invocationRef };
+ }
+
+ /** Asks a running setup to stop. Quiet when there is nothing to stop. */
+ cancel(): void {
+ this.dispatch({ type: "cancel-requested" });
+ }
+
+ /** The user has read the terminal screen; put the panel back to the form. */
+ dismiss(): void {
+ this.dispatch({ type: "dismissed" });
+ }
+
+ private dispatch(event: CoolifySetupEvent): void {
+ const result = coolifySetupTransition(this.store.getSnapshot(), event);
+ if (result.kind === "ignored") {
+ logger.info(`Ignored ${event.type}: ${result.reason}`);
+ return;
+ }
+ // Told first, then the work is started. A command that dispatches again —
+ // the flow reports its first step as soon as it is launched — then sends
+ // its own notification, so each one says something that changed rather
+ // than two saying the same thing.
+ //
+ // A disposed store keeps nothing and tells nobody, so running the
+ // commands anyway would start an install over SSH that no window could
+ // see, cancel, or hear the end of — while `start` reported it as running.
+ // A transition can ask for something without changing the state — a
+ // second cancel re-sends the abort — and setState answers false for that
+ // as well as for a disposed store. Only the disposed case is a reason to
+ // do nothing.
+ this.store.setState(result.state);
+ if (this.disposed) return;
+ for (const command of result.commands) this.run(command);
+ }
+
+ private run(command: CoolifySetupCommand): void {
+ switch (command.type) {
+ case "launch":
+ this.launch(command.invocationRef, command.target);
+ return;
+ case "abort":
+ this.aborters.get(command.invocationRef.operationId)?.abort();
+ return;
+ }
+ }
+
+ private launch(
+ invocationRef: CoolifySetupInvocationRef,
+ target: SetupTarget,
+ ): void {
+ const controller = new AbortController();
+ this.aborters.set(invocationRef.operationId, controller);
+
+ // Started inside a try, because an executor can throw before it returns a
+ // promise — reading the server key does, on a key file it cannot parse.
+ // Handlers attached to a promise that was never made never run, which
+ // would leave the machine running with nothing running.
+ let started: Promise;
+ try {
+ started = this.options.execute(target, {
+ signal: controller.signal,
+ onProgress: (step, output) =>
+ this.dispatch({ type: "progress", invocationRef, step, output }),
+ });
+ } catch (error) {
+ started = Promise.reject(error);
+ }
+
+ const work = started
+ .then((result) => {
+ this.dispatch({ type: "succeeded", invocationRef, result });
+ return result;
+ })
+ .catch((error: unknown) => {
+ const cancelled =
+ (error as { kind?: string }).kind === DyadErrorKind.UserCancelled;
+ this.dispatch({
+ type: "failed",
+ invocationRef,
+ message: error instanceof Error ? error.message : String(error),
+ cancelled,
+ // Set by whatever could not put the server back as it found it.
+ warning:
+ error instanceof Error
+ ? (error as Error & { warning?: string }).warning
+ : undefined,
+ });
+ throw error;
+ })
+ .finally(() => {
+ this.aborters.delete(invocationRef.operationId);
+ this.runs.delete(invocationRef.operationId);
+ });
+
+ // Kept so a second caller can await the same work rather than starting
+ // its own, and so the handler that asked can answer its own invoke.
+ this.runs.set(invocationRef.operationId, work);
+ // Nothing else attaches to this promise, and an install that fails with
+ // no window listening must not take the process down.
+ work.catch(() => {});
+ }
+}
diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts
new file mode 100644
index 0000000000..371986c272
--- /dev/null
+++ b/src/coolify_setup/https_setup.test.ts
@@ -0,0 +1,877 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ applyInstanceDomain,
+ plainUrlFor,
+ certificateDomainFor,
+ domainPointsAtServer,
+ hasTrustedCertificate,
+ tryEnableHttps,
+} from "./https_setup";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+
+function transcript(output: string): string {
+ return [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ output,
+ "__DYAD_OUT_END__",
+ ].join("\n");
+}
+
+function fakeSession() {
+ const commands: string[] = [];
+ const scripts: string[] = [];
+ const session: SshSession = {
+ run: vi.fn(async (command: string, options?: { input?: string }) => {
+ commands.push(command);
+ scripts.push(options?.input ?? "");
+ return { code: 0, stdout: transcript("applied"), stderr: "" };
+ }) as unknown as SshSession["run"],
+ end: vi.fn(),
+ };
+ return { session, commands, scripts };
+}
+
+describe("certificateDomainFor", () => {
+ it("spells a bare address as a name, since a certificate needs one", () => {
+ // Let's Encrypt will not issue for an IP address. sslip.io exists so an
+ // address can be written as a domain that resolves back to it.
+ expect(certificateDomainFor("203.0.113.5")).toBe("203.0.113.5.sslip.io");
+ });
+
+ it("prefers a name the user already has", () => {
+ // Theirs, and it does not spend a shared certificate allowance.
+ expect(certificateDomainFor("203.0.113.5", "coolify.example.com")).toBe(
+ "coolify.example.com",
+ );
+ });
+
+ it("takes a domain however the user pasted it", () => {
+ expect(
+ certificateDomainFor("203.0.113.5", "https://coolify.example.com/"),
+ ).toBe("coolify.example.com");
+ });
+
+ it("uses a hostname as given rather than wrapping it", () => {
+ expect(certificateDomainFor("coolify.example.com")).toBe(
+ "coolify.example.com",
+ );
+ });
+
+ it("declines an address no certificate authority could reach", () => {
+ // Validation happens over the public internet. A name pointing at a LAN
+ // or loopback address can never be given a certificate, and asking for
+ // one costs the whole poll — two minutes — before the same answer.
+ expect(certificateDomainFor("192.168.1.50")).toBeNull();
+ expect(certificateDomainFor("127.0.0.1")).toBeNull();
+ expect(certificateDomainFor("10.0.0.7")).toBeNull();
+ });
+
+ it("declines a name only this machine or this network answers to", () => {
+ // Same position as a private address: nothing public can validate it.
+ expect(certificateDomainFor("localhost")).toBeNull();
+ // mDNS, which is how a homelab box is usually reached on a LAN.
+ expect(certificateDomainFor("coolify.local")).toBeNull();
+ });
+
+ it("still takes an ordinary hostname", () => {
+ expect(certificateDomainFor("coolify.example.com")).toBe(
+ "coolify.example.com",
+ );
+ });
+
+ it("keeps a domain the user gave, whatever the address is", () => {
+ // Not because the certificate can be had — the caller turns this server
+ // down a step later for the address it resolves to. It decides which
+ // refusal the user reads: with the domain kept, the message names the
+ // address, which is the thing that is actually private. Nulled here, it
+ // would name the domain instead and send someone to check a name that
+ // was answering perfectly well.
+ expect(certificateDomainFor("192.168.1.50", "coolify.example.com")).toBe(
+ "coolify.example.com",
+ );
+ });
+
+ it("declines an IPv6 address rather than guessing at a spelling", () => {
+ expect(certificateDomainFor("2606:4700::1")).toBeNull();
+ });
+});
+
+describe("plainUrlFor", () => {
+ it("brackets an IPv6 address, or the URL will not parse", async () => {
+ const url = plainUrlFor("2606:4700::1");
+ expect(url).toBe("http://[2606:4700::1]:8000");
+ expect(() => new URL(url)).not.toThrow();
+ });
+
+ it("leaves an IPv4 address and a hostname alone", () => {
+ expect(plainUrlFor("203.0.113.5")).toBe("http://203.0.113.5:8000");
+ expect(plainUrlFor("box.example.com")).toBe("http://box.example.com:8000");
+ });
+});
+
+describe("applyInstanceDomain", () => {
+ it("sets the domain and rebuilds the proxy, which is what asks", async () => {
+ // The setting alone changes nothing; the proxy rebuild is what requests
+ // the certificate.
+ const { session, scripts } = fakeSession();
+ await applyInstanceDomain(session, "203.0.113.5.sslip.io");
+
+ expect(scripts[0]).toContain("$s->fqdn =");
+ expect(scripts[0]).toContain("setupDynamicProxyConfiguration");
+ });
+
+ it("keeps the domain out of the script", async () => {
+ const { session, scripts, commands } = fakeSession();
+ await applyInstanceDomain(session, "coolify.example.com");
+
+ expect(scripts[0]).not.toContain("coolify.example.com");
+ expect(commands[0]).toContain(
+ "-e DYAD_INSTANCE_DOMAIN='coolify.example.com'",
+ );
+ });
+
+ it("clears the domain when given null", async () => {
+ const { session, scripts } = fakeSession();
+ await applyInstanceDomain(session, null);
+ expect(scripts[0]).toContain("$s->fqdn = null;");
+ });
+
+ it("refuses a domain that could break out of the script", async () => {
+ const { session } = fakeSession();
+ await expect(
+ applyInstanceDomain(session, "example.com'; system('rm -rf /'); '"),
+ ).rejects.toMatchObject({ kind: "validation" });
+ });
+});
+
+describe("hasTrustedCertificate", () => {
+ it("is false when the certificate is not trusted", async () => {
+ // Node rejects an untrusted certificate rather than reporting it, so the
+ // request failing IS the answer. A self-signed one fails here, which is
+ // the point: it would fail in the user's browser too.
+ const fetchImpl = vi.fn(async () => {
+ throw new Error("unable to verify the first certificate");
+ }) as unknown as typeof fetch;
+ expect(
+ await hasTrustedCertificate("https://example.com", { fetchImpl }),
+ ).toBe(false);
+ });
+
+ it("is true when the request completes", async () => {
+ const fetchImpl = vi.fn(async () => ({
+ status: 302,
+ })) as unknown as typeof fetch;
+ expect(
+ await hasTrustedCertificate("https://example.com", { fetchImpl }),
+ ).toBe(true);
+ });
+});
+
+describe("certificateDomainFor, for a domain given by hand", () => {
+ it("refuses a name nothing public can validate", async () => {
+ // Same reasoning the derived names get. Asking anyway spends the whole
+ // certificate wait — two minutes — on an answer that cannot arrive.
+ expect(certificateDomainFor("203.0.113.5", "coolify.local")).toBeNull();
+ expect(certificateDomainFor("203.0.113.5", "localhost")).toBeNull();
+ expect(certificateDomainFor("203.0.113.5", "192.168.1.10")).toBeNull();
+ });
+
+ it("turns a bare address into the name the derived path would use", async () => {
+ // No authority certifies a bare address, so asking spends the whole wait
+ // on a refusal — while the same address has a spelling that is a name.
+ expect(certificateDomainFor("203.0.113.5", "203.0.113.5")).toBe(
+ "203.0.113.5.sslip.io",
+ );
+ });
+
+ it("keeps a name that could be validated", async () => {
+ expect(certificateDomainFor("203.0.113.5", "coolify.example.com")).toBe(
+ "coolify.example.com",
+ );
+ expect(
+ certificateDomainFor("203.0.113.5", "https://coolify.example.com/"),
+ ).toBe("coolify.example.com");
+ });
+});
+
+describe("domainPointsAtServer", () => {
+ const answers =
+ (addresses: string[], failed = false) =>
+ async () => ({ addresses, failed });
+
+ it("says it points here when the domain resolves to the server", async () => {
+ expect(
+ await domainPointsAtServer("coolify.example.com", "203.0.113.5", {
+ resolve: answers(["203.0.113.5"]),
+ }),
+ ).toBe("points-here");
+ });
+
+ it("is false when it still points at something else", async () => {
+ // The user's old website answers HTTPS with a valid certificate of its
+ // own, so the certificate check alone would call this a success.
+ expect(
+ await domainPointsAtServer("example.com", "203.0.113.5", {
+ resolve: answers(["198.51.100.9"]),
+ }),
+ ).toBe("points-elsewhere");
+ });
+
+ it("says it does not know when the resolver could not be reached", async () => {
+ // Told apart from an answer, because the certificate poll that follows
+ // settles for any address serving a certificate it trusts — so a domain
+ // still pointing at the machine the user is moving off would pass it.
+ expect(
+ await domainPointsAtServer("example.com", "203.0.113.5", {
+ resolve: answers([], true),
+ }),
+ ).toBe("no-answer");
+ });
+
+ it("says nothing is known when neither side resolved", async () => {
+ // The domain having no records is answered first, before ours is even
+ // looked at — so a server that does not resolve and a domain with none
+ // yet came back as agreement, having compared nothing at all.
+ const nothingAnywhere = async () => ({ addresses: [], failed: false });
+
+ expect(
+ await domainPointsAtServer("coolify.example.com", "box.internal", {
+ resolve: nothingAnywhere,
+ }),
+ ).toBe("server-unresolved");
+ });
+
+ it("says it does not know when the records cannot be compared", async () => {
+ // An IPv4 server and a domain carrying only an AAAA record. There is no
+ // overlap to find by construction, so this is no more an answer about
+ // where the domain points than a resolver that never replied — and the
+ // certificate poll after it would take the old machine for proof.
+ expect(
+ await domainPointsAtServer("coolify.example.com", "203.0.113.5", {
+ resolve: answers(["2001:db8::9"]),
+ }),
+ ).toBe("different-families");
+ });
+
+ it("does not object when the domain has no records yet", async () => {
+ // The resolver answered; the name simply has nothing yet. It may be
+ // minutes old, and the certificate wait is the real answer.
+ expect(
+ await domainPointsAtServer("example.com", "203.0.113.5", {
+ resolve: answers([]),
+ }),
+ ).toBe("points-here");
+ });
+
+ it("compares a server known by a name against what the name resolves to", async () => {
+ // Both sides are names here, so both are resolved. Accepting any domain
+ // when the server is named would point Coolify — and the root token
+ // stored with it — at whatever that domain happens to serve.
+ const byName = async (target: string) => ({
+ addresses:
+ target === "box.example.com" ? ["203.0.113.5"] : ["198.51.100.9"],
+ failed: false,
+ });
+
+ expect(
+ await domainPointsAtServer("coolify.example.com", "box.example.com", {
+ resolve: byName,
+ }),
+ ).toBe("points-elsewhere");
+ });
+
+ it("accepts a domain that resolves to the same place as the named server", async () => {
+ expect(
+ await domainPointsAtServer("coolify.example.com", "box.example.com", {
+ resolve: answers(["203.0.113.5"]),
+ }),
+ ).toBe("points-here");
+ });
+
+ it("says it does not know when the server's own name does not resolve", async () => {
+ // A name only this machine or this network answers to: connectSsh reaches
+ // it, plain DNS cannot see it, and there is nothing to hold the domain
+ // against. Accepting compared the user's domain with nothing at all —
+ // and the certificate poll afterwards takes any trusted answer for proof.
+ const nothingForTheServer = async (target: string) => ({
+ addresses: target === "box.internal" ? [] : ["198.51.100.9"],
+ failed: target === "box.internal",
+ });
+
+ expect(
+ await domainPointsAtServer("coolify.example.com", "box.internal", {
+ resolve: nothingForTheServer,
+ }),
+ ).toBe("server-unresolved");
+ });
+});
+
+describe("tryEnableHttps", () => {
+ const FAST = { timeoutMs: 40, intervalMs: 5 };
+
+ it("asks DNS about the server once, not once per check", async () => {
+ // The gate below and the domain comparison want the same answer, and a
+ // resolver that is slow to say so is slow twice.
+ const asked: string[] = [];
+ const { session } = fakeSession();
+ await tryEnableHttps(session, "box.example.com", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async (name: string) => {
+ asked.push(name);
+ return { addresses: ["203.0.113.5"], failed: false };
+ },
+ check: async () => true,
+ });
+
+ expect(asked.filter((n) => n === "box.example.com")).toHaveLength(1);
+ });
+
+ it("will not take a custom domain on trust when DNS could not be checked", async () => {
+ // The certificate poll settles for any address answering with a trusted
+ // certificate, and resolves the name through the system rather than the
+ // resolver asked here. A domain still pointing at the machine the user is
+ // moving off would pass it, and its address would become the instance the
+ // API token is sent to on every deploy.
+ const { session } = fakeSession();
+ const result = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async () => ({ addresses: [], failed: true }),
+ // Would say yes, which is the point: it is never asked.
+ check: async () => true,
+ });
+
+ expect(result.secure).toBe(false);
+ expect(result.instanceUrl).toBe("http://203.0.113.5:8000");
+ expect(result.reason).toMatch(/could not look up where/i);
+ });
+
+ it("names the domain it refused rather than the address", async () => {
+ // The address was fine; it was the domain the user typed that could not
+ // be certified, and blaming the address sends them to check that.
+ const { session } = fakeSession();
+ const result = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "coolify.local",
+ check: async () => true,
+ });
+
+ expect(result.secure).toBe(false);
+ expect(result.reason).toMatch(/^coolify\.local cannot be given/i);
+ });
+
+ it("says which of the two it could not settle", async () => {
+ // Two refusals with two different remedies. Telling someone whose domain
+ // resolved fine to go and check that it resolves sends them after the
+ // thing that was working.
+ const { session } = fakeSession();
+ const noAnswer = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async () => ({ addresses: [], failed: true }),
+ check: async () => true,
+ });
+ const crossFamily = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async () => ({ addresses: ["2001:db8::9"], failed: false }),
+ check: async () => true,
+ });
+
+ expect(noAnswer.secure).toBe(false);
+ expect(noAnswer.reason).toMatch(/could not look up where/i);
+ expect(crossFamily.secure).toBe(false);
+ expect(crossFamily.reason).toMatch(/different families/i);
+ expect(crossFamily.reason).not.toMatch(/could not look up/i);
+ });
+
+ it("refuses a custom domain when the server's name says nothing", async () => {
+ // The server answers to a name plain DNS cannot see. There is nothing to
+ // hold the domain against, and the poll below settles for any address
+ // serving a trusted certificate — so a domain pointing at the machine
+ // being moved off would be taken for proof.
+ const { session } = fakeSession();
+ const result = await tryEnableHttps(session, "box.internal", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async (name: string) => ({
+ addresses: name === "box.internal" ? [] : ["198.51.100.9"],
+ failed: name === "box.internal",
+ }),
+ check: async () => true,
+ });
+
+ expect(result.secure).toBe(false);
+ expect(result.instanceUrl).toBe("http://box.internal:8000");
+ // Names the server, which is what could not be looked up — not the
+ // domain, which may be perfectly correct.
+ expect(result.reason).toMatch(/could not look up an address for/i);
+ expect(result.reason).toContain("box.internal");
+ });
+
+ it("does not say which side holds which family", async () => {
+ // The server is the IPv6 side here. A message naming the domain as the
+ // one with IPv6 records would be false, and its remedy — give the
+ // server's address in the same family — is what the user already did.
+ const { session } = fakeSession();
+ const result = await tryEnableHttps(session, "box.example.com", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async (name: string) => ({
+ addresses:
+ name === "box.example.com" ? ["2001:db8::1"] : ["203.0.113.5"],
+ failed: false,
+ }),
+ check: async () => true,
+ });
+
+ expect(result.secure).toBe(false);
+ expect(result.reason).not.toMatch(/only IPv6/i);
+ expect(result.reason).toMatch(/different families/i);
+ });
+
+ it("does not ask DNS about a name it derived from this server", async () => {
+ // The sslip.io spelling of the host resolves to the host by construction,
+ // so checking it can only fail for reasons that have nothing to do with
+ // where it points — and the advice would be to fix a name that is right.
+ const { session } = fakeSession();
+ const result = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "203.0.113.5",
+ resolve: async () => ({ addresses: [], failed: true }),
+ check: async () => true,
+ });
+
+ expect(result.secure).toBe(true);
+ expect(result.instanceUrl).toBe("https://203.0.113.5.sslip.io");
+ });
+
+ it("still checks an address typed there that is not this server", async () => {
+ // Derived the same way, but from somewhere else — so it resolves
+ // somewhere else by construction, which is exactly what to object to.
+ const { session } = fakeSession();
+ const result = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "198.51.100.9",
+ resolve: async () => ({ addresses: ["198.51.100.9"], failed: false }),
+ check: async () => true,
+ });
+
+ expect(result.secure).toBe(false);
+ expect(result.reason).toMatch(/does not point at this server/i);
+ });
+
+ it("still accepts a custom domain whose name simply has no records yet", async () => {
+ // The resolver answered. A name minutes old has nothing to say and the
+ // certificate wait is the real test, which is not the same as Dyad never
+ // having got an answer at all.
+ const { session } = fakeSession();
+ const result = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async () => ({ addresses: [], failed: false }),
+ check: async () => true,
+ });
+
+ expect(result.secure).toBe(true);
+ expect(result.instanceUrl).toBe("https://coolify.example.com");
+ });
+
+ it("says what it is doing while it takes the domain back off", async () => {
+ // The only stretch with nothing behind it on screen. On a slow server a
+ // silent wait here reads as a hang.
+ const { session } = fakeSession();
+ const said: string[] = [];
+
+ await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => false,
+ onProgress: (message) => said.push(message),
+ });
+
+ expect(said.join("")).toMatch(/Removing the temporary domain/);
+ });
+
+ it("only says applied if the write and the proxy rebuild both ran", async () => {
+ // Tinker goes on to the next line when a statement throws, so a marker
+ // standing on its own would report a domain that was never set. One
+ // statement means a throw takes the marker with it.
+ const { session } = fakeSession();
+ const scripts: string[] = [];
+ session.run = (async (_command: string, options?: { input?: string }) => {
+ scripts.push(options?.input ?? "");
+ return { code: 0, stdout: transcript("applied"), stderr: "" };
+ }) as unknown as SshSession["run"];
+
+ await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => true,
+ });
+
+ const applying = scripts.find((script) => script.includes("fqdn")) ?? "";
+ // The save, the proxy rebuild and the marker are one statement, so the
+ // marker cannot be reached without them.
+ expect(applying).toContain("return 'applied'");
+ expect(applying).not.toMatch(/echo 'applied'/);
+ const oneLine = applying.split("\n").find((line) => line.includes("fqdn"))!;
+ expect(oneLine).toContain("save()");
+ expect(oneLine).toContain("setupDynamicProxyConfiguration()");
+ expect(oneLine).toContain("return 'applied'");
+ });
+
+ it("reads applied past a notice, and not out of the echoed script", async () => {
+ // The script it sends contains the word, so matching anywhere in the
+ // region would take the echo of the line that printed it.
+ const { session } = fakeSession();
+ session.run = (async () => ({
+ code: 0,
+ stdout: transcript("PHP Deprecated: something\napplied"),
+ stderr: "",
+ })) as unknown as SshSession["run"];
+
+ await expect(
+ applyInstanceDomain(session, "coolify.example.com"),
+ ).resolves.toBeUndefined();
+ });
+
+ it("does not take the word out of the middle of a line", async () => {
+ // The script it sends carries the word, so a region holding the echo of
+ // that line — and no answer of its own — is not the server saying yes.
+ const { session } = fakeSession();
+ session.run = (async () => ({
+ code: 0,
+ stdout: transcript("> echo (function () { return 'applied'; })();"),
+ stderr: "",
+ })) as unknown as SshSession["run"];
+
+ await expect(
+ applyInstanceDomain(session, "coolify.example.com"),
+ ).rejects.toThrow();
+ });
+
+ it("does not call a vetoed save applied", async () => {
+ // Eloquent answers false rather than throwing when a model event stops
+ // the write, so a run that reported success would go on to probe for a
+ // certificate against a domain Coolify never took.
+ const { session } = fakeSession();
+ session.run = (async () => ({
+ code: 0,
+ stdout: transcript("not-saved"),
+ stderr: "",
+ })) as unknown as SshSession["run"];
+
+ await expect(
+ applyInstanceDomain(session, "coolify.example.com"),
+ ).rejects.toThrow();
+ });
+
+ it("asks whether the save was taken, not only whether it threw", async () => {
+ // The answer above only matters if the script can produce it. Eloquent
+ // returns false rather than throwing when a model event stops the write,
+ // which the one-statement form does not catch on its own.
+ const { session, scripts } = fakeSession();
+ await applyInstanceDomain(session, "coolify.example.com");
+
+ expect(scripts[0]).toContain("if (!$s->save())");
+ expect(scripts[0]).toContain("return 'not-saved'");
+ });
+
+ it("says so when the domain will not come back off", async () => {
+ // The state the revert exists to avoid, arrived at anyway. Left silent,
+ // the last thing said was that the domain was being removed — which
+ // reads as it having worked, over a Coolify still set to answer at a
+ // name it has no certificate for.
+ const said: string[] = [];
+ const { session } = fakeSession();
+ const answering = session.run as unknown as (
+ command: string,
+ options?: { input?: string },
+ ) => Promise;
+ let domainWrites = 0;
+ session.run = (async (command: string, options?: { input?: string }) => {
+ // The first write puts the domain on; the second takes it back off,
+ // and that is the one this is about.
+ if ((options?.input ?? "").includes("fqdn")) {
+ domainWrites += 1;
+ if (domainWrites > 1) throw new Error("tinker is wedged");
+ }
+ return answering(command, options);
+ }) as unknown as SshSession["run"];
+
+ const result = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => false,
+ onProgress: (message) => said.push(message),
+ });
+
+ expect(said.join("")).toMatch(/Could not remove the temporary domain/);
+ // And carried where the panel will show it, not only in the log.
+ expect(result.reason).toMatch(/may still be configured for/);
+ expect(result.reason).toMatch(/clear the instance domain/);
+ // The install still stands: this is the way out of a failure, not a new
+ // one to throw.
+ expect(result.secure).toBe(false);
+ expect(result.instanceUrl).toBe("http://203.0.113.5:8000");
+ });
+
+ it("puts what is left to undo on a cancellation as it leaves", async () => {
+ // The panel says nothing about a cancelled run, so a domain that would
+ // not come back off would otherwise be reported only into a log the user
+ // is never shown.
+ const { session } = fakeSession();
+ const answering = session.run as unknown as (
+ command: string,
+ options?: { input?: string },
+ ) => Promise;
+ const controller = new AbortController();
+ let domainWrites = 0;
+ session.run = (async (command: string, options?: { input?: string }) => {
+ if ((options?.input ?? "").includes("fqdn")) {
+ domainWrites += 1;
+ // The domain goes on, then the user cancels, then taking it back off
+ // is what fails.
+ if (domainWrites === 1) {
+ const answer = await answering(command, options);
+ controller.abort();
+ return answer;
+ }
+ throw new Error("tinker is wedged");
+ }
+ return answering(command, options);
+ }) as unknown as SshSession["run"];
+
+ const error = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => false,
+ signal: controller.signal,
+ }).catch((e: unknown) => e);
+
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error & { warning?: string }).warning).toMatch(
+ /may still be configured for/,
+ );
+ });
+
+ it("gives the revert the full budget when nobody is waiting on a cancel", async () => {
+ // The short bound exists so "Stopping…" cannot hang. On the ordinary
+ // no-certificate path there is no cancel, and the domain still has to
+ // come off.
+ const asked: Array = [];
+ const session = {
+ run: vi.fn(async (_c: string, o?: { timeoutMs?: number }) => {
+ asked.push(o?.timeoutMs);
+ return { code: 0, stdout: transcript("applied"), stderr: "" };
+ }) as unknown as SshSession["run"],
+ end: vi.fn(),
+ };
+
+ await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => false,
+ });
+
+ expect(asked[0]).toBe(asked[1]);
+ });
+
+ it("does not wait for a certificate a private name can never get", async () => {
+ // The certificate poll is two minutes, and a LAN name pays all of it for
+ // an answer no certificate authority can give.
+ const { session, scripts } = fakeSession();
+ const outcome = await tryEnableHttps(session, "box.homelab.lan", {
+ ...FAST,
+ resolve: async () => ({ addresses: ["192.168.1.50"], failed: false }),
+ check: async () => true,
+ });
+
+ expect(outcome.secure).toBe(false);
+ expect(outcome.reason).toMatch(/cannot reach/);
+ // Nothing was applied, so nothing has to be taken back off.
+ expect(scripts).toHaveLength(0);
+ });
+
+ it("still tries when the name resolves somewhere public", async () => {
+ const { session } = fakeSession();
+ const outcome = await tryEnableHttps(session, "box.example.com", {
+ ...FAST,
+ resolve: async () => ({ addresses: ["203.0.113.5"], failed: false }),
+ check: async () => true,
+ });
+
+ expect(outcome.secure).toBe(true);
+ });
+
+ it("still tries when the name cannot be resolved at all", async () => {
+ // Not knowing where a server is is not the same as knowing it is private.
+ const { session } = fakeSession();
+ const outcome = await tryEnableHttps(session, "box.internal", {
+ ...FAST,
+ resolve: async () => ({ addresses: [], failed: true }),
+ check: async () => true,
+ });
+
+ expect(outcome.secure).toBe(true);
+ });
+
+ it("takes the domain back off when the cancel lands while it is being set", async () => {
+ // The script sets the fqdn before it rebuilds the proxy, so a cancel here
+ // has almost certainly already been written to the instance.
+ const controller = new AbortController();
+ const scripts: string[] = [];
+ let applies = 0;
+ const session = {
+ run: vi.fn(async (_command: string, options?: { input?: string }) => {
+ scripts.push(options?.input ?? "");
+ applies += 1;
+ if (applies === 1) {
+ controller.abort();
+ throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled);
+ }
+ return { code: 0, stdout: transcript("applied"), stderr: "" };
+ }) as unknown as SshSession["run"],
+ end: vi.fn(),
+ };
+
+ await expect(
+ tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ signal: controller.signal,
+ check: async () => false,
+ }),
+ ).rejects.toThrow(/Cancelled/);
+
+ expect(scripts.some((t) => t.includes("fqdn = null"))).toBe(true);
+ });
+
+ it("takes the domain back off when the user cancels the wait", async () => {
+ // The domain is set before the certificate is asked for. Cancelling in
+ // between and leaving it there points the dashboard at a name that serves
+ // nothing.
+ const { session, scripts } = fakeSession();
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(
+ tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ signal: controller.signal,
+ check: async () => false,
+ }),
+ ).rejects.toThrow(/Cancelled/);
+
+ expect(scripts.some((t) => t.includes("fqdn = null"))).toBe(true);
+ });
+
+ it("reports the encrypted address once a certificate arrives", async () => {
+ const { session } = fakeSession();
+ const outcome = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => true,
+ });
+
+ expect(outcome).toEqual({
+ instanceUrl: "https://203.0.113.5.sslip.io",
+ secure: true,
+ });
+ });
+
+ it("takes the domain back off when no certificate arrives", async () => {
+ // Left pointed at a domain with no certificate, Coolify would answer its
+ // own address with an error — a server nobody can open, which is worse
+ // than one that is merely unencrypted.
+ const { session, scripts } = fakeSession();
+ const outcome = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => false,
+ });
+
+ expect(outcome.secure).toBe(false);
+ expect(outcome.instanceUrl).toBe("http://203.0.113.5:8000");
+ expect(outcome.reason).toBeTruthy();
+ // The last thing it did was clear the domain again.
+ expect(scripts.at(-1)).toContain("$s->fqdn = null;");
+ });
+
+ it("does not point Coolify at a domain that is somewhere else", async () => {
+ // Applying it would take the dashboard off its own address, and the
+ // certificate check would pass against the host that answers there.
+ const { session, scripts } = fakeSession();
+ const outcome = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "example.com",
+ resolve: async () => ({ addresses: ["198.51.100.9"], failed: false }),
+ check: async () => true,
+ });
+
+ expect(outcome.secure).toBe(false);
+ expect(outcome.instanceUrl).toBe("http://203.0.113.5:8000");
+ expect(outcome.reason).toContain("does not point at this server");
+ expect(scripts).toHaveLength(0);
+ });
+
+ it("still uses the derived name without asking DNS about it", async () => {
+ // It is built from the address, so it resolves there by construction.
+ const resolve = vi.fn();
+ const { session } = fakeSession();
+ const outcome = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ resolve: resolve as never,
+ check: async () => true,
+ });
+
+ expect(outcome.secure).toBe(true);
+ expect(resolve).not.toHaveBeenCalled();
+ });
+
+ it("explains a failed custom domain differently from a shared one", async () => {
+ // A domain of the user's own fails for a reason they can act on; the free
+ // shared one fails for a reason they cannot.
+ const { session } = fakeSession();
+ const mine = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ customDomain: "coolify.example.com",
+ resolve: async () => ({ addresses: ["203.0.113.5"], failed: false }),
+ check: async () => false,
+ });
+ expect(mine.reason).toContain("points at this server");
+
+ const shared = await tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ check: async () => false,
+ });
+ expect(shared.reason).toContain("allowance");
+
+ // A server given by name uses that name directly, so the free service is
+ // not involved and blaming its allowance describes nothing.
+ const named = await tryEnableHttps(session, "coolify.example.com", {
+ ...FAST,
+ check: async () => false,
+ });
+ expect(named.reason).not.toContain("allowance");
+ expect(named.reason).toContain("points at this server");
+ });
+
+ it("does not touch the instance when there is no domain to ask for", async () => {
+ const { session, scripts } = fakeSession();
+ const outcome = await tryEnableHttps(session, "2606:4700::1", FAST);
+
+ expect(outcome.secure).toBe(false);
+ expect(scripts).toHaveLength(0);
+ });
+
+ it("stops when cancelled", async () => {
+ const { session } = fakeSession();
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(
+ tryEnableHttps(session, "203.0.113.5", {
+ ...FAST,
+ signal: controller.signal,
+ check: async () => false,
+ }),
+ ).rejects.toMatchObject({ kind: "user_cancelled" });
+ });
+});
diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts
new file mode 100644
index 0000000000..0719640f01
--- /dev/null
+++ b/src/coolify_setup/https_setup.ts
@@ -0,0 +1,538 @@
+import log from "electron-log";
+import { isIP } from "node:net";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { sleep } from "./sleep";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+import { answerLine, runTinker } from "./tinker";
+import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
+import { isPlausibleInstanceDomain } from "@/shared/coolify_domain";
+import { resolveBoth } from "@/ipc/utils/dns_resolve";
+import {
+ domainCheckVerdict,
+ isLoopbackAddress,
+ isNonRoutableAddress,
+} from "@/shared/domain_check";
+
+/**
+ * Getting the new instance onto HTTPS, if it can be had.
+ *
+ * A stock Coolify serves plain HTTP, so Dyad's API token — which carries root
+ * abilities and can read database connection strings — would cross the network
+ * in the clear on every deploy, not just once at setup.
+ *
+ * Coolify already knows how to fix this: give it a domain and its proxy asks
+ * Let's Encrypt for a certificate. The missing piece is the domain, and a
+ * server's own address is one: `1.2.3.4.sslip.io` resolves to 1.2.3.4 with
+ * nothing to configure.
+ *
+ * Attempted rather than required. Certificates depend on a third party that can
+ * refuse, so this checks whether it worked and puts the instance back on plain
+ * HTTP if it did not. Verified against Coolify 4.3.2: a domain that cannot be
+ * validated leaves port 8000 serving normally, so the fallback is a real one.
+ */
+
+/** Long enough for the proxy to be rebuilt, short enough to give up on. */
+const APPLY_DOMAIN_TIMEOUT_MS = 60_000;
+/**
+ * Shorter, and only when a cancel is waiting on it.
+ *
+ * The revert ignores the abort signal — it is undoing what the cancel
+ * interrupted — so this bounds how long a cancel already waiting is held.
+ * A cancel that lands once the revert is under way waits for it: giving up
+ * part-way is worse than the delay. Every other exit gets the same budget as
+ * the apply, because nobody is being held up.
+ */
+const CANCELLED_REVERT_TIMEOUT_MS = 10_000;
+
+/** It answered in about fifteen seconds on a new server; this is slack. */
+const CERTIFICATE_TIMEOUT_MS = 120_000;
+const POLL_INTERVAL_MS = 6_000;
+
+/**
+ * The name to ask for a certificate for.
+ *
+ * A hostname the user gave us is already a domain and is the better answer:
+ * it is theirs, and it does not spend anyone else's certificate allowance. Only
+ * a bare address needs sslip.io, which exists precisely so an address can be
+ * spelled as a name.
+ */
+export function certificateDomainFor(
+ host: string,
+ customDomain?: string | null,
+): string | null {
+ const custom = customDomain?.trim();
+ if (custom) {
+ const bareCustom = custom.replace(/^https?:\/\//, "").replace(/\/+$/, "");
+ // The same reasoning as the derived names below, which a domain given by
+ // hand needs just as much: nothing public can validate a name only this
+ // machine answers to, and asking anyway spends the whole certificate wait
+ // on an answer that cannot arrive.
+ if (isLoopbackAddress(bareCustom) || /\.local$/i.test(bareCustom)) {
+ return null;
+ }
+ if (isIP(bareCustom) === 4) {
+ if (isNonRoutableAddress(bareCustom)) return null;
+ // A bare address is not a name any authority will certify, so asking
+ // for one spends the whole wait on a refusal. The derived spelling of
+ // the same address is a name, and is what the address would have been
+ // turned into had it been left out of the domain field.
+ return `${bareCustom}.sslip.io`;
+ }
+ if (isIP(bareCustom) === 6) return null;
+ return bareCustom;
+ }
+
+ const bare = host.trim();
+ if (isIP(bare) === 4) {
+ // A certificate authority validates over the public internet, so a name
+ // that resolves to a private or loopback address can never be given one.
+ // Deriving it anyway meant waiting out the whole certificate poll — two
+ // minutes — for an answer that could not arrive, which is what a homelab
+ // server on a LAN address would do every time.
+ if (isLoopbackAddress(bare) || isNonRoutableAddress(bare)) return null;
+ return `${bare}.sslip.io`;
+ }
+ // A name only this machine or this network answers to is in the same
+ // position as a private address: nothing public can validate it, and asking
+ // anyway costs the whole certificate wait. mDNS names are spelled out here
+ // rather than folded into isLoopbackAddress, which means something narrower
+ // — a .local is a real machine, just not one the internet can reach.
+ if (isLoopbackAddress(bare) || /\.local$/i.test(bare)) return null;
+ // IPv6 has an sslip.io spelling with dashes, but Coolify's proxy and the
+ // certificate authority both have more to say about IPv6 than is worth
+ // guessing at here. A name is used as given.
+ if (isIP(bare) === 6) return null;
+ return bare || null;
+}
+
+export function httpsUrlFor(domain: string): string {
+ return `https://${domain}`;
+}
+
+/** Bracketed when it needs to be, or the colons read as a port. */
+export function urlHost(host: string): string {
+ return isIP(host) === 6 ? `[${host}]` : host;
+}
+
+/**
+ * Coolify's own port, or the one an e2e build was told to use.
+ *
+ * Fixed in production because Coolify's installer fixes it. Named by the
+ * environment under a test build so parallel workers do not have to share it.
+ */
+function dashboardPort(): number {
+ const override = IS_TEST_BUILD ? process.env.DYAD_E2E_DASHBOARD_PORT : null;
+ return override ? Number(override) : 8000;
+}
+
+/**
+ * Where the dashboard answers.
+ *
+ * The same address the setup stores and shows the user, from one definition:
+ * two copies meant a change to the port could move only half of them.
+ */
+export function plainUrlFor(host: string): string {
+ return `http://${urlHost(host)}:${dashboardPort()}`;
+}
+
+/**
+ * Points Coolify at a domain and reconfigures its proxy.
+ *
+ * Both halves are needed: the setting alone changes nothing until the proxy is
+ * rebuilt, which is what asks for the certificate.
+ *
+ * WORKAROUND: only the dashboard can set this, so it writes the model field
+ * and calls the proxy rebuild the dashboard calls.
+ *
+ * TODO: replace if Coolify gains an endpoint or command that sets the instance
+ * domain and reconfigures the proxy.
+ */
+export async function applyInstanceDomain(
+ session: SshSession,
+ domain: string | null,
+ {
+ signal,
+ timeoutMs = APPLY_DOMAIN_TIMEOUT_MS,
+ }: { signal?: AbortSignal; timeoutMs?: number } = {},
+): Promise {
+ if (domain !== null && !isPlausibleInstanceDomain(domain)) {
+ throw new DyadError(
+ `Refusing to set an unsafe instance domain: ${domain}`,
+ DyadErrorKind.Validation,
+ );
+ }
+ const answer = await runTinker(
+ session,
+ // One statement, so nothing after a throw runs. Tinker carries on to the
+ // next line when a statement fails, which for a marker on its own line
+ // means the write can fail and still report success — and this marker is
+ // what the caller reads to decide the domain went on, or came back off.
+ // Server 0 is the machine Coolify runs on, which is the one serving the
+ // dashboard this domain points at.
+ `echo (function () { $s = \\App\\Models\\InstanceSettings::get(); ` +
+ (domain === null
+ ? `$s->fqdn = null; `
+ : `$s->fqdn = 'https://' . getenv('DYAD_INSTANCE_DOMAIN'); `) +
+ // Eloquent answers false rather than throwing when something vetoes
+ // the write, so the throw-safety above is not enough on its own.
+ `if (!$s->save()) { return 'not-saved'; } ` +
+ `\\App\\Models\\Server::find(0)->setupDynamicProxyConfiguration(); ` +
+ `return 'applied'; })();`,
+ {
+ env: domain === null ? {} : { DYAD_INSTANCE_DOMAIN: domain },
+ signal,
+ // Bounded: the proxy rebuild is the slow part, and a server that never
+ // answers leaves "Setting up HTTPS" on screen with nothing behind it.
+ timeoutMs,
+ },
+ );
+ // Checked, because a Coolify that refused still prints and still ends. Left
+ // unread, its error became a two-minute wait blamed on the certificate
+ // authority. One line of the answer, like the other readers: tolerant of a
+ // notice beside it, and tight enough that the echoed script line — which
+ // carries the word — cannot pass for the answer.
+ if (!answerLine(answer, (line) => line === "applied")) {
+ throw new DyadError(
+ `Coolify would not take the domain: ${answer.trim()}`,
+ DyadErrorKind.External,
+ );
+ }
+}
+
+/**
+ * Whether the address serves HTTPS with a certificate this machine trusts.
+ *
+ * Node rejects an untrusted certificate rather than reporting it, so a request
+ * that resolves at all is the check. A self-signed certificate would fail here,
+ * which is the point: it would fail in the user's browser too.
+ */
+export async function hasTrustedCertificate(
+ url: string,
+ { fetchImpl = fetch }: { fetchImpl?: typeof fetch } = {},
+): Promise {
+ try {
+ const res = await fetchImpl(url, {
+ method: "GET",
+ redirect: "manual",
+ signal: AbortSignal.timeout(10_000),
+ });
+ return res.status > 0;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Whether a domain the user gave us points at the server being set up.
+ *
+ * Asked before the domain is applied, because a certificate is not the same
+ * question as "is this our server". A name still pointing at the user's old
+ * site answers HTTPS on the first poll with a valid certificate of its own,
+ * and taking that as success would store that host as Coolify's address — and
+ * send it an API token that carries root abilities.
+ *
+ * Advisory in one direction only: a resolver we could not reach says nothing,
+ * so it is not treated as a wrong answer.
+ */
+export async function domainPointsAtServer(
+ domain: string,
+ host: string,
+ {
+ resolve = resolveBoth,
+ hostAddresses,
+ }: { resolve?: typeof resolveBoth; hostAddresses?: string[] } = {},
+): Promise<
+ | "points-here"
+ | "points-elsewhere"
+ | "no-answer"
+ | "server-unresolved"
+ | "different-families"
+> {
+ // A server known by a name is resolved to the addresses it stands for, so
+ // the domain is compared against the same thing either way. A name that
+ // does not resolve leaves nothing to compare, which the verdict below reads
+ // as not knowing rather than as a wrong answer.
+ const expectedIps =
+ isIP(host) !== 0
+ ? [host]
+ : (hostAddresses ?? (await resolve(host)).addresses);
+ const resolved = await resolve(domain);
+ // A resolver that could not be reached is not a name with no records. Both
+ // arrive with nothing to compare, but only the second is the domain saying
+ // where it points — the first is Dyad not having asked successfully, which
+ // the caller has to be able to tell apart from an answer.
+ if (resolved.failed) return "no-answer";
+ const verdict = domainCheckVerdict({
+ expectedIps,
+ actualIps: resolved.addresses,
+ });
+ if (verdict === "points-elsewhere") return "points-elsewhere";
+ // Two different things arrive as "unknown", and neither is an answer.
+ //
+ // With addresses on both sides it is records that cannot be compared,
+ // because they are in different families. With none on ours it is the
+ // server's own name that did not resolve — a name only this machine or
+ // this network answers to, which plain DNS cannot see and connectSsh
+ // reaches anyway. Accepting there compared a user's domain against
+ // nothing at all, which is the whole of what this function is for.
+ // Nothing on our side to hold the domain against, whatever the domain
+ // turned out to say. domainCheckVerdict answers about the domain first — a
+ // name with no records is a fact about it, true whatever the server's
+ // address is — so that answer arrives before it ever looks at ours, and
+ // reading it as agreement would compare against nothing at all.
+ if (expectedIps.length === 0) return "server-unresolved";
+ if (verdict === "unknown") {
+ // Both sides answered, in families that cannot meet. A different thing
+ // from either lookup coming back empty, and a different remedy.
+ return "different-families";
+ }
+ return "points-here";
+}
+
+const logger = log.scope("coolify_https_setup");
+
+export interface HttpsOutcome {
+ /** What Dyad should store and talk to. */
+ instanceUrl: string;
+ secure: boolean;
+ /** Present when HTTPS was attempted and did not arrive. */
+ reason?: string;
+}
+
+/**
+ * Whether a name stands for something a certificate authority could reach.
+ *
+ * Only a definite private answer says no. A resolver that cannot answer, or a
+ * name with no records yet, leaves this true — refusing there would decline
+ * HTTPS for a server that could have had it.
+ */
+function resolvesPublicly(addresses: string[]): boolean {
+ if (addresses.length === 0) return true;
+ return addresses.some(
+ (address) => !isLoopbackAddress(address) && !isNonRoutableAddress(address),
+ );
+}
+
+/**
+ * Tries to put the instance on HTTPS, and settles for HTTP if it cannot.
+ *
+ * The instance is left on plain HTTP rather than pointed at a domain with no
+ * certificate: a half-configured proxy would answer the dashboard's own address
+ * with an error, and a working server nobody can open is worse than one that is
+ * merely unencrypted.
+ */
+export async function tryEnableHttps(
+ session: SshSession,
+ host: string,
+ {
+ customDomain,
+ signal,
+ timeoutMs = CERTIFICATE_TIMEOUT_MS,
+ intervalMs = POLL_INTERVAL_MS,
+ now = () => Date.now(),
+ check = hasTrustedCertificate,
+ resolve = resolveBoth,
+ onProgress,
+ }: {
+ customDomain?: string | null;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+ intervalMs?: number;
+ now?: () => number;
+ check?: typeof hasTrustedCertificate;
+ resolve?: typeof resolveBoth;
+ onProgress?: (message: string) => void;
+ } = {},
+): Promise {
+ const domain = certificateDomainFor(host, customDomain);
+ if (!domain) {
+ return {
+ instanceUrl: plainUrlFor(host),
+ secure: false,
+ // Says which of the two was refused. Blaming the address for a domain
+ // the user typed sends them to check the thing that was fine.
+ reason: customDomain?.trim()
+ ? `${customDomain.trim()} cannot be given a certificate, because ` +
+ `nothing on the public internet can reach it to check.`
+ : "This address cannot be given a certificate.",
+ };
+ }
+
+ // A name is only as reachable as what it stands for. Asked before the
+ // domain is applied, because a server on a LAN address would otherwise
+ // apply it, rebuild the proxy, wait out the whole certificate poll for an
+ // answer that cannot come, and take it all back off again.
+ const hostAddresses =
+ isIP(host) === 0 ? (await resolve(host)).addresses : [host];
+ if (!resolvesPublicly(hostAddresses)) {
+ return {
+ instanceUrl: plainUrlFor(host),
+ secure: false,
+ reason: `${host} resolves to an address the public internet cannot reach, so no certificate can be issued for it.`,
+ };
+ }
+
+ // Only a domain of the user's own can point somewhere else. A derived
+ // sslip.io name resolves to the address it was built from, by construction
+ // — including one derived from an address typed into the domain field,
+ // which is why this asks what the domain turned out to be rather than
+ // whether the field was filled. An address typed there that is not this
+ // server is still the user's own, and is still checked.
+ if (customDomain && domain !== `${host}.sslip.io`) {
+ const points = await domainPointsAtServer(domain, host, {
+ resolve,
+ hostAddresses,
+ });
+ if (points === "points-elsewhere") {
+ return {
+ instanceUrl: plainUrlFor(host),
+ secure: false,
+ reason:
+ `${domain} does not point at this server, so a certificate for it ` +
+ `would not describe this machine. Point it at ${host} and set the ` +
+ `domain in Coolify.`,
+ };
+ }
+ // Not knowing is not permission. The certificate poll below settles for
+ // any address that answers with a certificate it trusts, and it resolves
+ // the name through the system rather than the resolver asked here — so a
+ // domain still pointing at a machine the user is moving off would be
+ // taken as proof, and its address stored as the instance to send the API
+ // token to on every deploy.
+ if (points === "no-answer") {
+ return {
+ instanceUrl: plainUrlFor(host),
+ secure: false,
+ reason:
+ `Dyad could not look up where ${domain} points, so it cannot tell ` +
+ `whether a certificate for it would describe this machine. Check ` +
+ `the domain resolves to ${host} and try again.`,
+ };
+ }
+ // Nothing came back for the server itself, so there is nothing to hold
+ // the domain against — which is not the domain's fault and may not be a
+ // fault at all: a name only this network answers to is reached over SSH
+ // and invisible to plain DNS.
+ if (points === "server-unresolved") {
+ return {
+ instanceUrl: plainUrlFor(host),
+ secure: false,
+ reason:
+ `Dyad could not look up an address for ${host}, so it cannot tell ` +
+ `whether ${domain} points at this server. Reach the server by an ` +
+ `address or a name DNS can answer for, or set the domain in ` +
+ `Coolify yourself.`,
+ };
+ }
+ // Addresses came back for both, with no family in common — so there is
+ // nothing to compare, and which side is which varies. Naming a family
+ // here, or saying the domain has only those records, would state as fact
+ // something never established: one family's lookup can fail while the
+ // other answers, and resolveBoth reports that as an answer.
+ if (points === "different-families") {
+ return {
+ instanceUrl: plainUrlFor(host),
+ secure: false,
+ reason:
+ `Dyad could not compare where ${domain} points with ${host}: the ` +
+ `addresses it has for them are in different families, so neither ` +
+ `says anything about the other. Give the server's address in the ` +
+ `same family as the domain's records, or set the domain in ` +
+ `Coolify yourself.`,
+ };
+ }
+ }
+
+ const url = httpsUrlFor(domain);
+ onProgress?.(`Requesting a certificate for ${domain}…\n`);
+
+ // True only when the domain earned its place. Every other way out of the
+ // block below — no certificate, a cancel, a failure part-way through
+ // applying it — leaves Coolify answering at a name that serves nothing, so
+ // the domain comes back off before anyone is told what happened.
+ let keepDomain = false;
+ let settled: HttpsOutcome | null = null;
+ // Held so the revert below can add to whatever is on its way out. A cancel
+ // leaves by throwing, and its message is all the panel keeps.
+ let thrown: unknown;
+ try {
+ await applyInstanceDomain(session, domain, { signal });
+
+ const deadline = now() + timeoutMs;
+ while (now() < deadline) {
+ if (signal?.aborted) {
+ throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled);
+ }
+ if (await check(url)) {
+ keepDomain = true;
+ onProgress?.(`Coolify is available over HTTPS at ${url}\n`);
+ return { instanceUrl: url, secure: true };
+ }
+ await sleep(intervalMs, signal);
+ }
+
+ onProgress?.("No certificate arrived; leaving Coolify on plain HTTP.\n");
+ // Held rather than returned outright so the revert below can add to it.
+ // The caller has this object, not a copy of it.
+ settled = {
+ instanceUrl: plainUrlFor(host),
+ secure: false,
+ reason: !domain.endsWith(".sslip.io")
+ ? `No certificate was issued for ${domain}. Check that it points at this server.`
+ : `No certificate was issued for ${domain}. The free service that ` +
+ `provides these names shares one certificate allowance between ` +
+ `everyone using it, and it can run out.`,
+ };
+ return settled;
+ } catch (error) {
+ thrown = error;
+ throw error;
+ } finally {
+ // Without the signal, which by this point may be the reason we are here.
+ // Bounded by the tinker call's own timeout, so a wedged server cannot
+ // hold the cancel open.
+ if (!keepDomain) {
+ // Said out loud: this is the one stretch where the panel has nothing
+ // behind it, and on a slow server a silent wait reads as a hang.
+ onProgress?.("Removing the temporary domain…\n");
+ // Not raced against the signal. A cancel landing mid-revert waits for
+ // it, because abandoning a proxy rebuild half-way leaves Coolify
+ // answering at a name with no certificate — which is the state this
+ // whole block exists to avoid.
+ await applyInstanceDomain(session, null, {
+ timeoutMs: signal?.aborted
+ ? CANCELLED_REVERT_TIMEOUT_MS
+ : APPLY_DOMAIN_TIMEOUT_MS,
+ }).catch((error: unknown) => {
+ // The state the comment above calls the one to avoid, arrived at
+ // anyway. Swallowed, because the install itself stands and this is
+ // the way out of a failure rather than the failure — but not
+ // silently: the last thing the log said was that the domain was
+ // being removed, which reads as it having worked.
+ logger.error(`Could not remove the temporary domain ${domain}`, error);
+ onProgress?.(
+ `Could not remove the temporary domain ${domain}. Coolify may ` +
+ `still be set to answer at it.\n`,
+ );
+ // A cancel leaves this way, and the panel shows nothing for a
+ // cancelled run — so without this the one thing the user has to act
+ // on would be said only into a log nobody is shown.
+ if (thrown instanceof Error) {
+ (thrown as Error & { warning?: string }).warning =
+ `Coolify may still be configured for ${domain}; clear the ` +
+ `instance domain in its settings if the dashboard does not ` +
+ `answer at ${plainUrlFor(host)}.`;
+ }
+ if (settled) {
+ // Wrapped, so the trim applies to the joined sentence rather than
+ // binding to the last piece of it.
+ settled.reason = (
+ `${settled.reason ?? ""} Coolify may still be configured for ` +
+ `${domain}; clear the instance domain in its settings if the ` +
+ `dashboard does not answer at ${plainUrlFor(host)}.`
+ ).trimStart();
+ }
+ });
+ }
+ }
+}
diff --git a/src/coolify_setup/install.test.ts b/src/coolify_setup/install.test.ts
new file mode 100644
index 0000000000..fe1d9647c0
--- /dev/null
+++ b/src/coolify_setup/install.test.ts
@@ -0,0 +1,409 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildInstallScript,
+ installCoolify,
+ preflight,
+ waitForAdminSeeded,
+} from "./install";
+import { SshError } from "@/ipc/utils/ssh_client";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+import { DyadErrorKind } from "@/errors/dyad_error";
+
+/**
+ * What Dyad concludes when a server does not answer properly.
+ *
+ * The interesting cases here are not the ones where a server says something
+ * unexpected — they are the ones where it says nothing at all, because every
+ * answer is read out of one transcript and an empty transcript still parses.
+ */
+
+function sessionAnswering(run: SshSession["run"]): SshSession {
+ return { run, end: vi.fn() } as unknown as SshSession;
+}
+
+const HEALTHY = "mem=1967\ncontainer=\nbusy=no";
+
+/** What a tinker script's output looks like coming back off the wire. */
+function transcript(output: string): string {
+ return [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ output,
+ "__DYAD_OUT_END__",
+ ].join("\n");
+}
+
+describe("what the installer is sent", () => {
+ const CREDENTIALS = {
+ username: "dyad",
+ email: "me@gmail.com",
+ password: "Abc123@xyz",
+ };
+
+ it("keeps the password out of the command line", async () => {
+ // Anyone with a shell on that machine can read a command line out of ps.
+ let sentCommand = "";
+ let sentInput: string | undefined;
+ const session = {
+ run: async (command: string, options?: { input?: string }) => {
+ sentCommand = command;
+ sentInput = options?.input;
+ return { code: 0, stdout: "", stderr: "" };
+ },
+ end: () => {},
+ };
+
+ await installCoolify(session as never, CREDENTIALS);
+
+ expect(sentCommand).not.toContain(CREDENTIALS.password);
+ expect(sentInput).toContain(CREDENTIALS.password);
+ });
+
+ it("fails the install when the download fails", () => {
+ // curl reports the failure and bash, handed nothing, exits 0 — so without
+ // this the pipeline's status is 0 and a server with no Coolify on it
+ // reads as installed.
+ expect(buildInstallScript(CREDENTIALS)).toContain("set -o pipefail");
+ });
+
+ it("feeds the script over stdin rather than as arguments", async () => {
+ let sent: string | undefined;
+ const session = {
+ run: async (_command: string, options?: { input?: string }) => {
+ sent = options?.input;
+ return { code: 0, stdout: "", stderr: "" };
+ },
+ end: () => {},
+ };
+
+ await installCoolify(session as never, CREDENTIALS);
+
+ expect(sent).toContain(CREDENTIALS.password);
+ });
+
+ it("refuses a credential that could end its own quoting", () => {
+ expect(() =>
+ buildInstallScript({ ...CREDENTIALS, password: "a'; rm -rf /" }),
+ ).toThrow();
+ });
+});
+
+describe("preflight", () => {
+ it("reads a healthy server as ready", async () => {
+ const session = sessionAnswering(
+ vi.fn(async () => ({ code: 0, stdout: HEALTHY, stderr: "" })) as never,
+ );
+ await expect(preflight(session)).resolves.toMatchObject({
+ ready: true,
+ alreadyInstalled: false,
+ memoryMb: 1967,
+ });
+ });
+
+ it("refuses when docker is there but will not say what it is running", async () => {
+ // A stopped daemon reports no containers, which reads exactly like a
+ // machine with no Coolify — and installing over an instance that is
+ // merely stopped is the outcome worth refusing.
+ const session = sessionAnswering(
+ vi.fn(async () => ({
+ code: 0,
+ stdout: "mem=1967\ncontainer=\ndockerok=no\nbusy=no\n",
+ stderr: "",
+ })) as never,
+ );
+
+ await expect(preflight(session)).resolves.toMatchObject({
+ ready: false,
+ alreadyInstalled: false,
+ });
+ });
+
+ it("refuses a probe that came back with nothing", async () => {
+ // An empty transcript parses as "no memory, no container, not busy" —
+ // which reads as a healthy empty server, and that is the one wrong answer
+ // that matters: it stands between the user and installing over a Coolify
+ // that is already there.
+ const session = sessionAnswering(
+ vi.fn(async () => ({ code: 1, stdout: "", stderr: "" })) as never,
+ );
+ const checks = await preflight(session);
+
+ expect(checks.ready).toBe(false);
+ expect(checks.alreadyInstalled).toBe(false);
+ expect(checks.reason).toContain("could not read");
+ });
+
+ it("refuses a server whose memory it could not read", async () => {
+ // A server that answers the question but not this part of it sends back
+ // `mem=` — empty, not missing, so the whole-transcript guard above does
+ // not fire and the size check has nothing to compare. Read as ready, the
+ // 2GB rule is quietly absent: the install finishes and Coolify does not
+ // run, on a machine that now refuses a second attempt.
+ const session = sessionAnswering(
+ vi.fn(async () => ({
+ code: 0,
+ stdout: "mem=\ncontainer=\nbusy=no",
+ stderr: "",
+ })) as never,
+ );
+ const checks = await preflight(session);
+
+ expect(checks.ready).toBe(false);
+ expect(checks.memoryMb).toBeNull();
+ expect(checks.reason).toContain("could not read how much memory");
+ // It answered about Coolify, so that part is not in doubt.
+ expect(checks.installedKnown).toBe(true);
+ });
+
+ it("still reports a server that is busy", async () => {
+ const session = sessionAnswering(
+ vi.fn(async () => ({
+ code: 0,
+ stdout: "mem=1967\ncontainer=\nbusy=yes",
+ stderr: "",
+ })) as never,
+ );
+ await expect(preflight(session)).resolves.toMatchObject({ ready: false });
+ });
+});
+
+describe("a server that answers the connection but not the question", () => {
+ it("gives up on the probe rather than leaving the step running", async () => {
+ // A wedged docker answers nothing. Without a bound the panel sits on
+ // "Checking the server" until the user works out that nothing is
+ // happening and stops it themselves.
+ const asked: Array = [];
+ const session = sessionAnswering(
+ vi.fn(async (_c: string, options?: { timeoutMs?: number }) => {
+ asked.push(options?.timeoutMs);
+ return { code: 0, stdout: HEALTHY, stderr: "" };
+ }) as never,
+ );
+
+ await preflight(session);
+ expect(asked[0]).toBeGreaterThan(0);
+ });
+
+ it("leaves the installer alone, which legitimately takes minutes", async () => {
+ const asked: Array = [];
+ const session = sessionAnswering(
+ vi.fn(async (command: string, options?: { timeoutMs?: number }) => {
+ if (command.includes("install.sh")) asked.push(options?.timeoutMs);
+ return { code: 0, stdout: "", stderr: "" };
+ }) as never,
+ );
+
+ await installCoolify(session, {
+ username: "dyad-admin",
+ email: "me@gmail.com",
+ password: "Abc123@xyz",
+ });
+ expect(asked).toEqual([undefined]);
+ });
+});
+
+describe("waiting for the admin account", () => {
+ it("asks with a bound, so one hung attempt cannot outlast the loop", async () => {
+ // The deadline is only looked at between attempts, so an unbounded
+ // question outlasts every bound there is. The bound belongs in the
+ // command — giving up on the answer should also stop the asking — so
+ // what is checked here is that the question carries one.
+ const asked: Array<{ timeoutMs?: number }> = [];
+ const session = sessionAnswering(
+ vi.fn(async (_command: string, options?: { timeoutMs?: number }) => {
+ asked.push({ timeoutMs: options?.timeoutMs });
+ return { code: 0, stdout: transcript("yes"), stderr: "" };
+ }) as never,
+ );
+
+ await expect(
+ waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 2_000,
+ intervalMs: 1,
+ attemptTimeoutMs: 20,
+ }),
+ ).resolves.toEqual({ seeded: true });
+
+ expect(asked[0]?.timeoutMs).toBe(20);
+ });
+
+ it("hands back a server to sign in to when the seeder itself dies", async () => {
+ // Coolify is on the machine either way. Ending the run here would report
+ // an install that did not happen and take the password Dyad invented down
+ // with it, when the honest answer is to go and sign in by hand.
+ const session = sessionAnswering(
+ vi.fn(async (command: string) => {
+ if (command.includes("RootUserSeeder")) throw new Error("wedged");
+ return { code: 0, stdout: transcript("no"), stderr: "" };
+ }) as never,
+ );
+
+ await expect(
+ waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 20,
+ intervalMs: 1,
+ attemptTimeoutMs: 20,
+ }),
+ ).resolves.toEqual({ seeded: false, reason: undefined });
+ });
+
+ it("keeps a cancellation a cancellation", async () => {
+ // Stopping throws here like anywhere else. Swallowed, it would end the run
+ // by telling the user to sign in to an install they just stopped.
+ const controller = new AbortController();
+ const session = sessionAnswering(
+ vi.fn(async (command: string) => {
+ if (command.includes("RootUserSeeder")) {
+ controller.abort();
+ throw new Error("aborted");
+ }
+ return { code: 0, stdout: transcript("no"), stderr: "" };
+ }) as never,
+ );
+
+ await expect(
+ waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 20,
+ intervalMs: 1,
+ attemptTimeoutMs: 20,
+ signal: controller.signal,
+ }),
+ ).rejects.toMatchObject({ kind: DyadErrorKind.UserCancelled });
+ });
+
+ it("sees the account past a notice printed beside the answer", async () => {
+ // Required to be the whole answer, one deprecation notice would report a
+ // seeded account as missing — and the run then fails with Coolify
+ // refusing to create an account it already created.
+ const session = sessionAnswering(
+ vi.fn(async () => ({
+ code: 0,
+ stdout: transcript("PHP Deprecated: something\nyes"),
+ stderr: "",
+ })) as never,
+ );
+
+ await expect(
+ waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 50,
+ intervalMs: 1,
+ attemptTimeoutMs: 50,
+ }),
+ ).resolves.toMatchObject({ seeded: true });
+ });
+
+ it("bounds the repair and the confirmation after it, not only the poll", async () => {
+ // The loop expiring is where the seeder runs, and the question after it
+ // is the same question — asked on the same server that just failed to
+ // answer four times.
+ const asked: Array<{ command: string; timeoutMs?: number }> = [];
+ const session = sessionAnswering(
+ vi.fn(async (command: string, options?: { timeoutMs?: number }) => {
+ asked.push({ command, timeoutMs: options?.timeoutMs });
+ return { code: 0, stdout: transcript("no"), stderr: "" };
+ }) as never,
+ );
+
+ await waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 20,
+ intervalMs: 1,
+ attemptTimeoutMs: 50,
+ });
+
+ // Every question, including the seeder and the confirmation after it.
+ expect(asked.length).toBeGreaterThan(1);
+ for (const question of asked) {
+ expect(question.timeoutMs).toBeGreaterThan(0);
+ }
+ });
+
+ it("asks again when one attempt was merely slow", async () => {
+ // The bound is ours, not the server's: the link is fine and the poll has
+ // minutes left. Treating it as a dead link ended the wait on the first
+ // slow answer, on a server where the account had in fact been seeded.
+ let asked = 0;
+ const session = sessionAnswering(
+ vi.fn(async () => {
+ asked += 1;
+ if (asked === 1) {
+ throw new SshError(
+ "command-timeout",
+ "The server did not answer in time.",
+ DyadErrorKind.External,
+ );
+ }
+ return { code: 0, stdout: transcript("yes"), stderr: "" };
+ }) as never,
+ );
+
+ await expect(
+ waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 2_000,
+ intervalMs: 1,
+ }),
+ ).resolves.toEqual({ seeded: true });
+ expect(asked).toBeGreaterThan(1);
+ });
+
+ it("reports the seeder's words when the last question times out", async () => {
+ // A bound being hit is not an answer, and it must not become the answer:
+ // the seeder has already said why it refused, and that is what the user
+ // needs. Pinned because the bound and the rethrow rule are set in two
+ // different places and either could stop agreeing with the other.
+ let asked = 0;
+ const session = sessionAnswering(
+ vi.fn(async (command: string) => {
+ asked += 1;
+ if (command.includes("db:seed")) {
+ return {
+ code: 0,
+ stdout: "ERROR Invalid Root User Environment Variables\n",
+ stderr: "",
+ };
+ }
+ throw new SshError(
+ "command-timeout",
+ "The server did not answer in time.",
+ DyadErrorKind.External,
+ );
+ }) as never,
+ );
+
+ const outcome = await waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 20,
+ intervalMs: 1,
+ attemptTimeoutMs: 5,
+ });
+
+ expect(outcome.seeded).toBe(false);
+ expect(outcome.reason).toContain("Invalid Root User");
+ expect(asked).toBeGreaterThan(1);
+ });
+
+ it("does not report a dead link as Coolify refusing the address", async () => {
+ // Waiting longer cannot revive a connection. Swallowed, it becomes a
+ // complaint about the email address — after polling a dead link for a
+ // minute and a half and then running the seeder down it as well.
+ let asked = 0;
+ const session = sessionAnswering(
+ vi.fn(async () => {
+ asked += 1;
+ throw new SshError(
+ "timeout",
+ "The server stopped answering.",
+ DyadErrorKind.External,
+ );
+ }) as never,
+ );
+
+ await expect(
+ waitForAdminSeeded(session, "me@gmail.com", {
+ timeoutMs: 2_000,
+ intervalMs: 1,
+ }),
+ ).rejects.toMatchObject({ failure: "timeout" });
+ // Once: it gave up on the first answer rather than polling a dead link
+ // and then asking the seeder down the same one.
+ expect(asked).toBe(1);
+ });
+});
diff --git a/src/coolify_setup/install.ts b/src/coolify_setup/install.ts
new file mode 100644
index 0000000000..2d8220ba10
--- /dev/null
+++ b/src/coolify_setup/install.ts
@@ -0,0 +1,462 @@
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { sleep } from "./sleep";
+import { plainUrlFor } from "./https_setup";
+import { SshError } from "@/ipc/utils/ssh_client";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+import { answerLine, runTinker } from "./tinker";
+import type { AdminCredentials } from "./admin_credentials";
+import { isShellSafe } from "./admin_credentials";
+
+/**
+ * Installing Coolify on a bare server.
+ *
+ * The installer is fetched on the server and fed to a shell there, with the
+ * admin account's details in the environment. Coolify seeds that account itself
+ * on first start, which is what saves the user a trip to the dashboard.
+ */
+
+/** Coolify's own published installer. */
+const INSTALLER_URL = "https://cdn.coollabs.io/coolify/install.sh";
+
+/** Coolify asks for 2GB; below it the install completes and then falls over. */
+const MINIMUM_MEMORY_MB = 1900;
+
+export interface Preflight {
+ ready: boolean;
+ /** Present when ready is false, phrased for the user. */
+ reason?: string;
+ alreadyInstalled: boolean;
+ /**
+ * Whether the probe actually settled the Coolify question.
+ *
+ * False means the evidence was unusable, not that the server is empty —
+ * docker wedged, or nothing read back at all. The two are the same
+ * `alreadyInstalled: false` and must not be treated alike: one of them is
+ * what stands between a user and installing over an instance that is
+ * already there, or dropping the only copy of a live admin password.
+ */
+ installedKnown: boolean;
+ memoryMb: number | null;
+}
+
+/**
+ * How long a question may take before it is not worth waiting for.
+ *
+ * Each of these is short and has an honest answer in seconds. A server whose
+ * docker has wedged answers none of them at all, and unbounded the step simply
+ * never ends — the user has to work out that nothing is happening and stop it
+ * themselves. The installer stays unbounded: it genuinely runs for minutes
+ * with nothing to say.
+ */
+const PROBE_TIMEOUT_MS = 30_000;
+/** Longer, because the seeder writes to Coolify's own database. */
+const SEEDER_TIMEOUT_MS = 60_000;
+
+/**
+ * Looks at the server before touching it.
+ *
+ * Checked first because the install takes minutes and every one of these is
+ * something the user can act on immediately — and because installing over an
+ * existing Coolify would be far worse than declining to.
+ */
+
+export async function preflight(
+ session: SshSession,
+ { signal }: { signal?: AbortSignal } = {},
+): Promise {
+ const probe = await session.run(
+ // One round trip rather than several: each answer is a labelled line, so a
+ // missing one is distinguishable from an empty one.
+ [
+ "echo \"mem=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo 2>/dev/null)\"",
+ // Whether Coolify is actually there, rather than whether a directory
+ // with its name is. A failed install leaves the directory behind, and
+ // treating that as an install refuses the retry that would fix it.
+ `echo "container=$(docker ps -a --filter name=^coolify$ --format '{{.Names}}' 2>/dev/null | head -1)"`,
+ // Whether the answer above means anything. A docker that is installed
+ // but not running answers nothing at all, which reads the same as a
+ // machine with no Coolify on it.
+ `echo "dockerok=$(if docker info >/dev/null 2>&1; then echo yes; elif command -v docker >/dev/null 2>&1; then echo no; else echo absent; fi)"`,
+ // A cloud server runs its own updates on first boot and holds the
+ // package lock while it does. Coolify's installer needs that lock to
+ // install Docker, fails when it cannot get it, and leaves the server
+ // half-set-up — so this is worth a second of checking beforehand.
+ // Asked of the lock rather than of process names: the updater runs as
+ // python3 with its own name only in the command line, so no name match
+ // finds it. fuser is not everywhere, so a name check still backs it up.
+ `echo "busy=$(fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock >/dev/null 2>&1 || pgrep -x 'apt|apt-get|dpkg' >/dev/null 2>&1 && echo yes || echo no)"`,
+ ].join("; "),
+ { signal, timeoutMs: PROBE_TIMEOUT_MS },
+ );
+
+ const read = (key: string): string | null => {
+ const match = new RegExp(`^${key}=(.*)$`, "m").exec(probe.stdout);
+ return match ? match[1].trim() : null;
+ };
+
+ // Every answer below is read out of one round trip, so an empty transcript
+ // is not a server with nothing on it — it is a question that never got
+ // asked. Answering it as "ready, no Coolify here" is the one wrong answer
+ // that matters: it is what stands between the user and installing over an
+ // instance that is already there.
+ if (read("mem") === null && read("busy") === null) {
+ return {
+ ready: false,
+ alreadyInstalled: false,
+ installedKnown: false,
+ memoryMb: null,
+ reason:
+ "Dyad could not read anything back from this server. It answered the " +
+ "connection but not the question — check it and try again.",
+ };
+ }
+
+ // Docker is there but will not answer, so what it said about Coolify is
+ // not evidence. Installing over an instance that is merely stopped is the
+ // outcome worth refusing.
+ if (read("dockerok") === "no") {
+ return {
+ ready: false,
+ alreadyInstalled: false,
+ installedKnown: false,
+ memoryMb: null,
+ reason:
+ "Docker is installed on this server but not responding, so Dyad " +
+ "cannot tell whether Coolify is already on it. Start Docker and try " +
+ "again.",
+ };
+ }
+
+ const memoryRaw = read("mem");
+ const memoryMb =
+ memoryRaw && /^\d+$/.test(memoryRaw) ? Number(memoryRaw) : null;
+ const alreadyInstalled = Boolean(read("container"));
+
+ if (read("busy") === "yes") {
+ return {
+ ready: false,
+ alreadyInstalled,
+ installedKnown: true,
+ memoryMb,
+ reason:
+ "This server is still finishing its own first-boot setup, which holds " +
+ "the package manager Coolify's installer needs. Wait a minute and " +
+ "check again.",
+ };
+ }
+ if (alreadyInstalled) {
+ return {
+ ready: false,
+ alreadyInstalled,
+ installedKnown: true,
+ memoryMb,
+ reason:
+ "This server already has Coolify on it. Connect to it with an API token " +
+ "instead of installing again.",
+ };
+ }
+ // Unreadable, not unlimited. The transcript guard above only fires when
+ // nothing at all came back, and a server whose /proc/meminfo cannot be read
+ // answers `mem=` — an empty value, which reaches here as null and would
+ // walk straight past the check below. Installing then finishes and Coolify
+ // does not run, on a machine that now refuses a second attempt.
+ if (memoryMb === null) {
+ return {
+ ready: false,
+ alreadyInstalled,
+ installedKnown: true,
+ memoryMb: null,
+ reason:
+ "Dyad could not read how much memory this server has, so it cannot " +
+ "tell whether Coolify would run on it. Check the server and try " +
+ "again.",
+ };
+ }
+ if (memoryMb < MINIMUM_MEMORY_MB) {
+ return {
+ ready: false,
+ alreadyInstalled,
+ installedKnown: true,
+ memoryMb,
+ reason:
+ `This server has ${memoryMb}MB of memory and Coolify needs about 2GB. ` +
+ `Installing would finish and then fail to run.`,
+ };
+ }
+ return { ready: true, alreadyInstalled, installedKnown: true, memoryMb };
+}
+
+/**
+ * The command that installs Coolify with its admin account seeded.
+ *
+ * Values are single-quoted and anything that could end that quoting is refused
+ * rather than escaped — these run as root on somebody else's machine, and a
+ * near-miss there is a command injection.
+ *
+ * The installer itself is piped to a shell, which is Coolify's documented way
+ * of running it.
+ */
+export function buildInstallScript(credentials: AdminCredentials): string {
+ for (const [label, value] of Object.entries(credentials)) {
+ if (!isShellSafe(value)) {
+ throw new DyadError(
+ `The ${label} contains a character that cannot be sent safely.`,
+ DyadErrorKind.Validation,
+ );
+ }
+ }
+ return (
+ [
+ // Without it the pipeline reports what the last command did, and a curl
+ // that downloaded nothing still ends in a bash that exits 0 — so a
+ // failed download would read as a finished install.
+ "set -o pipefail",
+ `export ROOT_USERNAME='${credentials.username}'`,
+ `export ROOT_USER_EMAIL='${credentials.email}'`,
+ `export ROOT_USER_PASSWORD='${credentials.password}'`,
+ 'curl -fsSL "$1" | bash',
+ ].join("\n") + "\n"
+ );
+}
+
+/**
+ * The command the script above is fed to.
+ *
+ * The address is an argument and the credentials are not: a command line is
+ * readable by every user on the machine through `ps`, while stdin is not. The
+ * installer's address is public, and having it there says what a long-running
+ * root command is doing.
+ */
+export function buildInstallCommand(): string {
+ return `bash -s -- ${INSTALLER_URL}`;
+}
+
+export async function installCoolify(
+ session: SshSession,
+ credentials: AdminCredentials,
+ {
+ onOutput,
+ signal,
+ }: { onOutput?: (chunk: string) => void; signal?: AbortSignal } = {},
+): Promise {
+ const result = await session.run(buildInstallCommand(), {
+ input: buildInstallScript(credentials),
+ onOutput,
+ signal,
+ });
+ if (result.code !== 0) {
+ // The installer's own last words, rather than only its exit code. Its
+ // most common failure on a new cloud server is losing a race for the
+ // package lock against the server's own first-boot updates, and that is
+ // only visible in what it printed.
+ const tail = `${result.stdout}\n${result.stderr}`
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(-3)
+ .join(" ");
+ throw new DyadError(
+ `Installing Coolify failed (exit ${result.code}).` +
+ (tail ? ` The server said: ${tail}` : ""),
+ DyadErrorKind.External,
+ );
+ }
+}
+
+/**
+ * Waits for the dashboard to answer.
+ *
+ * The installer returns before Coolify is listening, so this is what stands
+ * between finishing the install and using it. Any answer counts — a redirect to
+ * the login page is the dashboard working, not a failure.
+ */
+export async function waitForDashboard(
+ host: string,
+ {
+ timeoutMs = 5 * 60 * 1000,
+ intervalMs = 5_000,
+ signal,
+ now = () => Date.now(),
+ fetchImpl = fetch,
+ }: {
+ timeoutMs?: number;
+ intervalMs?: number;
+ signal?: AbortSignal;
+ now?: () => number;
+ fetchImpl?: typeof fetch;
+ } = {},
+): Promise {
+ const deadline = now() + timeoutMs;
+ while (now() < deadline) {
+ if (signal?.aborted) {
+ throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled);
+ }
+ try {
+ const res = await fetchImpl(plainUrlFor(host), {
+ method: "GET",
+ redirect: "manual",
+ signal: AbortSignal.timeout(5_000),
+ });
+ if (res.status > 0) return true;
+ } catch {
+ // Not listening yet, which is the expected state for the first minute.
+ }
+ await sleep(intervalMs, signal);
+ }
+ return false;
+}
+
+/**
+ * Whether Coolify has the account yet.
+ *
+ * A question about right now, not a verdict: the account is created by a
+ * startup service, and the dashboard starts answering before that service has
+ * run. Asked once, immediately, this says "no" about a server that is merely
+ * still starting.
+ *
+ * WORKAROUND: nothing exposes whether the first account exists, so this asks
+ * the model.
+ *
+ * TODO: replace if Coolify gains a way to ask about, or create, the first user.
+ */
+export async function isAdminSeeded(
+ session: SshSession,
+ email: string,
+ { signal, timeoutMs }: { signal?: AbortSignal; timeoutMs?: number } = {},
+): Promise {
+ try {
+ const output = await runTinker(
+ session,
+ `echo \\App\\Models\\User::where('email', getenv('DYAD_ADMIN_EMAIL'))->exists() ? 'yes' : 'no';`,
+ { env: { DYAD_ADMIN_EMAIL: email }, signal, timeoutMs },
+ );
+ // One line of the answer: a notice printed beside it must not read as
+ // the account not being there.
+ return Boolean(answerLine(output, (line) => line === "yes"));
+ } catch (error) {
+ // A container still starting cannot answer at all. That is not the same as
+ // answering no, and treating it as one is how a healthy server gets
+ // reported as a rejected email address.
+ const kind = (error as { kind?: string }).kind;
+ if (kind === "user_cancelled") throw error;
+ // A connection that has died is not a container still starting: waiting
+ // longer cannot help, and swallowing it reports a lost link as Coolify
+ // refusing the address. A bound we imposed ourselves is the opposite —
+ // the link is fine and the question is worth asking again, which is the
+ // whole reason the caller is polling.
+ if (error instanceof SshError && error.failure !== "command-timeout") {
+ throw error;
+ }
+ return false;
+ }
+}
+
+/**
+ * Runs Coolify's own seeder and returns what it said.
+ *
+ * Used only after waiting has not produced an account. It is both the repair —
+ * if the startup service somehow did not run, this runs it — and the
+ * diagnosis, because the seeder names the reason it refuses rather than
+ * leaving us to guess at one.
+ *
+ * WORKAROUND: an internal seeder is the only thing that creates the first
+ * user, so it is invoked by name.
+ *
+ * TODO: replace if Coolify gains a supported way to create the first account.
+ */
+export async function runAdminSeeder(
+ session: SshSession,
+ { signal }: { signal?: AbortSignal } = {},
+): Promise {
+ const result = await session.run(
+ "docker exec -i coolify php artisan db:seed --class=RootUserSeeder --no-ansi --force",
+ { signal, timeoutMs: SEEDER_TIMEOUT_MS },
+ );
+ return `${result.stdout}\n${result.stderr}`.trim();
+}
+
+export interface AdminSeedOutcome {
+ seeded: boolean;
+ /** The seeder's own words, when it had something to say about refusing. */
+ reason?: string;
+}
+
+/**
+ * Waits for the admin account, repairing and diagnosing if it never appears.
+ *
+ * The account arrives a little after the dashboard does, so this polls rather
+ * than deciding on the first answer. If waiting is not enough it runs the
+ * seeder directly: that fixes the case where the startup service did not run,
+ * and where the address is genuinely refused it produces the reason instead of
+ * an accusation invented here.
+ */
+export async function waitForAdminSeeded(
+ session: SshSession,
+ email: string,
+ {
+ timeoutMs = 90_000,
+ intervalMs = 5_000,
+ attemptTimeoutMs = 20_000,
+ signal,
+ now = () => Date.now(),
+ }: {
+ timeoutMs?: number;
+ intervalMs?: number;
+ /** One question, bounded — the loop's deadline cannot bound it. */
+ attemptTimeoutMs?: number;
+ signal?: AbortSignal;
+ now?: () => number;
+ } = {},
+): Promise {
+ const deadline = now() + timeoutMs;
+ while (now() < deadline) {
+ // Each attempt is bounded, not only the loop: asking goes over SSH, and a
+ // wedged docker daemon answers nothing at all, so an unbounded attempt
+ // outlasts the deadline it is supposed to be inside. Bounded in the
+ // command rather than raced beside it, so giving up on the answer also
+ // stops the question.
+ const answered = await isAdminSeeded(session, email, {
+ signal,
+ timeoutMs: attemptTimeoutMs,
+ });
+ if (answered) return { seeded: true };
+ await sleep(intervalMs, signal);
+ }
+
+ let output = "";
+ try {
+ output = await runAdminSeeder(session, { signal });
+ } catch {
+ // Cancelling stops this the way it stops everything else, and that is not
+ // a server that would not seed. It has to stay a cancellation, or the run
+ // ends by telling the user to go and sign in to an install they stopped.
+ if (signal?.aborted) {
+ throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled);
+ }
+ // Anything else leaves the install standing. Coolify is on the server, and
+ // a seeder that timed out or died says nothing either way about whether
+ // the account exists — so it is asked below rather than assumed, and what
+ // comes back is a server to sign in to by hand rather than a failed run.
+ // Ending here instead would take the password down with it.
+ }
+ // Asked once more, bounded like every other question. A bound that is hit
+ // reads as "no account yet" rather than as a failure — isAdminSeeded already
+ // answers that way — so the seeder's own words, which say why it refused,
+ // are what gets reported below.
+ if (
+ await isAdminSeeded(session, email, { signal, timeoutMs: attemptTimeoutMs })
+ ) {
+ return { seeded: true };
+ }
+
+ // Coolify prints its complaint as "ERROR Invalid Root User Environment
+ // Variables" followed by the field it objected to. Handing that back beats
+ // naming a cause we only assumed.
+ const complaint = output
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.startsWith("→") || /invalid|error/i.test(line))
+ .join(" ")
+ .trim();
+ return { seeded: false, reason: complaint || undefined };
+}
diff --git a/src/coolify_setup/server_key.test.ts b/src/coolify_setup/server_key.test.ts
new file mode 100644
index 0000000000..4b93383f57
--- /dev/null
+++ b/src/coolify_setup/server_key.test.ts
@@ -0,0 +1,80 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const h = vi.hoisted(() => ({ userData: "" }));
+
+vi.mock("@/paths/paths", () => ({ getUserDataPath: () => h.userData }));
+
+const { ensureServerKey, serverKeyPath } = await import("./server_key");
+
+describe("the key the user installs on their server", () => {
+ beforeEach(() => {
+ h.userData = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-server-key-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(h.userData, { recursive: true, force: true });
+ });
+
+ it("refuses a private half that cannot be read, whatever the .pub says", () => {
+ // The stored public half is not evidence about the private one. Handing
+ // it back for a key that cannot be parsed gives the user a line to
+ // install that nothing can connect with.
+ ensureServerKey();
+ fs.writeFileSync(
+ serverKeyPath(),
+ "-----BEGIN OPENSSH PRIVATE KEY-----\nbroken\n-----END OPENSSH PRIVATE KEY-----\n",
+ );
+
+ expect(() => ensureServerKey()).toThrow();
+ });
+
+ it("ignores a stored public half that belongs to another key", () => {
+ // The .pub is kept for its comment, not taken as evidence. A line for a
+ // different key is one the server would accept from someone else.
+ const derived = ensureServerKey().publicKey;
+ fs.writeFileSync(
+ `${serverKeyPath()}.pub`,
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISOMEOTHERKEYENTIRELYAAAAAAAAAAAA someone-else\n",
+ );
+
+ // The key itself, not the comment: a rejected line falls back to the
+ // derived one, which names itself differently.
+ const blob = (line: string) => line.split(" ")[1];
+ expect(blob(ensureServerKey().publicKey)).toBe(blob(derived));
+ });
+
+ it("hands back one key, whatever else the file has collected", () => {
+ // What this returns is pasted into a server's authorized_keys. A second
+ // line riding along would install a key nobody checked.
+ const derived = ensureServerKey().publicKey;
+ fs.appendFileSync(
+ `${serverKeyPath()}.pub`,
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISOMEOTHERKEYENTIRELYAAAAAAAAAAAA someone-else\n",
+ );
+
+ const shown = ensureServerKey().publicKey;
+ expect(shown.trim().split("\n")).toHaveLength(1);
+ expect(shown).toBe(derived);
+ });
+
+ it("ignores a stored line that claims a different key type", () => {
+ // The key is ed25519. A line naming it as something else describes a key
+ // no server would accept it as.
+ const blob = ensureServerKey().publicKey.split(" ")[1];
+ fs.writeFileSync(`${serverKeyPath()}.pub`, `ssh-rsa ${blob} borrowed\n`);
+
+ expect(ensureServerKey().publicKey.startsWith("ssh-ed25519 ")).toBe(true);
+ });
+
+ it("shows the same line every time it is asked", () => {
+ // The user pastes this into their server once. A different line on the
+ // next launch reads as a different key.
+ const first = ensureServerKey().publicKey;
+ const second = ensureServerKey().publicKey;
+
+ expect(second).toBe(first);
+ });
+});
diff --git a/src/coolify_setup/server_key.ts b/src/coolify_setup/server_key.ts
new file mode 100644
index 0000000000..87d822e110
--- /dev/null
+++ b/src/coolify_setup/server_key.ts
@@ -0,0 +1,100 @@
+import * as fs from "fs";
+import * as path from "path";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { getUserDataPath } from "@/paths/paths";
+import {
+ generateDeployKeyPair,
+ publicKeyFromPrivate,
+} from "@/ipc/utils/coolify_deploy_key";
+
+/**
+ * The key Dyad uses to reach a server it is setting up.
+ *
+ * Separate from the deploy keys, which are per repository and handed to GitHub
+ * and Coolify. This one is Dyad's own identity for logging into a machine, so
+ * there is one of it, it is never uploaded anywhere, and it outlives any single
+ * server: the user adds its public half once and can reuse it for the next
+ * server they set up.
+ *
+ * Kept out of ~/.ssh deliberately, for the reason the deploy keys are: that
+ * directory holds identities the user maintains by hand, and Dyad treats it as
+ * off-limits everywhere else.
+ */
+
+const KEY_NAME = "server_access";
+const KEY_COMMENT = "dyad-server-access";
+
+export function serverKeyDirPath(): string {
+ return path.join(getUserDataPath(), "coolify_server_key");
+}
+
+export function serverKeyPath(): string {
+ return path.join(serverKeyDirPath(), KEY_NAME);
+}
+
+export interface ServerKey {
+ /** What the user adds to the server's authorized_keys. */
+ publicKey: string;
+ /** OpenSSH format, which is what the wire library accepts. */
+ privateKey: string;
+}
+
+/**
+ * The stored line when it names the key on disk, and the derived one when not.
+ *
+ * Both halves matter. Deriving alone renames the key — the comment comes from
+ * whichever constant did the deriving — and the panel would show a different
+ * line each launch than the one already installed on the server. Trusting the
+ * stored file alone would hand back a public half belonging to some other key.
+ */
+function storedMatching(keyPath: string, derived: string): string {
+ try {
+ // The first line only, and matched on both fields. What comes back is
+ // pasted into a server's authorized_keys, so anything further down the
+ // file would be installed alongside the key it was checked for.
+ const stored = fs
+ .readFileSync(`${keyPath}.pub`, "utf8")
+ .split("\n")[0]
+ .trim();
+ const [storedType, storedKey] = stored.split(/\s+/);
+ const [derivedType, derivedKey] = derived.split(/\s+/);
+ const sameKey = storedType === derivedType && storedKey === derivedKey;
+ return sameKey ? `${stored}\n` : derived;
+ } catch {
+ return derived;
+ }
+}
+
+/**
+ * Returns Dyad's server key, creating it the first time.
+ *
+ * Reused rather than regenerated per server: the public half is something the
+ * user pastes into a console by hand, and making them do that again for every
+ * server would be the most tedious part of the whole flow.
+ */
+export function ensureServerKey(): ServerKey {
+ const keyPath = serverKeyPath();
+ if (fs.existsSync(keyPath)) {
+ const privateKey = fs.readFileSync(keyPath, "utf8");
+ const derived = publicKeyFromPrivate(privateKey);
+ if (derived) {
+ return { privateKey, publicKey: storedMatching(keyPath, derived) };
+ }
+ // A file that cannot be read as a key is worse than none: it would fail at
+ // connect time with something about the wire format. Say so here instead.
+ throw new DyadError(
+ `The server key at ${keyPath} could not be read. Delete it and Dyad will ` +
+ `generate a new one — you will need to add the new public key to your ` +
+ `server.`,
+ DyadErrorKind.Precondition,
+ );
+ }
+
+ const pair = generateDeployKeyPair(KEY_COMMENT);
+ fs.mkdirSync(serverKeyDirPath(), { recursive: true, mode: 0o700 });
+ // 0600 is a no-op on Windows, where the directory's own permissions are what
+ // protects this. Set anyway, because it is what protects it everywhere else.
+ fs.writeFileSync(keyPath, pair.privateKey, { mode: 0o600 });
+ fs.writeFileSync(`${keyPath}.pub`, pair.publicKey, { mode: 0o644 });
+ return { privateKey: pair.privateKey, publicKey: pair.publicKey };
+}
diff --git a/src/coolify_setup/setup_flow.integration.test.ts b/src/coolify_setup/setup_flow.integration.test.ts
new file mode 100644
index 0000000000..adefa40a2f
--- /dev/null
+++ b/src/coolify_setup/setup_flow.integration.test.ts
@@ -0,0 +1,114 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { runServerSetup } from "./setup_flow";
+import { connectSsh, trustOnFirstUse } from "@/ipc/utils/ssh_client";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+import {
+ generateSshKeyPair,
+ startFakeSshServer,
+ type FakeSshServer,
+} from "@/testing/fake_ssh_server";
+
+/**
+ * The setup flow over a real SSH connection, without the app around it.
+ *
+ * Dyad's own SSH client talks to a real ssh2 server here, so preflight, the
+ * installer and the tinker transcripts these tests reach are parsed from the
+ * shapes the library actually produces rather than ones a hand-written fake
+ * finds convenient.
+ *
+ * Only the two HTTP steps are stubbed: whether a dashboard answers and
+ * whether a certificate is issued are not questions a server on loopback can
+ * be asked, and neither goes over SSH.
+ *
+ * These live here rather than in e2e because none of them looks at the
+ * screen. The packaged app proves the wiring — that the handler is registered
+ * and the panel renders what it returns — and that is what the two remaining
+ * Playwright specs are for.
+ */
+
+const KEY = generateSshKeyPair().private;
+
+let server: FakeSshServer | undefined;
+
+beforeEach(async () => {
+ server = await startFakeSshServer();
+});
+
+afterEach(async () => {
+ // Optional: a server that failed to start leaves nothing to close, and
+ // throwing here would bury the reason it failed under a second error.
+ await server?.close();
+ server = undefined;
+});
+
+function runSetup() {
+ const output: string[] = [];
+ const done = runServerSetup({
+ onProgress: ({ output: chunk }) => {
+ if (chunk) output.push(chunk);
+ },
+ target: {
+ host: "127.0.0.1",
+ port: server!.port,
+ username: "root",
+ privateKey: KEY,
+ },
+ adminEmail: "me@gmail.com",
+ verifyHostKey: trustOnFirstUse(() => {}),
+ connect: (target, verify, signal): Promise =>
+ connectSsh(target, verify, { signal }),
+ // The dashboard is a URL, not a server: there is nothing on loopback to
+ // answer it, and answering it is not what these tests are about.
+ waitForDashboardImpl: async () => true,
+ tryEnableHttpsImpl: async (_session, host) => ({
+ instanceUrl: `http://${host}:8000`,
+ secure: false,
+ reason: "A loopback address cannot be given a certificate.",
+ }),
+ });
+ return { done, output };
+}
+
+describe("a server that refuses the install", () => {
+ it("reports what the installer said, not only that it failed", async () => {
+ // The installer's most common failure is losing a race for the package
+ // lock, and an exit code alone says nothing about that. Its last words do.
+ server!.state.installExit = 1;
+ const { done, output } = runSetup();
+
+ await expect(done).rejects.toThrow(/Coolify is up/);
+ // And through the stream, which is a different path: the panel's log is
+ // built from what arrives here, not from the message thrown at the end.
+ expect(output.join("")).toContain("Coolify is up");
+ });
+});
+
+describe("a Coolify too old to make its own token", () => {
+ it("finishes anyway and hands back the sign-in details", async () => {
+ // The server is installed and usable; only the token has to be made by
+ // hand. Throwing here would discard a working install.
+ server!.state.version = "3.1.0";
+
+ const result = await runSetup().done;
+
+ expect(result.token).toBeNull();
+ expect(result.tokenUnavailableReason).toBeTruthy();
+ expect(result.credentials.email).toBe("me@gmail.com");
+ expect(result.credentials.password).not.toBe("");
+ });
+});
+
+describe("a server that reports its exit status late", () => {
+ it("does not read the end of the output as the end of the command", async () => {
+ // A real sshd closes a command's output when its stdout closes and reports
+ // the status when the process is reaped. Closing the channel on the first
+ // of those loses the status — a finished install read as a failure, and
+ // against a real server a command killed while it was still running.
+ server!.state.exitAfterEofMs = 50;
+
+ const result = await runSetup().done;
+
+ expect(result.token).toBeTruthy();
+ expect(result.version).toBeTruthy();
+ });
+});
diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts
new file mode 100644
index 0000000000..47ff093d42
--- /dev/null
+++ b/src/coolify_setup/setup_flow.test.ts
@@ -0,0 +1,780 @@
+import { describe, expect, it, vi } from "vitest";
+import { runServerSetup, type SetupStep } from "./setup_flow";
+import { waitForAdminSeeded } from "./install";
+import { tryEnableHttps } from "./https_setup";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { SshError } from "@/ipc/utils/ssh_client";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+
+const REAL_TOKEN = "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95";
+
+function transcript(output: string): string {
+ return [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ output,
+ "__DYAD_OUT_END__",
+ ].join("\n");
+}
+
+/**
+ * A server that answers each command by what the command is for.
+ *
+ * Matching on the command rather than on call order means a test that changes
+ * the number of steps does not silently start answering the wrong question.
+ */
+function fakeServer(
+ overrides: {
+ probe?: string;
+ probeAfterInstall?: string;
+ installExit?: number;
+ seeded?: string;
+ seederOutput?: string;
+ httpsWorks?: boolean;
+ version?: string;
+ apiEnabled?: string;
+ token?: string;
+ } = {},
+) {
+ const commands: string[] = [];
+ const scripts: string[] = [];
+ let probes = 0;
+ const session: SshSession = {
+ run: vi.fn(async (command: string, options?: { input?: string }) => {
+ commands.push(command);
+ // Tinker scripts travel on stdin, so what is being asked is in the
+ // input rather than in the command — which is the whole point of
+ // piping them.
+ const script = options?.input ?? "";
+ scripts.push(script);
+ if (command.includes("MemTotal")) {
+ probes += 1;
+ const after = overrides.probeAfterInstall;
+ return {
+ code: 0,
+ stdout:
+ (probes > 1 && after ? after : overrides.probe) ??
+ "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no\narch=x86_64",
+ stderr: "",
+ };
+ }
+ if (command.includes("install.sh")) {
+ return {
+ code: overrides.installExit ?? 0,
+ stdout: "installed",
+ stderr: "",
+ };
+ }
+ if (script.includes("constants.coolify.version")) {
+ return {
+ code: 0,
+ stdout: transcript(overrides.version ?? "4.3.2"),
+ stderr: "",
+ };
+ }
+ if (script.includes("->exists()")) {
+ return {
+ code: 0,
+ stdout: transcript(overrides.seeded ?? "yes"),
+ stderr: "",
+ };
+ }
+ if (command.includes("RootUserSeeder")) {
+ return {
+ code: 0,
+ stdout:
+ overrides.seederOutput ??
+ " ERROR Invalid Root User Environment Variables\n \u2192 The email field must be a valid email address.",
+ stderr: "",
+ };
+ }
+ if (script.includes("createToken")) {
+ return {
+ code: 0,
+ stdout: transcript(overrides.token ?? REAL_TOKEN),
+ stderr: "",
+ };
+ }
+ if (script.includes("setupDynamicProxyConfiguration")) {
+ return { code: 0, stdout: transcript("applied"), stderr: "" };
+ }
+ if (script.includes("is_api_enabled")) {
+ return {
+ code: 0,
+ stdout: transcript(overrides.apiEnabled ?? "enabled"),
+ stderr: "",
+ };
+ }
+ return { code: 0, stdout: "", stderr: "" };
+ }) as unknown as SshSession["run"],
+ end: vi.fn(),
+ };
+ return { session, commands, scripts, httpsWorks: overrides.httpsWorks };
+}
+
+function run(
+ server: ReturnType,
+ extra: Partial[0]> = {},
+) {
+ const steps: SetupStep[] = [];
+ return {
+ steps,
+ promise: runServerSetup({
+ target: { host: "203.0.113.5", username: "root", privateKey: "KEY" },
+ adminEmail: "admin@gmail.com",
+ verifyHostKey: () => true,
+ connect: async () => server.session,
+ waitForDashboardImpl: async () => true,
+ // The real waiter, wound right down: what matters is that it polls, and
+ // five-second sleeps would only make the suite slow.
+ // The real HTTPS logic, wound down. What matters is that it asks, checks,
+ // and reverts — five-second polls would only make the suite slow.
+ tryEnableHttpsImpl: (session, host, options) =>
+ tryEnableHttps(session, host, {
+ ...options,
+ timeoutMs: 40,
+ intervalMs: 5,
+ check: async () => server.httpsWorks !== false,
+ }),
+ waitForAdminSeededImpl: (session, email, options) =>
+ waitForAdminSeeded(session, email, {
+ ...options,
+ timeoutMs: 40,
+ intervalMs: 5,
+ }),
+ onProgress: ({ step }) => {
+ if (steps[steps.length - 1] !== step) steps.push(step);
+ },
+ ...extra,
+ }),
+ };
+}
+
+describe("runServerSetup", () => {
+ it("takes a bare server all the way to a token", async () => {
+ const server = fakeServer();
+ const { promise, steps } = run(server);
+ const result = await promise;
+
+ expect(result.token).toBe(REAL_TOKEN);
+ expect(result.version).toBe("4.3.2");
+ // HTTPS by default: the token carries root abilities and travels on every
+ // deploy, so the address it travels to is worth a certificate.
+ expect(result.dashboardUrl).toBe("https://203.0.113.5.sslip.io");
+ expect(result.secure).toBe(true);
+ expect(result.credentials.email).toBe("admin@gmail.com");
+ expect(steps).toEqual([
+ "connecting",
+ "checking-server",
+ "installing",
+ "waiting-for-dashboard",
+ "verifying-account",
+ "securing",
+ "creating-token",
+ "done",
+ ]);
+ });
+
+ it("looks at the server before installing anything", async () => {
+ // The two things a user can fix immediately cost a second to find, and the
+ // install costs minutes. Finding them afterwards wastes both.
+ const server = fakeServer({
+ probe: "os=ubuntu\nmem=1967\ndir=yes\ncontainer=coolify\nbusy=no",
+ });
+ await expect(run(server).promise).rejects.toMatchObject({
+ kind: "precondition",
+ });
+ expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false);
+ });
+
+ it("waits rather than racing the server's own first-boot updates", async () => {
+ // A new cloud server holds the package lock while it updates itself, and
+ // Coolify's installer needs that lock for Docker. Losing that race leaves
+ // the server half set up, which is worse than a second of checking.
+ const server = fakeServer({
+ probe: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=yes",
+ });
+ await expect(run(server).promise).rejects.toThrow(/first-boot setup/);
+ expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false);
+ });
+
+ it("allows a retry after an install that did not finish", async () => {
+ // A failed install leaves /data/coolify behind with nothing running.
+ // Reading that as "already installed" refuses the retry that would fix it.
+ const server = fakeServer({
+ probe: "os=ubuntu\nmem=1967\ndir=yes\ncontainer=\nbusy=no",
+ });
+ await expect(run(server).promise).resolves.toBeTruthy();
+ });
+
+ it("refuses a server too small to run what it would install", async () => {
+ const server = fakeServer({
+ probe: "os=ubuntu\nmem=980\ndir=no\ncontainer=\nbusy=no",
+ });
+ await expect(run(server).promise).rejects.toMatchObject({
+ kind: "precondition",
+ });
+ expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false);
+ });
+
+ it("keeps the install when only the token could not be created", async () => {
+ // The server is set up and usable; throwing here would discard that over
+ // the one step the user can complete by hand.
+ const server = fakeServer({ version: "3.1.0" });
+ const result = await run(server).promise;
+
+ expect(result.token).toBeNull();
+ expect(result.tokenUnavailableReason).toBeTruthy();
+ expect(result.dashboardUrl).toBe("https://203.0.113.5.sslip.io");
+ expect(result.credentials.password).toBeTruthy();
+ // Too old to set up automatically, so nothing was switched on and
+ // switching it on is still the user's to do.
+ expect(result.apiEnabled).toBe(false);
+ });
+
+ it("does not call an unreadable version an unsupported one", async () => {
+ // The whole install fetches the newest Coolify, so the version being too
+ // old is the least likely reason the token step did not finish. Reported
+ // that way, the user goes looking for a problem with a server that is
+ // minutes old.
+ const server = fakeServer({ version: "Command not found" });
+ const result = await run(server).promise;
+
+ expect(result.token).toBeNull();
+ expect(result.tokenUnavailableReason).toMatch(
+ /could not read which version/,
+ );
+ expect(result.tokenUnavailableReason).not.toMatch(
+ /version of Coolify could not be set up/,
+ );
+ // The install still stands, and the way on is on the screen.
+ expect(result.credentials.password).toBeTruthy();
+ });
+
+ it("does not tell the user a fresh install is too old when it was only slow", async () => {
+ // A tinker one-liner over the 30s bound on a 2GB box right after an
+ // install is ordinary. Reported as an unsupported version, the user is
+ // told something false about a Coolify they installed minutes ago, and
+ // preflight refuses to install again — so there is nothing to act on.
+ const server = fakeServer();
+ const answering = server.session.run as unknown as (
+ command: string,
+ options?: { input?: string },
+ ) => Promise;
+ server.session.run = (async (
+ command: string,
+ options?: { input?: string },
+ ) => {
+ if ((options?.input ?? "").includes("constants.coolify.version")) {
+ throw new SshError(
+ "command-timeout",
+ "timed out",
+ DyadErrorKind.External,
+ );
+ }
+ return answering(command, options);
+ }) as unknown as SshSession["run"];
+
+ const result = await run(server).promise;
+
+ expect(result.token).toBeNull();
+ expect(result.tokenUnavailableReason).toMatch(/did not answer in time/);
+ expect(result.tokenUnavailableReason).not.toMatch(/version of Coolify/);
+ // The install still stands, and the address is still usable.
+ expect(result.credentials.password).toBeTruthy();
+ });
+
+ it("does not claim the API was opened when opening it is what failed", async () => {
+ // Reported once the server has confirmed it, not when the attempt
+ // starts: saying it is on when it is not sends the user past the one
+ // step they still have to do by hand.
+ const server = fakeServer({ apiEnabled: "still-disabled" });
+ const result = await run(server).promise;
+
+ expect(result.token).toBeNull();
+ expect(result.apiEnabled).toBe(false);
+ });
+
+ it("names the token step when the link dies making one", async () => {
+ // The API is already on by then, and the panel no longer tells the user
+ // to enable it — so blaming the API step would state a cause its own
+ // remedy contradicts.
+ const server = fakeServer();
+ const original = server.session.run as unknown as (
+ command: string,
+ options?: { input?: string },
+ ) => Promise;
+ server.session.run = (async (
+ command: string,
+ options?: { input?: string },
+ ) => {
+ if ((options?.input ?? "").includes("createToken")) {
+ throw new SshError(
+ "timeout",
+ "the connection stopped answering",
+ DyadErrorKind.External,
+ );
+ }
+ return original(command, options);
+ }) as unknown as SshSession["run"];
+
+ const result = await run(server).promise;
+
+ expect(result.apiEnabled).toBe(true);
+ expect(result.tokenUnavailableReason).toBe(
+ "Coolify stopped answering while Dyad was making a token.",
+ );
+ });
+
+ it("remembers the API was opened even when the token step then failed", async () => {
+ // Opening the API and minting a token are two steps in that order, so a
+ // mint that fails leaves the first done. Reporting otherwise sends the
+ // user to turn on something that is already on.
+ const server = fakeServer({ token: "" });
+ const result = await run(server).promise;
+
+ expect(result.token).toBeNull();
+ expect(result.tokenUnavailableReason).toBeTruthy();
+ expect(result.apiEnabled).toBe(true);
+ });
+
+ it("keeps the install when HTTPS cannot even be attempted", async () => {
+ // The server is installed and running by this point. HTTPS improves it;
+ // failing to get it must not be able to throw it away.
+ const server = fakeServer();
+ const result = await run(server, {
+ tryEnableHttpsImpl: async () => {
+ throw new Error("proxy would not restart");
+ },
+ }).promise;
+
+ expect(result.secure).toBe(false);
+ expect(result.dashboardUrl).toBe("http://203.0.113.5:8000");
+ expect(result.insecureReason).toContain("proxy would not restart");
+ expect(result.credentials.password).toBeTruthy();
+ });
+
+ it("carries what a failed HTTPS attempt left behind onto the screen", async () => {
+ // The run goes on and succeeds from here, so the failed state that would
+ // otherwise carry this is never reached — the finished screen is the only
+ // place left to say a domain is still pointing at the server.
+ const server = fakeServer();
+ const result = await run(server, {
+ tryEnableHttpsImpl: async () => {
+ throw Object.assign(new Error("proxy would not restart"), {
+ warning: "Coolify may still be configured for x.sslip.io.",
+ });
+ },
+ }).promise;
+
+ expect(result.secure).toBe(false);
+ expect(result.insecureReason).toContain("proxy would not restart");
+ expect(result.insecureReason).toContain("may still be configured");
+ // Two sentences. The library's message may or may not end in a stop of
+ // its own, and neither spelling should run the two together.
+ expect(result.insecureReason).toContain("restart. Coolify");
+ });
+
+ it("does not leave a stop stranded after the message's own punctuation", async () => {
+ // Coolify's refusals end in a colon before whatever it printed, and an
+ // empty answer leaves that colon last.
+ const server = fakeServer();
+ const result = await run(server, {
+ tryEnableHttpsImpl: async () => {
+ throw Object.assign(new Error("Coolify did not apply the domain:"), {
+ warning: "Coolify may still be configured for x.sslip.io.",
+ });
+ },
+ }).promise;
+
+ expect(result.insecureReason).toContain("domain. Coolify");
+ expect(result.insecureReason).not.toContain(":.");
+ });
+
+ it("still stops when the user cancels during HTTPS", async () => {
+ // Cancelling is the user asking for the work to stop, which is not the
+ // same as a step that could not be done.
+ const server = fakeServer();
+ await expect(
+ run(server, {
+ tryEnableHttpsImpl: async () => {
+ throw Object.assign(new Error("Cancelled."), {
+ kind: "user_cancelled",
+ });
+ },
+ }).promise,
+ ).rejects.toMatchObject({ kind: "user_cancelled" });
+ });
+
+ it("gives up on a server that stops answering after a failed install", async () => {
+ // A frozen machine leaves the connection half-open: the question never
+ // comes back and nothing else times it out. Unbounded, the run never
+ // settles and the one-at-a-time slot is never freed, so no later setup
+ // can start at all.
+ const server = fakeServer({ installExit: 1 });
+ const original = server.session.run as unknown as (
+ command: string,
+ options?: unknown,
+ ) => Promise;
+ let installed = false;
+ server.session.run = (async (command: string, options?: unknown) => {
+ if (command.includes("install.sh")) {
+ installed = true;
+ return original(command, options);
+ }
+ // Every probe after the install goes unanswered.
+ if (installed) return new Promise(() => {});
+ return original(command, options);
+ }) as unknown as SshSession["run"];
+
+ const seen: string[] = [];
+ await expect(
+ run(server, {
+ recoveryProbeTimeoutMs: 20,
+ onAccountKnown: ({ credentials }) => seen.push(credentials.password),
+ }).promise,
+ ).rejects.toThrow(/Installing Coolify failed/);
+ // A question that never came back is not an answer of "nothing was
+ // installed". install.sh may already have written this password into
+ // Coolify's .env, and dropping the only copy of it cannot be undone from
+ // here — preflight refuses to install over the container again.
+ expect(seen).toHaveLength(1);
+ });
+
+ it("hands the account over when the probe cannot tell either way", async () => {
+ // Docker is there but will not answer, so what it said about Coolify is
+ // not evidence. preflight reports that as the same "no Coolify here" as
+ // an empty server, and only one of those means the password is dead.
+ const server = fakeServer({
+ installExit: 1,
+ probeAfterInstall: "os=ubuntu\nmem=1967\ndockerok=no\nbusy=no",
+ });
+ const seen: string[] = [];
+ await expect(
+ run(server, {
+ onAccountKnown: ({ credentials }) => seen.push(credentials.password),
+ }).promise,
+ ).rejects.toThrow(/Installing Coolify failed/);
+
+ expect(seen).toHaveLength(1);
+ });
+
+ it("hands the account over when a failed install still left Coolify there", async () => {
+ // install.sh writes the password into Coolify's own .env and brings the
+ // stack up partway through. Failing after that leaves an account nobody
+ // else knows the password for, and preflight refuses to install again.
+ const server = fakeServer({
+ installExit: 1,
+ probeAfterInstall: "os=ubuntu\nmem=1967\ncontainer=coolify\nbusy=no",
+ });
+ const seen: string[] = [];
+ await expect(
+ run(server, {
+ onAccountKnown: ({ credentials }) => seen.push(credentials.password),
+ }).promise,
+ ).rejects.toThrow();
+
+ expect(seen).toHaveLength(1);
+ });
+
+ it("keeps a failed install from overwriting another server's password", async () => {
+ // The common install failure is losing the package lock, which happens
+ // before install.sh writes anything. Handing the account over at that
+ // point would replace the credentials for a server that does exist with
+ // ones for a server that does not.
+ const server = fakeServer({ installExit: 1 });
+ const seen: string[] = [];
+ await expect(
+ run(server, {
+ onAccountKnown: ({ credentials }) => seen.push(credentials.password),
+ }).promise,
+ ).rejects.toThrow();
+
+ expect(seen).toHaveLength(0);
+ });
+
+ it("hands over the account even when the dashboard never answers", async () => {
+ // The poll runs on the user's side of their firewall; the account is
+ // created by the server from the .env the installer already wrote. A
+ // closed port 8000 says nothing about whether the account exists.
+ const server = fakeServer();
+ const seen: string[] = [];
+ await expect(
+ run(server, {
+ onAccountKnown: ({ credentials }) => seen.push(credentials.password),
+ waitForDashboardImpl: async () => false,
+ }).promise,
+ ).rejects.toThrow(/nothing answered on port 8000/);
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0]).toBeTruthy();
+ });
+
+ it("hands over the account as soon as it exists, not at the end", async () => {
+ // Everything after this point can fail on a server that is installed and
+ // running. Dyad invented this password and never showed it, so a caller
+ // that only learns it on success cannot store what it never received.
+ const server = fakeServer();
+ const seen: Array<{ password: string; dashboardUrl: string }> = [];
+ await expect(
+ run(server, {
+ onAccountKnown: ({ credentials, dashboardUrl }) =>
+ seen.push({ password: credentials.password, dashboardUrl }),
+ tryEnableHttpsImpl: async () => {
+ throw Object.assign(new Error("Cancelled."), {
+ kind: "user_cancelled",
+ });
+ },
+ }).promise,
+ ).rejects.toMatchObject({ kind: "user_cancelled" });
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0].password).toBeTruthy();
+ expect(seen[0].dashboardUrl).toBe("http://203.0.113.5:8000");
+ });
+
+ it("says so again once the address settles on HTTPS", async () => {
+ // The address is part of signing in, and it is not known until the
+ // certificate is. Left at the first answer, a later save would read as a
+ // different Coolify and drop the account.
+ const server = fakeServer();
+ const seen: string[] = [];
+ await run(server, {
+ onAccountKnown: ({ dashboardUrl }) => seen.push(dashboardUrl),
+ }).promise;
+
+ expect(seen).toEqual([
+ "http://203.0.113.5:8000",
+ "https://203.0.113.5.sslip.io",
+ ]);
+ });
+
+ it("keeps the install when enabling the API fails outright", async () => {
+ const server = fakeServer({ apiEnabled: "still-disabled" });
+ const result = await run(server).promise;
+
+ expect(result.token).toBeNull();
+ expect(result.tokenUnavailableReason).toContain("API");
+ });
+
+ it("reports the seeder's own reason rather than inventing one", async () => {
+ // The account never appears, so the seeder is run directly and what it
+ // says is handed back. Guessing here is how a working gmail address got
+ // reported as a domain that does not resolve.
+ const server = fakeServer({ seeded: "no" });
+ await expect(run(server).promise).rejects.toThrow(
+ /must be a valid email address/,
+ );
+ });
+
+ it("says where to finish when the account could not be seeded", async () => {
+ // Coolify is on the machine either way, and preflight refuses to install
+ // over it — so what it objected to is only half of what the user needs.
+ // The other half was only ever said when there was nothing to report.
+ const server = fakeServer({ seeded: "no" });
+ await expect(run(server).promise).rejects.toThrow(
+ /The server is installed — open http:\/\/203\.0\.113\.5:8000/,
+ );
+ });
+
+ it("waits for an account that has not been created yet", async () => {
+ // The dashboard answers before the startup service that seeds the account
+ // has run, so the first answer is no on a perfectly healthy server.
+ let asked = 0;
+ const server = fakeServer();
+ const inner = server.session.run as unknown as (
+ c: string,
+ o?: { input?: string },
+ ) => Promise;
+ server.session.run = (async (c: string, o?: { input?: string }) => {
+ if ((o?.input ?? "").includes("->exists()")) {
+ asked += 1;
+ return {
+ code: 0,
+ stdout: transcript(asked === 1 ? "no" : "yes"),
+ stderr: "",
+ };
+ }
+ return inner(c, o);
+ }) as unknown as SshSession["run"];
+
+ await expect(run(server).promise).resolves.toBeTruthy();
+ expect(asked).toBeGreaterThan(1);
+ });
+
+ it("falls back to plain HTTP when no certificate arrives", async () => {
+ // Certificates depend on a third party that can refuse. A server nobody
+ // can open would be worse than one that is merely unencrypted, so the
+ // domain is taken back off and the address returns to port 8000.
+ const server = fakeServer({ httpsWorks: false });
+ const result = await run(server).promise;
+
+ expect(result.secure).toBe(false);
+ expect(result.dashboardUrl).toBe("http://203.0.113.5:8000");
+ expect(result.insecureReason).toBeTruthy();
+ // The revert itself, not just that some tinker ran: reading the version
+ // and minting a token are tinkers too, and they run in this test either
+ // way.
+ expect(server.scripts.some((t) => t.includes("fqdn = null"))).toBe(true);
+ });
+
+ it("stops when the dashboard never answers", async () => {
+ const server = fakeServer();
+ await expect(
+ run(server, { waitForDashboardImpl: async () => false }).promise,
+ ).rejects.toMatchObject({ kind: "external" });
+ });
+
+ it("fails when the install itself fails", async () => {
+ const server = fakeServer({ installExit: 1 });
+ await expect(run(server).promise).rejects.toMatchObject({
+ kind: "external",
+ });
+ });
+
+ it("closes the connection whichever way it ends", async () => {
+ const ok = fakeServer();
+ await run(ok).promise;
+ expect(ok.session.end).toHaveBeenCalled();
+
+ const bad = fakeServer({ installExit: 1 });
+ await run(bad).promise.catch(() => {});
+ expect(bad.session.end).toHaveBeenCalled();
+ });
+
+ it("hands over the password before the installer is asked to use it", async () => {
+ // The installer writes it into the server's .env partway through a run of
+ // minutes. Anything that ends the process in between takes the only copy
+ // with it, and preflight then refuses to install over the container.
+ const server = fakeServer();
+ const seen: Array<{ password: string; at: number }> = [];
+ let installs = 0;
+ const original = server.session.run;
+ server.session.run = vi.fn(
+ async (command: string, options?: { input?: string }) => {
+ if (command.includes("bash -s")) installs += 1;
+ return (original as unknown as typeof server.session.run)(
+ command,
+ options,
+ );
+ },
+ ) as unknown as SshSession["run"];
+
+ await run(server, {
+ onCredentialsBuilt: ({
+ credentials,
+ }: {
+ credentials: { password: string };
+ }) => seen.push({ password: credentials.password, at: installs }),
+ }).promise;
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0].password).toBeTruthy();
+ // Before the installer had run, not after.
+ expect(seen[0].at).toBe(0);
+ });
+
+ it("does not install when the caller could not keep the credentials", async () => {
+ // The hook above runs before the installer for a reason, and the caller
+ // refuses there when it cannot record the password. That only costs a
+ // retry if nothing has been installed by then.
+ const server = fakeServer();
+ let installs = 0;
+ const original = server.session.run;
+ server.session.run = ((command: string, options?: { input?: string }) => {
+ if (command.includes("bash -s")) installs += 1;
+ return (original as unknown as typeof server.session.run)(
+ command,
+ options,
+ );
+ }) as unknown as SshSession["run"];
+
+ await expect(
+ run(server, {
+ onCredentialsBuilt: () => {
+ throw new DyadError("nowhere to keep it", DyadErrorKind.External);
+ },
+ }).promise,
+ ).rejects.toThrow(/nowhere to keep it/);
+
+ expect(installs).toBe(0);
+ });
+
+ it("says the link died rather than blaming the version for it", async () => {
+ // Answering null for a dead connection sends the user to the screen that
+ // tells them their freshly installed Coolify is too old to drive — for a
+ // question that never reached it. The install still stands either way.
+ const server = fakeServer();
+ server.session.run = vi.fn(
+ async (command: string, options?: { input?: string }) => {
+ const script = options?.input ?? "";
+ if (script.includes("constants.coolify.version")) {
+ throw new SshError(
+ "timeout",
+ "the connection stopped answering",
+ DyadErrorKind.External,
+ );
+ }
+ if (command.includes("MemTotal")) {
+ return {
+ code: 0,
+ stdout: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no",
+ stderr: "",
+ };
+ }
+ if (script.includes("->exists()")) {
+ return { code: 0, stdout: transcript("yes"), stderr: "" };
+ }
+ if (script.includes("setupDynamicProxyConfiguration")) {
+ return { code: 0, stdout: transcript("applied"), stderr: "" };
+ }
+ return { code: 0, stdout: "", stderr: "" };
+ },
+ ) as unknown as SshSession["run"];
+
+ const result = await run(server).promise;
+
+ expect(result.token).toBeNull();
+ expect(result.tokenUnavailableReason).toBe(
+ "Coolify did not answer while Dyad was opening its API.",
+ );
+ expect(result.credentials.password).toBeTruthy();
+ // The step that would have opened it is the one that failed, so it is
+ // still the user's to do. Reported after it settles, not when it starts.
+ expect(result.apiEnabled).toBe(false);
+ });
+
+ it("passes cancellation through rather than reporting it as a token problem", async () => {
+ // A cancelled setup is the user's decision, not an instance that could not
+ // be driven — reporting it as the latter would claim a server exists.
+ const server = fakeServer();
+ server.session.run = vi.fn(
+ async (command: string, options?: { input?: string }) => {
+ const script = options?.input ?? "";
+ if (script.includes("constants.coolify.version")) {
+ throw Object.assign(new Error("Cancelled."), {
+ kind: "user_cancelled",
+ });
+ }
+ if (command.includes("MemTotal")) {
+ return {
+ code: 0,
+ stdout: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no",
+ stderr: "",
+ };
+ }
+ if (script.includes("->exists()")) {
+ return { code: 0, stdout: transcript("yes"), stderr: "" };
+ }
+ if (script.includes("setupDynamicProxyConfiguration")) {
+ return { code: 0, stdout: transcript("applied"), stderr: "" };
+ }
+ return { code: 0, stdout: "", stderr: "" };
+ },
+ ) as unknown as SshSession["run"];
+
+ await expect(run(server).promise).rejects.toMatchObject({
+ kind: "user_cancelled",
+ });
+ });
+});
diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts
new file mode 100644
index 0000000000..4c425bbc9a
--- /dev/null
+++ b/src/coolify_setup/setup_flow.ts
@@ -0,0 +1,350 @@
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import type {
+ HostKeyVerifier,
+ SshSession,
+ SshTarget,
+} from "@/ipc/utils/ssh_client";
+import { buildAdminCredentials } from "./admin_credentials";
+import type { AdminCredentials } from "./admin_credentials";
+import {
+ installCoolify,
+ preflight,
+ waitForAdminSeeded,
+ waitForDashboard,
+} from "./install";
+import { SshError } from "@/ipc/utils/ssh_client";
+import { tryAutomaticAccess } from "./api_token";
+import { plainUrlFor, tryEnableHttps } from "./https_setup";
+import type { HttpsOutcome } from "./https_setup";
+
+/**
+ * Taking a bare server to a Coolify Dyad can deploy to.
+ *
+ * The order is not arbitrary: each step is the cheapest way to fail from where
+ * it sits. Looking at the server costs a second and rules out the two problems
+ * a user can fix immediately; installing costs minutes; and asking for a token
+ * only makes sense once there is an instance to ask about.
+ */
+
+export type SetupStep =
+ | "connecting"
+ | "checking-server"
+ | "installing"
+ | "waiting-for-dashboard"
+ | "verifying-account"
+ | "securing"
+ | "creating-token"
+ | "done";
+
+export interface SetupProgress {
+ step: SetupStep;
+ /** Installer output, forwarded so a long step does not look like a hang. */
+ output?: string;
+}
+
+export interface SetupResult {
+ dashboardUrl: string;
+ /** Whether the address above is encrypted. */
+ secure: boolean;
+ /** Present when HTTPS was attempted and could not be had. */
+ insecureReason?: string;
+ credentials: AdminCredentials;
+ /**
+ * Absent when Coolify was installed but its API could not be opened.
+ *
+ * Not an error: the server is set up and usable either way, and the caller
+ * asks for a token by hand rather than throwing away a working install.
+ */
+ token: string | null;
+ /**
+ * Whether Coolify's API was switched on, which outlives a failed mint.
+ *
+ * Not the same question as whether a token came back: Dyad turns the API on
+ * first, so telling the user to go and enable it is wrong from that point
+ * onward whatever happens next.
+ */
+ apiEnabled: boolean;
+ version: string | null;
+ /** Present when token is null, phrased for the user. */
+ tokenUnavailableReason?: string;
+}
+
+/**
+ * How long the after-a-failure question may take.
+ *
+ * It goes to a server that has just failed and may be frozen. The connection
+ * now notices a peer that stops answering, but only after its own keepalive
+ * has run out — minutes, against a question worth seconds. Bounded here so
+ * the answer arrives while the failure it is about is still on screen.
+ */
+const RECOVERY_PROBE_TIMEOUT_MS = 15_000;
+
+export interface SetupOptions {
+ target: SshTarget;
+ adminEmail: string;
+ verifyHostKey: HostKeyVerifier;
+ onProgress?: (progress: SetupProgress) => void;
+ signal?: AbortSignal;
+ /** Injected so the flow can be exercised without a server. */
+ connect: (
+ target: SshTarget,
+ verify: HostKeyVerifier,
+ signal?: AbortSignal,
+ ) => Promise;
+ waitForDashboardImpl?: typeof waitForDashboard;
+ waitForAdminSeededImpl?: typeof waitForAdminSeeded;
+ tryEnableHttpsImpl?: typeof tryEnableHttps;
+ /** Bounded so a frozen server cannot hold the setup open. */
+ recoveryProbeTimeoutMs?: number;
+ /** A domain the user owns, used instead of one derived from the address. */
+ customDomain?: string | null;
+ /**
+ * The account exists on the user's server from here on.
+ *
+ * Dyad invented this password and never showed it, so anything that fails
+ * after this point and takes the password with it leaves the user locked out
+ * of a Coolify that is installed and running. Called again once the address
+ * settles, since HTTPS can change it.
+ */
+ onAccountKnown?: (account: {
+ credentials: AdminCredentials;
+ dashboardUrl: string;
+ }) => void;
+ /**
+ * The password Dyad is about to give the server, before it is given.
+ *
+ * Dyad invents this rather than discovering it, so it is knowable a moment
+ * earlier than the account is — and the installer writes it into Coolify's
+ * own .env partway through a run that takes minutes. Quitting in between
+ * would otherwise leave a server nobody has the password for, which
+ * preflight then refuses to install over.
+ */
+ onCredentialsBuilt?: (account: {
+ credentials: AdminCredentials;
+ dashboardUrl: string;
+ }) => void;
+}
+
+export async function runServerSetup({
+ target,
+ adminEmail,
+ verifyHostKey,
+ onProgress,
+ signal,
+ connect,
+ onAccountKnown,
+ onCredentialsBuilt,
+ recoveryProbeTimeoutMs = RECOVERY_PROBE_TIMEOUT_MS,
+ waitForDashboardImpl = waitForDashboard,
+ waitForAdminSeededImpl = waitForAdminSeeded,
+ tryEnableHttpsImpl = tryEnableHttps,
+ customDomain,
+}: SetupOptions): Promise {
+ const report = (step: SetupStep, output?: string) =>
+ onProgress?.({ step, output });
+
+ report("connecting");
+ const session = await connect(target, verifyHostKey, signal);
+
+ try {
+ report("checking-server");
+ const checks = await preflight(session, { signal });
+ if (!checks.ready) {
+ throw new DyadError(
+ checks.reason ?? "This server cannot be set up automatically.",
+ DyadErrorKind.Precondition,
+ );
+ }
+
+ const credentials = buildAdminCredentials(adminEmail);
+ // Before the installer, not after it. What it is about to write into the
+ // server's .env is already decided here, and the run that writes it is
+ // where a crash costs the only copy.
+ onCredentialsBuilt?.({
+ credentials,
+ dashboardUrl: plainUrlFor(target.host),
+ });
+
+ report("installing");
+ try {
+ await installCoolify(session, credentials, {
+ signal,
+ onOutput: (chunk) => report("installing", chunk),
+ });
+ } catch (error) {
+ // The installer writes this password into Coolify's own .env and brings
+ // the stack up partway through its run, so a failure after that point
+ // leaves an account nobody else knows the password for — and preflight
+ // refuses to install again once the container exists. Asked without the
+ // signal, because a cancel is one of the ways to arrive here.
+ let timer: ReturnType | undefined;
+ try {
+ const after = await Promise.race([
+ preflight(session, {}),
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error("The server did not answer.")),
+ recoveryProbeTimeoutMs,
+ );
+ }),
+ ]);
+ // Or could not tell. "Docker is wedged, I cannot say" comes back
+ // from preflight as the same `alreadyInstalled: false` as an empty
+ // server, and only one of those means the password opens nothing.
+ if (after.alreadyInstalled || !after.installedKnown) {
+ onAccountKnown?.({
+ credentials,
+ dashboardUrl: plainUrlFor(target.host),
+ });
+ }
+ } catch {
+ // The probe never answered — it timed out, or the connection went
+ // with it. That is not an answer of "nothing was installed", and
+ // treating it as one takes the account off. Keeping a password that
+ // opens nothing is a nuisance the user can sign out of; dropping the
+ // only copy of one that does is not recoverable from here.
+ onAccountKnown?.({
+ credentials,
+ dashboardUrl: plainUrlFor(target.host),
+ });
+ } finally {
+ clearTimeout(timer);
+ }
+ throw error;
+ }
+
+ // Once the installer has finished, because only then is this password
+ // certainly on the machine: install.sh writes it into Coolify's own .env
+ // and the account is seeded from there. Before the checks below, because
+ // every one of them can fail on a server that is running fine — the
+ // dashboard poll runs on the user's side of their firewall — and Dyad is
+ // the only thing that knows what it invented.
+ onAccountKnown?.({
+ credentials,
+ dashboardUrl: plainUrlFor(target.host),
+ });
+
+ report("waiting-for-dashboard");
+ const answered = await waitForDashboardImpl(target.host, { signal });
+ if (!answered) {
+ throw new DyadError(
+ "Coolify was installed, but nothing answered on port 8000. That is " +
+ "usually a firewall or security group blocking the port rather than " +
+ "Coolify itself. Open it, then sign in at " +
+ `${plainUrlFor(target.host)} — Coolify is already on the server, so ` +
+ "starting over would be refused.",
+ DyadErrorKind.External,
+ );
+ }
+
+ // Waited for rather than checked once: the account is created by a startup
+ // service that runs after the dashboard starts answering, so asking
+ // immediately says no about a server that is only still starting.
+ report("verifying-account");
+ const seeded = await waitForAdminSeededImpl(session, credentials.email, {
+ signal,
+ });
+ if (!seeded.seeded) {
+ // The way out belongs in both, and the one with a reason is the one
+ // people reach — an address Coolify will not take is the ordinary cause.
+ // Saying only what it objected to leaves an installed server, no
+ // account, and a preflight that refuses to install again.
+ throw new DyadError(
+ (seeded.reason
+ ? `Coolify would not create its admin account: ${seeded.reason} `
+ : `Coolify has not created an admin account for ${credentials.email}. `) +
+ `The server is installed — open ${plainUrlFor(target.host)} to ` +
+ `finish setting it up there.`,
+ DyadErrorKind.External,
+ );
+ }
+
+ // Before the token, so what gets stored is the address the token will
+ // travel to. It carries root abilities and goes over the wire on every
+ // deploy, not once at setup, which is what makes plain HTTP worth this.
+ report("securing");
+ let https: HttpsOutcome;
+ try {
+ https = await tryEnableHttpsImpl(session, target.host, {
+ customDomain,
+ signal,
+ onProgress: (message) => report("securing", message),
+ });
+ } catch (error) {
+ // This improves a server that already works, so it must not be able to
+ // throw one away. A domain left set with no certificate still leaves
+ // port 8000 serving.
+ if ((error as { kind?: string }).kind === "user_cancelled") throw error;
+ // Whatever it could not put back comes with it. The run goes on and
+ // succeeds from here, so this is the only place that note can still
+ // reach the finished screen — the failed state it would otherwise
+ // travel on is never reached.
+ const leftBehind = (error as Error & { warning?: string }).warning;
+ const said =
+ error instanceof Error
+ ? error.message
+ : "HTTPS could not be set up on this server.";
+ https = {
+ instanceUrl: plainUrlFor(target.host),
+ secure: false,
+ // Two sentences, not a run-on: the message is the library's or
+ // Coolify's and may or may not end in a stop of its own.
+ reason: leftBehind
+ ? `${said.replace(/\s*[.:;,]?\s*$/, "")}. ${leftBehind}`
+ : said,
+ };
+ }
+ onAccountKnown?.({ credentials, dashboardUrl: https.instanceUrl });
+
+ report("creating-token");
+ const result: SetupResult = {
+ dashboardUrl: https.instanceUrl,
+ secure: https.secure,
+ insecureReason: https.reason,
+ credentials,
+ token: null,
+ apiEnabled: false,
+ version: null,
+ };
+ try {
+ const access = await tryAutomaticAccess(session, credentials.email, {
+ signal,
+ onApiEnabled: () => {
+ result.apiEnabled = true;
+ },
+ });
+ if (access) {
+ result.token = access.token;
+ result.version = access.version;
+ } else {
+ result.tokenUnavailableReason =
+ "This version of Coolify could not be set up automatically.";
+ }
+ } catch (error) {
+ // The install stands whatever happened here, so this reports rather than
+ // throws: losing a working server because the last step failed would be
+ // the worse outcome by far.
+ if ((error as { kind?: string }).kind === "user_cancelled") throw error;
+ // A lost link is reported as one. Handing back the transport's own
+ // words would tell the user about a socket when what they need to know
+ // is that the server stopped answering and the rest is theirs to do.
+ result.tokenUnavailableReason =
+ error instanceof SshError
+ ? // Which step the link died on decides what to say. The API is
+ // opened first, so once that has taken effect the loss belongs to
+ // the token step — and naming the API would sit over guidance
+ // that rightly no longer mentions it.
+ result.apiEnabled
+ ? "Coolify stopped answering while Dyad was making a token."
+ : "Coolify did not answer while Dyad was opening its API."
+ : error instanceof Error
+ ? error.message
+ : "Coolify's API could not be opened automatically.";
+ }
+
+ report("done");
+ return result;
+ } finally {
+ session.end();
+ }
+}
diff --git a/src/coolify_setup/sleep.ts b/src/coolify_setup/sleep.ts
new file mode 100644
index 0000000000..655fbb5b31
--- /dev/null
+++ b/src/coolify_setup/sleep.ts
@@ -0,0 +1,18 @@
+/**
+ * Waits, or stops waiting when the run is cancelled.
+ *
+ * A bare timer means a cancel is noticed at the top of the next loop, which
+ * for these intervals is seconds after the user pressed the button.
+ */
+export function sleep(ms: number, signal?: AbortSignal): Promise {
+ if (signal?.aborted) return Promise.resolve();
+ return new Promise((resolve) => {
+ const timer = setTimeout(finish, ms);
+ function finish() {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", finish);
+ resolve();
+ }
+ signal?.addEventListener("abort", finish, { once: true });
+ });
+}
diff --git a/src/coolify_setup/state.ts b/src/coolify_setup/state.ts
new file mode 100644
index 0000000000..57748bcc09
--- /dev/null
+++ b/src/coolify_setup/state.ts
@@ -0,0 +1,178 @@
+import type { InvocationRef } from "@/state_machines/invocation_ref";
+import type {
+ StaleOperationIgnoreReason,
+ TransitionResult as GenericTransitionResult,
+} from "@/state_machines/types";
+
+/**
+ * Written out here rather than imported from the wire schema.
+ *
+ * A pure machine module may not reach into `src/ipc` at all — the boundary
+ * check does not care that an import is type-only, and it is right not to:
+ * the rule is about which side owns the definition. The schemas beside
+ * `SetupSnapshotSchema` assert against these, so neither can drift.
+ */
+export type SetupStep =
+ | "connecting"
+ | "checking-server"
+ | "installing"
+ | "waiting-for-dashboard"
+ | "verifying-account"
+ | "securing"
+ | "creating-token"
+ | "done";
+
+export interface SetupTarget {
+ host: string;
+ username: string;
+ port?: number;
+ adminEmail: string;
+ customDomain?: string;
+}
+
+export interface SetupResult {
+ dashboardUrl: string;
+ secure: boolean;
+ insecureReason: string | null;
+ adminEmail: string;
+ adminPassword: string;
+ tokenStored: boolean;
+ /** Whether Coolify's API was switched on, which outlives a failed mint. */
+ apiEnabled: boolean;
+ tokenUnavailableReason: string | null;
+ version: string | null;
+}
+
+/**
+ * Types for the machine that sets up one Coolify server.
+ *
+ * Types only: the pure transition lives in `transition.ts` and the side
+ * effects in `controller.ts`. The state belongs to the main process because
+ * that is where the work happens — an install outlives the screen that started
+ * it, since leaving that screen is invited and a background refetch can
+ * replace it. A copy in the renderer goes stale the moment either happens.
+ *
+ * Every event from a running setup echoes its invocation ref, so an answer
+ * from a run that was cancelled or superseded cannot revive or overwrite
+ * whatever the user is looking at now. Correlation is by operation rather than
+ * by address: the same server can be set up twice, and the second run must not
+ * inherit the first one's answers.
+ *
+ * Nothing here reaches into `src/ipc`: a pure machine module owns its own
+ * types, and the wire schema asserts against them rather than the other way
+ * round.
+ */
+
+export const COOLIFY_SETUP_INVOCATION_KIND = "coolify-setup" as const;
+
+/** Keyed by host: one machine at a time, and which one matters. */
+export type CoolifySetupInvocationRef = InvocationRef<
+ typeof COOLIFY_SETUP_INVOCATION_KIND,
+ string
+>;
+
+/** How much installer output is kept. The end is what explains a failure. */
+export const MAX_LOG_CHARS = 20_000;
+
+export interface CoolifySetupRunning {
+ type: "running";
+ host: string;
+ invocationRef: CoolifySetupInvocationRef;
+ step: SetupStep;
+ log: string;
+ /** True once the user has asked it to stop and before it has. */
+ stopping: boolean;
+}
+
+export interface CoolifySetupDone {
+ type: "done";
+ host: string;
+ invocationRef: CoolifySetupInvocationRef;
+ result: SetupResult;
+}
+
+export interface CoolifySetupFailed {
+ type: "failed";
+ host: string;
+ invocationRef: CoolifySetupInvocationRef;
+ message: string;
+ /** Kept: the installer's own last words are what explain the failure. */
+ log: string;
+ /** Cancelling is the user's decision, not a fault of the install. */
+ cancelled: boolean;
+ /**
+ * Something the run changed and could not change back.
+ *
+ * Kept apart from `message`, which says why the run ended: this is what is
+ * left for the user to do about it, and it outlives a cancel — where the
+ * panel says nothing about the ending itself.
+ */
+ warning?: string;
+}
+
+export type CoolifySetupState =
+ | { type: "idle" }
+ | CoolifySetupRunning
+ | CoolifySetupDone
+ | CoolifySetupFailed;
+
+export const IDLE: CoolifySetupState = { type: "idle" };
+
+/**
+ * What the machine asks the controller to do.
+ *
+ * Kept out of the handler so the rules that decide them are testable without
+ * a server: one setup at a time, and cancelling only something that is
+ * running.
+ */
+export type CoolifySetupCommand =
+ | {
+ type: "launch";
+ invocationRef: CoolifySetupInvocationRef;
+ target: SetupTarget;
+ }
+ | { type: "abort"; invocationRef: CoolifySetupInvocationRef };
+
+export type CoolifySetupEvent =
+ | {
+ type: "start-requested";
+ invocationRef: CoolifySetupInvocationRef;
+ target: SetupTarget;
+ }
+ | { type: "cancel-requested" }
+ | {
+ type: "progress";
+ invocationRef: CoolifySetupInvocationRef;
+ step: SetupStep;
+ output?: string;
+ }
+ | {
+ type: "succeeded";
+ invocationRef: CoolifySetupInvocationRef;
+ result: SetupResult;
+ }
+ | {
+ type: "failed";
+ invocationRef: CoolifySetupInvocationRef;
+ message: string;
+ cancelled: boolean;
+ /** Something the run changed and could not change back. */
+ warning?: string;
+ }
+ /** The user has read the terminal screen and moved on. */
+ | { type: "dismissed" };
+
+export type CoolifySetupIgnoreReason =
+ | StaleOperationIgnoreReason
+ /** One server at a time: two installs would fight over the same docker. */
+ | "already-running"
+ /** Nothing is running, so there is nothing this could belong to. */
+ | "not-running"
+ /** Dismissing while it runs would hide work that is still going on. */
+ | "still-running";
+
+export type TransitionResult = GenericTransitionResult<
+ CoolifySetupState,
+ CoolifySetupCommand,
+ CoolifySetupIgnoreReason
+>;
diff --git a/src/coolify_setup/tinker.test.ts b/src/coolify_setup/tinker.test.ts
new file mode 100644
index 0000000000..a4a0aff648
--- /dev/null
+++ b/src/coolify_setup/tinker.test.ts
@@ -0,0 +1,280 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ answerLine,
+ extractOutput,
+ runTinker,
+ tinkerCommand,
+ wrapScript,
+} from "./tinker";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+
+/**
+ * A transcript captured from Coolify 4.3.2 rather than written by hand.
+ *
+ * The shape is the whole reason this module exists: tinker echoes every line it
+ * is fed with a `> ` prefix, so the markers appear twice — and the first line of
+ * real output shares a line with the last prompt.
+ */
+const REAL_TRANSCRIPT = [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "",
+ '> echo "line-one" . PHP_EOL; echo "line-two" . PHP_EOL;',
+ '> echo "__DYAD_OUT_END__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ "line-one",
+ "line-two",
+ "__DYAD_OUT_END__",
+].join("\n");
+
+/**
+ * The same shape from a later Coolify, captured the same way.
+ *
+ * Two versions apart, psysh still flushes its last prompt onto the line the
+ * first output lands on — which is the whole basis for telling real output
+ * from the echo of the script that produced it.
+ */
+const REAL_4_3_14 = [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "",
+ "> echo config('constants.coolify.version');",
+ '> echo PHP_EOL . "__DYAD_OUT_END__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ "4.3.14",
+ "__DYAD_OUT_END__",
+].join("\n");
+
+/**
+ * The same again, with psysh redrawing an echo it could not fit.
+ *
+ * A long input line comes back carrying a carriage return and overwritten
+ * partway, so the echo is neither complete nor clean. It still lands above
+ * the opening marker, which is what keeps it out of the answer.
+ */
+const REAL_MANGLED_ECHO = [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "",
+ "> \rexists() ? 'yes' : 'no';",
+ '> echo PHP_EOL . "__DYAD_OUT_END__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ "no",
+ "__DYAD_OUT_END__",
+].join("\n");
+
+describe("transcripts from a real Coolify", () => {
+ it("reads the version a 4.3.14 server gave back", () => {
+ expect(extractOutput(REAL_4_3_14)).toBe("4.3.14");
+ expect(
+ answerLine(extractOutput(REAL_4_3_14) ?? "", (l) => /^\d+\.\d+/.test(l)),
+ ).toBe("4.3.14");
+ });
+
+ it("is not confused by an echo psysh redrew", () => {
+ // The mangled line carries a CR and part of the script, including the
+ // words "yes" and "no" — everything the reader below is looking for.
+ const region = extractOutput(REAL_MANGLED_ECHO);
+ expect(region).toBe("no");
+ expect(answerLine(region ?? "", (l) => l === "no")).toBe("no");
+ expect(answerLine(region ?? "", (l) => l === "yes")).toBeNull();
+ });
+});
+
+describe("reading the answer out of a noisy region", () => {
+ it("finds it beside whatever else Coolify printed", () => {
+ const region = ["PHP Deprecated: Some notice", "yes", ""].join("\n");
+ expect(answerLine(region, (l) => l === "yes")).toBe("yes");
+ });
+
+ it("will not take a line that merely mentions it", () => {
+ // A warning naming the answer is not the answer.
+ expect(
+ answerLine("Warning: expected yes here", (l) => l === "yes"),
+ ).toBeNull();
+ });
+
+ it("answers nothing when the region has none", () => {
+ expect(answerLine("no\nstill-disabled", (l) => l === "yes")).toBeNull();
+ expect(answerLine("", (l) => l === "yes")).toBeNull();
+ });
+});
+
+describe("finding our output however psysh prompts", () => {
+ it("reads it when the prompt is missing or repeated", () => {
+ // The prompt sharing a line with the output is an artefact of when psysh
+ // flushes. A version that stopped echoing, or that stacked prompts, would
+ // otherwise make every call fail — and the caller reports that as Coolify
+ // refusing to create an account, on a server where it exists.
+ for (const marker of [
+ "__DYAD_OUT_START__",
+ "> __DYAD_OUT_START__",
+ "> > > __DYAD_OUT_START__",
+ ]) {
+ expect(
+ extractOutput([marker, "answer", "__DYAD_OUT_END__"].join("\n")),
+ ).toBe("answer");
+ }
+ });
+
+ it("still does not take the line that produced it for the output", () => {
+ // The echoed script line carries the marker too, and matching it would
+ // return the rest of the script as the answer.
+ expect(
+ extractOutput(
+ [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ "answer",
+ "__DYAD_OUT_END__",
+ ].join("\n"),
+ ),
+ ).toBe("answer");
+ });
+});
+
+function fakeSession(
+ onRun: (command: string, options?: { input?: string }) => { stdout: string },
+): SshSession & { calls: Array<{ command: string; input?: string }> } {
+ const calls: Array<{ command: string; input?: string }> = [];
+ return {
+ calls,
+ run: vi.fn(async (command: string, options?: { input?: string }) => {
+ calls.push({ command, input: options?.input });
+ return { code: 0, stderr: "", ...onRun(command, options) };
+ }) as unknown as SshSession["run"],
+ end: vi.fn(),
+ };
+}
+
+describe("the container it runs in", () => {
+ it("refuses a name that could end the command", async () => {
+ // The name is interpolated into a command that runs as root, and both
+ // command shapes build it themselves.
+ expect(() => tinkerCommand("coolify; rm -rf /")).toThrow();
+ await expect(
+ runTinker(
+ { run: async () => ({ code: 0, stdout: "", stderr: "" }) } as never,
+ "echo 1;",
+ {
+ container: "coolify; rm -rf /",
+ env: { A: "b" },
+ },
+ ),
+ ).rejects.toMatchObject({ kind: "validation" });
+ });
+});
+
+describe("extractOutput", () => {
+ it("takes the printed output, not the echo of the line printing it", () => {
+ // Both appear in the transcript. Matching the marker without its prompt
+ // would find the echoed input first and return the script back.
+ expect(extractOutput(REAL_TRANSCRIPT)).toBe("line-one\nline-two");
+ });
+
+ it("says nothing was found when the script never ran", () => {
+ // A container still starting answers with an error and no markers at all.
+ // Reading that as empty output would let a caller treat "no token" as a
+ // fact rather than as a failure.
+ expect(
+ extractOutput("Error response from daemon: container not running"),
+ ).toBeNull();
+ });
+
+ it("says nothing was found when the output stops halfway", () => {
+ const truncated = REAL_TRANSCRIPT.split("\n").slice(0, 6).join("\n");
+ expect(extractOutput(truncated)).toBeNull();
+ });
+
+ it("finds output from a script that printed no trailing newline", () => {
+ // The real shape before wrapScript was fixed: value and marker on one line.
+ const glued = [
+ '> echo "__DYAD_OUT_START__" . PHP_EOL;',
+ "> __DYAD_OUT_START__",
+ "yes",
+ "__DYAD_OUT_END__",
+ ].join("\n");
+ expect(extractOutput(glued)).toBe("yes");
+ });
+
+ it("survives carriage returns", () => {
+ expect(extractOutput(REAL_TRANSCRIPT.replace(/\n/g, "\r\n"))).toBe(
+ "line-one\nline-two",
+ );
+ });
+});
+
+describe("tinkerCommand", () => {
+ it("attaches stdin", () => {
+ // Without -i docker does not attach stdin, so the script is never read and
+ // the command succeeds having done nothing at all.
+ expect(tinkerCommand()).toContain("docker exec -i ");
+ });
+});
+
+describe("runTinker", () => {
+ it("feeds the script on stdin rather than the command line", async () => {
+ const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT }));
+ await runTinker(session, 'echo "line-one" . PHP_EOL;');
+
+ expect(session.calls[0].input).toContain('echo "line-one" . PHP_EOL;');
+ expect(session.calls[0].command).not.toContain("line-one");
+ });
+
+ it("passes a secret by name and value, quoted", async () => {
+ const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT }));
+ await runTinker(session, "echo getenv('DYAD_SECRET');", {
+ env: { DYAD_SECRET: "p@ssw0rd-+=" },
+ });
+
+ expect(session.calls[0].command).toContain("-e DYAD_SECRET='p@ssw0rd-+='");
+ // The value stays out of the script, so it never meets PHP's parser too.
+ expect(session.calls[0].input).not.toContain("p@ssw0rd");
+ });
+
+ it("refuses a value that would escape its quoting", async () => {
+ const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT }));
+ // Rejected rather than escaped: getting this subtly wrong runs arbitrary
+ // text as a command on the user's server.
+ await expect(
+ runTinker(session, "echo 1;", {
+ env: { DYAD_SECRET: "a'; rm -rf /; '" },
+ }),
+ ).rejects.toMatchObject({ kind: "internal" });
+ });
+
+ it("refuses a variable name that is not a plain identifier", async () => {
+ const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT }));
+ await expect(
+ runTinker(session, "echo 1;", { env: { "X; rm -rf /": "v" } }),
+ ).rejects.toMatchObject({ kind: "internal" });
+ });
+
+ it("fails loudly when the script did not run", async () => {
+ const session = fakeSession(() => ({ stdout: "container not running" }));
+ await expect(runTinker(session, "echo 1;")).rejects.toMatchObject({
+ kind: "external",
+ });
+ });
+
+ it("targets a named container when asked", async () => {
+ const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT }));
+ await runTinker(session, "echo 1;", { container: "coolify-staging" });
+ expect(session.calls[0].command).toContain(" coolify-staging ");
+ });
+});
+
+describe("wrapScript", () => {
+ it("breaks the line before the closing marker", () => {
+ // Captured from a real run: a script ending without PHP_EOL produced
+ // `yes__DYAD_OUT_END__`, and the marker was never found.
+ expect(wrapScript("echo 'yes';")).toContain(
+ 'echo PHP_EOL . "__DYAD_OUT_END__"',
+ );
+ });
+
+ it("puts the body between the markers", () => {
+ const wrapped = wrapScript("echo 42;");
+ const lines = wrapped.split("\n");
+ expect(lines[0]).toContain("__DYAD_OUT_START__");
+ expect(lines[1]).toBe("echo 42;");
+ expect(lines[2]).toContain("__DYAD_OUT_END__");
+ });
+});
diff --git a/src/coolify_setup/tinker.ts b/src/coolify_setup/tinker.ts
new file mode 100644
index 0000000000..fa06a6fe9f
--- /dev/null
+++ b/src/coolify_setup/tinker.ts
@@ -0,0 +1,189 @@
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import type { SshSession } from "@/ipc/utils/ssh_client";
+
+/**
+ * Running PHP inside the Coolify container.
+ *
+ * Coolify has no supported interface for what setup needs — turning the API
+ * on, minting a token, creating and finding the first user, setting the
+ * instance domain, reading the version before the API is reachable — so each
+ * is done by driving Laravel directly. That is a workaround, not an interface:
+ * every caller carries a TODO naming what an official API would replace.
+ */
+
+/**
+ * Marks where our output starts and stops.
+ *
+ * tinker echoes every line it is fed, prefixed `> `, and the first line of real
+ * output lands on the same line as the last prompt. So the transcript contains
+ * the marker twice: once in the echo of the line that prints it, once as the
+ * output itself. Looking for the marker anywhere on a line would take the
+ * echo, so the match is anchored to a whole line — which the echo cannot be,
+ * because it carries the script around the marker.
+ */
+const START = "__DYAD_OUT_START__";
+const END = "__DYAD_OUT_END__";
+
+/**
+ * The command that feeds a script to tinker.
+ *
+ * `-i` matters: without it docker does not attach stdin, the script is never
+ * read, and the whole thing succeeds having done nothing.
+ */
+export function tinkerCommand(container = "coolify"): string {
+ assertSafeContainer(container);
+ return `docker exec -i ${container} php artisan tinker --no-ansi`;
+}
+
+/** Docker's own grammar. The name is interpolated into a root command. */
+function assertSafeContainer(container: string): void {
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(container)) {
+ throw new DyadError(
+ `Refusing to run against an unsafe container name: ${container}`,
+ DyadErrorKind.Validation,
+ );
+ }
+}
+
+/**
+ * Wraps a script so its output can be found in the transcript.
+ *
+ * The closing marker starts with a newline of its own because a script whose
+ * last echo omits PHP_EOL would otherwise leave the marker stuck to the end of
+ * the value — `yes__DYAD_OUT_END__` — and nothing would find it. Putting the
+ * break here means callers do not have to remember.
+ */
+export function wrapScript(body: string): string {
+ return [
+ `echo "${START}" . PHP_EOL;`,
+ body.trim(),
+ `echo PHP_EOL . "${END}" . PHP_EOL;`,
+ ].join("\n");
+}
+
+/**
+ * Pulls our output back out of a tinker transcript.
+ *
+ * The opening marker is matched with the prompt it shares a line with, because
+ * that is what distinguishes the real output from the echo of the line that
+ * produced it. The prompt is allowed to be absent or repeated: it is an
+ * artefact of psysh flushing a prompt before the output arrives, and nothing
+ * here should turn a change in that into every call failing. Either way the
+ * echoed line cannot match, because it carries the script around the marker.
+ */
+const START_LINE = new RegExp(`^(?:>\\s*)*${START}$`);
+
+export function extractOutput(transcript: string): string | null {
+ const lines = transcript.split(/\r?\n/);
+ const startAt = lines.findIndex((line) => START_LINE.test(line.trim()));
+ if (startAt === -1) return null;
+ const rest = lines.slice(startAt + 1);
+ const endAt = rest.findIndex((line) => line.trimEnd() === END);
+ if (endAt === -1) return null;
+ return rest.slice(0, endAt).join("\n").trim();
+}
+
+/**
+ * Finds the answer among whatever else the region carries.
+ *
+ * Insurance rather than something observed: two real transcripts, 4.3.2 and
+ * 4.3.14, both came back with nothing but the answer between the markers. But
+ * a notice from Coolify would only have to happen once for a reader that
+ * demands the whole region to report a working server as broken, and reading
+ * a line at a time costs nothing when there is only one.
+ *
+ * Exactly per line rather than anywhere in it, so a notice that mentions the
+ * answer is not taken for it. Trimmed per line because psysh writes a
+ * carriage return mid-transcript when it redraws a long echo — the region's
+ * own trim only reaches the ends.
+ */
+export function answerLine(
+ region: string,
+ matches: (line: string) => boolean,
+): string | null {
+ for (const line of region.split(/\r?\n/)) {
+ const said = line.trim();
+ if (said && matches(said)) return said;
+ }
+ return null;
+}
+
+export interface TinkerOptions {
+ /**
+ * Values the script reads with getenv().
+ *
+ * Secrets travel this way rather than being written into the script, so they
+ * never have to survive PHP's parser as well as the shell's. They are still
+ * single-quoted into the docker command — sshd forwards almost no environment
+ * by default, so there is no way to hand them over out of band.
+ */
+ env?: Record;
+ container?: string;
+ signal?: AbortSignal;
+ /** Passed through: a tinker one-liner has a short honest answer. */
+ timeoutMs?: number;
+}
+
+/**
+ * Renders values as docker -e arguments.
+ *
+ * Rejects rather than escapes: a value carrying a quote or a backslash would
+ * end the quoting, and getting that subtly wrong runs arbitrary text as a
+ * command on the user's server. Everything passed here is generated by Dyad,
+ * so a rejection is a bug on our side rather than something a user hits.
+ */
+function envArgs(env: Record): string {
+ return Object.entries(env)
+ .map(([name, value]) => {
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
+ throw new DyadError(
+ `Unsafe environment variable name: ${name}`,
+ DyadErrorKind.Internal,
+ );
+ }
+ if (/['\\`$\n\r]/.test(value)) {
+ throw new DyadError(
+ `Value for ${name} contains a character that cannot be passed safely`,
+ DyadErrorKind.Internal,
+ );
+ }
+ return `-e ${name}='${value}'`;
+ })
+ .join(" ");
+}
+
+/**
+ * Runs a PHP script in the Coolify container and returns what it printed.
+ *
+ * Throws rather than returning an empty string when the markers are missing,
+ * because that means the script did not run — a container that is not up yet,
+ * or a tinker that died — and a caller reading "" as "no token" would go on to
+ * do something worse than stopping.
+ */
+export async function runTinker(
+ session: SshSession,
+ script: string,
+ { env = {}, container = "coolify", signal, timeoutMs }: TinkerOptions = {},
+): Promise {
+ assertSafeContainer(container);
+ const names = envArgs(env);
+ const command = names
+ ? `docker exec -i ${names} ${container} php artisan tinker --no-ansi`
+ : tinkerCommand(container);
+
+ const result = await session.run(command, {
+ input: wrapScript(script) + "\n",
+ signal,
+ timeoutMs,
+ });
+
+ const output = extractOutput(result.stdout);
+ if (output === null) {
+ throw new DyadError(
+ "Coolify did not answer as expected while being set up. It may still be " +
+ "starting — wait a moment and try again.",
+ DyadErrorKind.External,
+ );
+ }
+ return output;
+}
diff --git a/src/coolify_setup/transition.test.ts b/src/coolify_setup/transition.test.ts
new file mode 100644
index 0000000000..c25ba4043f
--- /dev/null
+++ b/src/coolify_setup/transition.test.ts
@@ -0,0 +1,386 @@
+import { describe, expect, it } from "vitest";
+import { coolifySetupTransition } from "./transition";
+import {
+ IDLE,
+ MAX_LOG_CHARS,
+ COOLIFY_SETUP_INVOCATION_KIND,
+ type CoolifySetupCommand,
+ type CoolifySetupEvent,
+ type CoolifySetupInvocationRef,
+ type CoolifySetupState,
+} from "./state";
+import type { SetupResult } from "@/ipc/types/coolify_setup";
+
+const ref = (host: string, operationId: string): CoolifySetupInvocationRef => ({
+ kind: COOLIFY_SETUP_INVOCATION_KIND,
+ entityKey: host,
+ operationId,
+});
+
+const HOST = "203.0.113.5";
+const REF = ref(HOST, "op-1");
+// A second run of the SAME server: the host cannot tell these apart, which is
+// why correlation is by operation and not by address.
+const AGAIN = ref(HOST, "op-2");
+const OTHER = ref("198.51.100.9", "op-3");
+
+const TARGET = { host: HOST, username: "root", adminEmail: "me@gmail.com" };
+
+const RESULT: SetupResult = {
+ dashboardUrl: "https://203.0.113.5.sslip.io",
+ secure: true,
+ insecureReason: null,
+ adminEmail: "me@gmail.com",
+ adminPassword: "Abc123@xyz",
+ tokenStored: true,
+ apiEnabled: true,
+ tokenUnavailableReason: null,
+ version: "4.3.2",
+};
+
+const running = (over: Record = {}): CoolifySetupState =>
+ ({
+ type: "running",
+ host: HOST,
+ invocationRef: REF,
+ step: "installing",
+ log: "",
+ stopping: false,
+ ...over,
+ }) as CoolifySetupState;
+
+const done = (): CoolifySetupState => ({
+ type: "done",
+ host: HOST,
+ invocationRef: REF,
+ result: RESULT,
+});
+const failed = (): CoolifySetupState => ({
+ type: "failed",
+ host: HOST,
+ invocationRef: REF,
+ message: "boom",
+ log: "output",
+ cancelled: false,
+});
+
+const ALL_STATES: CoolifySetupState[] = [IDLE, running(), done(), failed()];
+const ALL_EVENTS: CoolifySetupEvent[] = [
+ { type: "start-requested", invocationRef: REF, target: TARGET },
+ { type: "cancel-requested" },
+ { type: "progress", invocationRef: REF, step: "installing", output: "x" },
+ { type: "succeeded", invocationRef: REF, result: RESULT },
+ { type: "failed", invocationRef: REF, message: "boom", cancelled: false },
+ { type: "dismissed" },
+];
+
+/** The state a transition settles on, whether it applied or was ignored. */
+const next = (state: CoolifySetupState, event: CoolifySetupEvent) =>
+ coolifySetupTransition(state, event).state;
+
+const commandsOf = (
+ state: CoolifySetupState,
+ event: CoolifySetupEvent,
+): readonly CoolifySetupCommand[] => {
+ const result = coolifySetupTransition(state, event);
+ return result.kind === "applied" ? result.commands : [];
+};
+
+const reasonOf = (state: CoolifySetupState, event: CoolifySetupEvent) => {
+ const result = coolifySetupTransition(state, event);
+ return result.kind === "ignored" ? result.reason : null;
+};
+
+describe("totality", () => {
+ it("answers every event from every state", () => {
+ // The events come from a process that does not know what the user has
+ // done since, so there is no combination that cannot arrive.
+ for (const state of ALL_STATES) {
+ for (const event of ALL_EVENTS) {
+ expect(() => coolifySetupTransition(state, event)).not.toThrow();
+ expect(coolifySetupTransition(state, event)).toBeTruthy();
+ }
+ }
+ });
+
+ it("never mutates the state it was given", () => {
+ for (const state of ALL_STATES) {
+ for (const event of ALL_EVENTS) {
+ const before = JSON.stringify(state);
+ coolifySetupTransition(state, event);
+ expect(JSON.stringify(state)).toBe(before);
+ }
+ }
+ });
+});
+
+describe("starting", () => {
+ it("begins at connecting with nothing said yet", () => {
+ expect(
+ next(IDLE, {
+ type: "start-requested",
+ invocationRef: REF,
+ target: TARGET,
+ }),
+ ).toEqual({
+ type: "running",
+ host: HOST,
+ invocationRef: REF,
+ step: "connecting",
+ log: "",
+ stopping: false,
+ });
+ });
+
+ it("replaces a terminal screen rather than keeping it beside the new run", () => {
+ expect(
+ next(done(), {
+ type: "start-requested",
+ invocationRef: OTHER,
+ target: { ...TARGET, host: "198.51.100.9" },
+ }),
+ ).toMatchObject({ type: "running", host: "198.51.100.9" });
+ });
+});
+
+describe("while it runs", () => {
+ it("follows the step", () => {
+ const stepped = next(running(), {
+ type: "progress",
+ invocationRef: REF,
+ step: "securing",
+ });
+ expect(stepped).toMatchObject({ type: "running", step: "securing" });
+ });
+
+ it("accumulates output", () => {
+ let state = running();
+ state = next(state, {
+ type: "progress",
+ invocationRef: REF,
+ step: "installing",
+ output: "one ",
+ });
+ state = next(state, {
+ type: "progress",
+ invocationRef: REF,
+ step: "installing",
+ output: "two",
+ });
+ expect(state).toMatchObject({ log: "one two" });
+ });
+
+ it("keeps the end of a long installer's output", () => {
+ // The end is what explains a failure, and an installer can print a lot.
+ const state = next(running({ log: "a".repeat(MAX_LOG_CHARS) }), {
+ type: "progress",
+ invocationRef: REF,
+ step: "installing",
+ output: "TAIL",
+ });
+ const log = (state as { log: string }).log;
+ expect(log).toHaveLength(MAX_LOG_CHARS);
+ expect(log.endsWith("TAIL")).toBe(true);
+ });
+
+ it("records that the user has asked it to stop", () => {
+ expect(next(running(), { type: "cancel-requested" })).toMatchObject({
+ stopping: true,
+ });
+ });
+});
+
+describe("what it asks the controller to do", () => {
+ // The rules live here rather than beside the effect, so they can be tested
+ // without a server.
+
+ it("asks for the work to be launched when nothing is running", () => {
+ expect(
+ commandsOf(IDLE, {
+ type: "start-requested",
+ invocationRef: REF,
+ target: TARGET,
+ }),
+ ).toEqual([{ type: "launch", invocationRef: REF, target: TARGET }]);
+ });
+
+ it("refuses a second setup while one is going, and launches nothing", () => {
+ const state = running();
+ const event: CoolifySetupEvent = {
+ type: "start-requested",
+ invocationRef: AGAIN,
+ target: TARGET,
+ };
+ expect(reasonOf(state, event)).toBe("already-running");
+ expect(commandsOf(state, event)).toEqual([]);
+ expect(next(state, event)).toBe(state);
+ });
+
+ it("asks for the running invocation to be aborted, not the requested one", () => {
+ // The abort has to name what is actually going on, or a cancel arriving
+ // just after a supersede would stop the wrong run.
+ expect(commandsOf(running(), { type: "cancel-requested" })).toEqual([
+ { type: "abort", invocationRef: REF },
+ ]);
+ });
+
+ it("does not remake the state when it is already stopping", () => {
+ // A second Cancel — a double click inside the round trip, or another
+ // window — must not hand back a new object that says the same thing.
+ const state = running({ stopping: true });
+ const result = coolifySetupTransition(state, { type: "cancel-requested" });
+
+ expect(result.state).toBe(state);
+ expect(commandsOf(state, { type: "cancel-requested" })).toEqual([
+ { type: "abort", invocationRef: REF },
+ ]);
+ });
+
+ it("aborts nothing when nothing is running", () => {
+ expect(reasonOf(IDLE, { type: "cancel-requested" })).toBe("not-running");
+ expect(commandsOf(done(), { type: "cancel-requested" })).toEqual([]);
+ });
+
+ it("says why it ignored an answer from a superseded run", () => {
+ expect(
+ reasonOf(running(), {
+ type: "succeeded",
+ invocationRef: AGAIN,
+ result: RESULT,
+ }),
+ ).toBe("stale-operation");
+ });
+});
+
+describe("answers from a run that is no longer the one in hand", () => {
+ // The reason this is a machine. A run keeps going after the panel showing
+ // it is gone, so its answers arrive against whatever state came next.
+
+ it("ignores progress for another server", () => {
+ const state = running();
+ expect(
+ next(state, {
+ type: "progress",
+ invocationRef: OTHER,
+ step: "securing",
+ }),
+ ).toBe(state);
+ });
+
+ it("ignores a result for another server", () => {
+ const state = running();
+ expect(
+ next(state, { type: "succeeded", invocationRef: OTHER, result: RESULT }),
+ ).toBe(state);
+ });
+
+ it("keeps what the run could not put back", () => {
+ // Separate from the message, which says why it ended. A cancel says
+ // nothing about the ending, so this is the only thing the panel has to
+ // show for a domain that would not come back off.
+ expect(
+ next(running(), {
+ type: "failed",
+ invocationRef: REF,
+ message: "Cancelled.",
+ cancelled: true,
+ warning: "Coolify may still be configured for x.sslip.io.",
+ }),
+ ).toMatchObject({
+ type: "failed",
+ cancelled: true,
+ warning: "Coolify may still be configured for x.sslip.io.",
+ });
+ });
+
+ it("ignores a result that arrives after the screen was dismissed", () => {
+ // Exactly the shape that put a finished install over a panel the user had
+ // already moved on from.
+ expect(
+ next(IDLE, { type: "succeeded", invocationRef: REF, result: RESULT }),
+ ).toBe(IDLE);
+ });
+
+ it("ignores a failure from an earlier run of the same server", () => {
+ // Start, cancel, start again: the host is identical, so only the
+ // operation identity can tell the first run's answer from the second's.
+ const state = running({ invocationRef: AGAIN });
+ expect(
+ next(state, {
+ type: "failed",
+ invocationRef: REF,
+ message: "late",
+ cancelled: false,
+ }),
+ ).toBe(state);
+ });
+
+ it("ignores a second result for a run already finished", () => {
+ const state = done();
+ expect(
+ next(state, { type: "succeeded", invocationRef: REF, result: RESULT }),
+ ).toBe(state);
+ });
+});
+
+describe("finishing", () => {
+ it("carries the result through", () => {
+ expect(
+ next(running(), {
+ type: "succeeded",
+ invocationRef: REF,
+ result: RESULT,
+ }),
+ ).toEqual({
+ type: "done",
+ host: HOST,
+ invocationRef: REF,
+ result: RESULT,
+ });
+ });
+
+ it("keeps the output when it fails, since that is what explains it", () => {
+ expect(
+ next(running({ log: "3/6 Pulling..." }), {
+ type: "failed",
+ invocationRef: REF,
+ message: "exit 1",
+ cancelled: false,
+ }),
+ ).toEqual({
+ type: "failed",
+ host: HOST,
+ invocationRef: REF,
+ message: "exit 1",
+ log: "3/6 Pulling...",
+ cancelled: false,
+ });
+ });
+
+ it("marks a cancellation as one, so it is not reported as a fault", () => {
+ expect(
+ next(running({ stopping: true }), {
+ type: "failed",
+ invocationRef: REF,
+ message: "Cancelled.",
+ cancelled: true,
+ }),
+ ).toMatchObject({ type: "failed", cancelled: true });
+ });
+});
+
+describe("dismissing", () => {
+ it("clears a finished screen", () => {
+ expect(next(done(), { type: "dismissed" })).toEqual(IDLE);
+ });
+
+ it("clears a failed screen", () => {
+ expect(next(failed(), { type: "dismissed" })).toEqual(IDLE);
+ });
+
+ it("does not clear a running one", () => {
+ // There would still be an install going on, with nothing showing it.
+ const state = running();
+ expect(next(state, { type: "dismissed" })).toBe(state);
+ });
+});
diff --git a/src/coolify_setup/transition.ts b/src/coolify_setup/transition.ts
new file mode 100644
index 0000000000..2581ed8f7f
--- /dev/null
+++ b/src/coolify_setup/transition.ts
@@ -0,0 +1,154 @@
+import {
+ IDLE,
+ MAX_LOG_CHARS,
+ type CoolifySetupCommand,
+ type CoolifySetupEvent,
+ type CoolifySetupRunning,
+ type CoolifySetupState,
+ type TransitionResult,
+} from "./state";
+import { sameInvocationRef } from "@/state_machines/invocation_ref";
+import {
+ STALE_OPERATION_IGNORE_REASON,
+ change,
+ ignore,
+ stay,
+} from "@/state_machines/types";
+
+function appendLog(existing: string, chunk: string): string {
+ const combined = existing + chunk;
+ return combined.length > MAX_LOG_CHARS
+ ? combined.slice(combined.length - MAX_LOG_CHARS)
+ : combined;
+}
+
+/**
+ * Returns the running state only when the event claims the live invocation.
+ *
+ * This is what stops a setup the user has left behind from writing progress,
+ * a finished screen, or an error over whatever replaced it.
+ */
+function runningFor(
+ state: CoolifySetupState,
+ ref: CoolifySetupRunning["invocationRef"],
+): CoolifySetupRunning | null {
+ if (state.type !== "running") return null;
+ return sameInvocationRef(state.invocationRef, ref) ? state : null;
+}
+
+/** Progress and completions all answer the same question about identity. */
+function notForTheRunInHand(state: CoolifySetupState): TransitionResult {
+ return ignore(
+ state,
+ state.type === "running" ? STALE_OPERATION_IGNORE_REASON : "not-running",
+ );
+}
+
+/**
+ * Pure transition function for the Coolify server setup machine.
+ *
+ * Total: every event is answered from every state, and one that does not apply
+ * is ignored with a reason rather than throwing. That is the point rather than
+ * a convenience — these events come from a process that does not know what the
+ * user has done since, so an answer for a run that is no longer the one in
+ * hand is ordinary traffic, not an error.
+ */
+export function coolifySetupTransition(
+ state: CoolifySetupState,
+ event: CoolifySetupEvent,
+): TransitionResult {
+ switch (event.type) {
+ case "start-requested": {
+ // One at a time, decided here rather than by a check beside the effect.
+ // Two installs on one machine would interleave their output and fight
+ // over the same docker state.
+ if (state.type === "running") return ignore(state, "already-running");
+ const command: CoolifySetupCommand = {
+ type: "launch",
+ invocationRef: event.invocationRef,
+ target: event.target,
+ };
+ return change(
+ {
+ type: "running",
+ host: event.target.host,
+ invocationRef: event.invocationRef,
+ step: "connecting",
+ log: "",
+ stopping: false,
+ },
+ [command],
+ );
+ }
+
+ case "cancel-requested": {
+ // Nothing to stop is not a failure; it is the ordinary answer to a
+ // cancel that raced the run finishing.
+ if (state.type !== "running") return ignore(state, "not-running");
+ const abort: CoolifySetupCommand = {
+ type: "abort",
+ invocationRef: state.invocationRef,
+ };
+ // Already stopping: the abort is worth sending again, since aborting
+ // twice is the same as aborting once — but the state is not worth
+ // remaking. A value-equal snapshot with a new reference is a change
+ // that changes nothing, and every window would be told about it.
+ if (state.stopping) return stay(state, [abort]);
+ return change({ ...state, stopping: true }, [abort]);
+ }
+
+ case "progress": {
+ const running = runningFor(state, event.invocationRef);
+ if (!running) return notForTheRunInHand(state);
+ const log = event.output
+ ? appendLog(running.log, event.output)
+ : running.log;
+ // Same answer as last time. Returning a new object anyway would send
+ // every window a change that changes nothing.
+ if (running.step === event.step && running.log === log) {
+ return stay(running, []);
+ }
+ return change({ ...running, step: event.step, log });
+ }
+
+ case "succeeded": {
+ const running = runningFor(state, event.invocationRef);
+ if (!running) return notForTheRunInHand(state);
+ return change({
+ type: "done",
+ host: running.host,
+ invocationRef: running.invocationRef,
+ result: event.result,
+ });
+ }
+
+ case "failed": {
+ const running = runningFor(state, event.invocationRef);
+ if (!running) return notForTheRunInHand(state);
+ return change({
+ type: "failed",
+ host: running.host,
+ invocationRef: running.invocationRef,
+ message: event.message,
+ log: running.log,
+ cancelled: event.cancelled,
+ // Carried, not derived: what a run could not put back is not
+ // recoverable from why it ended, and a cancel says nothing else.
+ warning: event.warning,
+ });
+ }
+
+ case "dismissed":
+ if (state.type === "running") return ignore(state, "still-running");
+ if (state.type === "idle") return ignore(state, "not-running");
+ return change(IDLE);
+
+ default: {
+ // Total by construction: adding an event without answering it above
+ // stops this assignment compiling.
+ const unanswered: never = event;
+ void unanswered;
+ return ignore(state, "not-running");
+ }
+ }
+}
diff --git a/src/distributed_machines/boundary_inventory.test_support.ts b/src/distributed_machines/boundary_inventory.test_support.ts
index 41db7045df..d02e262bf2 100644
--- a/src/distributed_machines/boundary_inventory.test_support.ts
+++ b/src/distributed_machines/boundary_inventory.test_support.ts
@@ -278,6 +278,7 @@ export const nonRemoteDispatchOrEnqueueInventory = [
// A local main-process machine: dispatch here is its own transition, not
// distributed-machine transport.
owned("coolify_deploy/controller.ts", 7),
+ owned("coolify_setup/controller.ts", 6),
owned("hooks/useRunApp.ts", 1),
owned("ipc/services/app_runtime_service.ts", 2),
owned("ipc/services/app_runtime_transport.ts", 1),
diff --git a/src/hooks/useCoolifySetupSnapshot.test.tsx b/src/hooks/useCoolifySetupSnapshot.test.tsx
new file mode 100644
index 0000000000..99553f2585
--- /dev/null
+++ b/src/hooks/useCoolifySetupSnapshot.test.tsx
@@ -0,0 +1,140 @@
+import {
+ QueryClient,
+ QueryClientProvider,
+ useQuery,
+} from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { queryKeys } from "@/lib/queryKeys";
+import type { CoolifySetupState } from "@/coolify_setup/state";
+
+const listeners = vi.hoisted(
+ () => ({ current: [] }) as { current: ((state: unknown) => void)[] },
+);
+const snapshot = vi.hoisted(() => vi.fn());
+vi.mock("@/ipc/types", () => ({
+ ipc: {
+ coolifySetup: { snapshot },
+ events: {
+ coolifySetup: {
+ onChanged: (fn: (state: unknown) => void) => {
+ listeners.current.push(fn);
+ return () => {
+ listeners.current = listeners.current.filter((x) => x !== fn);
+ };
+ },
+ },
+ },
+ },
+}));
+
+const { useCoolifySetupSnapshot } = await import("./useCoolifySetupSnapshot");
+
+const RUNNING: CoolifySetupState = {
+ type: "running",
+ host: "203.0.113.5",
+ invocationRef: {
+ kind: "coolify-setup",
+ entityKey: "203.0.113.5",
+ operationId: "op-1",
+ },
+ step: "installing",
+ log: "",
+ stopping: false,
+};
+
+function push(state: unknown) {
+ act(() => {
+ for (const fn of listeners.current) fn(state);
+ });
+}
+
+function makeWrapper() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const Wrapper = ({ children }: PropsWithChildren) => (
+ {children}
+ );
+ return { client, Wrapper };
+}
+
+/**
+ * How many times the status query has been read.
+ *
+ * A run writes the account in the main process, so the status this panel
+ * already holds is stale from that moment — and staleTime keeps it. Counting
+ * reads is the only way to tell a refresh from a value that merely looks
+ * right because the test seeded it that way.
+ */
+function trackStatus() {
+ const calls = { count: 0 };
+ const useStatus = () =>
+ useQuery({
+ queryKey: queryKeys.coolify.status({ appId: 1 }),
+ queryFn: () => {
+ calls.count += 1;
+ return Promise.resolve({ serverUrl: "http://203.0.113.5:8000" });
+ },
+ // As the panel holds it: read once, then kept until something says
+ // otherwise. Without this the refetch below would prove nothing.
+ staleTime: 60_000,
+ });
+ return { calls, useStatus };
+}
+
+beforeEach(() => {
+ listeners.current = [];
+ snapshot.mockReset();
+ snapshot.mockResolvedValue({ type: "idle" });
+});
+
+describe("when a run settles", () => {
+ it("refreshes what the rest of the panel knows about this Coolify", async () => {
+ // The account is written partway through a run, in the main process. The
+ // only refresh was on the way out of the finished screen, which a failure
+ // never reaches, so the panel went on believing there was no server —
+ // and hid the sign-out that the refusal to install again asks for.
+ const { Wrapper } = makeWrapper();
+ const { calls, useStatus } = trackStatus();
+ renderHook(
+ () => {
+ useCoolifySetupSnapshot();
+ useStatus();
+ },
+ { wrapper: Wrapper },
+ );
+ await waitFor(() => expect(calls.count).toBe(1));
+
+ push(RUNNING);
+ // Nothing is written until a run settles, so nothing is stale yet.
+ expect(calls.count).toBe(1);
+
+ push({
+ type: "failed",
+ host: "203.0.113.5",
+ message: "boom",
+ cancelled: false,
+ });
+ await waitFor(() => expect(calls.count).toBe(2));
+ });
+
+ it("refreshes it after one that finished, too", async () => {
+ const { Wrapper } = makeWrapper();
+ const { calls, useStatus } = trackStatus();
+ renderHook(
+ () => {
+ useCoolifySetupSnapshot();
+ useStatus();
+ },
+ { wrapper: Wrapper },
+ );
+ await waitFor(() => expect(calls.count).toBe(1));
+
+ push({ type: "done", host: "203.0.113.5", result: { dashboardUrl: "x" } });
+
+ await waitFor(() => expect(calls.count).toBe(2));
+ });
+});
diff --git a/src/hooks/useCoolifySetupSnapshot.ts b/src/hooks/useCoolifySetupSnapshot.ts
new file mode 100644
index 0000000000..1fd3420281
--- /dev/null
+++ b/src/hooks/useCoolifySetupSnapshot.ts
@@ -0,0 +1,82 @@
+import { useEffect } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ipc } from "@/ipc/types";
+import { queryKeys } from "@/lib/queryKeys";
+import type { SetupSnapshot } from "@/ipc/types/coolify_setup";
+
+/**
+ * Renderer binding for the Coolify setup machine.
+ *
+ * The snapshot is owned by the main process. Subscribing happens before the
+ * initial read so a run that finishes mid-mount is not missed, and a
+ * late-arriving read never overwrites a state already pushed.
+ *
+ * One hook rather than a query in each panel that wants it. Two components
+ * read this at once, and React Query runs whichever observer's queryFn it
+ * picked for the key — so a plain one anywhere is a plain one everywhere,
+ * and the guard below would be bypassed by the copy that did not have it.
+ */
+/**
+ * How many pushed states have landed, so a read can tell whether it was
+ * overtaken. Counted rather than flagged: the read compares against what it
+ * started with, and a refetch after an earlier event must not read as
+ * overtaken by that old one.
+ *
+ * Shared rather than held per caller. React Query keeps one set of options
+ * per key, taken from whichever observer last fetched — and that caller can
+ * unmount while another still reads the query, leaving a count that has
+ * stopped advancing to decide whether a read was overtaken. Only its changing
+ * matters, so several callers counting the same event is not a problem.
+ */
+let eventCount = 0;
+
+export function useCoolifySetupSnapshot() {
+ const queryClient = useQueryClient();
+
+ // Pushed rather than polled, so the step and the log keep up with a run
+ // this window did not start.
+ useEffect(() => {
+ return ipc.events.coolifySetup.onChanged((state) => {
+ eventCount += 1;
+ queryClient.setQueryData(queryKeys.coolify.setup, state);
+ // A run writes the account and the token in the main process, so what
+ // the rest of the panel believes about this Coolify is out of date the
+ // moment one settles. Only the way out of the finished screen refreshed
+ // it, which a failure never reaches — leaving the panel offering to
+ // install over a server whose password it is already holding.
+ if (state.type === "done" || state.type === "failed") {
+ // Everything about this Coolify except the snapshot itself, which
+ // was just handed to us. Refetching that would ask the main process
+ // to say again what it has already said, and a read still in flight
+ // from before is exactly how a finished run gets put back on a step
+ // it has left.
+ void queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === queryKeys.coolify.all[0] &&
+ query.queryKey[1] !== queryKeys.coolify.setup[1],
+ });
+ }
+ });
+ }, [queryClient]);
+
+ // What is going on is asked for, not remembered. An install outlives the
+ // screen — leaving it is invited, and a background refetch can replace it —
+ // so anything kept here would be lost exactly when it mattered.
+ return useQuery({
+ queryKey: queryKeys.coolify.setup,
+ queryFn: async () => {
+ const before = eventCount;
+ const read = await ipc.coolifySetup.snapshot();
+ // Overtaken while in flight. Answering with the read would put the
+ // panel back on a step the run has already left, and leave a Cancel
+ // button over a run that has finished until something refetches.
+ if (eventCount !== before) {
+ return (
+ queryClient.getQueryData(queryKeys.coolify.setup) ??
+ read
+ );
+ }
+ return read;
+ },
+ });
+}
diff --git a/src/ipc/handlers/coolify_handlers.test.ts b/src/ipc/handlers/coolify_handlers.test.ts
index 1fa14c8244..13504bd9cb 100644
--- a/src/ipc/handlers/coolify_handlers.test.ts
+++ b/src/ipc/handlers/coolify_handlers.test.ts
@@ -10,6 +10,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
*/
const settings: Record = {};
+
+/** The settings fixture is untyped, and these assertions are about secrets. */
+function storedCoolify() {
+ return (settings.coolify ?? {}) as {
+ accessToken?: { value: string };
+ admin?: { email: string; password: { value: string }; instanceUrl: string };
+ };
+}
const rows: Record[] = [];
vi.mock("../../main/settings", () => ({
@@ -220,6 +228,28 @@ describe("naming the stored token", () => {
expect(JSON.stringify(status)).not.toContain("super-secret");
});
+ it("names a server Dyad set up, so the panel can hold the address to it", async () => {
+ settings.coolify = {
+ ...(settings.coolify as Record),
+ admin: {
+ email: "me@gmail.com",
+ password: { value: "Abc123@xyz" },
+ instanceUrl: "http://203.0.113.5:8000",
+ },
+ };
+ const status: any = await call("coolify:get-status", { appId: 1 });
+
+ expect(status.serverUrl).toBe("http://203.0.113.5:8000");
+ // The address, and nothing that opens it.
+ expect(JSON.stringify(status)).not.toContain("Abc123@xyz");
+ });
+
+ it("has no server address when Dyad set nothing up", async () => {
+ const status: any = await call("coolify:get-status", { appId: 1 });
+
+ expect(status.serverUrl).toBeNull();
+ });
+
it("is absent when there is no token", async () => {
settings.coolify = { instanceUrl: "https://coolify.example.com" };
const status: any = await call("coolify:get-status", { appId: 1 });
@@ -240,6 +270,25 @@ describe("clearing the token", () => {
expect(updateSet).not.toHaveBeenCalled();
});
+ it("forgets every stored detail of the instance", async () => {
+ // Dyad holds one Coolify at a time, so anything surviving here belongs to
+ // an instance nothing is connected to — and the next connection would put
+ // a stranger's password under its address. The dialog in front of this
+ // shows all of it and asks the user to confirm before it goes.
+ settings.coolify = {
+ ...(settings.coolify as Record),
+ admin: {
+ email: "me@gmail.com",
+ password: { value: "Abc123@xyz" },
+ instanceUrl: "https://coolify.example.com",
+ },
+ };
+
+ await call("coolify:clear-token");
+
+ expect(storedCoolify()).toEqual({});
+ });
+
it("still reports the app as disconnected", async () => {
await call("coolify:clear-token");
@@ -250,8 +299,9 @@ describe("clearing the token", () => {
};
expect(status.hasToken).toBe(false);
expect(status.connection).toBeNull();
- // Remembered so the token form does not make the user retype it.
- expect(status.instanceUrl).toBe("https://coolify.example.com");
+ // The address is part of the instance being forgotten, not a leftover
+ // convenience. It was on screen with a copy button on the way out.
+ expect(status.instanceUrl).toBeNull();
});
it("lets the same instance pick up where it left off", async () => {
@@ -306,6 +356,41 @@ describe("clearing the token", () => {
});
});
+describe("the admin account Dyad created", () => {
+ beforeEach(() => {
+ settings.coolify = {
+ ...(settings.coolify as Record),
+ admin: {
+ email: "me@gmail.com",
+ password: { value: "Abc123@xyz" },
+ instanceUrl: "https://coolify.example.com",
+ },
+ };
+ });
+
+ it("survives the token for it being saved", async () => {
+ // Setting up a server writes the account before there is any token, and
+ // this is the call that supplies one. Replacing rather than merging here
+ // would drop the password on the way in, and Dyad has the only copy.
+ await call("coolify:save-token", {
+ instanceUrl: "https://coolify.example.com",
+ token: "tok-2",
+ acknowledgedInsecure: false,
+ });
+
+ expect(storedCoolify().admin?.email).toBe("me@gmail.com");
+ expect(storedCoolify().admin?.password.value).toBe("Abc123@xyz");
+ });
+
+ it("goes when the instance is forgotten", async () => {
+ // The account only opens the instance being signed out of, so keeping it
+ // would leave a password for a Coolify nothing is connected to.
+ await call("coolify:clear-token");
+
+ expect(storedCoolify().admin).toBeUndefined();
+ });
+});
+
describe("moving an app to a different server or project", () => {
it("releases the application, which cannot move with it", async () => {
// Coolify cannot move an application between servers, so keeping its id
diff --git a/src/ipc/handlers/coolify_handlers.ts b/src/ipc/handlers/coolify_handlers.ts
index d5039eb97a..06b53e99f6 100644
--- a/src/ipc/handlers/coolify_handlers.ts
+++ b/src/ipc/handlers/coolify_handlers.ts
@@ -1,12 +1,13 @@
import { BrowserWindow } from "electron";
import { eq } from "drizzle-orm";
import log from "electron-log";
-import * as dns from "node:dns/promises";
import { createHash } from "node:crypto";
import { db } from "../../db";
import { apps } from "../../db/schema";
+import { resolveBoth } from "../utils/dns_resolve";
import { readSettings, writeSettings } from "../../main/settings";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { forgottenCoolify } from "@/lib/schemas";
import {
getClient,
readConnectionState,
@@ -26,7 +27,7 @@ import {
isLoopbackAddress,
isNonRoutableAddress,
isDeferredIpv6,
-} from "@/coolify_deploy/domain_check";
+} from "@/shared/domain_check";
import {
applyCoolifyConnectionChange,
isHostMove,
@@ -47,56 +48,6 @@ async function getApp(appId: number) {
return app;
}
-/** A resolver saying "no such record" — anything else is our problem, not DNS's. */
-const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
-
-/**
- * Bounded, because Save waits on it.
- *
- * The check is advisory, so a resolver that never answers must not be able to
- * hold the button indefinitely. The bound is per attempt per configured
- * nameserver, so the wait scales with how many the machine has: six seconds
- * against a single stub resolver, and proportionally more where several are
- * listed. Two tries rather than the default four keeps that multiple small. A
- * timeout still arrives as an error code the caller reads as "could not ask"
- * rather than as a missing record.
- */
-const resolver = new dns.Resolver({ timeout: 3_000, tries: 2 });
-
-/**
- * Both families, distinguishing "no record" from "could not ask".
- *
- * A timeout or an unreachable resolver must not be reported as a missing
- * record: telling someone to fix DNS that is already correct is exactly the
- * confident-but-wrong advice the unknown verdict exists to avoid.
- */
-async function resolveBoth(
- hostname: string,
-): Promise<{ addresses: string[]; failed: boolean }> {
- const attempt = async (fn: (h: string) => Promise) => {
- try {
- return { addresses: await fn(hostname), failed: false };
- } catch (error) {
- const code = (error as NodeJS.ErrnoException).code ?? "";
- return { addresses: [] as string[], failed: !NO_RECORD_CODES.has(code) };
- }
- };
- const [v4, v6] = await Promise.all([
- attempt((h) => resolver.resolve4(h)),
- attempt((h) => resolver.resolve6(h)),
- ]);
- return {
- addresses: [...v4.addresses, ...v6.addresses],
- // One family answering is enough. But a definitive "no such record" from
- // one and a failed lookup from the other is not the same as no records:
- // the family we could not reach may hold the one that works.
- failed:
- v4.addresses.length === 0 &&
- v6.addresses.length === 0 &&
- (v4.failed || v6.failed),
- };
-}
-
/** The app's stored connection, or nothing when it has none. */
function readConnection(
state: CoolifyConnectionState,
@@ -152,6 +103,7 @@ export function registerCoolifyHandlers() {
hasToken: Boolean(token),
tokenId: token ? tokenFingerprint(token) : null,
instanceUrl: settings.coolify?.instanceUrl ?? null,
+ serverUrl: settings.coolify?.admin?.instanceUrl ?? null,
connection: readConnection(state),
appUrl: deployed?.appUrl ?? null,
lastDeployedAt: deployed?.lastDeployedAt.getTime() ?? null,
@@ -177,8 +129,9 @@ export function registerCoolifyHandlers() {
await probe.listServers();
const normalized = instanceUrl.replace(/\/+$/, "");
const previous = readSettings().coolify?.instanceUrl;
- // Spread, as clearToken does: a field added to CoolifySchema later
- // should not be dropped by whichever of the two happens to run.
+ // Spread: the admin account for a server Dyad just installed is set
+ // before there is any token for it, and this is the call that supplies
+ // the token. Replacing would drop the password on the way in.
writeSettings({
coolify: {
...readSettings().coolify,
@@ -210,22 +163,26 @@ export function registerCoolifyHandlers() {
});
createTypedHandler(coolifyContracts.clearToken, async () => {
- // Only the token. Every app reads as disconnected without one, so there is
- // nothing to gain by clearing their rows — and doing so would throw away
- // each app's Coolify application id, which is the one value that cannot be
- // re-entered. The next deploy would then build a second application beside
- // the one already running and lose a fight with it over the domain.
+ // Every field, not just the token. Dyad holds one Coolify at a time, so
+ // an address or an admin account left behind belongs to an instance
+ // nothing is connected to any more — and the next connection would put a
+ // stranger's password under its address. The screen that reaches this
+ // shows all of it one last time and asks the user to confirm, because
+ // Dyad invented the password and is the only thing that has it.
+ //
+ // Every field named rather than an empty object: an absent key reads to
+ // writeSettings as one a consumer read could not decrypt, and it hands
+ // the ciphertext back — so an empty coolify would return the token this
+ // is here to forget. forgottenCoolify names them all and stops
+ // compiling when CoolifySchema grows one more.
//
- // The instance URL stays so that saveToken can still tell whether the next
- // token points somewhere else. Even then the rows are kept: those ids name
- // applications still running on the old instance, and nothing in Dyad
- // could reach them again once they were gone.
+ // The apps' rows are not touched. They read as disconnected without a
+ // token anyway, and they carry each app's Coolify application id — the
+ // one value that cannot be typed back in. Losing it would make the next
+ // deploy build a second application beside the one already running and
+ // lose a fight with it over the domain.
coolifyDeployRegistry.cancelAll();
- // The address survives; only the token goes. Spread rather than replaced,
- // so a field added to CoolifySchema later is not silently dropped here.
- writeSettings({
- coolify: { ...readSettings().coolify, accessToken: undefined },
- });
+ writeSettings({ coolify: forgottenCoolify() });
});
createTypedHandler(coolifyContracts.createProject, async (_, { name }) => {
diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts
new file mode 100644
index 0000000000..0767867c7d
--- /dev/null
+++ b/src/ipc/handlers/coolify_setup_handlers.test.ts
@@ -0,0 +1,1141 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+// The mocked class, so the handler recognises what it is handed.
+import { SshError } from "../utils/ssh_client";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import {
+ SETUP_MACHINE_REPORTED,
+ SetupResultSchema,
+} from "@/ipc/types/coolify_setup";
+
+const h = vi.hoisted(() => ({
+ settings: {} as Record,
+ written: [] as Array>,
+ serverKey: { publicKey: "ssh-ed25519 AAAAPUB dyad", privateKey: "PRIVATE" },
+ setupResult: null as unknown,
+ setupError: null as unknown,
+ lastSetupOptions: null as Record | null,
+ sessionEnded: 0,
+ reportsAccount: true,
+ runCalls: 0,
+ verifiedAgainst: [] as string[],
+ writeThrows: false,
+ /** How many writes fail before the store comes back. */
+ writeFailures: 0,
+ /**
+ * Writes that succeed before the failures above start.
+ *
+ * The record written on the way in is the first write, and failing it now
+ * refuses the run outright — so a case about the writes that come after the
+ * account exists has to let that one through.
+ */
+ writeOkFirst: 0,
+ reportsAccountTwice: false,
+ failsBeforeCredentials: false,
+ onRunStarted: null as null | (() => void),
+ preflightThrows: false,
+ preflightReady: true,
+ fingerprint: "SHA256:fingerprint",
+ lastConnectTarget: null as null | { host: string },
+ /** What the SSH connect throws, for the cases about a failed connect. */
+ connectError: null as unknown,
+}));
+
+vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => [] } }));
+
+const handlers = new Map Promise>();
+vi.mock("./base", () => ({
+ createTypedHandler: (
+ contract: { channel: string },
+ handler: (...args: unknown[]) => Promise,
+ ) => handlers.set(contract.channel, handler),
+}));
+
+vi.mock("@/main/settings", () => ({
+ readSettings: () => h.settings,
+ writeSettings: (value: Record) => {
+ if (h.writeOkFirst > 0) {
+ h.writeOkFirst -= 1;
+ h.written.push(value);
+ Object.assign(h.settings, value);
+ return;
+ }
+ if (h.writeFailures > 0) {
+ h.writeFailures -= 1;
+ throw new Error("keychain is unavailable");
+ }
+ if (h.writeThrows) throw new Error("keychain is unavailable");
+ h.written.push(value);
+ Object.assign(h.settings, value);
+ },
+}));
+
+vi.mock("@/coolify_setup/server_key", () => ({
+ ensureServerKey: () => h.serverKey,
+}));
+
+vi.mock("../utils/ssh_client", () => ({
+ // The real client runs the verifier during the handshake, which is what
+ // reports the fingerprint. A mock that skipped it would leave the handler
+ // looking correct while reporting nothing.
+ connectSsh: vi.fn(
+ async (target: { host: string }, verify: (fp: string) => boolean) => {
+ h.lastConnectTarget = target;
+ if (h.connectError) throw h.connectError;
+ verify(h.fingerprint);
+ return {
+ run: vi.fn(),
+ end: () => {
+ h.sessionEnded += 1;
+ },
+ };
+ },
+ ),
+ trustOnFirstUse: (onSeen: (fp: string) => void) => (fingerprint: string) => {
+ onSeen(fingerprint);
+ return true;
+ },
+ // Recorded where it is built, not where it is called: the flow is mocked
+ // here, so what this proves is which verifier the handler chose.
+ expectFingerprint: (expected: string) => {
+ h.verifiedAgainst.push(expected);
+ return (fingerprint: string) => fingerprint === expected;
+ },
+ // Close enough to the real class for what this file asserts: the failure,
+ // the kind, the errno, and the name `sshFailureOf` matches on. It is not a DyadError,
+ // so a case about what survives serialization would need more than this.
+ //
+ // The failure and the kind are asserted on for opposite reasons. The
+ // failure is read here and goes no further: it is what drops an unreachable server from
+ // telemetry, as the user's own network rather than a fault here, and the
+ // serialized error has no such field. The kind is not read for this
+ // failure — the filter has already answered on the failure — and it does
+ // cross. Between them they pin the error as the one the client threw.
+ SshError: class SshError extends Error {
+ constructor(
+ readonly failure: string,
+ message: string,
+ readonly kind?: string,
+ readonly systemCode?: string,
+ ) {
+ super(message);
+ this.name = "SshError";
+ }
+ },
+}));
+
+vi.mock("@/coolify_setup/install", () => ({
+ preflight: vi.fn(async () => {
+ if (h.preflightThrows) throw new Error("docker never answered");
+ return {
+ ready: h.preflightReady,
+ reason: h.preflightReady ? undefined : "It already has Coolify on it.",
+ alreadyInstalled: !h.preflightReady,
+ memoryMb: 1967,
+ };
+ }),
+}));
+
+vi.mock("@/coolify_setup/setup_flow", () => ({
+ runServerSetup: vi.fn(async (options: Record) => {
+ h.runCalls += 1;
+ h.lastSetupOptions = options;
+ // Connecting and the preflight both come before the password is handed
+ // over, and either can end the run.
+ if (h.failsBeforeCredentials) throw h.setupError;
+ // The real flow hands over the password before the installer runs, since
+ // it invented it rather than discovering it.
+ (
+ options.onCredentialsBuilt as (a: {
+ credentials: { email: string; password: string };
+ dashboardUrl: string;
+ }) => void
+ )({
+ credentials: { email: "me@gmail.com", password: "Abc123@xyz" },
+ dashboardUrl: "http://203.0.113.5:8000",
+ });
+ // The real flow reports the account the moment it exists, before the
+ // steps that can still fail.
+ if (h.reportsAccount) {
+ (
+ options.onAccountKnown as (a: {
+ credentials: { email: string; password: string };
+ dashboardUrl: string;
+ }) => void
+ )({
+ credentials: { email: "me@gmail.com", password: "Abc123@xyz" },
+ dashboardUrl: "http://203.0.113.5:8000",
+ });
+ // And again once HTTPS has settled the address it is reachable at.
+ if (h.reportsAccountTwice) {
+ (
+ options.onAccountKnown as (a: {
+ credentials: { email: string; password: string };
+ dashboardUrl: string;
+ }) => void
+ )({
+ credentials: { email: "me@gmail.com", password: "Abc123@xyz" },
+ dashboardUrl: "https://203.0.113.5.sslip.io",
+ });
+ }
+ }
+ // Something else writing while the run is in flight.
+ h.onRunStarted?.();
+ if (h.setupError) throw h.setupError;
+ return h.setupResult;
+ }),
+}));
+
+const { registerCoolifySetupHandlers, resetCoolifySetupStateForTests } =
+ await import("./coolify_setup_handlers");
+
+/** Install requires a check first, so this is what "run it" means now. */
+async function checkThenRun(input: Record = TARGET) {
+ await call("coolify-setup:inspect", input);
+ return call("coolify-setup:run", input);
+}
+
+function call(channel: string, input?: unknown) {
+ const handler = handlers.get(channel);
+ if (!handler) throw new Error(`No handler for ${channel}`);
+ return handler({}, input);
+}
+
+const TARGET = {
+ host: "203.0.113.5",
+ username: "root",
+ adminEmail: "me@gmail.com",
+};
+
+const RESULT = {
+ dashboardUrl: "http://203.0.113.5:8000",
+ // The ordinary end of a run: a certificate arrived. Named rather than left
+ // out, because a token for an address that is not encrypted is held for the
+ // user to agree to rather than stored, and omitting this reads as that.
+ secure: true,
+ credentials: {
+ username: "dyad-admin",
+ email: "me@gmail.com",
+ password: "Abc123@xyz",
+ },
+ token: "1|abc",
+ // A token comes from a mint, and Dyad opens the API to reach one.
+ apiEnabled: true,
+ version: "4.3.2",
+};
+
+beforeEach(() => {
+ handlers.clear();
+ h.settings = {};
+ h.written.length = 0;
+ h.setupResult = RESULT;
+ h.setupError = null;
+ h.lastSetupOptions = null;
+ h.sessionEnded = 0;
+ h.reportsAccount = true;
+ h.failsBeforeCredentials = false;
+ h.lastConnectTarget = null;
+ h.connectError = null;
+ h.onRunStarted = null;
+ h.runCalls = 0;
+ h.verifiedAgainst.length = 0;
+ h.writeThrows = false;
+ h.writeFailures = 0;
+ h.writeOkFirst = 0;
+ h.reportsAccountTwice = false;
+ h.preflightThrows = false;
+ h.preflightReady = true;
+ h.fingerprint = "SHA256:fingerprint";
+ resetCoolifySetupStateForTests();
+ registerCoolifySetupHandlers();
+});
+
+describe("getServerKey", () => {
+ it("hands over only the public half", async () => {
+ // The private key is what reaches the user's server; it belongs in the
+ // main process, exactly like the API token.
+ const result = (await call("coolify-setup:get-server-key")) as Record<
+ string,
+ unknown
+ >;
+ expect(result).toEqual({ publicKey: h.serverKey.publicKey });
+ expect(JSON.stringify(result)).not.toContain("PRIVATE");
+ });
+});
+
+describe("inspect", () => {
+ it("reports the fingerprint it saw", async () => {
+ const result = (await call("coolify-setup:inspect", TARGET)) as Record<
+ string,
+ unknown
+ >;
+ expect(result.hostFingerprint).toBe("SHA256:fingerprint");
+ expect(result.ready).toBe(true);
+ });
+
+ it("closes the connection it opened", async () => {
+ await call("coolify-setup:inspect", TARGET);
+ expect(h.sessionEnded).toBe(1);
+ });
+
+ /**
+ * What the client reports for a name that does not resolve.
+ *
+ * Written out rather than shortened: a connect the socket reports as failed
+ * does not arrive raw — `classify` turns those into an SshError with words
+ * of its own — so a fixture shaped like the library's own message would be
+ * a shape this path does not produce, and would let the hint be built
+ * against text the user never sees.
+ *
+ * A function rather than a constant, because the handler rewrites the
+ * message of the error it is given: one shared instance would carry the
+ * previous case's hint into the next one.
+ */
+ const UNREACHABLE = () =>
+ new SshError(
+ "unreachable",
+ "Could not reach the server (ENOTFOUND). Check the address and that " +
+ "port 22 is open.",
+ DyadErrorKind.External,
+ "ENOTFOUND",
+ );
+
+ it("says what to type instead, before anything else", async () => {
+ // The other way in — a token for a Coolify that already exists — asks for
+ // exactly that shape, so it is the likeliest wrong answer. What the client
+ // says on its own names the address as one of two suspects; the other is a
+ // port nobody is listening on, and that is the one people go and look at.
+ h.connectError = UNREACHABLE();
+
+ await expect(
+ call("coolify-setup:inspect", {
+ ...TARGET,
+ host: "https://203.0.113.5:8000",
+ }),
+ ).rejects.toThrow(/^Enter just the server address[\s\S]*ENOTFOUND/);
+ });
+
+ it("says it for an address with a path on it, too", async () => {
+ // The half with no scheme in it — an address someone put a path onto,
+ // copied out of a page that documented one. Worth its own case because
+ // this is where calling it "a URL" would have been a guess: the message
+ // states the rule instead, so it is true of both.
+ h.connectError = UNREACHABLE();
+
+ await expect(
+ call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }),
+ ).rejects.toThrow(/Enter just the server address/);
+ });
+
+ it("keeps the fault it was given, and what it was", async () => {
+ // Added to the error rather than replacing it. The code is the one part
+ // of what the client said that a bug report needs — the sentence around
+ // it offers a closed port, which the hint has just ruled out — and the
+ // failure decides, in this process before any of it is serialized,
+ // whether this is reported at all.
+ h.connectError = UNREACHABLE();
+
+ await expect(
+ call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }),
+ ).rejects.toMatchObject({
+ failure: "unreachable",
+ kind: DyadErrorKind.External,
+ // Ends there: the sentence the client wrapped the code in offers a
+ // closed port as the other suspect, and keeping it would put a second
+ // answer under the one this just gave.
+ message: expect.stringMatching(/\(ENOTFOUND\)$/),
+ });
+ });
+
+ it("falls back to the failure when the system named nothing", async () => {
+ // Only some of `classify`'s branches have an errno to pass on. The rest
+ // still carry a failure, which is a word rather than the sentence they
+ // wrote — and that sentence is the one offering a closed port, which the
+ // instruction it is appended to has just ruled out.
+ h.connectError = new SshError(
+ "timeout",
+ "The server did not answer in time. Check the address and that port " +
+ "22 is reachable.",
+ DyadErrorKind.External,
+ );
+
+ await expect(
+ call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }),
+ ).rejects.toThrow(/in it\. \(timeout\)$/);
+ });
+
+ it("says nothing about the address once the connection is open", async () => {
+ // Only the connect is covered, and nothing but this says so. Past it the
+ // address reached something, so what failed is the server — and the hint
+ // would be advice the connection that just succeeded has disproved.
+ h.preflightThrows = true;
+
+ await expect(
+ call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }),
+ ).rejects.toThrow(/^docker never answered$/);
+ });
+
+ it("says only the instruction when the error carries neither", async () => {
+ // Nothing to add is not a reason to add the message back: what is left
+ // is the one sentence that tells the user what to do.
+ h.connectError = new Error("something went wrong");
+
+ await expect(
+ call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }),
+ ).rejects.toThrow(/no \/ characters in it\.$/);
+ });
+
+ it("says nothing about an address it does not recognise", async () => {
+ // The whole point of adding this after the failure rather than before the
+ // connect. An address shaped like nothing in particular — a single-label
+ // name, an IPv6 literal — is none of its business, and a hint here would
+ // be a confident wrong answer on top of a real fault.
+ //
+ // Given the same failure as the cases that do get the hint, so the
+ // address is the only thing separating them from this one: a plainer
+ // error here would let an implementation that hints on every SshError
+ // pass this untouched.
+ for (const host of ["fe80::1", "coolify", "my_server.local"]) {
+ h.connectError = UNREACHABLE();
+ await expect(
+ call("coolify-setup:inspect", { ...TARGET, host }),
+ ).rejects.toThrow(/^Could not reach the server \(ENOTFOUND\)\./);
+ }
+ });
+});
+
+describe("run", () => {
+ it("refuses an address Coolify will not accept, before doing anything", async () => {
+ // Its seeder resolves the domain. Finding out afterwards costs the whole
+ // install and leaves an instance with no account on it.
+ await expect(
+ call("coolify-setup:run", { ...TARGET, adminEmail: "admin@dyad.test" }),
+ ).rejects.toMatchObject({ kind: "validation" });
+ });
+
+ it("holds the install to the identity the inspection saw", async () => {
+ // The panel shows that fingerprint and asks the user to commit minutes to
+ // it. Without the pin, the install accepts whatever answers the address by
+ // the time it starts.
+ await checkThenRun();
+
+ expect(h.verifiedAgainst).toContain("SHA256:fingerprint");
+ });
+
+ it("does not hold one port to what another on the same address showed", async () => {
+ // Two services on one address are two servers. Keyed by address alone,
+ // the second is checked against the first one's fingerprint and refused.
+ await call("coolify-setup:inspect", { ...TARGET, port: 22 });
+
+ await expect(
+ call("coolify-setup:run", { ...TARGET, port: 2222 }),
+ ).rejects.toThrow(/Check the server/);
+ });
+
+ it("does not hold one server to what another one showed", async () => {
+ // Addresses are remembered as themselves. Read as URLs, everything shaped
+ // like fe80::1 parses as a scheme with no hostname and shares one entry,
+ // so a second server would be refused for the first one's key.
+ await call("coolify-setup:inspect", { ...TARGET, host: "fe80::1" });
+
+ await expect(
+ call("coolify-setup:run", { ...TARGET, host: "fe80::2" }),
+ ).rejects.toThrow(/Check the server/);
+ });
+
+ it("says the identity changed rather than reporting a cancellation", async () => {
+ // host-key-rejected is how a user declining is reported too, and that
+ // reads as "nothing happened" — which is the wrong thing to say when a
+ // server has been swapped underneath the address.
+ await call("coolify-setup:inspect", TARGET);
+ h.setupError = new SshError(
+ "host-key-rejected",
+ "The server's identity was not accepted, so nothing was sent to it.",
+ DyadErrorKind.UserCancelled,
+ );
+
+ await expect(checkThenRun()).rejects.toThrow(/identity has changed/);
+ });
+
+ it("does not start an install it cannot record the password for", async () => {
+ // Before the installer, so nothing has been done to the server and this
+ // costs a retry. Carrying on would put an account on a machine whose
+ // password Dyad never managed to keep — and preflight then refuses to
+ // install again, so there is no way back to it.
+ h.writeThrows = true;
+
+ const error = (await checkThenRun().catch((e: unknown) => e)) as Error;
+
+ expect(error.message).toMatch(/could not save the admin/);
+ // What the keychain said is logged, not handed to a screen that already
+ // carries a password this run invented.
+ expect(error.message).not.toMatch(/Abc123@xyz/);
+ expect(error.message).not.toMatch(/keychain is unavailable/);
+ // And nothing was left behind to hold up the next attempt.
+ expect(
+ (h.settings.coolify as { admin?: unknown } | undefined)?.admin,
+ ).toBeUndefined();
+ });
+
+ it("finishes when the account cannot be written down", async () => {
+ // The account is on the server either way, and a retry is refused because
+ // Coolify is installed now — so ending the run here would lose the only
+ // copy of a password Dyad invented. The record on the way in lands: that
+ // one failing refuses the run instead, before anything is installed.
+ h.writeOkFirst = 1;
+ h.writeThrows = true;
+
+ const result = (await checkThenRun()) as {
+ adminPassword: string;
+ tokenStored: boolean;
+ tokenUnavailableReason: string;
+ };
+
+ expect(result.adminPassword).toBe("Abc123@xyz");
+ // Nothing was written, so the next screen has no token to use — saying
+ // otherwise sends the user to a panel that cannot work.
+ expect(result.tokenStored).toBe(false);
+ // Where the password actually is. The screen puts the card above this
+ // message, and on this path it is the only copy — so the direction is
+ // the part that matters, and it is written here rather than there.
+ expect(result.tokenUnavailableReason).toContain("could not save");
+ expect(result.tokenUnavailableReason).toContain("password above");
+ });
+
+ it("refuses a server it has not looked at", async () => {
+ // The form disables Install until the check has run, but this is the call
+ // that sends the credentials, so it says no on its own account.
+ await expect(call("coolify-setup:run", TARGET)).rejects.toThrow(
+ /Check the server/,
+ );
+ });
+
+ it("refuses a server whose check never finished", async () => {
+ // Neither the key nor the pass is recorded until a check has finished, so
+ // a connection that opened leaves nothing for an install to go on.
+ h.preflightThrows = true;
+ await expect(call("coolify-setup:inspect", TARGET)).rejects.toThrow();
+
+ await expect(call("coolify-setup:run", TARGET)).rejects.toThrow(
+ /Check the server/,
+ );
+ expect(h.runCalls).toBe(0);
+ });
+
+ it("refuses a server the check turned down", async () => {
+ h.preflightReady = false;
+ await call("coolify-setup:inspect", TARGET);
+
+ await expect(call("coolify-setup:run", TARGET)).rejects.toThrow(
+ /Check the server/,
+ );
+ expect(h.runCalls).toBe(0);
+ });
+
+ it("keeps the answer that stands when a re-check does not finish", async () => {
+ // The handshake happens before preflight, so recording the key there left
+ // the new machine's key beside the old machine's pass — an install onto a
+ // server whose check never came back.
+ await call("coolify-setup:inspect", TARGET);
+
+ // A different machine answers the address, and its check does not finish.
+ h.fingerprint = "SHA256:someone-else";
+ h.preflightThrows = true;
+ await expect(call("coolify-setup:inspect", TARGET)).rejects.toThrow();
+ h.preflightThrows = false;
+
+ // The pass from the finished check still stands, and it is still paired
+ // with the key that check saw — not with the one nobody approved.
+ await call("coolify-setup:run", TARGET);
+ expect(h.verifiedAgainst).toEqual(["SHA256:fingerprint"]);
+ });
+
+ it("drops a pass the next check takes back", async () => {
+ // A server that was ready and has since had Coolify put on it is not one
+ // to install onto, and the second answer is the true one.
+ await call("coolify-setup:inspect", TARGET);
+ h.preflightReady = false;
+ await call("coolify-setup:inspect", TARGET);
+
+ await expect(call("coolify-setup:run", TARGET)).rejects.toThrow(
+ /Check the server/,
+ );
+ });
+
+ it("leaves the one-at-a-time refusal unmarked", async () => {
+ // That refusal comes from the machine declining to start, not from a run
+ // it took on — so it has nothing on screen of its own, and the panel has
+ // to say it. What keeps it unmarked is where start() sits.
+ h.reportsAccount = false;
+ let release!: () => void;
+ h.setupResult = new Promise((resolve) => {
+ release = () => resolve(RESULT);
+ });
+ const first = checkThenRun();
+
+ await expect(checkThenRun()).rejects.not.toMatchObject({
+ code: SETUP_MACHINE_REPORTED,
+ });
+
+ release();
+ await first;
+ });
+
+ it("leaves an error that carries its own code alone", async () => {
+ // A system error names itself — ENOTFOUND and the like — and overwriting
+ // that loses what went wrong. Said twice is better than said wrongly.
+ h.setupError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
+ code: "ENOTFOUND",
+ });
+
+ await expect(checkThenRun()).rejects.toMatchObject({ code: "ENOTFOUND" });
+ });
+
+ it("stores the account on the way out when the first attempt failed", async () => {
+ // Coolify has the account either way, and preflight refuses to install
+ // over it — so a password stored nowhere is a server nobody can sign into.
+ // The record on the way in lands — failing that refuses the run — and
+ // the account's own write is the one that does not, so the retry runs.
+ h.writeOkFirst = 1;
+ h.writeFailures = 1;
+ h.reportsAccount = true;
+ h.setupError = new DyadError("exit 1", DyadErrorKind.External);
+
+ await expect(checkThenRun()).rejects.toThrow("exit 1");
+
+ const saved = h.written.at(-1) as {
+ coolify: { admin: { password: { value: string } } };
+ };
+ expect(saved.coolify.admin.password.value).toBe("Abc123@xyz");
+ });
+
+ it("does not put back an address a later write replaced", async () => {
+ // The account is reported twice — once when it exists, and again once
+ // HTTPS has settled where it answers. A copy kept from the first would
+ // write the earlier address back over the later one on the way out.
+ // The record on the way in lands, and the first of the two accounts does
+ // not — so the copy left behind is the one holding the old address.
+ h.writeOkFirst = 1;
+ h.writeFailures = 1;
+ h.reportsAccount = true;
+ h.reportsAccountTwice = true;
+ h.setupError = new DyadError("exit 1", DyadErrorKind.External);
+
+ await expect(checkThenRun()).rejects.toThrow("exit 1");
+
+ const saved = h.written.at(-1) as {
+ coolify: { admin: { instanceUrl: string } };
+ };
+ expect(saved.coolify.admin.instanceUrl).toBe(
+ "https://203.0.113.5.sslip.io",
+ );
+ });
+
+ it("reports what went wrong, not what the retry did", async () => {
+ // A write that fails again must not become the failure the user is told
+ // about — the install is what they were watching. The record on the way
+ // in lands, since failing that refuses the run before it starts.
+ h.writeOkFirst = 1;
+ h.writeThrows = true;
+ h.reportsAccount = true;
+ h.setupError = new DyadError("exit 1", DyadErrorKind.External);
+
+ await expect(checkThenRun()).rejects.toThrow("exit 1");
+ });
+
+ it("marks a failure the machine already put on screen", async () => {
+ // The panel suppresses what carries this and shows everything else, so
+ // the mark is what stops one failure being reported twice.
+ h.setupError = new DyadError("exit 1", DyadErrorKind.External);
+
+ await expect(checkThenRun()).rejects.toMatchObject({
+ code: SETUP_MACHINE_REPORTED,
+ });
+ });
+
+ it("leaves a refusal that never started unmarked", async () => {
+ // Nothing reached the machine, so nothing is on screen — and an unmarked
+ // error is the one the panel says out loud.
+ await expect(call("coolify-setup:run", TARGET)).rejects.not.toMatchObject({
+ code: SETUP_MACHINE_REPORTED,
+ });
+ });
+
+ it("stores the token it minted", async () => {
+ await checkThenRun();
+ const saved = h.written.at(-1) as {
+ coolify: { accessToken: { value: string }; instanceUrl: string };
+ };
+ expect(saved.coolify.accessToken.value).toBe("1|abc");
+ expect(saved.coolify.instanceUrl).toBe("http://203.0.113.5:8000");
+ });
+
+ it("stores the admin password, so the user is not locked out later", async () => {
+ // Dyad invented this password for a machine the user owns. Storing the
+ // token but not this leaves them unable to sign in to their own server.
+ await checkThenRun();
+ const saved = h.written.at(-1) as {
+ coolify: { admin?: { password: { value: string }; email: string } };
+ };
+ expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz");
+ expect(saved.coolify.admin?.email).toBe("me@gmail.com");
+ });
+
+ it("records which instance the account is on", async () => {
+ // Connecting Dyad to a different Coolify later has to know this account
+ // does not come along.
+ await checkThenRun();
+ const saved = h.written.at(-1) as {
+ coolify: { admin?: { instanceUrl: string } };
+ };
+ expect(saved.coolify.admin?.instanceUrl).toBe("http://203.0.113.5:8000");
+ });
+
+ it("returns the password so it can be shown once", async () => {
+ const result = (await checkThenRun()) as Record;
+ expect(result.adminPassword).toBe("Abc123@xyz");
+ expect(result.tokenStored).toBe(true);
+ });
+
+ it("answers in the shape the channel says it will", async () => {
+ // These cases mock the wrapper that parses this on the way out, so
+ // nothing else here would notice the answer drifting from the contract,
+ // or the fixture above drifting from what the flow now returns.
+ const result = await checkThenRun();
+ expect(SetupResultSchema.safeParse(result)).toMatchObject({
+ success: true,
+ });
+ });
+
+ it("keeps the install when no token could be created", async () => {
+ h.setupResult = {
+ ...RESULT,
+ token: null,
+ tokenUnavailableReason: "too old",
+ };
+ const result = (await checkThenRun()) as Record;
+
+ expect(result.tokenStored).toBe(false);
+ expect(result.tokenUnavailableReason).toBe("too old");
+ expect(result.adminPassword).toBe("Abc123@xyz");
+
+ // The account is kept even though the token is not: this is the one case
+ // where the user has to sign in to Coolify themselves, so throwing the
+ // password away here would take away the only way to do it.
+ const saved = h.written.at(-1) as {
+ coolify: {
+ admin?: { password: { value: string } };
+ accessToken?: unknown;
+ instanceUrl?: string;
+ };
+ };
+ expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz");
+ // No token and no address, because there is no instance Dyad can talk to.
+ expect(saved.coolify.accessToken).toBeUndefined();
+ expect(saved.coolify.instanceUrl).toBeUndefined();
+ });
+
+ it("keeps the password when the install fails after the account exists", async () => {
+ // The dashboard never answering does not un-create the account. Dyad is
+ // the only thing that knows the password it invented, so failing here
+ // without storing it locks the user out of a server that is running.
+ h.setupError = new Error(
+ "Coolify was installed but its dashboard did not start.",
+ );
+ await checkThenRun().catch(() => {});
+
+ const saved = h.written.at(-1) as {
+ coolify: {
+ admin?: { password: { value: string }; instanceUrl: string };
+ };
+ };
+ expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz");
+ expect(saved.coolify.admin?.instanceUrl).toBe("http://203.0.113.5:8000");
+ });
+
+ it("leaves no account behind for a server that never got one", async () => {
+ // The password goes down before the installer runs, so a run that ends
+ // without ever seeding an account has to take it back off — it opens
+ // nothing, and leaving it would stand in the way of installing again.
+ h.reportsAccount = false;
+ h.setupError = new Error("This server cannot be set up automatically.");
+ await checkThenRun().catch(() => {});
+
+ const saved = h.written.at(-1) as { coolify: { admin?: unknown } };
+ expect(saved.coolify.admin).toBeUndefined();
+ });
+
+ it("has the password down before the installer is finished with it", async () => {
+ // The installer writes it into Coolify's own .env partway through a run
+ // that takes minutes. Quitting in between is what loses the only copy.
+ h.reportsAccount = false;
+ h.setupError = new Error("boom");
+ await checkThenRun().catch(() => {});
+
+ const early = h.written[0] as {
+ coolify: { admin?: { password?: { value: string } } };
+ };
+ expect(early.coolify.admin?.password?.value).toBeTruthy();
+ });
+
+ it("hands ssh2 an address it recognises, brackets or not", async () => {
+ // [2001:db8::1] is how documentation writes a v6 literal, and how anyone
+ // would paste one. ssh2 reads the brackets as part of a hostname and
+ // looks it up, so a reachable server reports as unreachable.
+ await call("coolify-setup:inspect", {
+ ...TARGET,
+ host: "[2001:db8::1]",
+ });
+
+ expect(h.lastConnectTarget?.host).toBe("2001:db8::1");
+ });
+
+ it("does not put back a record something else replaced mid-run", async () => {
+ // Minutes of installing sit between the record going down and the way
+ // out. Signing out in another window during that time is a newer answer
+ // than anything this run knows, and clearing on the way out would write
+ // over whatever that left.
+ h.reportsAccount = false;
+ h.setupError = new Error("boom");
+ h.onRunStarted = () => {
+ // As another window connecting to a Coolify would leave it. The admin
+ // record matters more than the token: clearing on the way out spreads
+ // what it read and names only admin, so a token would survive either
+ // way and prove nothing about the guard.
+ h.settings.coolify = {
+ instanceUrl: "https://elsewhere.example.com",
+ accessToken: { value: "1|theirs" },
+ admin: {
+ email: "other@gmail.com",
+ password: { value: "Other123@xyz" },
+ instanceUrl: "https://elsewhere.example.com",
+ },
+ };
+ };
+ await checkThenRun().catch(() => {});
+
+ // Untouched: what is there is newer than anything this run knows, and it
+ // is the only copy of that server's password.
+ const coolify = h.settings.coolify as {
+ accessToken?: { value: string };
+ admin?: { password?: { value: string } };
+ };
+ expect(coolify.accessToken?.value).toBe("1|theirs");
+ expect(coolify.admin?.password?.value).toBe("Other123@xyz");
+ });
+
+ it("takes its own record back off even if the keychain relocked meanwhile", async () => {
+ // readSettings drops a password it cannot decrypt and keeps the account,
+ // so this run's own record comes back without one. That is it gone
+ // unreadable rather than somebody else's writing — and leaving it behind
+ // holds a password that opens nothing, which is what refuses the next
+ // install.
+ h.reportsAccount = false;
+ h.setupError = new Error("boom");
+ h.onRunStarted = () => {
+ const coolify = h.settings.coolify as { admin?: Record };
+ expect(coolify.admin).toBeTruthy();
+ delete coolify.admin!.password;
+ };
+ await checkThenRun().catch(() => {});
+
+ expect(
+ (h.settings.coolify as { admin?: unknown } | undefined)?.admin,
+ ).toBeUndefined();
+ });
+
+ it("refuses a second setup on a different machine", async () => {
+ // Two installs at once would interleave their output, and the second
+ // machine's run has nothing to do with the first's.
+ h.reportsAccount = false;
+ let release!: () => void;
+ h.setupResult = new Promise((resolve) => {
+ release = () => resolve(RESULT);
+ });
+ const first = checkThenRun();
+ // No account seeded by the first run, so the refusal below is the
+ // one-at-a-time rule rather than the gate that asks for a check or the
+ // one that holds an account — all three refuse the same way.
+ await call("coolify-setup:inspect", { ...TARGET, host: "198.51.100.7" });
+ await expect(
+ call("coolify-setup:run", { ...TARGET, host: "198.51.100.7" }),
+ ).rejects.toThrow(/already being set up/);
+ release();
+ await first;
+ });
+
+ it("refuses a second setup on the same machine too", async () => {
+ // Nobody needs to press Install to get back to a run any more: the panel
+ // asks what is going on and shows it. So a second press is a genuine
+ // second request, and two installs on one machine would fight.
+ h.reportsAccount = false;
+ let release!: () => void;
+ h.setupResult = new Promise((resolve) => {
+ release = () => resolve(RESULT);
+ });
+ const first = checkThenRun();
+ await expect(checkThenRun()).rejects.toMatchObject({
+ kind: "precondition",
+ });
+ release();
+ await first;
+ });
+
+ it("hands back what is going on, so a panel can show it", async () => {
+ h.reportsAccount = false;
+ let release!: () => void;
+ h.setupResult = new Promise((resolve) => {
+ release = () => resolve(RESULT);
+ });
+ await call("coolify-setup:inspect", TARGET);
+ const running = call("coolify-setup:run", TARGET);
+
+ const snapshot = (await call("coolify-setup:snapshot")) as {
+ type: string;
+ host: string;
+ };
+ expect(snapshot.type).toBe("running");
+ expect(snapshot.host).toBe("203.0.113.5");
+
+ release();
+ await running;
+ expect(
+ ((await call("coolify-setup:snapshot")) as { type: string }).type,
+ ).toBe("done");
+ });
+
+ it("puts the finished screen away when the user moves on", async () => {
+ await checkThenRun();
+ await call("coolify-setup:dismiss");
+
+ expect(
+ ((await call("coolify-setup:snapshot")) as { type: string }).type,
+ ).toBe("idle");
+ });
+
+ it("refuses to install over an account Dyad is holding", async () => {
+ // The screen that offers this stands aside while a failure is being
+ // reported, so its message and log stay reachable — and the form comes
+ // with it. Retrying that same server is refused by preflight once Coolify
+ // is on it, so what is left here is a different one, whose run would
+ // write its own account over the only copy of this one's password.
+ h.settings = {
+ coolify: {
+ admin: {
+ email: "me@gmail.com",
+ password: { value: "TheEarlierOne" },
+ instanceUrl: "http://198.51.100.9:8000",
+ },
+ },
+ } as Record;
+
+ await expect(checkThenRun()).rejects.toThrow(/Sign out of Coolify first/);
+ expect(h.runCalls).toBe(0);
+ });
+
+ it("frees the slot even when setup failed", async () => {
+ // No account seeded, so nothing is held afterwards and the next run is
+ // admitted — the slot is the machine's, not the account's.
+ h.reportsAccount = false;
+ h.setupError = new Error("boom");
+ await checkThenRun().catch(() => {});
+ h.setupError = null;
+ await expect(checkThenRun()).resolves.toBeTruthy();
+ });
+});
+
+describe("a token for an unencrypted address", () => {
+ it("is not stored by the run that made it", async () => {
+ // Held instead, so closing the screen, quitting or crashing leaves Dyad
+ // unconnected rather than connected to something nobody agreed to.
+ h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" };
+ await checkThenRun();
+
+ const saved = h.written.at(-1) as {
+ coolify: { accessToken?: unknown; instanceUrl?: string };
+ };
+ expect(saved.coolify.accessToken).toBeUndefined();
+ expect(saved.coolify.instanceUrl).toBeUndefined();
+ });
+
+ it("reaches disk only once it has been agreed to", async () => {
+ h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" };
+ await checkThenRun();
+
+ await call("coolify-setup:accept-insecure-token");
+
+ const saved = h.written.at(-1) as {
+ coolify: { accessToken?: { value: string }; instanceUrl?: string };
+ };
+ expect(saved.coolify.accessToken?.value).toBe("1|abc");
+ expect(saved.coolify.instanceUrl).toBeTruthy();
+ });
+
+ it("is gone once the screen is put away without a word", async () => {
+ h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" };
+ await checkThenRun();
+ await call("coolify-setup:dismiss");
+
+ const before = h.written.length;
+ await call("coolify-setup:accept-insecure-token");
+
+ expect(h.written).toHaveLength(before);
+ });
+
+ it("is not left behind for the next case to accept", async () => {
+ // The third thing this module owns across a process. A case that ends an
+ // insecure run without accepting or dismissing would otherwise leave one
+ // here, and the next could store a credential the previous one made.
+ h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" };
+ await checkThenRun();
+
+ resetCoolifySetupStateForTests();
+ registerCoolifySetupHandlers();
+ const before = h.written.length;
+ await call("coolify-setup:accept-insecure-token");
+
+ expect(h.written).toHaveLength(before);
+ });
+
+ it("stores a token for an encrypted address without asking", async () => {
+ // Nothing crosses the network in the clear, so there is nothing to agree
+ // to and nothing held.
+ await checkThenRun();
+
+ const saved = h.written.at(-1) as {
+ coolify: { accessToken?: { value: string } };
+ };
+ expect(saved.coolify.accessToken?.value).toBeTruthy();
+ });
+});
+
+describe("revealCredentials", () => {
+ const ADMIN = {
+ email: "me@gmail.com",
+ password: { value: "Abc123@xyz" },
+ instanceUrl: "http://203.0.113.5:8000",
+ };
+
+ it("hands back what Dyad knows about getting in", async () => {
+ h.settings = {
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ accessToken: { value: "1|abc" },
+ admin: ADMIN,
+ },
+ };
+ const result = (await call("coolify-setup:reveal-credentials")) as Record<
+ string,
+ unknown
+ >;
+ expect(result).toEqual({
+ instance: { url: "http://203.0.113.5:8000", apiToken: "1|abc" },
+ server: {
+ url: "http://203.0.113.5:8000",
+ email: "me@gmail.com",
+ password: "Abc123@xyz",
+ },
+ });
+ });
+
+ it("describes a server installed before any token as a server alone", async () => {
+ // Nothing was ever connected, so there is no instance — but the machine
+ // Dyad built is still named by the account it made on it.
+ h.settings = { coolify: { admin: ADMIN } };
+ const result = (await call("coolify-setup:reveal-credentials")) as Record<
+ string,
+ unknown
+ >;
+ expect(result.instance).toBeNull();
+ expect(result.server).toEqual({
+ url: "http://203.0.113.5:8000",
+ email: "me@gmail.com",
+ password: "Abc123@xyz",
+ });
+ });
+
+ it("keeps each address with what it opens when they are different", async () => {
+ // Installed a server whose token could not be minted, then connected to a
+ // different Coolify. One address over both would put the installed
+ // server's password under the other one's address.
+ h.settings = {
+ coolify: {
+ instanceUrl: "https://someone-elses.example.com",
+ accessToken: { value: "1|for-the-other-one" },
+ admin: ADMIN,
+ },
+ };
+ const result = (await call("coolify-setup:reveal-credentials")) as {
+ instance: { url: string; apiToken: string };
+ server: { url: string; password: string };
+ };
+ expect(result.instance.url).toBe("https://someone-elses.example.com");
+ expect(result.instance.apiToken).toBe("1|for-the-other-one");
+ expect(result.server.url).toBe("http://203.0.113.5:8000");
+ expect(result.server.password).toBe("Abc123@xyz");
+ });
+
+ it("hides a password it cannot read, keeping the server it belongs to", async () => {
+ h.settings = {
+ coolify: {
+ admin: { email: "me@gmail.com", instanceUrl: "http://h:8000" },
+ },
+ };
+ const result = (await call("coolify-setup:reveal-credentials")) as {
+ server: { url: string; email: string; password: string | null };
+ };
+ expect(result.server.password).toBeNull();
+ expect(result.server.email).toBe("me@gmail.com");
+ });
+
+ it("has nothing to hand back once the instance is forgotten", async () => {
+ h.settings = { coolify: {} };
+ const result = (await call("coolify-setup:reveal-credentials")) as Record<
+ string,
+ unknown
+ >;
+ expect(result).toEqual({ instance: null, server: null });
+ });
+
+ it("answers a null server for an instance Dyad did not set up", async () => {
+ // Connected by pasting a token, so there is no account Dyad created.
+ h.settings = {
+ coolify: {
+ instanceUrl: "https://coolify.example.com",
+ accessToken: { value: "1|abc" },
+ },
+ };
+ const result = (await call("coolify-setup:reveal-credentials")) as Record<
+ string,
+ unknown
+ >;
+ expect(result.server).toBeNull();
+ expect(result.instance).toEqual({
+ url: "https://coolify.example.com",
+ apiToken: "1|abc",
+ });
+ });
+});
+
+describe("cancel", () => {
+ it("aborts the running setup", async () => {
+ h.reportsAccount = false;
+ let release!: () => void;
+ h.setupResult = new Promise((resolve) => {
+ release = () => resolve(RESULT);
+ });
+ await call("coolify-setup:inspect", TARGET);
+ const running = call("coolify-setup:run", TARGET);
+ // The flow is handed a signal; cancelling is what trips it.
+ const signal = h.lastSetupOptions?.signal as AbortSignal;
+ expect(signal.aborted).toBe(false);
+
+ await call("coolify-setup:cancel");
+ expect(signal.aborted).toBe(true);
+ release();
+ await running;
+ });
+
+ it("does nothing when nothing is running", async () => {
+ await expect(call("coolify-setup:cancel")).resolves.toBeUndefined();
+ });
+});
diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts
new file mode 100644
index 0000000000..c7a52544a3
--- /dev/null
+++ b/src/ipc/handlers/coolify_setup_handlers.ts
@@ -0,0 +1,679 @@
+import { BrowserWindow } from "electron";
+import log from "electron-log";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+import { createTypedHandler } from "./base";
+import {
+ SETUP_MACHINE_REPORTED,
+ coolifySetupContracts,
+ coolifySetupEvents,
+} from "../types/coolify_setup";
+import type { SetupServer, SetupSnapshot } from "../types/coolify_setup";
+import { safeSend } from "../utils/safe_sender";
+import { readSettings, writeSettings } from "@/main/settings";
+import type { Coolify } from "@/lib/schemas";
+import {
+ SshError,
+ connectSsh,
+ expectFingerprint,
+ trustOnFirstUse,
+} from "../utils/ssh_client";
+import type { SshSession } from "../utils/ssh_client";
+import { ensureServerKey } from "@/coolify_setup/server_key";
+import { preflight } from "@/coolify_setup/install";
+import { runServerSetup } from "@/coolify_setup/setup_flow";
+import { CoolifySetupController } from "@/coolify_setup/controller";
+import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities";
+import { uuidIdSource } from "@/state_machines/clock";
+import { adminEmailRefusal } from "@/shared/coolify_admin_email";
+import { isPlausibleInstanceDomain } from "@/shared/coolify_domain";
+import { sshFailureOf } from "@/shared/ssh_failure";
+import { IS_TEST_BUILD } from "../utils/test_utils";
+
+const logger = log.scope("coolify_setup_handlers");
+
+/**
+ * How long looking at a server may take.
+ *
+ * Generous, because the probe runs on somebody else's machine and a slow one
+ * is not a broken one — but bounded, because a wedged docker daemon never
+ * answers at all and the button would spin for as long as the panel was open.
+ */
+const INSPECT_TIMEOUT_MS = 30_000;
+
+/**
+ * The setup, and everything anyone needs to know about it.
+ *
+ * Held here rather than per window, because the machine being set up is the
+ * shared resource — and because an install outlives any one window. What is
+ * going on is asked for, not remembered on the other side.
+ */
+let controller: CoolifySetupController | null = null;
+
+/**
+ * What the last look at each server saw its host key to be.
+ *
+ * The panel shows this fingerprint and asks the user to commit to a
+ * minutes-long install on the strength of it, so the install talks to the
+ * machine they were shown rather than to whatever answers that address by the
+ * time it starts. Held here rather than sent through the renderer, which
+ * would make it something the caller could choose.
+ */
+const inspectedFingerprints = new Map();
+
+/**
+ * The servers a check got through and liked.
+ *
+ * Separate from the pin above because the two do not come and go together: a
+ * server that was ready and has since had Coolify put on it loses its pass
+ * while the key it showed still stands. Both are written once a check has
+ * finished, so a pass here always belongs to the key recorded there.
+ */
+const readyHosts = new Set();
+
+/**
+ * A token for an address that is not encrypted, waiting to be agreed to.
+ *
+ * Held rather than stored, because the screen that asks appears after the run
+ * has ended: writing it first and taking it back off if the answer was no
+ * meant closing the panel, quitting, or crashing left it on disk with nobody
+ * having agreed to anything. Kept in the process, so it is lost on a restart
+ * — which is the safe direction, and lands the user on the same screen a run
+ * whose token could not be minted already produces.
+ *
+ * One at a time, like the machine itself.
+ */
+let heldInsecureToken: { instanceUrl: string; token: string } | null = null;
+
+function broadcastState(state: SetupSnapshot) {
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) {
+ safeSend(window.webContents, coolifySetupEvents.changed.channel, state);
+ }
+ }
+}
+
+function setupController(): CoolifySetupController {
+ controller ??= new CoolifySetupController({
+ ids: uuidIdSource,
+ onChanged: broadcastState,
+ execute: (target, hooks) => {
+ const key = ensureServerKey();
+ // Trust on first use only when there has been no first use. A server
+ // that was looked at is held to what it showed then.
+ const pinned = inspectedFingerprints.get(serverKeyFor(target));
+ /** Whether the server ever reported an account, so a run that ended
+ before one existed can put back what it wrote on the way in. */
+ let accountConfirmed = false;
+ /**
+ * The record this run put down, or nothing if it never got that far.
+ *
+ * Connecting and the preflight both come before the password is handed
+ * over, and either can end the run — a cancel, a server that is not
+ * answering, an address that already has Coolify on it. There is
+ * nothing of this run's to take back off then, and the account standing
+ * there belongs to a server that still has it.
+ *
+ * Kept whole rather than as a flag, because the way out has to tell
+ * this record from anyone else's writing in the minutes since.
+ */
+ let provisional: NonNullable | undefined;
+ /**
+ * What the account write could not store, if it could not store it.
+ *
+ * A run that then fails takes the only copy of the password with it:
+ * the failed screen carries a message and a log, and the call never
+ * returns the result that shows it. So it is tried once more where it
+ * starts to matter, which turns a keychain that was briefly busy into
+ * nothing at all.
+ */
+ let unsavedAccount: {
+ credentials: { email: string; password: string };
+ dashboardUrl: string;
+ } | null = null;
+ return runServerSetup({
+ target: targetFrom(target, key.privateKey),
+ adminEmail: target.adminEmail,
+ verifyHostKey: pinned
+ ? expectFingerprint(pinned)
+ : trustOnFirstUse((fp) => {
+ inspectedFingerprints.set(serverKeyFor(target), fp);
+ }),
+ customDomain: target.customDomain,
+ signal: hooks.signal,
+ connect: (t, verify, signal): Promise =>
+ connectSsh(t, verify, { signal }),
+ onProgress: ({ step, output }) => hooks.onProgress(step, output),
+ // Written before the installer runs, because the password it is about
+ // to put in the server's .env is already decided — and a run that ends
+ // without reaching the code below is exactly how the only copy of it
+ // gets lost. Put back on the way out if no account ever appeared, so a
+ // server that never got one does not leave a password behind for it.
+ onCredentialsBuilt: ({ credentials, dashboardUrl }) => {
+ try {
+ const current = readSettings().coolify;
+ writeSettings({
+ coolify: {
+ ...current,
+ admin: {
+ email: credentials.email,
+ password: { value: credentials.password },
+ instanceUrl: dashboardUrl,
+ },
+ },
+ });
+ provisional = {
+ email: credentials.email,
+ password: { value: credentials.password },
+ instanceUrl: dashboardUrl,
+ };
+ } catch (error) {
+ // Refused rather than carried, and only here: nothing has been
+ // done to the server yet, so this costs a retry. Past this point
+ // the account exists and ending the run would throw away the only
+ // copy of its password, which is why the write below carries on
+ // instead.
+ //
+ // The reason is not passed through. This run carries a password
+ // and the handler is marked not to be logged, so what went wrong
+ // goes to the log and the user gets words of ours.
+ logger.error("Could not store the admin account early", error);
+ throw new DyadError(
+ "Dyad could not save the admin password on this computer, so " +
+ "it has not started the install — a server it cannot record " +
+ "the password for is one nobody can sign in to. Nothing was " +
+ "sent to the server. Try again once there is room on disk " +
+ "and the keychain is available.",
+ DyadErrorKind.External,
+ );
+ }
+ },
+ // Written the moment the account exists rather than at the end. A
+ // server whose dashboard never answers still has this account on it,
+ // and Dyad is the only thing that knows the password it invented.
+ onAccountKnown: ({ credentials, dashboardUrl }) => {
+ try {
+ writeSettings({
+ coolify: {
+ ...readSettings().coolify,
+ admin: {
+ email: credentials.email,
+ password: { value: credentials.password },
+ instanceUrl: dashboardUrl,
+ },
+ },
+ });
+ unsavedAccount = null;
+ accountConfirmed = true;
+ } catch (error) {
+ // The account exists on the server whatever happened here, and a
+ // second attempt is refused because Coolify is now installed. The
+ // finished screen still shows the password, so ending the run
+ // over this would throw away the only copy of it.
+ unsavedAccount = { credentials, dashboardUrl };
+ logger.error("Could not store the admin account", error);
+ }
+ },
+ })
+ .catch((error: unknown) => {
+ // The run is ending badly, so this is the last chance to keep a
+ // password nothing else holds. Guarded, because a write that fails
+ // again must not become the failure the user is told about.
+ if (unsavedAccount) {
+ try {
+ writeSettings({
+ coolify: {
+ ...readSettings().coolify,
+ admin: {
+ email: unsavedAccount.credentials.email,
+ password: {
+ value: unsavedAccount.credentials.password,
+ },
+ instanceUrl: unsavedAccount.dashboardUrl,
+ },
+ },
+ });
+ } catch (retryError) {
+ logger.error("Could not store the admin account", retryError);
+ }
+ } else if (provisional && !accountConfirmed) {
+ // Nothing was ever seeded, so the password written on the way in
+ // opens nothing and comes back off. Nothing stood here before it:
+ // a run cannot start while Dyad holds an account, which is what
+ // the gate above is for.
+ try {
+ const now = readSettings().coolify;
+ // Minutes of installing sit between the record going down and
+ // this, so what stood before is only worth putting back if this
+ // run's record is still the one there. Anything else means
+ // something during the run had its own say, and the snapshot
+ // from before it started is not the newer answer.
+ //
+ // A password that will not decrypt is dropped on the way out of
+ // readSettings, with the account kept — so its absence is this
+ // record gone unreadable rather than somebody else's writing,
+ // and the rest of it still says whose it is.
+ const stillOurs =
+ now?.admin !== undefined &&
+ now.admin.email === provisional.email &&
+ now.admin.instanceUrl === provisional.instanceUrl &&
+ (now.admin.password === undefined ||
+ now.admin.password.value === provisional.password?.value);
+ if (stillOurs) {
+ // Named, not merely absent: a key that is gone reads to the
+ // write as one a consumer dropped, and the ciphertext on disk
+ // is handed back rather than cleared.
+ writeSettings({ coolify: { ...now, admin: undefined } });
+ }
+ } catch (restoreError) {
+ logger.error(
+ "Could not put back the admin account",
+ restoreError,
+ );
+ }
+ }
+ // A key that does not match is not the user declining, and reporting
+ // it as one would file it as a cancellation and say nothing.
+ if (
+ pinned &&
+ error instanceof SshError &&
+ error.failure === "host-key-rejected"
+ ) {
+ throw new DyadError(
+ "This server is not the one Dyad looked at: its SSH identity " +
+ "has changed since. Nothing was sent to it. Check the address " +
+ "and look at the server again before installing.",
+ DyadErrorKind.External,
+ );
+ }
+ throw error;
+ })
+ .then((result) => {
+ let stored = true;
+ // The account exists either way, so it is stored either way. Keeping
+ // it only when a token was also minted discards the password in the
+ // one case the user needs it — where they must sign in to Coolify and
+ // make a token by hand, which is what the token failing means.
+ try {
+ writeSettings({
+ coolify: {
+ ...readSettings().coolify,
+ admin: {
+ email: result.credentials.email,
+ password: { value: result.credentials.password },
+ // Stored even when no token was minted, because then it is
+ // the only thing naming the server this account is on.
+ instanceUrl: result.dashboardUrl,
+ },
+ // The address and token go together: an address stored without a
+ // token would read as an instance Dyad can talk to and cannot.
+ //
+ // Only when the address is encrypted. `coolify:save-token`
+ // will not store a token against a plain-HTTP address without
+ // the user saying so, and there is no honest reason this path
+ // should differ — whether HTTPS was possible is only known
+ // once the install has run, which says the question cannot be
+ // asked before, not that it can be skipped. So the token is
+ // held instead, and the finished screen asks.
+ ...(result.token && result.secure
+ ? {
+ instanceUrl: result.dashboardUrl,
+ accessToken: { value: result.token },
+ }
+ : {}),
+ },
+ });
+ // Nothing has agreed to this yet, so it waits where a restart
+ // loses it rather than where a restart finds it.
+ heldInsecureToken =
+ result.token && !result.secure
+ ? { instanceUrl: result.dashboardUrl, token: result.token }
+ : null;
+ } catch (error) {
+ // Same reason as the write above: the server is set up, a retry is
+ // refused because Coolify is on it now, and the screen this
+ // returns to is where the password is shown.
+ stored = false;
+ logger.error("Could not store the finished setup", error);
+ }
+ return {
+ dashboardUrl: result.dashboardUrl,
+ secure: result.secure,
+ insecureReason: result.insecureReason ?? null,
+ adminEmail: result.credentials.email,
+ adminPassword: result.credentials.password,
+ tokenStored: stored && Boolean(result.token),
+ apiEnabled: result.apiEnabled,
+ tokenUnavailableReason: stored
+ ? (result.tokenUnavailableReason ?? null)
+ : // Above, not below: the credentials card sits over this
+ // panel, and the sentence the screen appends after this one
+ // already says "the details above". On this path the screen
+ // is the only copy of that password.
+ "Dyad could not save these details on this computer. Copy the " +
+ "password above before leaving this screen.",
+ version: result.version,
+ };
+ });
+ },
+ });
+ return controller;
+}
+
+/**
+ * The key a server's fingerprint and verdict are remembered under.
+ *
+ * The port is part of it: two services on one address are two servers, and
+ * holding the second to the first one's key would refuse a valid one. Built
+ * from the address as typed rather than by parsing it as a URL, which a bare
+ * address is not — `fe80::1` parses as the scheme `fe80:` and leaves no
+ * hostname at all, so every address shaped that way would share one entry.
+ */
+function serverKeyFor(input: SetupServer): string {
+ const host = input.host
+ .trim()
+ .toLowerCase()
+ .replace(/^\[|\]$/g, "");
+ return `${host}:${sshPort(input) ?? 22}`;
+}
+
+/**
+ * Which port to knock on.
+ *
+ * The form asks for an address rather than an address and a port, because a
+ * server that has moved sshd is not the case this is for. Under an e2e build
+ * the port is named by the environment, so a test can stand a server on one
+ * it is allowed to bind — the same seam every other e2e-only behaviour here
+ * goes through.
+ */
+function sshPort(input: SetupServer): number | undefined {
+ const override = IS_TEST_BUILD ? process.env.DYAD_E2E_SSH_PORT : undefined;
+ return override ? Number(override) : input.port;
+}
+
+function targetFrom(input: SetupServer, privateKey: string) {
+ return {
+ // Unbracketed, the way ssh2 and node's isIP both want an address. A
+ // literal typed the way documentation writes it — [2001:db8::1] — is a
+ // hostname to both of them: ssh2 looks it up and fails, and urlHost sees
+ // something that is not an IP and hands it on without the brackets a URL
+ // does need. The same spelling serverKeyFor already reduces to.
+ host: input.host.trim().replace(/^\[|\]$/g, ""),
+ port: sshPort(input),
+ username: input.username.trim(),
+ privateKey,
+ };
+}
+
+/**
+ * The same failure, with a line about the address when the address explains it.
+ *
+ * A whole URL pasted here is the likeliest wrong answer, because the token
+ * form asks for exactly that shape — and ssh2 takes it as a hostname, so the
+ * lookup fails. What the client says on its own hedges between the address
+ * and a port nobody is listening on, and the port is the one people go and
+ * look at; this settles it. Said as the rule rather than as a diagnosis: a
+ * slash is all that got us here, so telling someone their address "looks like
+ * a URL" would be a guess, and wrong for one they typed a path onto.
+ *
+ * Deliberately not a check on the way in. Nothing here decides whether an
+ * address is usable: an address this does not recognise connects exactly as
+ * it did before, and this only ever speaks after a connection has already
+ * failed. That is what keeps it from refusing a name that would have worked
+ * — a single-label name, a .local, a zone id — none of which this has to
+ * know about.
+ *
+ * The error object is kept rather than replaced with one of ours. The failure
+ * it carries never reaches the renderer — the serialized error has no such
+ * field — but it is read on the way out: shouldFilterTelemetryException drops
+ * an "unreachable" outright, as a server that does not answer is the user's
+ * own network rather than a fault here. A plain Error would carry no failure,
+ * match nothing else that filter looks for, and start reporting the connects
+ * this speaks for — the ones from an address with a slash in it — as
+ * exceptions. Every other failed connect keeps the error it was given.
+ *
+ * What the client said is still replaced, which leaves its wording unread on
+ * this one path. That is the trade: a message written where the address came
+ * from can be specific, and one written in the transport cannot.
+ */
+function withHostShapeHint(host: string, error: T): T {
+ if (!(error instanceof Error)) return error;
+ // A slash, and only a slash. A scheme brings two of its own and a path is
+ // one, so this catches both without naming either — and an address that is
+ // colons all the way down, which is how an IPv6 literal is written, has
+ // none and is left alone.
+ if (!host.includes("/")) return error;
+ // What went wrong, in the shortest form the error carries it: the errno if
+ // the system named one, and otherwise the failure. Both are read off the
+ // error rather than out of its message, and both are a word rather than a
+ // sentence — the sentence the client wrote offers a closed port as the
+ // other suspect, which this has just ruled out.
+ const detail =
+ (error as { systemCode?: string }).systemCode ?? sshFailureOf(error);
+ error.message =
+ "Enter just the server address, for example 203.0.113.5 — with no " +
+ `https:// and no / characters in it.${detail ? ` (${detail})` : ""}`;
+ return error;
+}
+
+/** Test-only: the pin map and the controller both outlive a single case. */
+export function resetCoolifySetupStateForTests(): void {
+ inspectedFingerprints.clear();
+ readyHosts.clear();
+ // The third thing this module owns across a process. A case that finishes
+ // an insecure run without accepting or dismissing leaves one here, and the
+ // next case could then accept a credential the previous one made.
+ heldInsecureToken = null;
+ // Cancelled before disposed: disposing stops the controller talking, it does
+ // not stop what it started, and a run left going would go on writing
+ // settings while the next case is watching them.
+ controller?.cancel();
+ controller?.dispose();
+ controller = null;
+}
+
+export function registerCoolifySetupHandlers() {
+ createTypedHandler(coolifySetupContracts.getServerKey, async () => {
+ const key = ensureServerKey();
+ // Only the public half crosses to the renderer. The private half never
+ // leaves the main process — unlike the API token, which revealCredentials
+ // does hand over so the panel can show it.
+ return { publicKey: key.publicKey };
+ });
+
+ createTypedHandler(coolifySetupContracts.inspect, async (_, input) => {
+ const key = ensureServerKey();
+ let inspectTimer: ReturnType | undefined;
+ let fingerprint: string | null = null;
+ const session = await connectSsh(
+ targetFrom(input, key.privateKey),
+ // Only remembered here. What is recorded is decided once the check has
+ // finished, so the key and the verdict cannot disagree.
+ trustOnFirstUse((fp) => {
+ fingerprint = fp;
+ }),
+ ).catch((error: unknown) => {
+ // Only the connect. Once it is open the address reached something, and
+ // anything after this is about the server rather than what was typed.
+ throw withHostShapeHint(input.host, error);
+ });
+ try {
+ // Bounded, because nothing else bounds it: the probe asks docker, and a
+ // wedged daemon never answers. Left unbounded the button span forever
+ // and every retry leaked another connection.
+ const checks = await Promise.race([
+ preflight(session),
+ new Promise((_, reject) => {
+ inspectTimer = setTimeout(
+ () =>
+ reject(
+ new DyadError(
+ "The server did not answer. It is reachable over SSH, so " +
+ "something on it is not responding — try again in a moment.",
+ DyadErrorKind.External,
+ ),
+ ),
+ INSPECT_TIMEOUT_MS,
+ );
+ }),
+ ]);
+ // Both together, and only now. Recording the key during the handshake
+ // left a check that then failed with the new machine's key beside the
+ // old machine's pass, which is an install onto a server nobody looked
+ // at. A check that does not finish changes neither, so what stands is
+ // whatever the last finished check said.
+ if (fingerprint) {
+ inspectedFingerprints.set(serverKeyFor(input), fingerprint);
+ }
+ // Kept only while the answer stands: a server that was ready and has
+ // since had Coolify put on it must not keep an old pass.
+ if (checks.ready) readyHosts.add(serverKeyFor(input));
+ else readyHosts.delete(serverKeyFor(input));
+ return {
+ ready: checks.ready,
+ reason: checks.reason ?? null,
+ alreadyInstalled: checks.alreadyInstalled,
+ memoryMb: checks.memoryMb,
+ hostFingerprint: fingerprint,
+ };
+ } finally {
+ clearTimeout(inspectTimer);
+ session.end();
+ }
+ });
+
+ // DO NOT LOG this handler: its result carries the generated admin password.
+ createTypedHandler(coolifySetupContracts.run, async (_, input) => {
+ // Checked before anything is done, though the two cost differently.
+ // Coolify resolves the domain when it seeds its admin, so a domain it
+ // will not take is found out minutes in, on a finished install with no
+ // account on it. An address buildInstallScript cannot put in a shell word
+ // never reaches the server at all — but not before a connection, a
+ // preflight, and an account record written and then taken back off.
+ const emailRefusal = adminEmailRefusal(input.adminEmail);
+ if (emailRefusal) {
+ throw new DyadError(emailRefusal, DyadErrorKind.Validation);
+ }
+ if (input.customDomain && !isPlausibleInstanceDomain(input.customDomain)) {
+ throw new DyadError(
+ "Enter the domain on its own, with no port or path — for example " +
+ "coolify.yourdomain.com.",
+ DyadErrorKind.Validation,
+ );
+ }
+ // Not while one is going: a run in flight has already written a record of
+ // its own, and answering a second window with "sign out first" would name
+ // a remedy that does not apply. The machine's own refusal is the true one,
+ // and it comes when the run below is started.
+ if (
+ selectCoolifySetupCapabilities(setupController().getState()).canStart &&
+ readSettings().coolify?.admin
+ ) {
+ // Dyad holds the only copy of one server's admin password, and a run
+ // writes its own over it before the installer starts. The screen that
+ // offers this refuses while an account is held, but not over a failure
+ // it is reporting — the message and the log live on that screen, so it
+ // stays up, and the form stays with it. Retrying the same server is
+ // already impossible by then, because preflight refuses a machine that
+ // has Coolify on it; what is left is installing a different one, which
+ // is this.
+ throw new DyadError(
+ "Dyad is holding the admin password for a server it set up. Sign out " +
+ "of Coolify first — that shows the password one last time and then " +
+ "forgets it — before setting up another.",
+ DyadErrorKind.Precondition,
+ );
+ }
+ if (!readyHosts.has(serverKeyFor(input))) {
+ throw new DyadError(
+ "Check the server before installing. Dyad shows you its fingerprint " +
+ "first, so the install goes to the machine that answered rather " +
+ "than to whatever holds the address by then.",
+ DyadErrorKind.Precondition,
+ );
+ }
+ // One at a time is the machine's rule, not a check here; it refuses by
+ // throwing, and the panel shows that. Outside the try on purpose: that
+ // refusal is the machine declining to start, so it must not be marked as
+ // something the machine is already showing.
+ const run = setupController().start(input);
+ try {
+ return await run.result;
+ } catch (error) {
+ // Awaited only to mark it: the machine recorded this before rethrowing,
+ // so the panel is already showing it and a toast would be the same news
+ // twice. Everything above never got that far and stays unmarked, which
+ // is what makes it speak.
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ Object.isExtensible(error) &&
+ !("code" in error)
+ ) {
+ Object.assign(error, { code: SETUP_MACHINE_REPORTED });
+ }
+ throw error;
+ }
+ });
+
+ createTypedHandler(coolifySetupContracts.snapshot, async () =>
+ setupController().getState(),
+ );
+
+ createTypedHandler(coolifySetupContracts.dismiss, async () => {
+ // Putting the screen away without having agreed is the answer being no.
+ heldInsecureToken = null;
+ setupController().dismiss();
+ });
+
+ // DO NOT LOG this handler: it exists to return secrets.
+ createTypedHandler(coolifySetupContracts.revealCredentials, async () => {
+ // The user's own credentials for their own server, on their own machine.
+ // Dyad generated the password on their behalf, so refusing to show it
+ // would lock them out of something they own.
+ const coolify = readSettings().coolify;
+ // Each with the address it belongs to, rather than one address over both.
+ // They are usually the same server and occasionally not, and handing back
+ // a single address would mean deciding which one it is — a decision that
+ // shows one server's password under another's address when it guesses
+ // wrong. Kept apart, there is nothing to guess.
+ return {
+ instance: coolify?.instanceUrl
+ ? {
+ url: coolify.instanceUrl,
+ apiToken: coolify.accessToken?.value ?? null,
+ }
+ : null,
+ server: coolify?.admin
+ ? {
+ url: coolify.admin.instanceUrl,
+ email: coolify.admin.email,
+ password: coolify.admin.password?.value ?? null,
+ }
+ : null,
+ };
+ });
+
+ createTypedHandler(coolifySetupContracts.acceptInsecureToken, async () => {
+ // Nothing to write if nothing is being held: the screen only offers this
+ // where a run ended on an address that is not encrypted, and a run that
+ // ended any other way stored its token itself.
+ if (!heldInsecureToken) return;
+ writeSettings({
+ coolify: {
+ ...readSettings().coolify,
+ instanceUrl: heldInsecureToken.instanceUrl,
+ accessToken: { value: heldInsecureToken.token },
+ },
+ });
+ heldInsecureToken = null;
+ });
+
+ createTypedHandler(coolifySetupContracts.cancel, async () => {
+ // Abandoning mid-install leaves whatever the installer had done on the
+ // server. Nothing here tries to undo it: a half-installed Coolify is
+ // something the user can see and remove, whereas a Dyad that started
+ // deleting directories on their machine is not.
+ logger.info("Cancelling Coolify server setup");
+ setupController().cancel();
+ });
+}
diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts
index 2be0fb59e2..a8068a6d1a 100644
--- a/src/ipc/ipc_host.ts
+++ b/src/ipc/ipc_host.ts
@@ -59,6 +59,7 @@ import { registerImageGenerationHandlers } from "./handlers/image_generation_han
import { registerCoolifyHandlers } from "./handlers/coolify_handlers";
import { registerPreviewViewHandlers } from "./handlers/preview_view_handlers";
import { registerNativeThemeHandlers } from "./handlers/native_theme_handlers";
+import { registerCoolifySetupHandlers } from "./handlers/coolify_setup_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
@@ -123,4 +124,5 @@ export function registerIpcHandlers() {
registerCoolifyHandlers();
registerPreviewViewHandlers();
registerNativeThemeHandlers();
+ registerCoolifySetupHandlers();
}
diff --git a/src/ipc/preload/channels.test.ts b/src/ipc/preload/channels.test.ts
index 255b1a44a3..954753cf97 100644
--- a/src/ipc/preload/channels.test.ts
+++ b/src/ipc/preload/channels.test.ts
@@ -6,6 +6,10 @@ import {
previewViewEvents,
previewViewSendContracts,
} from "../types/preview_view";
+import {
+ coolifySetupContracts,
+ coolifySetupEvents,
+} from "../types/coolify_setup";
import {
VALID_INVOKE_CHANNELS,
VALID_RECEIVE_CHANNELS,
@@ -49,3 +53,17 @@ describe("preview view preload channels", () => {
}
});
});
+
+describe("coolify-setup preload channels", () => {
+ it("allows every Coolify setup invoke and receive contract", () => {
+ // The preload's on() takes `ValidReceiveChannel | string`, so a channel
+ // missing from the list compiles and fails only when the install runs —
+ // which is minutes in, on a real server.
+ for (const contract of Object.values(coolifySetupContracts)) {
+ expect(VALID_INVOKE_CHANNELS).toContain(contract.channel);
+ }
+ for (const event of Object.values(coolifySetupEvents)) {
+ expect(VALID_RECEIVE_CHANNELS).toContain(event.channel);
+ }
+ });
+});
diff --git a/src/ipc/preload/channels.ts b/src/ipc/preload/channels.ts
index ec5b371aaf..a059d4f115 100644
--- a/src/ipc/preload/channels.ts
+++ b/src/ipc/preload/channels.ts
@@ -27,6 +27,10 @@ import {
import { mcpContracts } from "../types/mcp";
import { vercelContracts } from "../types/vercel";
import { coolifyContracts, coolifyEvents } from "../types/coolify";
+import {
+ coolifySetupContracts,
+ coolifySetupEvents,
+} from "../types/coolify_setup";
import { supabaseContracts, supabaseEvents } from "../types/supabase";
import { neonContracts } from "../types/neon";
import { migrationContracts } from "../types/migration";
@@ -115,6 +119,7 @@ export const VALID_INVOKE_CHANNELS = [
...getInvokeChannels(mcpContracts),
...getInvokeChannels(vercelContracts),
...getInvokeChannels(coolifyContracts),
+ ...getInvokeChannels(coolifySetupContracts),
...getInvokeChannels(supabaseContracts),
...getInvokeChannels(neonContracts),
...getInvokeChannels(migrationContracts),
@@ -184,6 +189,7 @@ export const VALID_RECEIVE_CHANNELS = [
...getReceiveChannels(agentEvents),
...getReceiveChannels(gitEvents),
...getReceiveChannels(coolifyEvents),
+ ...getReceiveChannels(coolifySetupEvents),
...getReceiveChannels(connectionFlowEvents),
...getReceiveChannels(supabaseEvents),
...getReceiveChannels(systemEvents),
diff --git a/src/ipc/types/coolify.ts b/src/ipc/types/coolify.ts
index 8414cfa4f2..df2a56cd01 100644
--- a/src/ipc/types/coolify.ts
+++ b/src/ipc/types/coolify.ts
@@ -89,8 +89,15 @@ export const CoolifyStatusSchema = z.object({
* tell that the token changed without ever being told what it is.
*/
tokenId: z.string().nullable(),
- /** Remembered across a token clear, so re-entering a token does not retype it. */
+ /** The Coolify Dyad is connected to, or null once it has been forgotten. */
instanceUrl: z.string().nullable(),
+ /**
+ * The address of a server Dyad set up and holds an admin account for, or
+ * null. Not a secret, and the panel needs it without the password: while
+ * this is set the only Coolify Dyad will connect to is this one, so it
+ * pins the address field and stands in the way of setting up another.
+ */
+ serverUrl: z.string().nullable(),
connection: CoolifyConnectionSchema.nullable(),
appUrl: z.string().nullable(),
/** Epoch millis of the last successful deploy, or null if never deployed. */
diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts
new file mode 100644
index 0000000000..49c53db03c
--- /dev/null
+++ b/src/ipc/types/coolify_setup.ts
@@ -0,0 +1,343 @@
+import { z } from "zod";
+import type { CoolifySetupState } from "@/coolify_setup/state";
+import {
+ defineContract,
+ defineEvent,
+ createClient,
+ createEventClient,
+} from "../contracts/core";
+
+// =============================================================================
+// Coolify Setup Schemas
+// =============================================================================
+
+export const SetupStepSchema = z.enum([
+ "connecting",
+ "checking-server",
+ "installing",
+ "waiting-for-dashboard",
+ "verifying-account",
+ "securing",
+ "creating-token",
+ "done",
+]);
+
+export const ServerKeySchema = z.object({
+ /** The line the user adds to their server's authorized_keys. */
+ publicKey: z.string(),
+});
+
+/**
+ * Marks a failure the machine took on and has already put on screen.
+ *
+ * The panel shows anything it cannot attribute, because an error nobody
+ * reports is a button that does nothing. So the mark goes on the case that
+ * IS accounted for — a run that failed or was cancelled, which the finished
+ * screen carries with the installer's own words — and everything else,
+ * including whatever the IPC layer refuses before this handler is reached,
+ * is said out loud.
+ */
+export const SETUP_MACHINE_REPORTED = "coolify-setup-machine-reported";
+
+/**
+ * Where the server is. Everything needed to reach it, and nothing else.
+ *
+ * Separate from the address of the account to create, because looking at a
+ * server does not need one — demanding it there rejected a check the panel
+ * was offering before the email had been typed.
+ */
+export const SetupServerSchema = z.object({
+ host: z.string().min(1),
+ /** Coolify's installer needs root, and says so in its own documentation. */
+ username: z.string().min(1).default("root"),
+ port: z.number().int().positive().max(65535).optional(),
+});
+
+export const SetupTargetSchema = SetupServerSchema.extend({
+ adminEmail: z.string().min(3),
+ /**
+ * A domain the user owns, pointed at this server.
+ *
+ * Optional because Dyad can derive one from the address. Supplying one is
+ * better where they have it: it is theirs, and it does not draw on the free
+ * shared service's certificate allowance.
+ */
+ customDomain: z.string().optional(),
+});
+
+/**
+ * What Dyad found when it looked at the server, before touching it.
+ *
+ * Reported rather than acted on, so the panel can explain the two problems a
+ * user can fix immediately instead of failing several minutes into an install.
+ */
+export const SetupPreflightSchema = z.object({
+ ready: z.boolean(),
+ reason: z.string().nullable(),
+ alreadyInstalled: z.boolean(),
+ memoryMb: z.number().nullable(),
+ /** Shown so the user can compare it against their provider's console. */
+ hostFingerprint: z.string().nullable(),
+});
+
+export const SetupResultSchema = z.object({
+ dashboardUrl: z.string(),
+ /**
+ * Whether that address is encrypted.
+ *
+ * Dyad asks for a certificate and settles for plain HTTP when none arrives,
+ * so this is the outcome rather than a setting — and the token it carries has
+ * root abilities and travels on every deploy.
+ */
+ secure: z.boolean(),
+ insecureReason: z.string().nullable(),
+ adminEmail: z.string(),
+ /**
+ * Returned so the fallback screen can show it when no token was created.
+ *
+ * On the ordinary path it is stored instead, and read back through
+ * revealCredentials — a password shown once is a password nobody can use.
+ */
+ adminPassword: z.string(),
+ /**
+ * Whether Dyad ended up holding a token, which is not the same as one
+ * having been created: the address may be unencrypted with the offer to
+ * keep it not taken, or storing it may have failed on this computer.
+ * tokenUnavailableReason is what says Coolify's API was the thing that
+ * could not be opened.
+ */
+ tokenStored: z.boolean(),
+ /**
+ * Whether Coolify's API was switched on before anything went wrong.
+ *
+ * Separate from tokenStored because Dyad enables the API first and mints
+ * afterwards: a mint that fails leaves the API on, so the guidance to go
+ * and enable it is wrong even though no token came back.
+ */
+ apiEnabled: z.boolean(),
+ tokenUnavailableReason: z.string().nullable(),
+ version: z.string().nullable(),
+});
+
+/**
+ * What Dyad can tell the user about getting into their own server.
+ *
+ * Two records, each carrying its own address, because they are facts about
+ * two different things: the instance Dyad talks to, and a machine Dyad built.
+ * Usually the same server, but not always — an install whose token could not
+ * be minted leaves an account behind while the user connects somewhere else.
+ * Folded into one address they would have to be checked against each other
+ * before either could be shown, and a check that guessed wrong would put one
+ * server's password under another's address.
+ *
+ * Null where Dyad never had it: an instance connected by pasting a token has
+ * no admin account Dyad created, and a server set up but not connected to has
+ * no instance.
+ */
+export const RevealedCredentialsSchema = z.object({
+ instance: z
+ .object({
+ url: z.string(),
+ apiToken: z.string().nullable(),
+ })
+ .nullable(),
+ server: z
+ .object({
+ url: z.string(),
+ email: z.string(),
+ password: z.string().nullable(),
+ })
+ .nullable(),
+});
+
+/**
+ * What the main process is doing with a server, as the panel sees it.
+ *
+ * The panel keeps none of this: an install outlives the screen that started
+ * it, so the screen asks rather than remembers. Mirrors CoolifySetupState,
+ * with an assertion beside that type so neither can drift from the other.
+ */
+export const SetupInvocationRefSchema = z.object({
+ kind: z.literal("coolify-setup"),
+ entityKey: z.string(),
+ operationId: z.string(),
+});
+
+export const SetupSnapshotSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("idle") }),
+ z.object({
+ type: z.literal("running"),
+ host: z.string(),
+ invocationRef: SetupInvocationRefSchema,
+ step: SetupStepSchema,
+ log: z.string(),
+ stopping: z.boolean(),
+ }),
+ z.object({
+ type: z.literal("done"),
+ host: z.string(),
+ invocationRef: SetupInvocationRefSchema,
+ result: SetupResultSchema,
+ }),
+ z.object({
+ type: z.literal("failed"),
+ host: z.string(),
+ invocationRef: SetupInvocationRefSchema,
+ message: z.string(),
+ log: z.string(),
+ cancelled: z.boolean(),
+ /** Left behind on the server, and still the user's to undo. */
+ warning: z.string().optional(),
+ }),
+]);
+
+// =============================================================================
+// Coolify Setup Contracts
+// =============================================================================
+
+export const coolifySetupContracts = {
+ /**
+ * The public key the user has to install before anything else can happen.
+ *
+ * First, because it is the one manual step and nothing works until it is
+ * done. Generated on demand and reused afterwards.
+ */
+ getServerKey: defineContract({
+ channel: "coolify-setup:get-server-key",
+ input: z.void(),
+ output: ServerKeySchema,
+ }),
+
+ /**
+ * Connects and looks, without changing anything.
+ *
+ * Separate from running the setup so the panel can show what it found — the
+ * host fingerprint especially — and let the user decide before a minutes-long
+ * install starts.
+ */
+ inspect: defineContract({
+ channel: "coolify-setup:inspect",
+ input: SetupServerSchema,
+ output: SetupPreflightSchema,
+ }),
+
+ // DO NOT LOG this handler: its result carries an admin password.
+ run: defineContract({
+ channel: "coolify-setup:run",
+ input: SetupTargetSchema,
+ output: SetupResultSchema,
+ // A token stored here makes every app readable as connected, exactly as
+ // saving one by hand does.
+ invalidates: () => [{ family: "apps" }, { family: "coolify" }],
+ // Claimed, because this panel refreshes coolify itself and chooses when:
+ // an install that ended on plain HTTP has a warning to show first, and a
+ // refresh here flips the panel to connected and unmounts the screen
+ // carrying it. `apps` is not claimed — nothing repeats that locally.
+ originHandles: () => [{ family: "coolify" }],
+ }),
+
+ /**
+ * The credentials for a server Dyad set up, on request.
+ *
+ * A separate call rather than part of the status, so the panel can show
+ * them long after the install that made them. The finished screen carries
+ * its own copy in the snapshot while it is up; this is how they are read
+ * once it is gone.
+ */
+ revealCredentials: defineContract({
+ channel: "coolify-setup:reveal-credentials",
+ input: z.void(),
+ output: RevealedCredentialsSchema,
+ }),
+
+ /**
+ * What is going on right now, asked on mount rather than remembered.
+ *
+ * DO NOT LOG this handler. A finished run carries the admin password Dyad
+ * invented, the same secret `run` and `revealCredentials` are marked for —
+ * and this hands back the identical payload, as does the `changed` event
+ * that pushes it to every window.
+ */
+ snapshot: defineContract({
+ channel: "coolify-setup:snapshot",
+ input: z.void(),
+ output: SetupSnapshotSchema,
+ }),
+
+ /**
+ * The user read the unencrypted-address warning and accepted it.
+ *
+ * A token for an address that is not encrypted is held rather than stored
+ * when the run ends, so that closing the screen, quitting, or a crash
+ * leaves Dyad unconnected rather than connected to something nobody agreed
+ * to. This is the only way it reaches disk. Nothing to decline: not
+ * accepting is simply never calling it.
+ */
+ acceptInsecureToken: defineContract({
+ channel: "coolify-setup:accept-insecure-token",
+ input: z.void(),
+ output: z.void(),
+ // The same write `coolify:save-token` makes, so the same reach: a window
+ // that watched this install finish is otherwise still offering to set a
+ // server up, and pressing it there is refused for holding an account.
+ invalidates: () => [{ family: "apps" }, { family: "coolify" }],
+ // The finished screen refreshes on its own way out, in the order it needs
+ // — the panel behind it must not be handed back before the token lands.
+ originHandles: () => [{ family: "coolify" }],
+ }),
+
+ /** The user has read the finished screen; put the panel back to the form. */
+ dismiss: defineContract({
+ channel: "coolify-setup:dismiss",
+ input: z.void(),
+ output: z.void(),
+ }),
+
+ cancel: defineContract({
+ channel: "coolify-setup:cancel",
+ input: z.void(),
+ output: z.void(),
+ }),
+} as const;
+
+export const coolifySetupEvents = {
+ /**
+ * The whole of what is going on, rather than a step at a time.
+ *
+ * Sending the state instead of a delta is what lets a window that was not
+ * there for the earlier events still show the install correctly.
+ */
+ // DO NOT LOG. Carries the same finished-run payload as `snapshot`, admin
+ // password and all, to every window.
+ changed: defineEvent({
+ channel: "coolify-setup:changed",
+ payload: SetupSnapshotSchema,
+ }),
+} as const;
+
+/**
+ * The wire shape and the machine's state are the same thing.
+ *
+ * Asserted rather than assumed. The machine owns its types — a pure machine
+ * module may not import from here — so this is where the two are held
+ * together, and either drifting fails type-checking rather than failing in a
+ * window that cannot parse what it was sent. Tupled, because a bare `extends`
+ * distributes over the union and answers true when any one member fits.
+ */
+type AssignableTo = [Source] extends [Target] ? true : never;
+const _snapshotMatchesState: [
+ AssignableTo,
+ AssignableTo,
+] = [true, true];
+void _snapshotMatchesState;
+
+export const coolifySetupClient = createClient(coolifySetupContracts);
+export const coolifySetupEventClient = createEventClient(coolifySetupEvents);
+
+export type SetupTarget = z.infer;
+export type SetupServer = z.infer;
+export type SetupPreflight = z.infer;
+export type SetupResult = z.infer;
+export type SetupSnapshot = z.infer;
+export type SetupStep = z.infer;
+export type RevealedCredentials = z.infer;
diff --git a/src/ipc/types/index.ts b/src/ipc/types/index.ts
index 0c4e2d3d65..1735438530 100644
--- a/src/ipc/types/index.ts
+++ b/src/ipc/types/index.ts
@@ -38,6 +38,19 @@ export {
export { mcpContracts } from "./mcp";
export { vercelContracts } from "./vercel";
export { coolifyContracts, coolifyEvents, coolifyEventClient } from "./coolify";
+export {
+ coolifySetupContracts,
+ coolifySetupEvents,
+ coolifySetupEventClient,
+} from "./coolify_setup";
+export type {
+ SetupTarget,
+ SetupPreflight,
+ SetupResult,
+ SetupSnapshot,
+ SetupStep,
+ RevealedCredentials,
+} from "./coolify_setup";
export type {
CoolifyConnection,
CoolifyDeploySnapshot,
@@ -517,6 +530,7 @@ import {
import { mcpClient } from "./mcp";
import { vercelClient } from "./vercel";
import { coolifyClient, coolifyEventClient } from "./coolify";
+import { coolifySetupClient, coolifySetupEventClient } from "./coolify_setup";
import { supabaseClient, supabaseEventClient } from "./supabase";
import { neonClient } from "./neon";
import { migrationClient } from "./migration";
@@ -600,6 +614,7 @@ export const ipc = {
mcp: mcpClient,
vercel: vercelClient,
coolify: coolifyClient,
+ coolifySetup: coolifySetupClient,
supabase: supabaseClient,
neon: neonClient,
migration: migrationClient,
@@ -651,6 +666,7 @@ export const ipc = {
tests: testsEventClient,
userInput: userInputEventClient,
coolify: coolifyEventClient,
+ coolifySetup: coolifySetupEventClient,
imageGeneration: imageGenerationEventClient,
windowInfrastructure: windowInfrastructureEventClient,
distributedMachine: distributedMachineEventClient,
diff --git a/src/ipc/utils/dns_resolve.ts b/src/ipc/utils/dns_resolve.ts
new file mode 100644
index 0000000000..f0f30708dc
--- /dev/null
+++ b/src/ipc/utils/dns_resolve.ts
@@ -0,0 +1,60 @@
+import * as dns from "node:dns/promises";
+
+/**
+ * Asking DNS where a name points, without mistaking silence for an answer.
+ *
+ * Shared because two callers need the same distinction: the app-domain check
+ * the user runs before saving, and the instance domain Dyad points Coolify at
+ * during setup. Both are advisory, and both are wrong in the same expensive
+ * way if a resolver that could not be reached reads as "no such record".
+ */
+
+/** A resolver saying "no such record" — anything else is our problem, not DNS's. */
+const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
+
+/**
+ * Bounded, because Save waits on it.
+ *
+ * The check is advisory, so a resolver that never answers must not be able to
+ * hold the button indefinitely. The bound is per attempt per configured
+ * nameserver, so the wait scales with how many the machine has: six seconds
+ * against a single stub resolver, and proportionally more where several are
+ * listed. Two tries rather than the default four keeps that multiple small. A
+ * timeout still arrives as an error code the caller reads as "could not ask"
+ * rather than as a missing record.
+ */
+const resolver = new dns.Resolver({ timeout: 3_000, tries: 2 });
+
+/**
+ * Both families, distinguishing "no record" from "could not ask".
+ *
+ * A timeout or an unreachable resolver must not be reported as a missing
+ * record: telling someone to fix DNS that is already correct is exactly the
+ * confident-but-wrong advice the unknown verdict exists to avoid.
+ */
+export async function resolveBoth(
+ hostname: string,
+): Promise<{ addresses: string[]; failed: boolean }> {
+ const attempt = async (fn: (h: string) => Promise) => {
+ try {
+ return { addresses: await fn(hostname), failed: false };
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code ?? "";
+ return { addresses: [] as string[], failed: !NO_RECORD_CODES.has(code) };
+ }
+ };
+ const [v4, v6] = await Promise.all([
+ attempt((h) => resolver.resolve4(h)),
+ attempt((h) => resolver.resolve6(h)),
+ ]);
+ return {
+ addresses: [...v4.addresses, ...v6.addresses],
+ // One family answering is enough. But a definitive "no such record" from
+ // one and a failed lookup from the other is not the same as no records:
+ // the family we could not reach may hold the one that works.
+ failed:
+ v4.addresses.length === 0 &&
+ v6.addresses.length === 0 &&
+ (v4.failed || v6.failed),
+ };
+}
diff --git a/src/ipc/utils/ssh_client.test.ts b/src/ipc/utils/ssh_client.test.ts
new file mode 100644
index 0000000000..1726973f42
--- /dev/null
+++ b/src/ipc/utils/ssh_client.test.ts
@@ -0,0 +1,625 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { EventEmitter } from "events";
+
+const h = vi.hoisted(() => {
+ /** A real ed25519 host key, so the fingerprint below is a golden value. */
+ const HOST_KEY_B64 =
+ "AAAAC3NzaC1lZDI1NTE5AAAAIJiGrEZTbNIEO9U84zpD4H6mNsXVcl4il3RsnZ4MQImg";
+ return {
+ clients: [] as FakeClientShape[],
+ HOST_KEY_B64,
+ // Scripted here rather than on the target, because connectSsh builds the
+ // library's config from named fields and would drop anything extra.
+ nextFailure: null as unknown,
+ };
+});
+
+interface FakeStreamShape extends EventEmitter {
+ stderr: EventEmitter;
+ written: string | null;
+ closed: boolean;
+ write(data: string, cb?: () => void): void;
+ eof(): void;
+ end(data?: string): void;
+ close(): void;
+}
+
+interface FakeClientShape extends EventEmitter {
+ connectConfig: Record | null;
+ execHandler:
+ | ((command: string, cb: (err: unknown, stream: unknown) => void) => void)
+ | null;
+ ended: boolean;
+}
+
+/**
+ * Stands in for ssh2's Client.
+ *
+ * Scripted rather than a real server: what this file checks is how the wrapper
+ * reads the library's own signals, and those are values the library hands over
+ * rather than behaviour a server produces. The values themselves were captured
+ * from a live box — see the failure table below.
+ */
+vi.mock("ssh2", () => {
+ const { EventEmitter: EE } = require("events");
+ class FakeClient extends EE {
+ connectConfig: Record | null = null;
+ execHandler:
+ | ((command: string, cb: (err: unknown, stream: unknown) => void) => void)
+ | null = null;
+ ended = false;
+
+ connect(config: Record) {
+ this.connectConfig = config;
+ h.clients.push(this as unknown as FakeClientShape);
+ const verifier = config.hostVerifier as
+ | ((key: Buffer) => boolean)
+ | undefined;
+ queueMicrotask(() => {
+ // The verifier runs during the handshake, before anything is sent.
+ if (verifier && !verifier(Buffer.from(h.HOST_KEY_B64, "base64"))) {
+ this.emit(
+ "error",
+ Object.assign(new Error("Host denied"), { level: "handshake" }),
+ );
+ return;
+ }
+ if (h.nextFailure) {
+ this.emit("error", h.nextFailure);
+ return;
+ }
+ this.emit("ready");
+ });
+ }
+
+ exec(command: string, cb: (err: unknown, stream: unknown) => void) {
+ this.execHandler?.(command, cb);
+ }
+
+ end() {
+ this.ended = true;
+ }
+ }
+ return { Client: FakeClient };
+});
+
+const HOST_KEY_FINGERPRINT =
+ "SHA256:3FQS9D0B0DVizoYtw1hNV09EClubwWqRUXoFnRTu6nA";
+
+import {
+ connectSsh,
+ hostKeyFingerprint,
+ trustOnFirstUse,
+ expectFingerprint,
+} from "./ssh_client";
+import type { SshError } from "./ssh_client";
+
+const TARGET = { host: "203.0.113.5", username: "root", privateKey: "KEY" };
+
+beforeEach(() => {
+ h.clients.length = 0;
+ h.nextFailure = null;
+});
+
+describe("hostKeyFingerprint", () => {
+ it("prints what ssh-keygen prints for the same key", () => {
+ // Golden pair captured from a live server, so this compares against
+ // OpenSSH rather than against a second run of our own arithmetic. The user
+ // reads this string next to their provider's console; two spellings of one
+ // key would read as two keys.
+ expect(hostKeyFingerprint(Buffer.from(h.HOST_KEY_B64, "base64"))).toBe(
+ HOST_KEY_FINGERPRINT,
+ );
+ });
+});
+
+describe("connecting", () => {
+ it("reports the fingerprint before trusting the server", async () => {
+ let seen: string | null = null;
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse((fp) => (seen = fp)),
+ );
+ expect(seen).toBe(HOST_KEY_FINGERPRINT);
+ session.end();
+ });
+
+ it("connects when the fingerprint is the one a provider promised", async () => {
+ const session = await connectSsh(
+ TARGET,
+ expectFingerprint(HOST_KEY_FINGERPRINT),
+ );
+ expect(h.clients[0].connectConfig?.host).toBe("203.0.113.5");
+ session.end();
+ });
+
+ it("refuses a server whose key is not the promised one", async () => {
+ await expect(
+ connectSsh(TARGET, expectFingerprint("SHA256:something-else")),
+ ).rejects.toMatchObject({ failure: "host-key-rejected" });
+ });
+
+ it("calls a declined key the user's decision, not a warning", async () => {
+ // Both arrive as a handshake failure, but only one of them means the
+ // server's identity changed. Reporting a decline as a mismatch would tell
+ // someone their machine was tampered with because they clicked no.
+ const error = (await connectSsh(TARGET, () => false).catch(
+ (e) => e,
+ )) as SshError;
+ expect(error.failure).toBe("host-key-rejected");
+ expect((error as unknown as { kind: string }).kind).toBe("user_cancelled");
+ });
+});
+
+/**
+ * The five shapes a connection fails in, with the level and code the library
+ * actually produced for each against a real server.
+ */
+describe("classifying a failed connection", () => {
+ const CASES: Array<{
+ name: string;
+ raw: Record;
+ failure: string;
+ kind: string;
+ /** What the system called it, where it called it anything. */
+ systemCode?: string;
+ }> = [
+ {
+ name: "a key the server will not take",
+ raw: {
+ level: "client-authentication",
+ message: "All configured authentication methods failed",
+ },
+ failure: "auth-rejected",
+ kind: "auth",
+ },
+ {
+ name: "a host that never answers",
+ raw: {
+ level: "client-timeout",
+ message: "Timed out while waiting for handshake",
+ },
+ failure: "timeout",
+ kind: "external",
+ },
+ {
+ name: "a name that does not resolve",
+ raw: {
+ level: "client-socket",
+ code: "ENOTFOUND",
+ message: "getaddrinfo ENOTFOUND nope.invalid",
+ },
+ failure: "unreachable",
+ kind: "external",
+ systemCode: "ENOTFOUND",
+ },
+ {
+ name: "a closed port",
+ raw: {
+ level: "client-socket",
+ code: "ECONNREFUSED",
+ message: "connect ECONNREFUSED",
+ },
+ failure: "unreachable",
+ kind: "external",
+ systemCode: "ECONNREFUSED",
+ },
+ {
+ name: "two ends that cannot agree on ciphers",
+ raw: {
+ level: "handshake",
+ message: "Handshake failed: no matching key exchange algorithm",
+ },
+ failure: "handshake-failed",
+ kind: "external",
+ },
+ {
+ // The bucket, but a named one: a socket error this does not recognise
+ // still says what the system called it, and that name is what makes it
+ // worth reporting.
+ name: "an error nothing here recognises, named",
+ raw: { level: "client-socket", code: "EPIPE", message: "broken pipe" },
+ failure: "unknown",
+ kind: "external",
+ systemCode: "EPIPE",
+ },
+ {
+ name: "anything else",
+ raw: { level: "client-socket", message: "kernel exploded" },
+ failure: "unknown",
+ kind: "external",
+ },
+ ];
+
+ it("does not call a failed negotiation a changed identity", async () => {
+ // The key being turned down is answered before this is reached, where
+ // the verifier said no. What is left is an old or hardened sshd with no
+ // algorithm in common — and telling that user their machine may have
+ // been swapped is a false alarm about the one thing this checks for.
+ h.nextFailure = Object.assign(
+ new Error("Handshake failed: no matching host key format"),
+ { level: "handshake" },
+ );
+ const error = (await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ ).catch((e) => e)) as SshError;
+
+ expect(error.failure).not.toBe("host-key-rejected");
+ expect(error.message).not.toMatch(/different host key/);
+ expect(error.message).toMatch(/could not agree on how to connect/);
+ // What the server said, so the real cause is not lost.
+ expect(error.message).toMatch(/no matching host key format/);
+ // Said once: the sentence above already reports a handshake that failed.
+ expect(error.message).not.toMatch(/connect: Handshake failed/i);
+ });
+
+ it.each(CASES)(
+ "reads $name as $failure",
+ async ({ raw, failure, kind, systemCode }) => {
+ h.nextFailure = Object.assign(new Error(String(raw.message)), raw);
+ const error = (await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ ).catch((e) => e)) as SshError;
+ expect(error.failure).toBe(failure);
+ expect((error as unknown as { kind: string }).kind).toBe(kind);
+ // Carried across rather than left in the sentence it was written into,
+ // so a caller that wants to name the fault does not have to read one.
+ expect(error.systemCode).toBe(systemCode);
+ },
+ );
+});
+
+describe("running a command", () => {
+ function scriptStream(
+ client: FakeClientShape,
+ script: (stream: FakeStream) => void,
+ ) {
+ client.execHandler = (_command, cb) => {
+ const stream = new FakeStream();
+ cb(null, stream);
+ script(stream);
+ };
+ }
+
+ class FakeStream extends EventEmitter implements FakeStreamShape {
+ stderr = new EventEmitter();
+ written: string | null = null;
+ eofSent = false;
+ ended = false;
+ closed = false;
+ write(data: string, cb?: () => void) {
+ this.written = (this.written ?? "") + data;
+ cb?.();
+ }
+ eof() {
+ this.eofSent = true;
+ }
+ end(data?: string) {
+ this.written = data ?? null;
+ this.ended = true;
+ }
+ close() {
+ this.closed = true;
+ }
+ }
+
+ it("sends EOF on stdin without finishing the write side", async () => {
+ // Both halves matter. Without the EOF, a command that reads stdin waits
+ // for a line that is never coming. With `end()` instead, Node destroys
+ // the channel as soon as the server sends its own EOF, which loses the
+ // exit status and, against a real sshd, kills a command still running.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let stream!: FakeStream;
+ scriptStream(h.clients[0], (s) => {
+ stream = s;
+ queueMicrotask(() => s.emit("close", 0));
+ });
+
+ await session.run("uname -a");
+
+ expect(stream.eofSent).toBe(true);
+ expect(stream.ended).toBe(false);
+ expect(stream.written).toBeNull();
+ });
+
+ it("hands input to stdin rather than the command line", async () => {
+ // Everything this runs remotely is a script, and a script on a command
+ // line has to survive a shell. Feeding stdin removes that layer entirely.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let stream!: FakeStreamShape;
+ scriptStream(h.clients[0], (s) => {
+ stream = s;
+ queueMicrotask(() => s.emit("close", 0));
+ });
+
+ await session.run("cat", { input: "$(rm -rf /) 'quoted'\n" });
+
+ expect(stream.written).toBe("$(rm -rf /) 'quoted'\n");
+ });
+
+ it("reports output as it arrives, not only at the end", async () => {
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ scriptStream(h.clients[0], (s) => {
+ s.emit("data", Buffer.from("first\n"));
+ s.stderr.emit("data", Buffer.from("warning\n"));
+ s.emit("data", Buffer.from("second\n"));
+ queueMicrotask(() => s.emit("close", 0));
+ });
+
+ const chunks: string[] = [];
+ const result = await session.run("build", {
+ onOutput: (c) => chunks.push(c),
+ });
+
+ expect(chunks).toEqual(["first\n", "warning\n", "second\n"]);
+ expect(result.stdout).toBe("first\nsecond\n");
+ expect(result.stderr).toBe("warning\n");
+ expect(result.code).toBe(0);
+ });
+
+ it("stops when cancelled mid-run", async () => {
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let stream!: FakeStreamShape;
+ scriptStream(h.clients[0], (s) => {
+ stream = s;
+ });
+ const controller = new AbortController();
+
+ const running = session.run("long-install", { signal: controller.signal });
+ controller.abort();
+
+ await expect(running).rejects.toMatchObject({ kind: "user_cancelled" });
+ expect(stream.closed).toBe(true);
+ });
+
+ it("stops when the server never opens the channel", async () => {
+ // A frozen box still answers TCP but stops answering SSH, so the callback
+ // that hands over the stream never runs. A listener attached inside it
+ // would never exist, and Cancel would do nothing for as long as the
+ // command was outstanding — which wedged the whole setup.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ // Asked for, never answered.
+ h.clients[0].execHandler = () => {};
+ const controller = new AbortController();
+
+ const running = session.run("long-install", { signal: controller.signal });
+ controller.abort();
+
+ await expect(running).rejects.toMatchObject({ kind: "user_cancelled" });
+ });
+
+ it("lets go of a channel that arrives after the cancel", async () => {
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let hand!: (stream: FakeStream) => void;
+ h.clients[0].execHandler = (_command, cb) => {
+ hand = (stream) => cb(null, stream);
+ };
+ const controller = new AbortController();
+ const running = session.run("long-install", { signal: controller.signal });
+
+ controller.abort();
+ const arrived = new FakeStream();
+ hand(arrived);
+
+ await expect(running).rejects.toMatchObject({ kind: "user_cancelled" });
+ expect(arrived.closed).toBe(true);
+ });
+
+ it("asks the connection to notice a peer that stops answering", async () => {
+ // Nothing else bounds a command: readyTimeout covers the handshake only.
+ await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ expect(h.clients.at(-1)?.connectConfig?.keepaliveInterval).toBeTruthy();
+ });
+
+ it("reports the connection dying rather than an exit code", async () => {
+ // The keepalive tears down a peer that stopped answering, which closes
+ // any open channel with no exit code. Read as an ordinary finish, that
+ // becomes "the installer failed (exit undefined)" — the connection's
+ // death reported as the command's verdict.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let stream!: FakeStream;
+ scriptStream(h.clients[0], (s) => {
+ stream = s;
+ });
+
+ const running = session.run("preflight");
+ h.clients[0].emit("error", {
+ level: "client-timeout",
+ message: "Keepalive timeout",
+ });
+ // What ssh2 actually emits when no exit-status arrived: its Channel
+ // initialises _exit.code to undefined, and utils.js emits that value.
+ stream.emit("close", undefined);
+
+ await expect(running).rejects.toMatchObject({ kind: "external" });
+ });
+
+ it("keeps a result the command did report, even if the link then died", async () => {
+ // The command finished and said how. A connection that dies immediately
+ // afterwards must not turn that into a failure of the install.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let stream!: FakeStream;
+ scriptStream(h.clients[0], (s) => {
+ stream = s;
+ });
+
+ const running = session.run("preflight");
+ h.clients[0].emit("error", {
+ level: "client-timeout",
+ message: "Keepalive timeout",
+ });
+ stream.emit("close", 0);
+
+ await expect(running).resolves.toMatchObject({ code: 0 });
+ });
+
+ it("says the server stopped rather than telling them to check the address", async () => {
+ // Before the handshake, "check the address and that port 22 is open" is
+ // the right advice. After it, the address plainly worked — repeating it
+ // sends the user to look at the thing that is not wrong.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let stream!: FakeStream;
+ scriptStream(h.clients[0], (s) => {
+ stream = s;
+ });
+
+ const running = session.run("preflight");
+ h.clients[0].emit("error", {
+ level: "client-timeout",
+ message: "Keepalive timeout",
+ });
+ stream.emit("close", undefined);
+
+ await expect(running).rejects.toMatchObject({
+ message: expect.stringContaining("stopped answering"),
+ });
+ });
+
+ it("blames the connection, not the channel, when the link died", async () => {
+ // The channel error is only how a dead connection surfaced here. Reported
+ // as itself it says "Not connected", which describes the symptom.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ h.clients[0].emit("error", {
+ level: "client-timeout",
+ message: "Keepalive timeout",
+ });
+ h.clients[0].execHandler = (_command, cb) => {
+ cb(new Error("Not connected"), undefined);
+ };
+
+ await expect(session.run("preflight")).rejects.toMatchObject({
+ failure: "timeout",
+ });
+ });
+
+ it("still reports an ordinary non-zero exit as itself", async () => {
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ scriptStream(h.clients[0], (s) => {
+ s.emit("close", 1);
+ });
+
+ await expect(session.run("preflight")).resolves.toMatchObject({ code: 1 });
+ });
+
+ it("reports a channel error instead of letting it take the process down", async () => {
+ // A stream with no error listener throws where it stands, which in the
+ // main process means the whole app rather than the command.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ scriptStream(h.clients[0], (s) => {
+ queueMicrotask(() => s.emit("error", new Error("channel died")));
+ });
+
+ await expect(session.run("uname -a")).rejects.toThrow(/channel died/);
+ });
+
+ it("lets go of the signal when the socket is already gone", async () => {
+ // ssh2 throws where it stands rather than calling back, which would skip
+ // the cleanup and strand a listener on a signal nobody will ever answer.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ h.clients[0].execHandler = () => {
+ throw new Error("Not connected");
+ };
+ const controller = new AbortController();
+ const letGo = vi.spyOn(controller.signal, "removeEventListener");
+
+ await expect(
+ session.run("preflight", { signal: controller.signal }),
+ ).rejects.toBeTruthy();
+
+ // Watched directly: an AbortSignal does not say how many listeners it
+ // holds, and "nothing bad happened afterwards" is true whether or not one
+ // was left behind.
+ expect(letGo).toHaveBeenCalled();
+ });
+
+ it("gives up on a command that never answers, and closes the channel", async () => {
+ // Giving up on the answer is not the same as stopping the work: a command
+ // left running holds its channel open until the whole session ends.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ let stream!: FakeStream;
+ scriptStream(h.clients[0], (s) => {
+ stream = s;
+ });
+
+ // Named apart from a lost connection: the link is still good, so a
+ // caller that polls can ask again rather than giving up on the server.
+ await expect(
+ session.run("docker ps", { timeoutMs: 10 }),
+ ).rejects.toMatchObject({ failure: "command-timeout" });
+ expect(stream.closed).toBe(true);
+ });
+
+ it("leaves a command alone when no bound was asked for", async () => {
+ // An installer legitimately runs for minutes with nothing to say, so
+ // unbounded stays the default.
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ scriptStream(h.clients[0], (s) => {
+ setTimeout(() => s.emit("close", 0), 30);
+ });
+
+ await expect(session.run("install.sh")).resolves.toMatchObject({ code: 0 });
+ });
+
+ it("refuses before starting when already cancelled", async () => {
+ const session = await connectSsh(
+ TARGET,
+ trustOnFirstUse(() => {}),
+ );
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(
+ session.run("long-install", { signal: controller.signal }),
+ ).rejects.toMatchObject({ kind: "user_cancelled" });
+ });
+});
diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts
new file mode 100644
index 0000000000..cf6834dcac
--- /dev/null
+++ b/src/ipc/utils/ssh_client.ts
@@ -0,0 +1,471 @@
+import type { ClientChannel, ConnectConfig } from "ssh2";
+import type { SshFailure } from "@/shared/ssh_failure";
+import { createHash } from "crypto";
+import log from "electron-log";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
+
+const logger = log.scope("ssh_client");
+
+/**
+ * Dyad's SSH client, for setting a server up before Coolify exists on it.
+ *
+ * Deploying needs no SSH at all — Coolify clones from GitHub with a key Dyad
+ * hands it. This is the other half: reaching a bare machine to install Coolify
+ * in the first place, which nothing else in Dyad does.
+ */
+
+/** Long enough for a slow link, short enough that a wrong address gives up. */
+const CONNECT_TIMEOUT_MS = 20_000;
+
+export interface SshTarget {
+ host: string;
+ port?: number;
+ username: string;
+ /** OpenSSH format. Node's PEM export is rejected by the wire library. */
+ privateKey: string;
+}
+
+/**
+ * What went wrong, from the connection itself rather than from its message.
+ *
+ * Kept as a closed set because the caller decides what to tell the user from
+ * it: a rejected key asks them to check the key is on the server, and an
+ * unreachable host asks them to check the address. Reading English out of a
+ * message to make that choice breaks the first time the wording moves.
+ */
+export type { SshFailure };
+
+export class SshError extends DyadError {
+ constructor(
+ readonly failure: SshFailure,
+ message: string,
+ kind: DyadErrorKind,
+ /**
+ * What the operating system called it, where it said anything.
+ *
+ * Carried rather than left in the message for the same reason `failure`
+ * is: a caller that wants to say ENOTFOUND should not have to find it in
+ * a sentence. Deliberately not `code`, which a handler writes a mark to
+ * so the panel does not report a failure the screen is already showing —
+ * and writes only while nothing holds that name yet. Declared here, the
+ * name is held whether or not anything is passed, so calling this `code`
+ * would stop that mark being written at all and tell the user twice.
+ */
+ readonly systemCode?: string,
+ ) {
+ super(message, kind);
+ this.name = "SshError";
+ }
+}
+
+/**
+ * The fingerprint OpenSSH would print for the same key.
+ *
+ * Matching its format matters because the user checks this against something
+ * else — their provider's console, or `ssh-keyscan` — and two spellings of the
+ * same key read as two different keys.
+ */
+export function hostKeyFingerprint(key: Buffer): string {
+ return (
+ "SHA256:" +
+ createHash("sha256").update(key).digest("base64").replace(/=+$/, "")
+ );
+}
+
+/**
+ * Decides whether to trust the server presenting this key.
+ *
+ * A parameter rather than a policy baked in here, because the answer differs by
+ * how we arrived. A server the user typed the address of has nothing to check
+ * against, so the honest answer is to show them the fingerprint. One Dyad has
+ * already looked at can be checked exactly.
+ *
+ * Synchronous: ssh2 decides during the handshake, with nothing to await into,
+ * so anything that needs asking is answered before connecting.
+ */
+export type HostKeyVerifier = (fingerprint: string) => boolean;
+
+/** Accepts any host, reporting the fingerprint. Trust on first use. */
+export function trustOnFirstUse(
+ onSeen: (fingerprint: string) => void,
+): HostKeyVerifier {
+ return (fingerprint) => {
+ onSeen(fingerprint);
+ return true;
+ };
+}
+
+/** Accepts only the fingerprint a provider already told us to expect. */
+export function expectFingerprint(expected: string): HostKeyVerifier {
+ return (fingerprint) => fingerprint === expected;
+}
+
+function classify(
+ err: NodeJS.ErrnoException & { level?: string },
+ { connected = false }: { connected?: boolean } = {},
+): SshError {
+ const level = err.level ?? "";
+ if (level === "client-authentication") {
+ return new SshError(
+ "auth-rejected",
+ "The server refused this key. Add Dyad's public key to the server's " +
+ "authorized_keys and try again.",
+ DyadErrorKind.Auth,
+ );
+ }
+ if (level === "handshake") {
+ // Not the key being turned down: that is answered before this is asked,
+ // where the verifier said no. What is left is the two ends failing to
+ // agree — so this must not say the machine may have been swapped, which
+ // is what a user with an old sshd would otherwise be told.
+ return new SshError(
+ "handshake-failed",
+ // The library's own words, with its "Handshake failed:" preamble taken
+ // off — this sentence has already said that much.
+ `Dyad and this server could not agree on how to connect${
+ err.message
+ ? `: ${err.message.replace(/^handshake failed:\s*/i, "")}`
+ : ""
+ }. That usually means the server's SSH is older or more restricted ` +
+ `than Dyad's defaults.`,
+ DyadErrorKind.External,
+ );
+ }
+ if (level === "client-timeout") {
+ return new SshError(
+ "timeout",
+ connected
+ ? "The server stopped answering. It may have run out of memory or " +
+ "frozen; check it and try again."
+ : "The server did not answer in time. Check the address and that " +
+ "port 22 is reachable.",
+ DyadErrorKind.External,
+ );
+ }
+ if (
+ err.code === "ENOTFOUND" ||
+ err.code === "ECONNREFUSED" ||
+ err.code === "EHOSTUNREACH"
+ ) {
+ return new SshError(
+ "unreachable",
+ `Could not reach the server (${err.code}). Check the address and that ` +
+ `port 22 is open.`,
+ DyadErrorKind.External,
+ err.code,
+ );
+ }
+ return new SshError(
+ "unknown",
+ connected
+ ? `The connection to the server failed: ${err.message}`
+ : `Could not connect over SSH: ${err.message}`,
+ DyadErrorKind.External,
+ err.code,
+ );
+}
+
+export interface SshResult {
+ code: number | null;
+ stdout: string;
+ stderr: string;
+}
+
+export interface SshSession {
+ /**
+ * Runs a command, optionally feeding it on stdin.
+ *
+ * Input goes to stdin rather than into the command line because everything
+ * interesting here is a script, and a script on a command line has to survive
+ * a shell — quoting that is where this kind of code goes wrong.
+ */
+ run(
+ command: string,
+ options?: {
+ input?: string;
+ onOutput?: (chunk: string) => void;
+ signal?: AbortSignal;
+ /**
+ * How long to wait for this command, in milliseconds.
+ *
+ * Unbounded by default, because an installer legitimately runs for
+ * minutes with nothing to say. Anything with a shorter honest answer —
+ * a probe, a tinker one-liner — should give one: without it, a server
+ * whose docker has wedged leaves the step it is on hanging forever, and
+ * the user has to work out for themselves that nothing is happening.
+ */
+ timeoutMs?: number;
+ },
+ ): Promise;
+ end(): void;
+}
+
+/**
+ * Opens a session, or throws having said which part failed.
+ *
+ * The verifier runs before anything is sent, so a server that fails it never
+ * receives the credentials for the account being set up.
+ */
+export async function connectSsh(
+ target: SshTarget,
+ verifyHostKey: HostKeyVerifier,
+ { signal }: { signal?: AbortSignal } = {},
+): Promise {
+ // Loaded when someone actually connects, not when this module is imported.
+ // The setup handlers are registered during boot, so a value import here put
+ // ssh2 — and the optional native probe it runs on load — on the startup of
+ // every app, whether or not anyone ever set a server up. That also turned a
+ // packaging miss into a process that does not start, rather than one
+ // feature that does not work.
+ const { Client } = await import("ssh2");
+ const conn = new Client();
+ /**
+ * Why the connection died, for commands that were in flight when it did.
+ *
+ * The keepalive above turns a frozen peer into a torn-down socket, which
+ * closes any open channel with no exit code — and a command that reads that
+ * as an ordinary finish reports the connection's death as the installer's
+ * verdict. Kept here so the command can say what actually happened.
+ */
+ let connectionError: SshError | null = null;
+ /** Whether the handshake got through, which changes what advice fits. */
+ let ready = false;
+ let rejectedHostKey = false;
+
+ // Registered before the handshake is awaited, not after: an error arriving
+ // in the same tick as "ready" would otherwise have nothing listening. The
+ // promise's own listener still owns rejecting while it is pending; this one
+ // only remembers, and keeps an unhandled 'error' on a Client — which would
+ // take the process down — from ever being unhandled.
+ conn.on("error", (err) => {
+ connectionError = classify(
+ err as NodeJS.ErrnoException & { level?: string },
+ // Anything reaching here after the handshake is a session that was
+ // working and stopped, so the advice is about the machine rather than
+ // about the address that was typed.
+ { connected: ready },
+ );
+ });
+
+ await new Promise((resolve, reject) => {
+ // Reaching an address that is firewalled takes as long as the handshake
+ // timeout allows, and until this landed Cancel did nothing at all for
+ // that whole stretch — the panel said "Stopping…" and kept going.
+ if (signal?.aborted) {
+ conn.end();
+ reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled));
+ return;
+ }
+ const onAbort = () => {
+ conn.end();
+ reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled));
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ const settled = (fn: () => void) => () => {
+ signal?.removeEventListener("abort", onAbort);
+ fn();
+ };
+ conn.on(
+ "ready",
+ settled(() => {
+ ready = true;
+ resolve();
+ }),
+ );
+ conn.on("error", (rawError) => {
+ signal?.removeEventListener("abort", onAbort);
+ const err = rawError;
+ // A host key the verifier turned down surfaces as a handshake failure,
+ // which is also what a genuinely mismatched key looks like. Saying which
+ // it was matters: one is the user declining, the other is a warning.
+ if (rejectedHostKey) {
+ reject(
+ new SshError(
+ "host-key-rejected",
+ "The server's identity was not accepted, so nothing was sent to it.",
+ DyadErrorKind.UserCancelled,
+ ),
+ );
+ return;
+ }
+ reject(classify(err as NodeJS.ErrnoException & { level?: string }));
+ });
+
+ const config: ConnectConfig = {
+ host: target.host,
+ port: target.port ?? 22,
+ username: target.username,
+ privateKey: target.privateKey,
+ readyTimeout: CONNECT_TIMEOUT_MS,
+ // Only the handshake is bounded by readyTimeout. Without a keepalive
+ // nothing notices a peer that still answers TCP but has stopped
+ // answering SSH — a frozen box — and a command sent to it never
+ // settles, taking the whole setup with it.
+ keepaliveInterval: 15_000,
+ hostVerifier: (key: Buffer) => {
+ const accepted = verifyHostKey(hostKeyFingerprint(key));
+ if (!accepted) rejectedHostKey = true;
+ return accepted;
+ },
+ };
+ conn.connect(config);
+ });
+
+ return {
+ run(command, { input, onOutput, signal, timeoutMs } = {}) {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled));
+ return;
+ }
+ let timer: ReturnType | undefined;
+ // Wired before the channel is asked for, not inside the callback
+ // that answers. Opening a channel takes a round trip, and on a server
+ // that has stopped answering that callback never runs — so a listener
+ // attached there would never exist, and Cancel would do nothing at
+ // all for as long as the command was outstanding.
+ let openStream: ClientChannel | null = null;
+ let cancelled = false;
+ const onAbort = () => {
+ cancelled = true;
+ stopListening();
+ openStream?.close();
+ reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled));
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ const stopListening = () => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ };
+
+ if (timeoutMs !== undefined) {
+ timer = setTimeout(() => {
+ // The channel is closed as well as the promise settled: giving up
+ // on an answer is not the same as stopping the work, and a
+ // command left running holds its channel open until the whole
+ // session ends.
+ cancelled = true;
+ openStream?.close();
+ signal?.removeEventListener("abort", onAbort);
+ reject(
+ new SshError(
+ "command-timeout",
+ "The server did not answer in time.",
+ DyadErrorKind.External,
+ ),
+ );
+ }, timeoutMs);
+ }
+
+ const askForChannel = () =>
+ conn.exec(command, (err, stream) => {
+ if (err) {
+ stopListening();
+ // The latched reason first, as everywhere else: when the
+ // connection died, that is what went wrong, and the channel
+ // error is only how it surfaced here.
+ reject(
+ connectionError ??
+ classify(err as NodeJS.ErrnoException & { level?: string }, {
+ connected: true,
+ }),
+ );
+ return;
+ }
+ let stdout = "";
+ let stderr = "";
+ openStream = stream;
+ // The cancel landed while the channel was opening: the listener
+ // above has already rejected, so all this has to do is let go of
+ // the channel it was handed.
+ if (cancelled) {
+ stream.close();
+ return;
+ }
+
+ const failCommand = (error: unknown) => {
+ stopListening();
+ reject(
+ connectionError ??
+ classify(
+ error as NodeJS.ErrnoException & { level?: string },
+ {
+ connected: true,
+ },
+ ),
+ );
+ };
+ // A channel error with nobody listening is thrown, which would end
+ // the main process rather than the command.
+ stream.on("error", failCommand);
+ stream.stderr.on("error", failCommand);
+
+ stream.on("data", (chunk: Buffer) => {
+ const text = chunk.toString("utf8");
+ stdout += text;
+ onOutput?.(text);
+ });
+ stream.stderr.on("data", (chunk: Buffer) => {
+ const text = chunk.toString("utf8");
+ stderr += text;
+ onOutput?.(text);
+ });
+ stream.on("close", (code: number | null) => {
+ stopListening();
+ // Only when the command never said how it ended. A channel
+ // closed without an exit status is the connection dying under
+ // it, and reporting that as an exit code blames the installer
+ // for a lost link — but a command that did report its own
+ // result gets to keep it, even if the link died straight after.
+ //
+ // Loose equality on purpose: ssh2 initialises the exit code to
+ // undefined and only assigns it when an exit-status arrives, so
+ // "never said" is undefined here rather than null.
+ if (connectionError && code == null) {
+ reject(connectionError);
+ return;
+ }
+ resolve({ code: code ?? null, stdout, stderr });
+ });
+
+ // Every command gets EOF on stdin, so one that reads it sees the
+ // end rather than a pipe that stays open for the life of the
+ // channel. `eof()` rather than `end()`: ending finishes the write
+ // side, and Node destroys a Duplex whose write side is finished as
+ // soon as its read side ends — which in ssh2 sends CHANNEL_CLOSE,
+ // dropping an exit status still on its way and killing a command
+ // still running.
+ if (input !== undefined) {
+ stream.write(input, () => stream.eof());
+ } else {
+ stream.eof();
+ }
+ });
+
+ try {
+ askForChannel();
+ } catch (error) {
+ // ssh2 throws where it stands when the socket is already gone,
+ // which would skip the cleanup below and leave the abort listener
+ // attached to a signal nobody will ever answer.
+ stopListening();
+ reject(
+ connectionError ??
+ classify(error as NodeJS.ErrnoException & { level?: string }, {
+ connected: true,
+ }),
+ );
+ }
+ });
+ },
+ end() {
+ try {
+ conn.end();
+ } catch (error) {
+ // Closing a connection that already died is not a failure worth
+ // raising over whatever actually went wrong first.
+ logger.debug("Ignored error while closing SSH session", error);
+ }
+ },
+ };
+}
diff --git a/src/ipc/utils/telemetry.test.ts b/src/ipc/utils/telemetry.test.ts
index 55840ecbb8..57b94e0fb9 100644
--- a/src/ipc/utils/telemetry.test.ts
+++ b/src/ipc/utils/telemetry.test.ts
@@ -35,6 +35,44 @@ describe("shouldFilterTelemetryException", () => {
).toBe(true);
});
+ it("does not report a server that could not be reached over SSH", async () => {
+ // A mistyped address or a firewalled port 22 is the user's own network,
+ // and the message carries whatever they typed.
+ const { SshError } = await import("@/ipc/utils/ssh_client");
+ expect(
+ shouldFilterTelemetryException(
+ new SshError(
+ "unreachable",
+ "Could not reach the server (ENOTFOUND).",
+ DyadErrorKind.External,
+ ),
+ ),
+ ).toBe(true);
+ expect(
+ shouldFilterTelemetryException(
+ new SshError(
+ "timeout",
+ "The server did not answer in time.",
+ DyadErrorKind.External,
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it("still reports an SSH failure nothing here recognised", async () => {
+ // The bucket for what was not classified is the one worth hearing about.
+ const { SshError } = await import("@/ipc/utils/ssh_client");
+ expect(
+ shouldFilterTelemetryException(
+ new SshError(
+ "unknown",
+ "Could not connect over SSH: something new",
+ DyadErrorKind.External,
+ ),
+ ),
+ ).toBe(false);
+ });
+
it("filters RateLimitError 429s from retryWithRateLimit", () => {
const error = new Error("Rate limited (429): Too Many Requests");
error.name = "RateLimitError";
@@ -137,6 +175,27 @@ describe("exceptions from a self-hosted instance", () => {
expect(payload.ipc_channel).toBe("coolify:discover");
});
+ it("redacts setting a server up, not only deploying to one", () => {
+ // Its failures quote the installer's own output, the server's address, and
+ // the address the user signs in with. The prefix differs from the deploy
+ // channels by one word, which is all it took to miss the filter.
+ sendTelemetryException(
+ new Error(
+ "Installing Coolify failed. The server said: connect ECONNRESET " +
+ "203.0.113.5:22 for someone@theirdomain.com",
+ ),
+ { ipc_channel: "coolify-setup:run" },
+ );
+
+ const payload = sent.calls[0];
+ // The stack header repeats the message, so the whole payload is checked
+ // rather than the message field alone.
+ expect(JSON.stringify(payload)).not.toContain("203.0.113.5");
+ expect(JSON.stringify(payload)).not.toContain("theirdomain.com");
+ expect(payload.exception_message).toBeUndefined();
+ expect(payload.ipc_channel).toBe("coolify-setup:run");
+ });
+
it("keeps the message for every other channel", () => {
sendTelemetryException(new Error("something broke"), {
ipc_channel: "apps:list",
diff --git a/src/ipc/utils/telemetry.ts b/src/ipc/utils/telemetry.ts
index 5682115a16..54e00e71aa 100644
--- a/src/ipc/utils/telemetry.ts
+++ b/src/ipc/utils/telemetry.ts
@@ -6,6 +6,7 @@ import {
} from "@/errors/dyad_error";
import { isGenericFetchFailedError } from "@/lib/posthogTelemetry";
import { TelemetryEventPayload } from "@/ipc/types";
+import { sshFailureOf } from "@/shared/ssh_failure";
import {
COOLIFY_REQUEST_ERROR_NAME,
COOLIFY_TRANSPORT_ERROR_NAME,
@@ -79,11 +80,21 @@ export function sendTelemetryException(
});
}
-/** Channels that talk to a server the user runs, rather than to Dyad's own. */
+/**
+ * Channels that talk to a server the user runs, rather than to Dyad's own.
+ *
+ * Every prefix a self-hosted surface uses has to be listed. Setting a server up
+ * is the same class as deploying to one and carries more: its failures quote
+ * the installer's own output, the address, and the address the user signs in
+ * with.
+ */
+const SELF_HOSTED_CHANNEL_PREFIXES = ["coolify:", "coolify-setup:"];
+
function isSelfHostedChannel(context?: Record): boolean {
- return (
- typeof context?.ipc_channel === "string" &&
- context.ipc_channel.startsWith("coolify:")
+ const channel = context?.ipc_channel;
+ if (typeof channel !== "string") return false;
+ return SELF_HOSTED_CHANNEL_PREFIXES.some((prefix) =>
+ channel.startsWith(prefix),
);
}
@@ -117,6 +128,16 @@ export function shouldFilterTelemetryException(error: unknown): boolean {
return true;
}
+ // The same thing again over SSH rather than HTTP: an address that does not
+ // answer, or a port nobody is listening on, is the user's own network being
+ // reported as a fault here — and the message carries whatever they typed.
+ // Only the two that say what went wrong. "unknown" is the bucket for a
+ // failure nothing here recognised, which is what telemetry is for.
+ const sshFailure = sshFailureOf(error);
+ if (sshFailure === "unreachable" || sshFailure === "timeout") {
+ return true;
+ }
+
if (error instanceof DyadError) {
return isDyadErrorKindFilteredFromTelemetry(error.kind);
}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index 508232cda1..69e9ec2519 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -343,6 +343,12 @@ export const queryKeys = {
instanceUrl ?? "none",
tokenId ?? "none",
] as const,
+ /** What the main process is doing with a server right now. */
+ setup: ["coolify", "setup"] as const,
+ /** The public half of the key Dyad puts on servers it sets up. */
+ serverKey: ["coolify", "serverKey"] as const,
+ /** What Dyad knows about signing in to the server it set up. */
+ credentials: ["coolify", "credentials"] as const,
/** Every instance's list, for invalidating after a token change. */
discoveryAll: ["coolify", "discovery"] as const,
},
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 5f96464467..6eb1527191 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -215,6 +215,45 @@ export type SupabaseOrganizationCredentials = z.infer<
typeof SupabaseOrganizationCredentialsSchema
>;
+/**
+ * The admin account on a server Dyad set up itself.
+ *
+ * Its own shape rather than fields on the instance below, because it is a
+ * fact about a machine Dyad built rather than about the Coolify Dyad talks
+ * to. Usually the same server; not always.
+ */
+export const CoolifyAdminSchema = z.object({
+ email: z.string(),
+ /**
+ * Optional only because it can become unreadable, never because it was not
+ * written: a keychain that cannot open it leaves the account behind without
+ * it. Required here instead would make the account itself vanish, and the
+ * ciphertext on disk has nowhere to be put back into once it has.
+ */
+ password: SecretSchema.optional(),
+ /**
+ * The address of the server this account opens.
+ *
+ * There are two addresses stored, and they are usually the same one. This
+ * is the machine Dyad installed Coolify on. `instanceUrl` on the object
+ * below is the Coolify Dyad is currently talking to.
+ *
+ * They come apart in one case. Coolify has no API for making API tokens, so
+ * Dyad mints one through a workaround, and that workaround can fail. The
+ * install still succeeded, so the finished screen hands over this account
+ * and says to make a token by hand — and the next screen offers the token
+ * form with this address already filled in. Someone who instead points that
+ * form at a different Coolify they already had ends up with this account
+ * for one server and a token for another.
+ *
+ * Keeping the address next to the account is what lets the panel put each
+ * secret under the server it actually opens. One address over both would
+ * have to pick, and picking wrong shows this password under the other
+ * server's name, which reads as a way into it and is not one.
+ */
+ instanceUrl: z.string(),
+});
+
/**
* A Coolify instance Dyad can deploy to.
*
@@ -226,9 +265,49 @@ export type SupabaseOrganizationCredentials = z.infer<
export const CoolifySchema = z.object({
instanceUrl: z.string().optional(),
accessToken: SecretSchema.optional(),
+ /**
+ * The admin account on a server Dyad set up itself.
+ *
+ * Kept because Dyad invented this password on the user's behalf, for their
+ * own machine — showing it once and forgetting it leaves them locked out of
+ * a server they own. Encrypted like the token, and like the token it
+ * reaches the renderer whenever settings are read, not only when the panel
+ * asks to show it.
+ *
+ * One object rather than three fields, because they are only ever
+ * meaningful together: an account without the address it opens is a
+ * password for nothing, and they are written and forgotten at once.
+ */
+ admin: CoolifyAdminSchema.optional(),
});
export type Coolify = z.infer;
+/**
+ * Every field of a Coolify, named and empty.
+ *
+ * writeSettings reads an absent key as a field some consumer read could not
+ * decrypt and hands the stored ciphertext back, so forgetting an instance by
+ * writing an empty object returns the token instead of clearing it. Only a
+ * key that is present and undefined reads as a deliberate clear.
+ *
+ * Typed so that a field added to CoolifySchema later fails to compile until
+ * it is named here too, which is what an empty object was reaching for.
+ *
+ * Built fresh each call rather than shared. writeSettings merges one level
+ * deep and then edits what it merged by path, so a single object handed to it
+ * is the object it edits — and one kept at module scope would carry another
+ * write's edits into every sign-out after it.
+ */
+export function forgottenCoolify(): {
+ [K in keyof Required]: undefined;
+} {
+ return {
+ instanceUrl: undefined,
+ accessToken: undefined,
+ admin: undefined,
+ };
+}
+
export const SupabaseSchema = z.object({
// Map keyed by organizationSlug -> organization credentials
organizations: z
diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts
index 29c71c3e07..96347a1ad8 100644
--- a/src/main/settings.test.ts
+++ b/src/main/settings.test.ts
@@ -21,7 +21,7 @@ import {
rewriteRecoveredSafeStorageSecretsAfterKeychainUnlock,
} from "@/main/settings";
import { getUserDataPath } from "@/paths/paths";
-import { UserSettings } from "@/lib/schemas";
+import { forgottenCoolify, UserSettings } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getRemoteDesktopConfig } from "@/ipc/shared/remote_desktop_config";
import {
@@ -1120,6 +1120,155 @@ describe("preserving undecryptable secrets", () => {
});
});
+ it("hides a Coolify token that will not decrypt", () => {
+ // Handing the ciphertext through would have it sent to Coolify as the
+ // token, and it would be rejected with no way to tell why.
+ store[mockSettingsPath] = JSON.stringify({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ accessToken: lockedSecret("coolify"),
+ },
+ });
+
+ const read = readSettings();
+ expect(read.coolify?.accessToken).toBeUndefined();
+ expect(read.coolify?.instanceUrl).toBe("http://203.0.113.5:8000");
+ });
+
+ it("puts the Coolify admin password through encryption", () => {
+ // Dyad made this one up and is the only thing holding it, which is a
+ // reason to keep it readable and not a reason to keep it in the clear.
+ writeSettings({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ admin: {
+ email: "me@gmail.com",
+ password: { value: "invented-password" },
+ instanceUrl: "http://203.0.113.5:8000",
+ },
+ },
+ });
+
+ expect(readStoredFile().coolify.admin).toEqual({
+ email: "me@gmail.com",
+ password: { value: "invented-password", encryptionType: "plaintext" },
+ instanceUrl: "http://203.0.113.5:8000",
+ });
+ });
+
+ it("forgets a Coolify token that will not decrypt when signing out", () => {
+ // The preservation pass puts a secret back whenever the container it
+ // lives in is still there, treating an absent key as a consumer read that
+ // could not decrypt it rather than as a clear. An empty coolify object is
+ // still a container, so the one write whose whole job is to forget the
+ // instance would hand the token straight back.
+ store[mockSettingsPath] = JSON.stringify({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ accessToken: lockedSecret("coolify"),
+ },
+ });
+
+ writeSettings({ coolify: forgottenCoolify() });
+
+ expect(readStoredFile().coolify).toEqual({});
+ });
+
+ it("clears again on a second sign-out", () => {
+ // writeSettings edits the object it was handed, so a shape kept at module
+ // scope would carry one write's edits into the next sign-out.
+ store[mockSettingsPath] = JSON.stringify({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ accessToken: lockedSecret("coolify"),
+ },
+ });
+ writeSettings({ coolify: forgottenCoolify() });
+ expect(readStoredFile().coolify).toEqual({});
+
+ store[mockSettingsPath] = JSON.stringify({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ accessToken: lockedSecret("coolify"),
+ },
+ });
+ writeSettings({ coolify: forgottenCoolify() });
+ expect(readStoredFile().coolify).toEqual({});
+ });
+
+ it("keeps a locked Coolify admin password across an unrelated write", () => {
+ // The preservation path hands the ciphertext back after encryption, having
+ // deleted it from the merged settings first. Reading the account and then
+ // reaching for its password without checking finds nothing there — and
+ // every settings write, Coolify or not, dies on it.
+ const locked = lockedSecret("coolify");
+ store[mockSettingsPath] = JSON.stringify({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ admin: {
+ email: "me@gmail.com",
+ password: locked,
+ instanceUrl: "http://203.0.113.5:8000",
+ },
+ },
+ });
+
+ writeSettings({ enableAutoUpdate: false });
+
+ expect(readStoredFile().enableAutoUpdate).toBe(false);
+ expect(readStoredFile().coolify.admin.password).toEqual(locked);
+ });
+
+ it("keeps a locked admin password when a Coolify write rebuilds the object without it", () => {
+ // What `coolify:save-token` does: spread what readSettings could decrypt,
+ // then add the token. The account came back without its password, so
+ // without the container still standing there is nowhere to put the
+ // ciphertext back and the only copy of it goes.
+ const locked = lockedSecret("coolify");
+ store[mockSettingsPath] = JSON.stringify({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ admin: {
+ email: "me@gmail.com",
+ password: locked,
+ instanceUrl: "http://203.0.113.5:8000",
+ },
+ },
+ });
+
+ writeSettings({
+ coolify: {
+ ...readSettings().coolify,
+ instanceUrl: "http://203.0.113.5:8000",
+ accessToken: { value: "1|fresh" },
+ },
+ });
+
+ expect(readStoredFile().coolify.admin.password).toEqual(locked);
+ expect(readStoredFile().coolify.admin.email).toBe("me@gmail.com");
+ });
+
+ it("hides an admin password that will not decrypt, keeping the account", () => {
+ // Handing the ciphertext through would put it on screen as the password,
+ // and it would not open anything. The account it belongs to stays, so a
+ // later write still has somewhere to put the ciphertext back.
+ store[mockSettingsPath] = JSON.stringify({
+ coolify: {
+ instanceUrl: "http://203.0.113.5:8000",
+ admin: {
+ email: "me@gmail.com",
+ password: lockedSecret("coolify"),
+ instanceUrl: "http://203.0.113.5:8000",
+ },
+ },
+ });
+
+ const read = readSettings();
+ expect(read.coolify?.admin?.password).toBeUndefined();
+ expect(read.coolify?.admin?.email).toBe("me@gmail.com");
+ expect(read.coolify?.admin?.instanceUrl).toBe("http://203.0.113.5:8000");
+ });
+
it("preserves a locked provider apiKey when a write rebuilds providerSettings without it", () => {
const locked = lockedSecret("openai");
store[mockSettingsPath] = JSON.stringify({
diff --git a/src/main/settings.ts b/src/main/settings.ts
index 1403064de2..987a6befad 100644
--- a/src/main/settings.ts
+++ b/src/main/settings.ts
@@ -413,6 +413,19 @@ export function writeSettings(settings: Partial): void {
accessToken: encrypt(newSettings.coolify.accessToken.value),
};
}
+ // Guarded on the password rather than the account, because the two do not
+ // arrive together: the preservation pass above strips a password it means
+ // to write back verbatim, leaving the account here with none.
+ const coolifyAdmin = newSettings.coolify?.admin;
+ if (coolifyAdmin?.password) {
+ newSettings.coolify = {
+ ...newSettings.coolify,
+ admin: {
+ ...coolifyAdmin,
+ password: encrypt(coolifyAdmin.password.value),
+ },
+ };
+ }
if (newSettings.supabase) {
// Encrypt legacy tokens (kept for backwards compat)
if (newSettings.supabase.accessToken) {
@@ -685,6 +698,29 @@ function readExistingSettingsFile(
combinedSettings.coolify = rest;
}
}
+ const admin = combinedSettings.coolify?.admin;
+ const adminPassword = admin?.password;
+ if (admin && adminPassword) {
+ const resolved = resolveStoredSecret(
+ adminPassword,
+ "Coolify admin password",
+ ["coolify", "admin", "password"],
+ ctx,
+ );
+ if (resolved) {
+ combinedSettings.coolify = {
+ ...combinedSettings.coolify,
+ admin: { ...admin, password: resolved },
+ };
+ } else {
+ // Only the password. The account stays so that the ciphertext has a
+ // container to be written back into on the next write — dropping it
+ // whole reads to the write path as the user having deleted the account,
+ // and throws away a password a repaired keychain could still open.
+ const { password: _dropped, ...rest } = admin;
+ combinedSettings.coolify = { ...combinedSettings.coolify, admin: rest };
+ }
+ }
for (const provider in combinedSettings.providerSettings) {
if (combinedSettings.providerSettings[provider].apiKey) {
const resolved = resolveStoredSecret(
diff --git a/src/shared/coolify_admin_email.test.ts b/src/shared/coolify_admin_email.test.ts
new file mode 100644
index 0000000000..dd4f4dd6f0
--- /dev/null
+++ b/src/shared/coolify_admin_email.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { adminEmailRefusal } from "./coolify_admin_email";
+
+describe("reserved domains", () => {
+ it("refuses a subdomain of a documentation domain", () => {
+ // RFC 2606 reserves everything under these, so Coolify's seeder will
+ // refuse the address minutes into an install.
+ expect(adminEmailRefusal("admin@mail.example.com")).not.toBeNull();
+ expect(adminEmailRefusal("admin@notexample.com")).toBeNull();
+ });
+});
+
+describe("addresses Coolify would turn down", () => {
+ // Coolify resolves the domain, so these fail on the server however
+ // well-formed they look. Catching them here means the user finds out while
+ // typing rather than after a multi-minute install that seeds nothing.
+ it.each([
+ ["admin@dyad.test", false, "a reserved TLD that cannot resolve"],
+ ["admin@my.localhost", false, "reserved for loopback"],
+ ["admin@thing.invalid", false, "reserved to always fail"],
+ ["admin@example.com", false, "reserved for documentation"],
+ ["admin", false, "not an address at all"],
+ ["admin@nodomain", false, "no dot, so no resolvable domain"],
+ ["admin@ dyad.sh", false, "a space is not allowed"],
+ ["someone@gmail.com", true, "an ordinary address"],
+ ["dev+coolify@sub.domain.co.uk", true, "tagging and subdomains are fine"],
+ ])("reads %s as %s (%s)", (email, usable) => {
+ expect(adminEmailRefusal(email) === null).toBe(usable);
+ });
+
+ it("does not reject a domain merely for containing a reserved word", () => {
+ // The check looks at the last label; a stricter rule would turn away
+ // addresses that work perfectly well.
+ expect(adminEmailRefusal("admin@test-lab.com")).toBeNull();
+ expect(adminEmailRefusal("admin@example-corp.io")).toBeNull();
+ });
+});
+
+describe("adminEmailRefusal", () => {
+ it("does not call an address Dyad cannot send undeliverable", () => {
+ // `!` and `#` are legal in a local part, so this address may work
+ // perfectly well — the refusal is Dyad's, and saying the domain does not
+ // resolve sends the user to check something that was never wrong.
+ const refusal = adminEmailRefusal("will!s@gmail.com");
+
+ expect(refusal).toMatch(/can't send/);
+ expect(refusal).not.toMatch(/receive mail at/);
+ });
+
+ it("names every character it turns an address down for", () => {
+ // The message lists them, so the list has to be the one the check uses.
+ // Being told to remove a character you did not type leaves you with
+ // nothing to change, which is the whole of what this message is for.
+ const refusal = adminEmailRefusal("will!s@gmail.com") ?? "";
+ for (const named of ["quote", "backslash", "`", "$", "#", "!"]) {
+ expect(refusal).toContain(named);
+ }
+
+ // And every one of them really does land on that message rather than on
+ // the one about mail. A newline cannot reach it — the shape check reads
+ // it as whitespace first — so it is not among these.
+ for (const address of [
+ "will's@gmail.com",
+ 'will"s@gmail.com',
+ "will\\s@gmail.com",
+ "will`s@gmail.com",
+ "will$s@gmail.com",
+ "will#s@gmail.com",
+ "will!s@gmail.com",
+ ]) {
+ expect(adminEmailRefusal(address)).toBe(refusal);
+ }
+ });
+
+ it("does not blame characters for an address that is not one yet", () => {
+ // The branch a half-typed address lands on, which is most of the
+ // keystrokes anyone makes here — so the wrong message on it is the one a
+ // user sees most. `foo..com` is the same: a shape Coolify will not
+ // resolve, not a character Dyad cannot send.
+ const undeliverable = adminEmailRefusal("admin@dyad.test");
+
+ expect(adminEmailRefusal("adm")).toBe(undeliverable);
+ expect(adminEmailRefusal("admin@nodomain")).toBe(undeliverable);
+ expect(adminEmailRefusal("admin@foo..com")).toBe(undeliverable);
+ // Reserved for documentation, which is a fact about where mail goes and
+ // not about anything Dyad cannot send.
+ expect(adminEmailRefusal("admin@example.com")).toBe(undeliverable);
+ expect(adminEmailRefusal("admin@mail.example.net")).toBe(undeliverable);
+ });
+
+ it("still says what a domain nobody can reach is", () => {
+ expect(adminEmailRefusal("admin@dyad.test")).toMatch(/receive mail at/);
+ });
+
+ it("says nothing about an address it takes", () => {
+ expect(adminEmailRefusal("me@gmail.com")).toBeNull();
+ });
+});
diff --git a/src/shared/coolify_admin_email.ts b/src/shared/coolify_admin_email.ts
new file mode 100644
index 0000000000..b103b784f8
--- /dev/null
+++ b/src/shared/coolify_admin_email.ts
@@ -0,0 +1,64 @@
+const UNDELIVERABLE =
+ "Use an email address you can receive mail at. Coolify checks that the " +
+ "domain resolves when it creates the admin account.";
+
+const UNSENDABLE =
+ "Dyad can't send an address containing quotes, backslashes or ` $ # ! to " +
+ "the server. Try one without them.";
+
+/**
+ * Why this address cannot be the admin account, in words for the person who
+ * typed it, or null where it can. Not always Coolify's answer: one of the two
+ * rules below is Dyad's own limit, and an address it refuses may be one
+ * Coolify would have taken.
+ *
+ * Lives in shared/ so the panel can warn while the user is still typing and
+ * the handler can refuse before it starts, without the two drifting apart.
+ * Getting the domain wrong is expensive in a way most validation is not:
+ * Coolify checks it when it seeds the account, minutes into an install, and a
+ * rejected address leaves a finished install with no account on it.
+ *
+ * Two different rules refuse an address here and they are not the same news.
+ * One is about where mail goes; the other is about what Dyad can put in a
+ * shell command. Saying the first for both told someone whose address does
+ * work that they could not receive mail at it, which is not true and leaves
+ * them nothing to change.
+ *
+ * Kept free of any Node import — the renderer imports this, and a `crypto`
+ * import here would take the whole window down with it.
+ */
+export function adminEmailRefusal(email: string): string | null {
+ const trimmed = email.trim();
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return UNDELIVERABLE;
+ // The address goes to the server inside a quoted shell word and from there
+ // into a .env file, and buildInstallScript refuses every character that
+ // could break either — so the set is wider than the quoting alone would
+ // need. Said here too, so it is said while the address is being typed
+ // rather than after Dyad has connected and looked the server over.
+ //
+ // Some of these are legal in a local part, so this is Dyad's limit rather
+ // than the address being wrong — and it says so, because telling someone
+ // their working address is undeliverable sends them to fix the wrong thing.
+ if (/['"\\`$\n\r#!]/.test(trimmed)) return UNSENDABLE;
+ // One trailing dot is a legal way to write an absolute name, and Coolify
+ // resolves the same domain either way — so it is removed before the checks
+ // below rather than letting `dyad.test.` past the reserved list.
+ const domain = trimmed
+ .slice(trimmed.lastIndexOf("@") + 1)
+ .toLowerCase()
+ .replace(/\.$/, "");
+ // Every label has to be a label. `foo..com` and `.com` are not addresses
+ // Coolify will accept, and finding that out costs the whole install. Said
+ // as "not empty" rather than as an alphabet, so an internationalised domain
+ // is left alone.
+ if (!/^[^\s.@]+(\.[^\s.@]+)+$/.test(domain)) return UNDELIVERABLE;
+ // Reserved by RFC 2606 and RFC 6761 for testing and documentation, so none
+ // of them resolve and none of them can ever be accepted.
+ const reserved = ["test", "example", "invalid", "localhost", "local"];
+ const lastLabel = domain.slice(domain.lastIndexOf(".") + 1);
+ if (reserved.includes(lastLabel)) return UNDELIVERABLE;
+ const documentation = ["example.com", "example.net", "example.org"];
+ return documentation.some((d) => domain === d || domain.endsWith(`.${d}`))
+ ? UNDELIVERABLE
+ : null;
+}
diff --git a/src/shared/coolify_domain.test.ts b/src/shared/coolify_domain.test.ts
new file mode 100644
index 0000000000..f8a1aff5e9
--- /dev/null
+++ b/src/shared/coolify_domain.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { isPlausibleInstanceDomain } from "./coolify_domain";
+
+/**
+ * Pinned directly, not only through the panel and the script builder.
+ *
+ * What this decides ends up inside a command that runs as root on the user's
+ * server, so the characters it lets through are the point — and a rule with
+ * no test of its own is a rule that can be widened without anyone noticing.
+ */
+describe("what Coolify will be given as a domain", () => {
+ it("takes a name however the user pasted it", () => {
+ for (const written of [
+ "coolify.example.com",
+ "https://coolify.example.com",
+ "http://coolify.example.com",
+ "https://coolify.example.com/",
+ " coolify.example.com ",
+ ]) {
+ expect(isPlausibleInstanceDomain(written)).toBe(true);
+ }
+ });
+
+ it("refuses anything that could end the quoting around it", () => {
+ // Each of these would leave the script saying something other than what
+ // it was built to say. Refused rather than escaped: escaping is a thing
+ // to get subtly wrong once, on someone else's machine, as root.
+ for (const hostile of [
+ "coolify.example.com'",
+ 'coolify.example.com"',
+ "coolify.example.com`id`",
+ "coolify.example.com$(id)",
+ "coolify.example.com;id",
+ "coolify.example.com id",
+ "coolify.example.com\nid",
+ "coolify.example.com\\",
+ "coolify.example.com&&id",
+ "coolify.example.com|id",
+ "coolify.example.com#",
+ "$HOME.example.com",
+ ]) {
+ expect(
+ isPlausibleInstanceDomain(hostile),
+ `${JSON.stringify(hostile)} should be refused`,
+ ).toBe(false);
+ }
+ });
+
+ it("refuses a value with nothing left once the scheme is off", () => {
+ for (const empty of ["", " ", "https://", "https:///"]) {
+ expect(isPlausibleInstanceDomain(empty)).toBe(false);
+ }
+ });
+});
diff --git a/src/shared/coolify_domain.ts b/src/shared/coolify_domain.ts
new file mode 100644
index 0000000000..4e001bdfb8
--- /dev/null
+++ b/src/shared/coolify_domain.ts
@@ -0,0 +1,22 @@
+/**
+ * Whether a domain can be handed to Coolify's own configuration.
+ *
+ * The characters are what makes this narrow rather than the shape: the value
+ * ends up inside a script that runs on the user's server, so anything that
+ * could end the quoting around it is refused rather than escaped.
+ *
+ * Lives in shared/ so the panel can say so while the domain is being typed and
+ * the code that builds the script can refuse the same thing — that refusal is
+ * a guard, and a guard is not a good place to learn you made a typo, since it
+ * fires minutes into an install.
+ *
+ * Free of any Node import, because the renderer imports it.
+ */
+export function isPlausibleInstanceDomain(value: string): boolean {
+ const bare = value
+ .trim()
+ .replace(/^https?:\/\//, "")
+ .replace(/\/+$/, "");
+ if (!bare) return false;
+ return /^[A-Za-z0-9.-]+$/.test(bare);
+}
diff --git a/src/shared/coolify_scopes.test.ts b/src/shared/coolify_scopes.test.ts
new file mode 100644
index 0000000000..365761b5b2
--- /dev/null
+++ b/src/shared/coolify_scopes.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import {
+ COOLIFY_REQUIRED_SCOPES,
+ COOLIFY_SCOPES,
+ COOLIFY_SCOPES_PHP_ARRAY,
+} from "./coolify_scopes";
+
+describe("the scopes Dyad asks for", () => {
+ it("asks for exactly these four", () => {
+ // Written out rather than derived. Every other assertion about scopes in
+ // the repo comes from this array, so only a literal notices the array
+ // itself losing an entry — dropping `write` here would otherwise pass the
+ // whole suite and fail the first deploy.
+ expect(COOLIFY_SCOPES).toEqual([
+ "read",
+ "read:sensitive",
+ "write",
+ "deploy",
+ ]);
+ // The two spellings are written out for the same reason. One is read by a
+ // user ticking boxes; the other is interpolated into a script that mints a
+ // token on their server, where a well-formed but wrong list would be taken
+ // at face value.
+ expect(COOLIFY_REQUIRED_SCOPES).toBe("read, read:sensitive, write, deploy");
+ expect(COOLIFY_SCOPES_PHP_ARRAY).toBe(
+ "['read', 'read:sensitive', 'write', 'deploy']",
+ );
+ });
+
+ it("keeps read:sensitive, which the deployment log is hidden behind", () => {
+ // Coolify's api.sensitive middleware hides a deployment's `logs` and an
+ // application's `private_key_id` without it, and the deploy path reads
+ // both. Dropping it costs the build output on every failed deploy, and
+ // nothing fails — the field simply stops arriving.
+ expect(COOLIFY_SCOPES).toContain("read:sensitive");
+ });
+
+ it("does not ask for root", () => {
+ // Coolify treats root as a bypass of the ability check rather than as a
+ // set of abilities, and no route asks for it.
+ expect(COOLIFY_REQUIRED_SCOPES).not.toContain("root");
+ });
+});
diff --git a/src/shared/coolify_scopes.ts b/src/shared/coolify_scopes.ts
index 1d18b5039c..7c3d9aea02 100644
--- a/src/shared/coolify_scopes.ts
+++ b/src/shared/coolify_scopes.ts
@@ -2,8 +2,26 @@
* Every scope the integration needs, in the order Coolify's token form lists
* them.
*
- * Lives in shared/ so the setup instructions in the renderer and the 403
- * message in the main process cannot drift apart: Coolify fixes a token's
- * scopes when it is created, so a token missing one has to be recreated.
+ * Lives in shared/ so the setup instructions in the renderer, the 403 message
+ * in the main process, and the token Dyad mints for itself cannot drift apart:
+ * Coolify fixes a token's scopes when it is created, so a token missing one
+ * has to be recreated.
+ *
+ * `read:sensitive` earns its place — Coolify hides a deployment's `logs` and an
+ * application's `private_key_id` without it, and the deploy path reads both.
+ * `root` is not here: Coolify treats that as a bypass of the ability check
+ * rather than as a set of abilities, and no route asks for it.
*/
-export const COOLIFY_REQUIRED_SCOPES = "read, read:sensitive, write, deploy";
+export const COOLIFY_SCOPES = [
+ "read",
+ "read:sensitive",
+ "write",
+ "deploy",
+] as const;
+
+export const COOLIFY_REQUIRED_SCOPES = COOLIFY_SCOPES.join(", ");
+
+/** The same list as the PHP array literal the tinker script needs. */
+export const COOLIFY_SCOPES_PHP_ARRAY = `[${COOLIFY_SCOPES.map(
+ (scope) => `'${scope}'`,
+).join(", ")}]`;
diff --git a/src/coolify_deploy/domain_check.test.ts b/src/shared/domain_check.test.ts
similarity index 100%
rename from src/coolify_deploy/domain_check.test.ts
rename to src/shared/domain_check.test.ts
diff --git a/src/coolify_deploy/domain_check.ts b/src/shared/domain_check.ts
similarity index 97%
rename from src/coolify_deploy/domain_check.ts
rename to src/shared/domain_check.ts
index ffb1f0f417..f70ede58cd 100644
--- a/src/coolify_deploy/domain_check.ts
+++ b/src/shared/domain_check.ts
@@ -1,5 +1,13 @@
import { isIP } from "node:net";
+/**
+ * Reading DNS answers, for whoever needs to.
+ *
+ * Lives in shared/ rather than beside the deploy machine: the setup flow asks
+ * the same question about a server's own domain, and one machine reaching into
+ * another's directory is what the boundary check exists to stop.
+ */
+
/** What Coolify reports as the address of the machine it runs on. */
const DOCKER_HOST_ALIAS = "host.docker.internal";
diff --git a/src/shared/ssh_failure.test.ts b/src/shared/ssh_failure.test.ts
new file mode 100644
index 0000000000..1a7e40914c
--- /dev/null
+++ b/src/shared/ssh_failure.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { sshFailureOf } from "./ssh_failure";
+
+describe("reading a failure off an error", () => {
+ it("agrees with the error the client actually throws", async () => {
+ // Matched by name so that asking costs no load-time dependency, which
+ // means the name is a contract between two files that do not import one
+ // another. Renaming it there would quietly stop every check here.
+ const { SshError } = await import("@/ipc/utils/ssh_client");
+ const { DyadErrorKind } = await import("@/errors/dyad_error");
+
+ expect(
+ sshFailureOf(
+ new SshError("unreachable", "nothing answered", DyadErrorKind.External),
+ ),
+ ).toBe("unreachable");
+ expect(
+ sshFailureOf(
+ new SshError("command-timeout", "too slow", DyadErrorKind.External),
+ ),
+ ).toBe("command-timeout");
+ });
+
+ it("says nothing about errors that are not the client's", () => {
+ expect(sshFailureOf(new Error("plain"))).toBeNull();
+ expect(sshFailureOf(null)).toBeNull();
+ expect(sshFailureOf(undefined)).toBeNull();
+ expect(
+ sshFailureOf({ name: "SshError", failure: "unreachable" }),
+ ).toBeNull();
+ });
+
+ it("does not hand back a failure that is not one of ours", () => {
+ // The name is all that got us this far, and anything can carry it.
+ // Answering with an unrecognised string would put a value past a caller
+ // that has covered every case the type admits.
+ const odd = Object.assign(new Error("odd"), {
+ name: "SshError",
+ failure: "made-up",
+ });
+ expect(sshFailureOf(odd)).toBeNull();
+ });
+
+ it("does not answer for one of ours carrying no failure", () => {
+ // A shape that passes the name check but has nothing to read.
+ const odd = Object.assign(new Error("odd"), { name: "SshError" });
+ expect(sshFailureOf(odd)).toBeNull();
+ });
+});
diff --git a/src/shared/ssh_failure.ts b/src/shared/ssh_failure.ts
new file mode 100644
index 0000000000..a7d7f35e01
--- /dev/null
+++ b/src/shared/ssh_failure.ts
@@ -0,0 +1,65 @@
+/**
+ * What went wrong on an SSH attempt, and how to ask about it from anywhere.
+ *
+ * Separate from the client so that asking costs nothing: telemetry is reached
+ * from `main.ts` and from every typed handler, and it only ever needs to know
+ * which failure an error carries. The client itself is a large module whose
+ * one job is to talk to a server, and nothing on the startup path has any use
+ * for that. `connectSsh` loads `ssh2` when it actually connects, which is what
+ * keeps it off the boot; this keeps the question askable without reaching for
+ * the client at all.
+ */
+/**
+ * Every failure the client reports, and the only source of the type.
+ *
+ * One list rather than a union beside a lookup: the accessor below has to
+ * decide whether a value is one of these at runtime, and a second copy of
+ * the members is a copy that can fall behind the first.
+ */
+export const SSH_FAILURES = [
+ "auth-rejected",
+ "host-key-rejected",
+ "unreachable",
+ /** The connection stopped answering: nothing on it will work again. */
+ "timeout",
+ /**
+ * We gave up on one command, having asked it to be quick.
+ *
+ * Distinct from "timeout" because the connection is still good and the
+ * question can be asked again — a caller that polls must be able to tell
+ * "this attempt was slow" from "this link is dead", or one slow answer ends
+ * a wait that had minutes left in it.
+ */
+ "command-timeout",
+ /**
+ * The handshake failed for a reason that is not the key being turned down.
+ *
+ * Key exchange or host-key algorithms with nothing in common, or a
+ * protocol error. Kept apart from "host-key-rejected" because that one
+ * says the server may not be the machine it was — which is alarming, and
+ * wrong for an old or hardened sshd that simply cannot agree on ciphers.
+ */
+ "handshake-failed",
+ /** Nothing here recognised it. */
+ "unknown",
+] as const;
+
+export type SshFailure = (typeof SSH_FAILURES)[number];
+
+/**
+ * The failure an error carries, or null if it is not one of ours.
+ *
+ * Matched on the name rather than with `instanceof` so that asking the
+ * question costs nothing at load time. `SshError` sets its own `name`, and
+ * the failure is one of the values above.
+ */
+export function sshFailureOf(error: unknown): SshFailure | null {
+ if (!(error instanceof Error) || error.name !== "SshError") return null;
+ const { failure } = error as Error & { failure?: unknown };
+ // Checked, not asserted. The name is all that got us here, and anything
+ // may carry it — handing back a value the type says cannot exist would
+ // put it past a caller that has covered every case there is.
+ return SSH_FAILURES.includes(failure as SshFailure)
+ ? (failure as SshFailure)
+ : null;
+}
diff --git a/src/state_machines/boundaries.test.ts b/src/state_machines/boundaries.test.ts
index c401c74c33..f40c1ca1fb 100644
--- a/src/state_machines/boundaries.test.ts
+++ b/src/state_machines/boundaries.test.ts
@@ -20,6 +20,7 @@ const MACHINE_DIRECTORIES = [
"version_preview",
"voice_to_text",
"user_input",
+ "coolify_setup",
] as const;
type MachineDirectory = (typeof MACHINE_DIRECTORIES)[number];
type BoundaryRule =
diff --git a/src/testing/fake_ssh_server.ts b/src/testing/fake_ssh_server.ts
new file mode 100644
index 0000000000..c66a62f99f
--- /dev/null
+++ b/src/testing/fake_ssh_server.ts
@@ -0,0 +1,288 @@
+import ssh2 from "ssh2";
+import type { Connection } from "ssh2";
+
+// Destructured from the default import: ssh2 is CommonJS, and named imports
+// off it work under a bundler but not under plain Node ESM.
+const { Server, utils } = ssh2;
+
+/**
+ * A server Dyad can install Coolify onto, enough of one to drive the flow.
+ *
+ * Started by the spec rather than inside the fake HTTP server, so a test can
+ * read what was asked of it directly instead of through a control endpoint.
+ *
+ * The install path speaks SSH rather than HTTP, so the fake Coolify beside
+ * this one cannot answer it. Dyad sends a small, fixed set of commands, and
+ * this answers them the way a real box would — down to details the parser
+ * depends on, like a tinker transcript echoing the script back with a "> "
+ * prompt before the output arrives.
+ *
+ * Nothing here validates the key it is offered. Whether Dyad's key reaches a
+ * server is the user's own step and there is nothing to check it against; what
+ * matters for a test is that a key is offered at all, which is asserted by
+ * refusing a connection that offers none.
+ */
+
+/**
+ * An ed25519 key ssh2 will accept.
+ *
+ * Its own generator writes the public point without its leading byte when that
+ * byte happens to be zero — one key in 256 — and its own parser then rejects
+ * what it wrote. Unchecked, that is a suite which fails a few runs in a
+ * thousand with a parse error nothing in the diff explains. Retried rather
+ * than replaced with a fixed key, which would mean a private key in a public
+ * repository.
+ */
+export function generateSshKeyPair(): { private: string; public: string } {
+ for (let attempt = 0; attempt < 8; attempt++) {
+ const pair = utils.generateKeyPairSync("ed25519");
+ if (!(utils.parseKey(pair.private) instanceof Error)) return pair;
+ }
+ throw new Error("ssh2 generated 8 unusable ed25519 keys in a row.");
+}
+
+const START = "__DYAD_OUT_START__";
+const END = "__DYAD_OUT_END__";
+
+interface FakeServerBehaviour {
+ /** What the batched probe reports. Defaults to a healthy empty machine. */
+ probe?: string;
+ /** Exit code for install.sh. Non-zero makes the install fail. */
+ installExit?: number;
+ /** Coolify's reported version, which decides the automatic token path. */
+ version?: string;
+ /**
+ * Reports the exit status this long after closing the command's output.
+ *
+ * A real sshd sends EOF when the command's stdout closes and the exit
+ * status when the process is reaped, and those are not the same moment.
+ * Answering both in one batch — which is what this fake does otherwise —
+ * hides every bug that depends on the gap.
+ */
+ exitAfterEofMs?: number;
+}
+
+interface FakeServerState extends FakeServerBehaviour {
+ installed: boolean;
+ /** Every command the flow sent, so a test can say what was asked. */
+ commands: string[];
+ keyOffered: boolean;
+}
+
+/**
+ * A tinker transcript, shaped the way a real one is.
+ *
+ * tinker echoes every line it is fed with a "> " prompt, and the output of a
+ * statement lands on the line after the prompt that produced it — so the
+ * opening marker arrives with a prompt attached.
+ *
+ * The prompt is emitted because a real transcript carries one, not because
+ * the parser demands it: that tolerates the prompt being absent or repeated,
+ * since which of those psysh prints is its own business. A fake that dropped
+ * it would simply stop modelling what a real one sends.
+ */
+function transcript(script: string, output: string): string {
+ const echoed = script
+ .split(/\r?\n/)
+ .map((line) => `> ${line}`)
+ .join("\n");
+ return [
+ "Psy Shell v0.11 (PHP 8.2) by Justin Hileman",
+ echoed,
+ `> ${START}`,
+ output,
+ END,
+ "",
+ ].join("\n");
+}
+
+/**
+ * The environment a `docker exec -e NAME='value'` command carries.
+ *
+ * Modelled because the scripts read their inputs with getenv() rather than
+ * having them interpolated — that is the whole point of sending them that
+ * way — so a fake that ignores it cannot tell a script reading the right
+ * variable from one reading nothing at all.
+ */
+function environmentOf(command: string): Record {
+ const env: Record = {};
+ const pattern = /-e ([A-Z_][A-Z0-9_]*)='([^']*)'/g;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(command)) !== null) {
+ env[match[1]] = match[2];
+ }
+ return env;
+}
+
+/** What the fake answers for one tinker script. */
+function answerTinker(
+ script: string,
+ env: Record,
+ state: FakeServerState,
+): string {
+ if (script.includes("constants.coolify.version")) {
+ return state.version ?? "4.3.2";
+ }
+ if (script.includes("is_api_enabled")) {
+ return "enabled";
+ }
+ if (script.includes("->exists()")) {
+ // Answered about the address the script actually asked about. A script
+ // that read no variable, or the wrong one, gets told there is no account.
+ if (!env.DYAD_ADMIN_EMAIL) return "no";
+ return state.installed ? "yes" : "no";
+ }
+ if (script.includes("createToken")) {
+ if (!env.DYAD_ADMIN_EMAIL) return "no-user";
+ // Sanctum's shape: an id, a pipe, then 40+ alphanumerics. Dyad checks
+ // that before storing it, so a token that merely looks token-ish is
+ // rejected — as a stray warning line should be.
+ return "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95";
+ }
+ if (script.includes("setupDynamicProxyConfiguration")) {
+ return "applied";
+ }
+ return "";
+}
+
+function answer(
+ command: string,
+ stdin: string,
+ state: FakeServerState,
+): {
+ stdout: string;
+ code: number;
+} {
+ state.commands.push(command);
+
+ if (command.includes("MemTotal")) {
+ return {
+ stdout:
+ state.probe ??
+ `mem=1967\ncontainer=${state.installed ? "coolify" : ""}\nbusy=no\n`,
+ code: 0,
+ };
+ }
+ if (command.includes("install.sh")) {
+ const code = state.installExit ?? 0;
+ if (code === 0) state.installed = true;
+ return {
+ stdout: "1/6 Installing Docker...\n6/6 Coolify is up\n",
+ code,
+ };
+ }
+ if (command.includes("db:seed")) {
+ return { stdout: "Seeding: RootUserSeeder\n", code: 0 };
+ }
+ if (command.includes("tinker")) {
+ // The script arrives on stdin, which is the whole reason it does: a
+ // script on a command line has to survive a shell as well as PHP.
+ return {
+ stdout: transcript(
+ stdin,
+ answerTinker(stdin, environmentOf(command), state),
+ ),
+ code: 0,
+ };
+ }
+ return { stdout: "", code: 0 };
+}
+
+export interface FakeSshServer {
+ port: number;
+ state: FakeServerState;
+ close: () => Promise;
+}
+
+export async function startFakeSshServer(): Promise {
+ const state: FakeServerState = {
+ installed: false,
+ commands: [],
+ keyOffered: false,
+ };
+
+ const open = new Set();
+ const server = new Server(
+ // Generated per server: a fixed key checked into the repo would be a
+ // private key in a public repository, however inert.
+ { hostKeys: [generateSshKeyPair().private] },
+ (client: Connection) => {
+ open.add(client);
+ client.on("close", () => open.delete(client));
+ client.on("error", () => open.delete(client));
+ client.on("authentication", (ctx) => {
+ if (ctx.method === "publickey") {
+ state.keyOffered = true;
+ ctx.accept();
+ return;
+ }
+ // Anything else is refused, so a test can prove a key was offered.
+ ctx.reject(["publickey"]);
+ });
+
+ client.on("ready", () => {
+ client.on("session", (accept) => {
+ const session = accept();
+ session.on("exec", (acceptExec, _reject, info) => {
+ const stream = acceptExec();
+ let stdin = "";
+ stream.on("data", (chunk: Buffer) => {
+ stdin += chunk.toString("utf8");
+ });
+ const finish = () => {
+ const { stdout, code } = answer(info.command, stdin, state);
+ stream.write(stdout);
+ if (!state.exitAfterEofMs) {
+ stream.exit(code);
+ stream.end();
+ return;
+ }
+ // Output first, then the status, with a gap — a client that
+ // closes the channel on EOF loses the status, and against a real
+ // sshd would kill a command that was still running. Sent through
+ // the protocol because ssh2's own exit() refuses once the write
+ // side is done, which is not a restriction sshd has. Private
+ // API, written against ssh2 1.17.0: an upgrade that breaks this
+ // is this line rather than the product.
+ stream.eof();
+ setTimeout(() => {
+ const inner = stream as unknown as {
+ _client: {
+ _protocol: { exitStatus(id: number, c: number): void };
+ };
+ outgoing: { id: number };
+ };
+ inner._client._protocol.exitStatus(inner.outgoing.id, code);
+ stream.close();
+ }, state.exitAfterEofMs);
+ };
+ // Answered when stdin closes, which the client always does —
+ // with the script for the commands that take one, and empty for
+ // the rest. Waiting on that rather than on a timer is why this
+ // cannot answer a question it has not finished reading.
+ stream.on("end", finish);
+ });
+ });
+ });
+ },
+ );
+
+ const port = await new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ resolve((server.address() as { port: number }).port);
+ });
+ });
+
+ return {
+ port,
+ state,
+ close: () =>
+ new Promise((resolve) => {
+ // Closed as well as stopped: a server with a connection still open
+ // never finishes closing, and the wait lands in afterEach.
+ for (const client of open) client.end();
+ open.clear();
+ server.close(() => resolve());
+ }),
+ };
+}
diff --git a/src/window_infrastructure/renderer_query_invalidation.test.ts b/src/window_infrastructure/renderer_query_invalidation.test.ts
index 5dcbbf5bd6..476ac696d6 100644
--- a/src/window_infrastructure/renderer_query_invalidation.test.ts
+++ b/src/window_infrastructure/renderer_query_invalidation.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { coolifyContracts } from "@/ipc/types/coolify";
import { supabaseContracts } from "@/ipc/types/supabase";
+import { coolifySetupContracts } from "@/ipc/types/coolify_setup";
import { queryInvalidationScopeKey, type WindowSessionId } from "./types";
import { RendererQueryInvalidationConsumer } from "./renderer_query_invalidation";
@@ -202,6 +203,54 @@ describe("Coolify contracts and the window that acted", () => {
}
});
+ it("lets the installer decide when its own window refreshes", () => {
+ // coolify-setup:run stores a token, which makes every app read as
+ // connected — so the panel that ran the install would be unmounted by its
+ // own invalidation, taking with it the screen that says the server ended
+ // up unencrypted. The panel refreshes coolify itself, when it is ready.
+ const contract = coolifySetupContracts.run as {
+ originHandles?: (input: unknown) => Array<{ family: string }>;
+ invalidates?: (input: unknown) => Array<{ family: string }>;
+ };
+ const claims = (contract.originHandles?.({}) ?? []).map((s) => s.family);
+ const publishes = (contract.invalidates?.({}) ?? []).map((s) => s.family);
+
+ expect(claims).toContain("coolify");
+ // Not apps: nothing repeats that locally, so it must still arrive.
+ expect(claims).not.toContain("apps");
+ expect(publishes).toContain("apps");
+ expect(publishes).toContain("coolify");
+
+ // There is no second publisher to worry about any more: a window that
+ // wants to see a run in progress asks for the snapshot rather than
+ // pressing Install again, so every run is published by the one window
+ // that started it.
+ });
+
+ it("tells other windows when an unencrypted token is kept", () => {
+ // The same write saveToken makes: it is what turns every app connected.
+ // Without it a second window that watched the install finish goes on
+ // offering to set a server up, and pressing Install there is refused for
+ // holding an account it does not know about.
+ const contract = coolifySetupContracts.acceptInsecureToken as {
+ originHandles?: (input: unknown) => Array<{ family: string }>;
+ invalidates?: (input: unknown) => Array<{ family: string }>;
+ };
+ const claims = (contract.originHandles?.(undefined) ?? []).map(
+ (s) => s.family,
+ );
+ const publishes = (contract.invalidates?.(undefined) ?? []).map(
+ (s) => s.family,
+ );
+
+ expect(publishes).toContain("apps");
+ expect(publishes).toContain("coolify");
+ // As with run: the finished screen refreshes coolify on its own way out,
+ // in the order it needs, so it is not handed back mid-write.
+ expect(claims).toContain("coolify");
+ expect(claims).not.toContain("apps");
+ });
+
it("publishes project creation so other windows see the new project", () => {
const { publishes } = handled("createProject", { name: "x" });
expect(publishes).toContain("coolify");
diff --git a/testing/fake-llm-server/coolify.ts b/testing/fake-llm-server/coolify.ts
index ba13b75880..7af1976cf4 100644
--- a/testing/fake-llm-server/coolify.ts
+++ b/testing/fake-llm-server/coolify.ts
@@ -1,3 +1,4 @@
+import { Router } from "express";
import type { Express, Request, Response } from "express";
/**
@@ -76,6 +77,12 @@ export function registerFakeCoolify(app: Express): void {
const base = "/coolify/api/v1";
+ // The same API at the root of the host too: an installed Coolify lives
+ // there, while a pasted URL points at the /coolify mount. Mounted twice
+ // rather than rewriting the URL — a rewrite would move every /api/v1
+ // request on this shared server, including one another fake meant to answer.
+ const api = Router();
+
// Lets a spec choose the shape of the run before it starts. Named fields
// rather than a spread, which would also let a caller replace the Maps.
app.post("/coolify/test/reset", (req, res) => {
@@ -92,31 +99,31 @@ export function registerFakeCoolify(app: Express): void {
res.json([...state.applications.values()]);
});
- app.get(`${base}/servers`, (req, res) => {
+ api.get("/servers", (req, res) => {
if (!authed(req, res)) return;
res.json(state.servers);
});
- app.get(`${base}/projects`, (req, res) => {
+ api.get("/projects", (req, res) => {
if (!authed(req, res)) return;
res.json(state.projects);
});
- app.post(`${base}/projects`, (req, res) => {
+ api.post("/projects", (req, res) => {
if (!authed(req, res)) return;
const project = { uuid: id("prj"), name: String(req.body?.name ?? "") };
state.projects.push(project);
res.json(project);
});
- app.get(`${base}/security/keys`, (req, res) => {
+ api.get("/security/keys", (req, res) => {
if (!authed(req, res)) return;
// The private half is echoed back the way an instance with
// read:sensitive would, so a spec can assert what Dyad uploaded.
res.json(state.keys);
});
- app.post(`${base}/security/keys`, (req, res) => {
+ api.post("/security/keys", (req, res) => {
if (!authed(req, res)) return;
const key: FakeKey = {
uuid: id("key"),
@@ -128,7 +135,7 @@ export function registerFakeCoolify(app: Express): void {
res.json({ uuid: key.uuid });
});
- app.post(`${base}/applications/private-deploy-key`, (req, res) => {
+ api.post("/applications/private-deploy-key", (req, res) => {
if (!authed(req, res)) return;
const uuid = id("app");
// Resolved, not guessed: the pipeline compares this against the key it
@@ -162,7 +169,7 @@ export function registerFakeCoolify(app: Express): void {
res.json({ uuid });
});
- app.get(`${base}/applications/:uuid`, (req, res) => {
+ api.get("/applications/:uuid", (req, res) => {
if (!authed(req, res)) return;
const found = state.applications.get(req.params.uuid);
if (!found) {
@@ -172,7 +179,7 @@ export function registerFakeCoolify(app: Express): void {
res.json(found);
});
- app.patch(`${base}/applications/:uuid`, (req, res) => {
+ api.patch("/applications/:uuid", (req, res) => {
if (!authed(req, res)) return;
const found = state.applications.get(req.params.uuid);
if (!found) {
@@ -183,13 +190,13 @@ export function registerFakeCoolify(app: Express): void {
res.json({ uuid: found.uuid });
});
- app.delete(`${base}/applications/:uuid`, (req, res) => {
+ api.delete("/applications/:uuid", (req, res) => {
if (!authed(req, res)) return;
state.applications.delete(req.params.uuid);
res.json({ ok: true });
});
- app.post(`${base}/applications/:uuid/envs`, (req, res) => {
+ api.post("/applications/:uuid/envs", (req, res) => {
if (!authed(req, res)) return;
const found = state.applications.get(req.params.uuid);
if (!found) {
@@ -201,7 +208,7 @@ export function registerFakeCoolify(app: Express): void {
});
// The client falls back to this when POSTing an existing variable 409s.
- app.patch(`${base}/applications/:uuid/envs`, (req, res) => {
+ api.patch("/applications/:uuid/envs", (req, res) => {
if (!authed(req, res)) return;
const found = state.applications.get(req.params.uuid);
if (!found) {
@@ -212,7 +219,7 @@ export function registerFakeCoolify(app: Express): void {
res.json({ ok: true });
});
- app.post(`${base}/applications/:uuid/start`, (req, res) => {
+ api.post("/applications/:uuid/start", (req, res) => {
if (!authed(req, res)) return;
const deploymentUuid = id("dep");
state.deployments.set(deploymentUuid, {
@@ -222,7 +229,7 @@ export function registerFakeCoolify(app: Express): void {
res.json({ deployment_uuid: deploymentUuid });
});
- app.get(`${base}/deployments/:uuid`, (req, res) => {
+ api.get("/deployments/:uuid", (req, res) => {
if (!authed(req, res)) return;
const deployment = state.deployments.get(req.params.uuid);
if (!deployment) {
@@ -242,4 +249,7 @@ export function registerFakeCoolify(app: Express): void {
: JSON.stringify([{ output: "Build finished" }]),
});
});
+
+ app.use(base, api);
+ app.use("/api/v1", api);
}
diff --git a/vite.main.config.mts b/vite.main.config.mts
index 2b19f32d9a..72725330ab 100644
--- a/vite.main.config.mts
+++ b/vite.main.config.mts
@@ -39,6 +39,7 @@ export default defineConfig(({ forgeConfigSelf }) => ({
"better-sqlite3",
"dyad-keychain-reader",
"node-pty",
+ "ssh2",
"mustardscript",
"pg",
"ws",