mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 05:35:30 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebeb95cefd | |||
| 2d8dfee9f3 | |||
| 3c6a96a30a | |||
| 546686ea35 | |||
| 217ca07842 | |||
| bd8ba128cd | |||
| 72a554de5f | |||
| cda047feb2 | |||
| feccb51287 | |||
| 875baa139c | |||
| e2ab2eb6bc | |||
| bee6918d9a |
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"worktree": {
|
||||
"baseRef": "fresh",
|
||||
"symlinkDirectories": ["node_modules"]
|
||||
"baseRef": "fresh"
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "EnterWorktree",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
|
||||
|
||||
@@ -4,7 +4,23 @@ description: Implement a GitHub issue with reproduction and verification
|
||||
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
|
||||
---
|
||||
|
||||
The issue number is passed as $1. An isolated instance is running at $DOKPLOY_BASE_URL.
|
||||
The issue number is passed as $1.
|
||||
|
||||
## Instance
|
||||
|
||||
No instance is running yet — start your own, isolated to this worktree:
|
||||
|
||||
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
|
||||
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
|
||||
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
|
||||
until it answers (usually ~10-15s).
|
||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||
|
||||
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
|
||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||
to relaunch with `DOKPLOY_BASE_URL` exported first.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -93,4 +93,46 @@ describe("getCreateEnvFileCommand", () => {
|
||||
expect(actual[key], key).toBe(value);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("still interpolates a ${VAR} self-reference in the .env file", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
const serviceEnv = [
|
||||
"APP_URL=https://example.com",
|
||||
'ASSET_URL="${APP_URL}"',
|
||||
].join("\n");
|
||||
|
||||
const command = getCreateEnvFileCommand({
|
||||
appName,
|
||||
composePath: "docker-compose.yml",
|
||||
env: serviceEnv,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||
|
||||
execFileSync("bash", ["-c", command]);
|
||||
|
||||
const composeFile =
|
||||
"services:\n test:\n image: busybox\n environment:\n - ASSET_URL=${ASSET_URL}\n";
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"run",
|
||||
"--rm",
|
||||
"-T",
|
||||
"test",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '%s' \"$ASSET_URL\"",
|
||||
],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(out).toBe("https://example.com");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
+51
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -642,3 +643,53 @@ SPECIAL=café résumé naïve
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForFile (.env file escaping)", () => {
|
||||
it("keeps a well-formed ${VAR} self-reference interpolatable", () => {
|
||||
const serviceEnv = `
|
||||
APP_URL=https://example.com
|
||||
ASSET_URL=\${APP_URL}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
`APP_URL="https://example.com"`,
|
||||
`ASSET_URL="\${APP_URL}"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a ${VAR:-default} self-reference with a modifier interpolatable", () => {
|
||||
const serviceEnv = `ASSET_URL=\${APP_URL:-https://default.com}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`ASSET_URL="\${APP_URL:-https://default.com}"`]);
|
||||
});
|
||||
|
||||
it("escapes a literal dollar sign followed by a word so it isn't interpolated", () => {
|
||||
const serviceEnv = "PASSWORD=pa$word";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PASSWORD="pa\\$word"`]);
|
||||
});
|
||||
|
||||
it("escapes a trailing dollar sign", () => {
|
||||
const serviceEnv = "PRICE=100$";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PRICE="100\\$"`]);
|
||||
});
|
||||
|
||||
it("still escapes double quotes and backslashes", () => {
|
||||
const serviceEnv = String.raw`MESSAGE=say "hi" and \backslash`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
String.raw`MESSAGE="say \"hi\" and \\backslash"`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { docker } from "@dokploy/server/constants";
|
||||
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 120000;
|
||||
const SERVICE_NAME = "dokploy-monitoring";
|
||||
const TEST_IMAGE = "busybox:latest";
|
||||
|
||||
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
|
||||
// the local daemon, so the legacy-container cleanup is exercised for real.
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn().mockResolvedValue({
|
||||
serverId: "test-server",
|
||||
serverType: "deploy",
|
||||
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
|
||||
metricsConfig: {
|
||||
server: {
|
||||
type: "Remote",
|
||||
port: 4500,
|
||||
token: "test-token",
|
||||
urlCallback: "http://localhost/callback",
|
||||
cronJob: "0 0 * * *",
|
||||
retentionDays: 2,
|
||||
refreshRate: 60,
|
||||
thresholds: { cpu: 0, memory: 0 },
|
||||
},
|
||||
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/settings", () => ({
|
||||
getDokployImageTag: vi.fn(() => "latest"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/docker/utils", () => ({
|
||||
pullImage: vi.fn().mockResolvedValue(undefined),
|
||||
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
}));
|
||||
|
||||
const containerExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getContainer(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const serviceExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getService(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const swarmTaskNames = async () => {
|
||||
const list = await docker.listContainers({ all: true });
|
||||
return list
|
||||
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
|
||||
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
|
||||
};
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await docker.getService(SERVICE_NAME).remove();
|
||||
} catch {}
|
||||
try {
|
||||
await docker.getContainer(SERVICE_NAME).remove({ force: true });
|
||||
} catch {}
|
||||
for (const name of await swarmTaskNames()) {
|
||||
try {
|
||||
await docker.getContainer(name).remove({ force: true });
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
|
||||
const createLegacyZombie = async () => {
|
||||
const container = await docker.createContainer({
|
||||
name: SERVICE_NAME,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: [
|
||||
"sh",
|
||||
"-c",
|
||||
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
|
||||
],
|
||||
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
|
||||
});
|
||||
await container.start();
|
||||
};
|
||||
|
||||
const pullTestImage = async () => {
|
||||
try {
|
||||
await docker.getImage(TEST_IMAGE).inspect();
|
||||
return;
|
||||
} catch {}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
|
||||
err
|
||||
? reject(err)
|
||||
: docker.modem.followProgress(stream, (e: any) =>
|
||||
e ? reject(e) : resolve(),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// The code under test hardcodes the production name, so this suite cannot
|
||||
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
|
||||
const hasRealMonitoring = () => {
|
||||
const query = (cmd: string) => {
|
||||
try {
|
||||
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
query(
|
||||
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
|
||||
) !== "" ||
|
||||
query(
|
||||
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
|
||||
) !== ""
|
||||
);
|
||||
};
|
||||
|
||||
describe.skipIf(hasRealMonitoring())(
|
||||
"setupMonitoring - legacy container cleanup (real docker)",
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
await pullTestImage();
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
it(
|
||||
"removes the legacy standalone container left behind by the swarm migration",
|
||||
async () => {
|
||||
await createLegacyZombie();
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(true);
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
|
||||
async () => {
|
||||
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
|
||||
const task = await docker.createContainer({
|
||||
name: taskName,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: ["sleep", "3600"],
|
||||
});
|
||||
await task.start();
|
||||
await createLegacyZombie();
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await containerExists(taskName)).toBe(true);
|
||||
const inspect = await docker.getContainer(taskName).inspect();
|
||||
expect(inspect.State.Running).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"is idempotent when no legacy container exists",
|
||||
async () => {
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"deploys the service even when removing the legacy container fails",
|
||||
async () => {
|
||||
const failingDocker = {
|
||||
getContainer: () => ({
|
||||
remove: async () => {
|
||||
const error: any = new Error("device or resource busy");
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
getService: docker.getService.bind(docker),
|
||||
createService: docker.createService.bind(docker),
|
||||
};
|
||||
|
||||
const remoteDocker = await import(
|
||||
"@dokploy/server/utils/servers/remote-docker"
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(remoteDocker, "getRemoteDocker")
|
||||
.mockResolvedValue(failingDocker as any);
|
||||
|
||||
try {
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,31 @@ const getMonitoringImage = () => {
|
||||
return imageName;
|
||||
};
|
||||
|
||||
// Swarm tasks are dokploy-monitoring.<slot>.<id>, so this only matches the
|
||||
// pre-v0.30.0 standalone container. A cleanup failure must not block the deploy.
|
||||
const removeLegacyContainer = async (
|
||||
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
|
||||
serviceName: string,
|
||||
) => {
|
||||
try {
|
||||
await docker.getContainer(serviceName).remove({ force: true });
|
||||
console.log("Removed legacy monitoring container ✅");
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 404) {
|
||||
console.warn(
|
||||
`Could not remove legacy monitoring container: ${error?.message ?? error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deployMonitoringService = async (
|
||||
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
|
||||
serviceName: string,
|
||||
settings: CreateServiceOptions,
|
||||
) => {
|
||||
await removeLegacyContainer(docker, serviceName);
|
||||
|
||||
try {
|
||||
const service = docker.getService(serviceName);
|
||||
const inspect = await service.inspect();
|
||||
|
||||
@@ -86,6 +86,7 @@ export const serverSetup = async (
|
||||
...server.metricsConfig.server,
|
||||
token: token,
|
||||
urlCallback: urlCallback,
|
||||
cronJob: server.metricsConfig.server.cronJob || "0 0 * * *",
|
||||
},
|
||||
containers: server.metricsConfig.containers,
|
||||
},
|
||||
|
||||
@@ -548,7 +548,7 @@ export const prepareEnvironmentVariablesForFile = (
|
||||
const escapedValue = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, "\\$");
|
||||
.replace(/\$(?!\{[A-Za-z_][A-Za-z0-9_]*(?::?[-+?][^}]*)?\})/g, "\\$");
|
||||
return `${key}="${escapedValue}"`;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export const readValidDirectory = (
|
||||
directory: string,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
if (!/^[\w/. :[\]-]{1,500}$/.test(directory)) {
|
||||
if (!/^[\w/. :[\]+@~(),=%-]{1,500}$/.test(directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ async function findFreePort(start) {
|
||||
return port;
|
||||
}
|
||||
|
||||
const start = Number.parseInt(process.argv[2] || process.env.PORT || "3000", 10);
|
||||
const start = Number.parseInt(
|
||||
process.argv[2] || process.env.PORT || "3000",
|
||||
10,
|
||||
);
|
||||
const port = await findFreePort(start);
|
||||
process.stdout.write(String(port));
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PAYLOAD=$(cat)
|
||||
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
|
||||
|
||||
if [ -z "$WORKTREE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Turbopack refuses to resolve through anything outside its detected
|
||||
# workspace root, so a symlinked node_modules (whole dir or per-entry)
|
||||
# doesn't work for apps/dokploy. A real `pnpm install` is required, but
|
||||
# since pnpm's global content-addressable store is already warm, this
|
||||
# only links locally — no network fetch, a few seconds.
|
||||
cd "$WORKTREE_PATH"
|
||||
pnpm install --prefer-offline
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:?usage: spawn-agent-worktree.sh <name>}"
|
||||
# --show-toplevel would return the CURRENT worktree's own root if this is
|
||||
# run from inside one (e.g. another agent's worktree) instead of the main
|
||||
# checkout. --git-common-dir always points at the shared .git regardless of
|
||||
# which worktree you're standing in.
|
||||
REPO_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
|
||||
WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$NAME"
|
||||
BRANCH="worktree-$NAME"
|
||||
|
||||
if [ -e "$WORKTREE_PATH" ]; then
|
||||
echo "Worktree already exists: $WORKTREE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$REPO_ROOT" fetch origin canary --quiet
|
||||
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WORKTREE_PATH" origin/canary >&2
|
||||
|
||||
# .worktreeinclude lists gitignored files (.env, .env.local) that a plain
|
||||
# `git worktree add` won't check out on its own - copy them in.
|
||||
while IFS= read -r pattern; do
|
||||
[ -z "$pattern" ] && continue
|
||||
find "$REPO_ROOT" \( -path "$REPO_ROOT/.claude/worktrees" -o -path "$REPO_ROOT/node_modules" \) -prune -o -name "$pattern" -print 2>/dev/null
|
||||
done < "$REPO_ROOT/.worktreeinclude" | while read -r src; do
|
||||
rel="${src#"$REPO_ROOT"/}"
|
||||
dest="$WORKTREE_PATH/$rel"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
|
||||
cd "$WORKTREE_PATH"
|
||||
pnpm install --prefer-offline >&2
|
||||
|
||||
FREE_PORT=$(node "$REPO_ROOT/scripts/find-free-port.mjs")
|
||||
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" apps/dokploy/.env
|
||||
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" apps/dokploy/.env
|
||||
rm -f apps/dokploy/.env.bak
|
||||
|
||||
pnpm --filter=dokploy run dev > "$WORKTREE_PATH/dev-server.log" 2>&1 &
|
||||
echo $! > "$WORKTREE_PATH/dev-server.pid"
|
||||
|
||||
BASE_URL="http://localhost:$FREE_PORT"
|
||||
for _ in $(seq 1 30); do
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$BASE_URL/" || true)
|
||||
if [ "$CODE" != "000" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "export WORKTREE_PATH=$WORKTREE_PATH"
|
||||
echo "export DOKPLOY_BASE_URL=$BASE_URL"
|
||||
echo "export PORT=$FREE_PORT"
|
||||
Reference in New Issue
Block a user