Compare commits
11
Commits
2f53b1901b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9de6ba62c | ||
|
|
060b181e71 | ||
|
|
69287ebec0 | ||
|
|
b61ff21710 | ||
|
|
69b2481299 | ||
|
|
9801b9475b | ||
|
|
02f55b4543 | ||
|
|
df2315087d | ||
|
|
97f31315bd | ||
|
|
a037b882e9 | ||
|
|
9733ba6e3c |
+8
-1
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
# API
|
# API
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DATABASE_URL=postgres://wursor:wursor@localhost:5432/wursor
|
# Set DATABASE_URL only when Postgres is running (see infrastructure/DEVOPS.md).
|
||||||
|
# Leave empty for the in-memory dev store.
|
||||||
|
DATABASE_URL=
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -18,4 +20,9 @@ LLM_FALLBACK_PROVIDER=
|
|||||||
|
|
||||||
# Sandbox
|
# Sandbox
|
||||||
DOCKER_HOST=
|
DOCKER_HOST=
|
||||||
|
# Set to 1 to enable sandbox spin-up via Docker (leave unset to return 503 on /sessions).
|
||||||
|
WUR_ENABLE_SANDBOX=
|
||||||
|
WUR_IMAGE=wursor-base:latest
|
||||||
|
WUR_WEB_PORT=8080
|
||||||
|
PREVIEW_BASE_URL=http://localhost:8080
|
||||||
WARM_POOL_HOT_SPARES=2
|
WARM_POOL_HOT_SPARES=2
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { runAgent } from '../../src/agents/agent-loop.ts';
|
||||||
|
import type { LlmClient, LlmResponse } from '../../src/agents/llm-client.ts';
|
||||||
|
import type { ToolExecutor } from '../../src/agents/tool-executor.ts';
|
||||||
|
|
||||||
|
class ScriptedLlm implements LlmClient {
|
||||||
|
private i = 0;
|
||||||
|
|
||||||
|
constructor(private readonly responses: LlmResponse[]) {}
|
||||||
|
|
||||||
|
async complete(): Promise<LlmResponse> {
|
||||||
|
const response = this.responses[Math.min(this.i, this.responses.length - 1)];
|
||||||
|
this.i += 1;
|
||||||
|
return response as LlmResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingExecutor implements ToolExecutor {
|
||||||
|
calls: Array<{ name: string; args: Record<string, string> }> = [];
|
||||||
|
|
||||||
|
async execute(name: string, args: Record<string, string>) {
|
||||||
|
this.calls.push({ name, args });
|
||||||
|
return { result: `ok:${name}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCall(id: string, name: string, args: Record<string, string>) {
|
||||||
|
return { id, type: 'function', function: { name, arguments: JSON.stringify(args) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function callResp(calls: ReturnType<typeof toolCall>[]): LlmResponse {
|
||||||
|
return { choices: [{ message: { role: 'assistant', content: null, tool_calls: calls } }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneResp(content: string): LlmResponse {
|
||||||
|
return { choices: [{ message: { role: 'assistant', content, tool_calls: undefined } }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runAgent', () => {
|
||||||
|
it('executes multiple tool calls across rounds until the agent finishes', async () => {
|
||||||
|
const llm = new ScriptedLlm([
|
||||||
|
callResp([toolCall('1', 'read_page', { page: 'home' })]),
|
||||||
|
callResp([toolCall('2', 'update_post', { page: 'home', title: 'New' })]),
|
||||||
|
doneResp('Done'),
|
||||||
|
]);
|
||||||
|
const executor = new RecordingExecutor();
|
||||||
|
|
||||||
|
const result = await runAgent(llm, executor, { systemPrompt: 's', userPrompt: 'u', maxRounds: 5 });
|
||||||
|
|
||||||
|
expect(executor.calls).toEqual([
|
||||||
|
{ name: 'read_page', args: { page: 'home' } },
|
||||||
|
{ name: 'update_post', args: { page: 'home', title: 'New' } },
|
||||||
|
]);
|
||||||
|
expect(result.toolCalls).toBe(2);
|
||||||
|
expect(result.finalContent).toBe('Done');
|
||||||
|
expect(result.halted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feeds tool results back to the model as tool messages', async () => {
|
||||||
|
const llm = new ScriptedLlm([
|
||||||
|
callResp([toolCall('1', 'read_page', { page: 'home' })]),
|
||||||
|
doneResp('final'),
|
||||||
|
]);
|
||||||
|
const executor = new RecordingExecutor();
|
||||||
|
|
||||||
|
await runAgent(llm, executor, { systemPrompt: 's', userPrompt: 'u', maxRounds: 5 });
|
||||||
|
expect(executor.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('halts when the round budget is exhausted', async () => {
|
||||||
|
const llm = new ScriptedLlm([callResp([toolCall('1', 'read_page', { page: 'home' })])]);
|
||||||
|
const executor = new RecordingExecutor();
|
||||||
|
|
||||||
|
const result = await runAgent(llm, executor, { systemPrompt: 's', userPrompt: 'u', maxRounds: 1 });
|
||||||
|
expect(result.halted).toBe(true);
|
||||||
|
expect(result.toolCalls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CircuitBreaker } from '../../src/agents/circuit-breaker.ts';
|
||||||
|
|
||||||
|
describe('CircuitBreaker', () => {
|
||||||
|
it('halts after two consecutive failures', () => {
|
||||||
|
const breaker = new CircuitBreaker({ maxConsecutiveFailures: 2 });
|
||||||
|
breaker.recordFailure();
|
||||||
|
breaker.recordFailure();
|
||||||
|
expect(breaker.shouldHalt()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not halt after a single failure', () => {
|
||||||
|
const breaker = new CircuitBreaker({ maxConsecutiveFailures: 2 });
|
||||||
|
breaker.recordFailure();
|
||||||
|
expect(breaker.shouldHalt()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the failure count after a success', () => {
|
||||||
|
const breaker = new CircuitBreaker({ maxConsecutiveFailures: 2 });
|
||||||
|
breaker.recordFailure();
|
||||||
|
breaker.recordSuccess();
|
||||||
|
breaker.recordFailure();
|
||||||
|
expect(breaker.shouldHalt()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ALLOWED_TOOL_NAMES, generateToolSchemas } from '../../src/agents/tool-schemas.ts';
|
||||||
|
|
||||||
|
describe('generateToolSchemas', () => {
|
||||||
|
it('returns a non-empty tool set', () => {
|
||||||
|
expect(generateToolSchemas().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only exposes allowlisted semantic tools', () => {
|
||||||
|
const names = generateToolSchemas().map((s) => s.function.name);
|
||||||
|
expect(names.length).toBeGreaterThan(0);
|
||||||
|
for (const name of names) {
|
||||||
|
expect(ALLOWED_TOOL_NAMES).toContain(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes no eval, config, raw SQL, rm, or arbitrary plugin install surface', () => {
|
||||||
|
const blob = generateToolSchemas()
|
||||||
|
.map((s) => JSON.stringify(s))
|
||||||
|
.join(' ');
|
||||||
|
expect(blob).not.toMatch(/wp eval|wp config|DROP TABLE|DELETE FROM|\brm\b|plugin install http/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildApp } from '../src/app.ts';
|
||||||
|
|
||||||
|
describe('POST /auth/signup', () => {
|
||||||
|
it('creates a user and returns a session token', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/signup',
|
||||||
|
payload: { email: 'a@example.com', password: 'password123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.user.email).toBe('a@example.com');
|
||||||
|
expect(body.user.id).toBeTruthy();
|
||||||
|
expect(body.sessionToken).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid email with 400', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/signup',
|
||||||
|
payload: { email: 'not-an-email', password: 'password123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a short password with 400', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/signup',
|
||||||
|
payload: { email: 'a@example.com', password: 'short' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate email with 409', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const payload = { email: 'a@example.com', password: 'password123' };
|
||||||
|
await app.inject({ method: 'POST', url: '/auth/signup', payload });
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/auth/signup', payload });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return the password hash', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/signup',
|
||||||
|
payload: { email: 'a@example.com', password: 'password123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.json().user.passwordHash).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateHmacSecret, generatePairingCode, generateToken, isHttpsUrl } from '../../src/lib/codes.ts';
|
||||||
|
|
||||||
|
describe('generatePairingCode', () => {
|
||||||
|
it('returns an 8-char A-Z0-9 code', () => {
|
||||||
|
expect(generatePairingCode()).toMatch(/^[A-Z0-9]{8}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns distinct codes across calls', () => {
|
||||||
|
const seen = new Set(Array.from({ length: 50 }, () => generatePairingCode()));
|
||||||
|
expect(seen.size).toBeGreaterThan(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateToken / generateHmacSecret', () => {
|
||||||
|
it('returns a 32-byte base64url token', () => {
|
||||||
|
const token = generateToken();
|
||||||
|
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
expect(token).not.toEqual(generateToken());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a distinct hmac secret', () => {
|
||||||
|
expect(generateHmacSecret()).not.toEqual(generateHmacSecret());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isHttpsUrl', () => {
|
||||||
|
it('accepts https URLs', () => {
|
||||||
|
expect(isHttpsUrl('https://example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects http and malformed URLs', () => {
|
||||||
|
expect(isHttpsUrl('http://example.com')).toBe(false);
|
||||||
|
expect(isHttpsUrl('not-a-url')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { buildApp } from '../../src/app.ts';
|
||||||
|
import type { LlmClient, LlmResponse } from '../../src/agents/llm-client.ts';
|
||||||
|
import type { ToolExecutor } from '../../src/agents/tool-executor.ts';
|
||||||
|
|
||||||
|
class FakeLlm implements LlmClient {
|
||||||
|
async complete(): Promise<LlmResponse> {
|
||||||
|
return { choices: [{ message: { role: 'assistant', content: 'Done', tool_calls: undefined } }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeExecutor implements ToolExecutor {
|
||||||
|
async execute() {
|
||||||
|
return { result: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signup(app: FastifyInstance): Promise<string> {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/signup',
|
||||||
|
payload: { email: `a-${Date.now()}@example.com`, password: 'password123' },
|
||||||
|
});
|
||||||
|
return res.json().sessionToken as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /chat', () => {
|
||||||
|
it('requires a session', async () => {
|
||||||
|
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/chat', payload: { message: 'hi' } });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the agent and returns the reply', async () => {
|
||||||
|
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
|
||||||
|
const token = await signup(app);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/chat',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { message: 'Change the heading' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().reply).toBe('Done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when the agent is not configured', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const token = await signup(app);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/chat',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { message: 'hi' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for an empty message', async () => {
|
||||||
|
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
|
||||||
|
const token = await signup(app);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/chat',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { message: ' ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildApp } from '../../src/app.ts';
|
||||||
|
import { SandboxManager } from '../../src/services/sandbox-manager.ts';
|
||||||
|
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
class FakeDocker implements DockerClient {
|
||||||
|
async createSandbox(): Promise<SandboxInfo> {
|
||||||
|
return { id: 'sb-1', status: 'running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySandbox(): Promise<void> {}
|
||||||
|
|
||||||
|
async status(): Promise<SandboxInfo | undefined> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /sessions', () => {
|
||||||
|
it('spins up a sandbox and returns a preview URL', async () => {
|
||||||
|
const manager = new SandboxManager(new FakeDocker(), {
|
||||||
|
image: 'wursor-base:latest',
|
||||||
|
previewBaseUrl: 'https://preview.wursor.dev',
|
||||||
|
});
|
||||||
|
const app = await buildApp({ sandboxManager: manager });
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/sessions' });
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.sandboxId).toBe('sb-1');
|
||||||
|
expect(body.previewUrl).toBe('https://preview.wursor.dev/sb-1');
|
||||||
|
expect(body.sessionId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when no sandbox manager is configured', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/sessions' });
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
expect(res.json().error).toBe('sandbox_not_configured');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { buildApp } from '../../src/app.ts';
|
||||||
|
|
||||||
|
async function signup(app: FastifyInstance): Promise<string> {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/signup',
|
||||||
|
payload: { email: `a-${Date.now()}@example.com`, password: 'password123' },
|
||||||
|
});
|
||||||
|
return res.json().sessionToken as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sites pairing flow', () => {
|
||||||
|
it('requires a session to pair', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/sites/pair' });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redeems a code once, binds site_url, and returns tokens', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const token = await signup(app);
|
||||||
|
|
||||||
|
const pair = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/sites/pair',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(pair.statusCode).toBe(201);
|
||||||
|
const { code } = pair.json();
|
||||||
|
|
||||||
|
const redeem = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/sites/redeem',
|
||||||
|
payload: { code, siteUrl: 'https://example.com' },
|
||||||
|
});
|
||||||
|
expect(redeem.statusCode).toBe(201);
|
||||||
|
const body = redeem.json();
|
||||||
|
expect(body.siteId).toBeTruthy();
|
||||||
|
expect(body.readToken).toBeTruthy();
|
||||||
|
expect(body.deployToken).toBeTruthy();
|
||||||
|
expect(body.hmacSecret).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/sites/redeem',
|
||||||
|
payload: { code, siteUrl: 'https://example.com' },
|
||||||
|
});
|
||||||
|
expect(second.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark the site connected until confirmed', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const token = await signup(app);
|
||||||
|
|
||||||
|
const pair = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/sites/pair',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const { code } = pair.json();
|
||||||
|
|
||||||
|
const redeem = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/sites/redeem',
|
||||||
|
payload: { code, siteUrl: 'https://example.com' },
|
||||||
|
});
|
||||||
|
const { siteId } = redeem.json();
|
||||||
|
|
||||||
|
const before = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/sites/${siteId}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(before.json().connected).toBe(false);
|
||||||
|
|
||||||
|
const confirm = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/sites/${siteId}/confirm`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(confirm.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const after = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/sites/${siteId}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(after.json().connected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DockerodeClient, type ContainerLike, type DockerEngine } from '../../src/sandbox/dockerode-client.ts';
|
||||||
|
|
||||||
|
class FakeEngine implements DockerEngine {
|
||||||
|
createdImages: string[] = [];
|
||||||
|
names: string[] = [];
|
||||||
|
removed: string[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly opts: { running?: boolean; failInspect?: boolean } = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private container(): ContainerLike {
|
||||||
|
return {
|
||||||
|
start: async () => {},
|
||||||
|
inspect: async () => {
|
||||||
|
if (this.opts.failInspect) {
|
||||||
|
throw new Error('not found');
|
||||||
|
}
|
||||||
|
return { Id: 'id-1', State: { Running: this.opts.running ?? true } };
|
||||||
|
},
|
||||||
|
remove: async () => {
|
||||||
|
this.removed.push('id-1');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContainer(spec: { Image: string; name: string }): Promise<ContainerLike> {
|
||||||
|
this.createdImages.push(spec.Image);
|
||||||
|
this.names.push(spec.name);
|
||||||
|
return this.container();
|
||||||
|
}
|
||||||
|
|
||||||
|
getContainer(_id: string): ContainerLike {
|
||||||
|
return this.container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DockerodeClient', () => {
|
||||||
|
it('spins up a sandbox container from an image and reports it running', async () => {
|
||||||
|
const engine = new FakeEngine();
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
const info = await client.createSandbox('wursor-base:latest');
|
||||||
|
expect(info).toEqual({ id: 'id-1', status: 'running' });
|
||||||
|
expect(engine.createdImages).toEqual(['wursor-base:latest']);
|
||||||
|
expect(engine.names[0]?.startsWith('wursor-')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports destroyed when the container is not running after start', async () => {
|
||||||
|
const engine = new FakeEngine({ running: false });
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
expect(await client.createSandbox('wursor-base:latest')).toEqual({ id: 'id-1', status: 'destroyed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys a sandbox container', async () => {
|
||||||
|
const engine = new FakeEngine();
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
await client.destroySandbox('id-1');
|
||||||
|
expect(engine.removed).toEqual(['id-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined status when the container is gone', async () => {
|
||||||
|
const engine = new FakeEngine({ failInspect: true });
|
||||||
|
const client = new DockerodeClient(engine);
|
||||||
|
|
||||||
|
expect(await client.status('id-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { decideGc } from '../../src/sandbox/gc.ts';
|
||||||
|
|
||||||
|
const opts = { idleMs: 15 * 60 * 1000, hardMs: 24 * 60 * 60 * 1000 };
|
||||||
|
const now = 1_000_000;
|
||||||
|
|
||||||
|
describe('decideGc', () => {
|
||||||
|
it('pauses a running sandbox after the idle threshold', () => {
|
||||||
|
expect(decideGc({ status: 'running', lastActiveAt: now - opts.idleMs - 1, createdAt: 0 }, now, opts)).toBe('pause');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an active running sandbox', () => {
|
||||||
|
expect(decideGc({ status: 'running', lastActiveAt: now - 1000, createdAt: 0 }, now, opts)).toBe('keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys any sandbox past the hard timeout regardless of activity', () => {
|
||||||
|
expect(decideGc({ status: 'running', lastActiveAt: now - 1000, createdAt: now - opts.hardMs - 1 }, now, opts)).toBe(
|
||||||
|
'destroy',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-pause an already paused sandbox', () => {
|
||||||
|
expect(decideGc({ status: 'paused', lastActiveAt: now - opts.idleMs - 1, createdAt: 0 }, now, opts)).toBe('keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a destroyed sandbox', () => {
|
||||||
|
expect(decideGc({ status: 'destroyed', lastActiveAt: 0, createdAt: 0 }, now, opts)).toBe('keep');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ImageManager } from '../../src/sandbox/image-manager.ts';
|
||||||
|
|
||||||
|
describe('ImageManager', () => {
|
||||||
|
const manager = new ImageManager({ baseImage: 'wursor-base:latest', webPort: 8080 });
|
||||||
|
|
||||||
|
it('returns the pre-baked image ref', () => {
|
||||||
|
expect(manager.imageRef()).toBe('wursor-base:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a container spec with the web port and management label', () => {
|
||||||
|
expect(manager.containerSpec('wursor-abc')).toEqual({
|
||||||
|
image: 'wursor-base:latest',
|
||||||
|
name: 'wursor-abc',
|
||||||
|
ports: [{ container: 80, host: 8080 }],
|
||||||
|
labels: { 'wursor.managed': 'true' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { diffManifest } from '../../src/sandbox/manifest.ts';
|
||||||
|
|
||||||
|
describe('diffManifest', () => {
|
||||||
|
it('detects added, changed, and removed paths', () => {
|
||||||
|
const before = {
|
||||||
|
'/theme/style.css': 'a1',
|
||||||
|
'/theme/theme.json': 'b2',
|
||||||
|
'/theme/old.css': 'c3',
|
||||||
|
};
|
||||||
|
const after = {
|
||||||
|
'/theme/style.css': 'a1',
|
||||||
|
'/theme/theme.json': 'b2-new',
|
||||||
|
'/theme/new.css': 'd4',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(diffManifest(before, after)).toEqual({
|
||||||
|
added: ['/theme/new.css'],
|
||||||
|
changed: ['/theme/theme.json'],
|
||||||
|
removed: ['/theme/old.css'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports empty arrays when nothing changed', () => {
|
||||||
|
const manifest = { '/a.css': 'x' };
|
||||||
|
expect(diffManifest(manifest, { ...manifest })).toEqual({ added: [], changed: [], removed: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mediaProxyTarget, resolveMediaPath, stageReplacement } from '../../src/sandbox/media-proxy.ts';
|
||||||
|
|
||||||
|
describe('media proxy', () => {
|
||||||
|
it('proxies an upload to the origin', () => {
|
||||||
|
expect(mediaProxyTarget('https://example.com', '/wp-content/uploads/2024/hero.jpg')).toBe(
|
||||||
|
'https://example.com/wp-content/uploads/2024/hero.jpg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a staged replacement locally instead of proxying', () => {
|
||||||
|
const staged = new Set(['/wp-content/uploads/2024/hero.jpg']);
|
||||||
|
expect(resolveMediaPath('https://example.com', '/wp-content/uploads/2024/hero.jpg', staged)).toBe(
|
||||||
|
'/wp-content/uploads/2024/hero.jpg',
|
||||||
|
);
|
||||||
|
expect(resolveMediaPath('https://example.com', '/wp-content/uploads/2024/other.jpg', staged)).toBe(
|
||||||
|
'https://example.com/wp-content/uploads/2024/other.jpg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a file only when it is replaced', () => {
|
||||||
|
expect(stageReplacement('https://example.com', '/wp-content/uploads/2024/hero.jpg', 12)).toEqual({
|
||||||
|
copiedPaths: ['/wp-content/uploads/2024/hero.jpg'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { exportDbSubset } from '../../src/sandbox/subset.ts';
|
||||||
|
import type { SiteExport } from '../../src/sandbox/types.ts';
|
||||||
|
|
||||||
|
const dump = (): SiteExport => ({
|
||||||
|
origin: 'https://example.com',
|
||||||
|
tables: {
|
||||||
|
wp_posts: [{ ID: 1, post_title: 'Home' }],
|
||||||
|
wp_postmeta: [{ post_id: 1, meta_key: '_edit_lock', meta_value: '1' }],
|
||||||
|
wp_options: [
|
||||||
|
{ option_name: 'blogname', option_value: 'Biz' },
|
||||||
|
{ option_name: 'woocommerce_stripe_secret_key', option_value: 'sk_live_xxx' },
|
||||||
|
{ option_name: 'smtp_pass', option_value: 'secret' },
|
||||||
|
],
|
||||||
|
wp_terms: [{ term_id: 1, name: 'Menu' }],
|
||||||
|
wp_term_taxonomy: [{ term_taxonomy_id: 1 }],
|
||||||
|
wp_term_relationships: [{ object_id: 1 }],
|
||||||
|
wp_wc_orders: [{ id: 99, total: '40.00' }],
|
||||||
|
wp_comments: [{ comment_ID: 1, comment_content: 'hi' }],
|
||||||
|
},
|
||||||
|
uploads: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportDbSubset', () => {
|
||||||
|
it('keeps posts, postmeta, and options for a content playbook', () => {
|
||||||
|
expect(exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).tables).toEqual(
|
||||||
|
expect.arrayContaining(['wp_posts', 'wp_postmeta', 'wp_options']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops Woo orders and comments from a content-edit slice', () => {
|
||||||
|
const tables = exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).tables;
|
||||||
|
expect(tables).not.toContain('wp_wc_orders');
|
||||||
|
expect(tables).not.toContain('wp_comments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds taxonomy tables for a design playbook', () => {
|
||||||
|
const tables = exportDbSubset(dump(), { playbook: 'design', postIds: [1] }).tables;
|
||||||
|
expect(tables).toEqual(expect.arrayContaining(['wp_terms', 'wp_term_taxonomy', 'wp_term_relationships']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits a plugin playbook to options only', () => {
|
||||||
|
expect(exportDbSubset(dump(), { playbook: 'plugin', postIds: [1] }).tables).toEqual(['wp_options']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts option names ending in _key, _secret, or smtp_pass', () => {
|
||||||
|
const options = exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).options;
|
||||||
|
expect(options).toContain('blogname');
|
||||||
|
expect(options.some((name) => /(_key|_secret|smtp_pass)$/.test(name))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PairingService } from '../../src/services/pairing-service.ts';
|
||||||
|
|
||||||
|
describe('PairingService', () => {
|
||||||
|
it('issues an 8-char code with a 5-minute expiry', () => {
|
||||||
|
const svc = new PairingService({ now: () => 1_000_000 });
|
||||||
|
const { code, expiresAt } = svc.issue('acct-1');
|
||||||
|
expect(code).toMatch(/^[A-Z0-9]{8}$/);
|
||||||
|
expect(expiresAt).toBe(1_000_000 + 5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redeems a valid code once and returns distinct scoped tokens', () => {
|
||||||
|
const svc = new PairingService({ now: () => 1_000_000 });
|
||||||
|
const { code } = svc.issue('acct-1');
|
||||||
|
const result = svc.redeem(code, 'https://example.com');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.accountId).toBe('acct-1');
|
||||||
|
expect(result.readToken).toBeTruthy();
|
||||||
|
expect(result.deployToken).toBeTruthy();
|
||||||
|
expect(result.hmacSecret).toBeTruthy();
|
||||||
|
expect(result.readToken).not.toBe(result.deployToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a second redeem of the same code', () => {
|
||||||
|
const svc = new PairingService({ now: () => 1_000_000 });
|
||||||
|
const { code } = svc.issue('acct-1');
|
||||||
|
svc.redeem(code, 'https://example.com');
|
||||||
|
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'consumed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires a code after five minutes', () => {
|
||||||
|
let t = 1_000_000;
|
||||||
|
const svc = new PairingService({ now: () => t });
|
||||||
|
const { code } = svc.issue('acct-1');
|
||||||
|
t += 5 * 60 * 1000 + 1;
|
||||||
|
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'expired' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks a code after five failed attempts', () => {
|
||||||
|
const svc = new PairingService({ now: () => 1_000_000 });
|
||||||
|
const { code } = svc.issue('acct-1');
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
svc.redeem(code, 'http://insecure.example.com');
|
||||||
|
}
|
||||||
|
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'locked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-https site URL as an invalid attempt', () => {
|
||||||
|
const svc = new PairingService({ now: () => 1_000_000 });
|
||||||
|
const { code } = svc.issue('acct-1');
|
||||||
|
expect(svc.redeem(code, 'http://insecure.example.com')).toEqual({ ok: false, error: 'invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid for an unknown code', () => {
|
||||||
|
expect(new PairingService().redeem('NOPE0000', 'https://example.com')).toEqual({ ok: false, error: 'invalid' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createHash, createHmac } from 'node:crypto';
|
||||||
|
import { PluginClient } from '../../src/services/plugin-client.ts';
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const creds = { siteUrl: 'https://example.com', readToken: 'r-token', hmacSecret: 'h-secret' };
|
||||||
|
|
||||||
|
function expectedSignature(secret: string, timestamp: string, method: string, path: string, body: string): string {
|
||||||
|
const canonical = `${timestamp}\n${method}\n${path}\n${createHash('sha256').update(body).digest('hex')}`;
|
||||||
|
return createHmac('sha256', secret).update(canonical).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginClient', () => {
|
||||||
|
it('signs requests and sends the token in Authorization, never the URL', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ theme: 'twentytwentyfour' }) });
|
||||||
|
const client = new PluginClient(creds);
|
||||||
|
|
||||||
|
await client.get('/site-info');
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
|
||||||
|
expect(url).toBe('https://example.com/wp-json/wursor/v1/site-info');
|
||||||
|
expect(String(url)).not.toContain('r-token');
|
||||||
|
expect(init.headers.Authorization).toBe('Bearer r-token');
|
||||||
|
expect(init.headers['X-Wursor-Timestamp']).toBeTruthy();
|
||||||
|
expect(init.headers['X-Wursor-Signature']).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes the HMAC over timestamp + method + path + body hash', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||||
|
const client = new PluginClient(creds);
|
||||||
|
|
||||||
|
await client.post('/files', { a: 1 });
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
|
||||||
|
const ts = init.headers['X-Wursor-Timestamp'];
|
||||||
|
expect(init.headers['X-Wursor-Signature']).toBe(expectedSignature('h-secret', ts, 'POST', '/wursor/v1/files', '{"a":1}'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a 401 to Authentication failed', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 401 });
|
||||||
|
const client = new PluginClient(creds);
|
||||||
|
|
||||||
|
await expect(client.get('/site-info')).rejects.toThrow('Authentication failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to construct a client with an http:// site URL', () => {
|
||||||
|
expect(() => new PluginClient({ ...creds, siteUrl: 'http://example.com' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PostgresUserStore } from '../../src/services/postgres-user-store.ts';
|
||||||
|
import type { Queryable } from '../../src/services/postgres-user-store.ts';
|
||||||
|
|
||||||
|
class FakeDb implements Queryable {
|
||||||
|
calls: Array<{ text: string; values?: unknown[] }> = [];
|
||||||
|
rows: unknown[] = [];
|
||||||
|
|
||||||
|
async query(text: string, values?: unknown[]) {
|
||||||
|
this.calls.push({ text, values });
|
||||||
|
return { rows: this.rows };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PostgresUserStore', () => {
|
||||||
|
it('returns a user when the email exists', async () => {
|
||||||
|
const db = new FakeDb();
|
||||||
|
db.rows = [{ id: 'u1', email: 'a@example.com', password_hash: 'hash' }];
|
||||||
|
const store = new PostgresUserStore(db);
|
||||||
|
|
||||||
|
const user = await store.findByEmail('a@example.com');
|
||||||
|
expect(user).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||||
|
expect(db.calls[0]?.text).toContain('SELECT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the email is absent', async () => {
|
||||||
|
const db = new FakeDb();
|
||||||
|
db.rows = [];
|
||||||
|
const store = new PostgresUserStore(db);
|
||||||
|
|
||||||
|
expect(await store.findByEmail('nope@example.com')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts a new user and returns it', async () => {
|
||||||
|
const db = new FakeDb();
|
||||||
|
const store = new PostgresUserStore(db);
|
||||||
|
|
||||||
|
const created = await store.create({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||||
|
expect(created).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||||
|
const insert = db.calls[0];
|
||||||
|
expect(insert?.text).toContain('INSERT INTO users');
|
||||||
|
expect(insert?.values).toEqual(['u1', 'a@example.com', 'hash']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SandboxManager } from '../../src/services/sandbox-manager.ts';
|
||||||
|
import type { DockerClient } from '../../src/sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
class FakeDocker implements DockerClient {
|
||||||
|
created: string[] = [];
|
||||||
|
destroyed: string[] = [];
|
||||||
|
|
||||||
|
async createSandbox(image: string) {
|
||||||
|
this.created.push(image);
|
||||||
|
return { id: 'sb-1', status: 'running' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySandbox(id: string) {
|
||||||
|
this.destroyed.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(id: string) {
|
||||||
|
return this.destroyed.includes(id) ? undefined : { id, status: 'running' as const };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SandboxManager', () => {
|
||||||
|
it('spins up a sandbox and returns a preview URL', async () => {
|
||||||
|
const docker = new FakeDocker();
|
||||||
|
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
|
||||||
|
|
||||||
|
const result = await manager.start();
|
||||||
|
expect(result).toEqual({ sandboxId: 'sb-1', previewUrl: 'https://preview.wursor.dev/sb-1' });
|
||||||
|
expect(docker.created).toEqual(['wursor-base:latest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors an explicit image override', async () => {
|
||||||
|
const docker = new FakeDocker();
|
||||||
|
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
|
||||||
|
|
||||||
|
await manager.start('wursor-base:canary');
|
||||||
|
expect(docker.created).toEqual(['wursor-base:canary']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys a sandbox', async () => {
|
||||||
|
const docker = new FakeDocker();
|
||||||
|
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
|
||||||
|
|
||||||
|
await manager.destroy('sb-1');
|
||||||
|
expect(docker.destroyed).toEqual(['sb-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WarmPool, computeSpares } from '../../src/services/warm-pool.ts';
|
||||||
|
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
class FakeDocker implements DockerClient {
|
||||||
|
created: string[] = [];
|
||||||
|
|
||||||
|
async createSandbox(image: string): Promise<SandboxInfo> {
|
||||||
|
this.created.push(image);
|
||||||
|
return { id: `sb-${this.created.length}`, status: 'running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySandbox(): Promise<void> {}
|
||||||
|
|
||||||
|
async status(): Promise<SandboxInfo | undefined> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeSpares', () => {
|
||||||
|
it('returns the shortfall', () => {
|
||||||
|
expect(computeSpares(1, 3)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a negative number', () => {
|
||||||
|
expect(computeSpares(5, 2)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WarmPool', () => {
|
||||||
|
it('tops up the pool to the target hot-spare count', async () => {
|
||||||
|
const docker = new FakeDocker();
|
||||||
|
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
|
||||||
|
|
||||||
|
const result = await pool.topUp(0);
|
||||||
|
expect(result.created).toBe(2);
|
||||||
|
expect(docker.created).toEqual(['wursor-base:latest', 'wursor-base:latest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the pool is already full', async () => {
|
||||||
|
const docker = new FakeDocker();
|
||||||
|
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
|
||||||
|
|
||||||
|
const result = await pool.topUp(3);
|
||||||
|
expect(result.created).toBe(0);
|
||||||
|
expect(docker.created).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
+18
-1
@@ -2,5 +2,22 @@
|
|||||||
"name": "@wursor/api",
|
"name": "@wursor/api",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {}
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dockerode": "^5.0.1",
|
||||||
|
"fastify": "^5.2.0",
|
||||||
|
"pg": "^8.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dockerode": "^4.0.1",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/pg": "^8.21.0",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { LlmClient, LlmMessage } from './llm-client.ts';
|
||||||
|
import type { ToolExecutor } from './tool-executor.ts';
|
||||||
|
|
||||||
|
export type RunAgentOptions = {
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
maxRounds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunAgentResult = {
|
||||||
|
rounds: number;
|
||||||
|
toolCalls: number;
|
||||||
|
finalContent: string | null;
|
||||||
|
halted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runAgent(
|
||||||
|
client: LlmClient,
|
||||||
|
executor: ToolExecutor,
|
||||||
|
opts: RunAgentOptions,
|
||||||
|
): Promise<RunAgentResult> {
|
||||||
|
const messages: LlmMessage[] = [
|
||||||
|
{ role: 'system', content: opts.systemPrompt },
|
||||||
|
{ role: 'user', content: opts.userPrompt },
|
||||||
|
];
|
||||||
|
let toolCalls = 0;
|
||||||
|
|
||||||
|
for (let round = 0; round < opts.maxRounds; round += 1) {
|
||||||
|
const response = await client.complete(messages);
|
||||||
|
const message = response.choices[0]?.message;
|
||||||
|
const calls = message?.tool_calls ?? [];
|
||||||
|
|
||||||
|
if (calls.length === 0) {
|
||||||
|
return { rounds: round + 1, toolCalls, finalContent: message?.content ?? null, halted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({ role: 'assistant', content: message?.content ?? null, tool_calls: calls });
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const args = JSON.parse(call.function.arguments) as Record<string, string>;
|
||||||
|
const result = await executor.execute(call.function.name, args);
|
||||||
|
toolCalls += 1;
|
||||||
|
messages.push({ role: 'tool', content: result.result, tool_call_id: call.id, name: call.function.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rounds: opts.maxRounds, toolCalls, finalContent: null, halted: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type CircuitBreakerOptions = {
|
||||||
|
maxConsecutiveFailures: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CircuitBreaker {
|
||||||
|
private failures = 0;
|
||||||
|
|
||||||
|
constructor(private readonly opts: CircuitBreakerOptions) {}
|
||||||
|
|
||||||
|
recordFailure(): void {
|
||||||
|
this.failures += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSuccess(): void {
|
||||||
|
this.failures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldHalt(): boolean {
|
||||||
|
return this.failures >= this.opts.maxConsecutiveFailures;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type LlmToolCall = {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LlmMessage = {
|
||||||
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
|
content: string | null;
|
||||||
|
tool_calls?: LlmToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LlmResponse = {
|
||||||
|
choices: Array<{
|
||||||
|
message: { role: 'assistant'; content: string | null; tool_calls?: LlmToolCall[] };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LlmClient {
|
||||||
|
complete(messages: LlmMessage[]): Promise<LlmResponse>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { generateToolSchemas } from './tool-schemas.ts';
|
||||||
|
import type { LlmClient, LlmMessage, LlmResponse } from './llm-client.ts';
|
||||||
|
|
||||||
|
export type OpenRouterOptions = {
|
||||||
|
apiKey: string;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OpenRouterLlmClient implements LlmClient {
|
||||||
|
constructor(private readonly opts: OpenRouterOptions) {}
|
||||||
|
|
||||||
|
async complete(messages: LlmMessage[]): Promise<LlmResponse> {
|
||||||
|
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.opts.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://wursor.dev',
|
||||||
|
'X-Title': 'Wursor',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.opts.model ?? 'x-ai/grok-4.6',
|
||||||
|
messages,
|
||||||
|
tools: generateToolSchemas(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`LLM HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as LlmResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const AGENT_SYSTEM_PROMPT =
|
||||||
|
'You are an expert WordPress agent working inside a sandboxed copy of the user\u2019s site. ' +
|
||||||
|
'Use the provided tools to complete the request. Work step by step: read what you need, make the ' +
|
||||||
|
'changes, and verify. Never touch anything outside the sandbox. When finished, reply with a one-line ' +
|
||||||
|
'summary of what you changed.';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type ToolResult = {
|
||||||
|
result: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ToolExecutor {
|
||||||
|
execute(name: string, args: Record<string, string>): Promise<ToolResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export type ToolSchema = {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALLOWED_TOOL_NAMES = [
|
||||||
|
'read_page',
|
||||||
|
'update_post',
|
||||||
|
'update_option',
|
||||||
|
'create_page',
|
||||||
|
'update_theme_json',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AllowedToolName = (typeof ALLOWED_TOOL_NAMES)[number];
|
||||||
|
|
||||||
|
const TOOLS: ToolSchema[] = [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'read_page',
|
||||||
|
description: 'Read the raw content and title of a page by its slug.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { page: { type: 'string', description: 'Page slug' } },
|
||||||
|
required: ['page'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'update_post',
|
||||||
|
description: 'Update the title and/or content of an existing page or post.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
page: { type: 'string', description: 'Page slug' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
content: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['page'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'update_option',
|
||||||
|
description: 'Update a WordPress option such as blogname or blogdescription.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: { type: 'string' },
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['key', 'value'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'create_page',
|
||||||
|
description: 'Create a new page with a title and content.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string' },
|
||||||
|
content: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['title', 'content'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'update_theme_json',
|
||||||
|
description: 'Apply a JSON patch to theme.json (colors, fonts, layout).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
patch: { type: 'string', description: 'JSON patch object as a string' },
|
||||||
|
},
|
||||||
|
required: ['patch'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generateToolSchemas(): ToolSchema[] {
|
||||||
|
return TOOLS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { ToolExecutor, ToolResult } from './tool-executor.ts';
|
||||||
|
|
||||||
|
export type WpRestCredentials = {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
appPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function basicAuth(username: string, password: string): string {
|
||||||
|
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WpRestExecutor implements ToolExecutor {
|
||||||
|
private readonly base: string;
|
||||||
|
private readonly auth: string;
|
||||||
|
|
||||||
|
constructor(private readonly creds: WpRestCredentials) {
|
||||||
|
this.base = creds.baseUrl.replace(/\/$/, '');
|
||||||
|
this.auth = basicAuth(creds.username, creds.appPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(name: string, args: Record<string, string>): Promise<ToolResult> {
|
||||||
|
switch (name) {
|
||||||
|
case 'read_page':
|
||||||
|
return this.readPage(args.page);
|
||||||
|
case 'update_post':
|
||||||
|
return this.updatePost(args);
|
||||||
|
case 'create_page':
|
||||||
|
return this.createPage(args);
|
||||||
|
case 'update_option':
|
||||||
|
return this.updateOption(args);
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported tool: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async json(url: string, init?: RequestInit): Promise<unknown> {
|
||||||
|
const res = await fetch(url, { ...init, headers: { Authorization: this.auth, ...(init?.headers ?? {}) } });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`WP HTTP ${res.status} ${url}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readPage(page: string): Promise<ToolResult> {
|
||||||
|
const data = await this.json(`${this.base}/wp-json/wp/v2/pages?slug=${encodeURIComponent(page)}&_fields=id,title,content`);
|
||||||
|
return { result: JSON.stringify(data) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pageIdBySlug(slug: string): Promise<number | undefined> {
|
||||||
|
const data = (await this.json(
|
||||||
|
`${this.base}/wp-json/wp/v2/pages?slug=${encodeURIComponent(slug)}&_fields=id`,
|
||||||
|
)) as Array<{ id: number }>;
|
||||||
|
return data[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updatePost(args: Record<string, string>): Promise<ToolResult> {
|
||||||
|
const id = await this.pageIdBySlug(args.page ?? '');
|
||||||
|
if (id === undefined) {
|
||||||
|
return { result: `no page with slug ${args.page}` };
|
||||||
|
}
|
||||||
|
const body: Record<string, string> = {};
|
||||||
|
if (args.title !== undefined) body.title = args.title;
|
||||||
|
if (args.content !== undefined) body.content = args.content;
|
||||||
|
const updated = await this.json(`${this.base}/wp-json/wp/v2/pages/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { result: JSON.stringify(updated) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPage(args: Record<string, string>): Promise<ToolResult> {
|
||||||
|
const created = await this.json(`${this.base}/wp-json/wp/v2/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: args.title, content: args.content, status: 'publish' }),
|
||||||
|
});
|
||||||
|
return { result: JSON.stringify(created) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateOption(args: Record<string, string>): Promise<ToolResult> {
|
||||||
|
const updated = await this.json(`${this.base}/wp-json/wp/v2/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [args.key ?? '']: args.value }),
|
||||||
|
});
|
||||||
|
return { result: JSON.stringify(updated) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import { authRoutes } from './routes/auth.ts';
|
||||||
|
import { chatRoutes } from './routes/chat.ts';
|
||||||
|
import { sessionRoutes } from './routes/sessions.ts';
|
||||||
|
import { siteRoutes } from './routes/sites.ts';
|
||||||
|
import type { LlmClient } from './agents/llm-client.ts';
|
||||||
|
import type { ToolExecutor } from './agents/tool-executor.ts';
|
||||||
|
import { PairingService } from './services/pairing-service.ts';
|
||||||
|
import { InMemorySessionStore, type SessionStore } from './services/session-store.ts';
|
||||||
|
import { InMemorySiteStore, type SiteStore } from './services/site-store.ts';
|
||||||
|
import { InMemoryUserStore, type UserStore } from './services/user-store.ts';
|
||||||
|
import type { SandboxManager } from './services/sandbox-manager.ts';
|
||||||
|
|
||||||
|
export type BuildAppOptions = {
|
||||||
|
userStore?: UserStore;
|
||||||
|
sessionStore?: SessionStore;
|
||||||
|
siteStore?: SiteStore;
|
||||||
|
pairingService?: PairingService;
|
||||||
|
sandboxManager?: SandboxManager;
|
||||||
|
llmClient?: LlmClient;
|
||||||
|
toolExecutor?: ToolExecutor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function buildApp(opts: BuildAppOptions = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
const userStore = opts.userStore ?? new InMemoryUserStore();
|
||||||
|
const sessionStore = opts.sessionStore ?? new InMemorySessionStore();
|
||||||
|
const siteStore = opts.siteStore ?? new InMemorySiteStore();
|
||||||
|
const pairingService = opts.pairingService ?? new PairingService();
|
||||||
|
|
||||||
|
app.get('/health', async () => ({ ok: true }));
|
||||||
|
await authRoutes(app, userStore, sessionStore);
|
||||||
|
await sessionRoutes(app, opts.sandboxManager);
|
||||||
|
await siteRoutes(app, { sessionStore, siteStore, pairingService });
|
||||||
|
await chatRoutes(app, { sessionStore, llmClient: opts.llmClient, toolExecutor: opts.toolExecutor });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { loadEnvFile } from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { buildApp } from './app.ts';
|
||||||
|
import { OpenRouterLlmClient } from './agents/openrouter-client.ts';
|
||||||
|
import { WpRestExecutor } from './agents/wp-executor.ts';
|
||||||
|
import { DockerodeClient } from './sandbox/dockerode-client.ts';
|
||||||
|
import { ImageManager } from './sandbox/image-manager.ts';
|
||||||
|
import { PostgresUserStore } from './services/postgres-user-store.ts';
|
||||||
|
import { SandboxManager } from './services/sandbox-manager.ts';
|
||||||
|
import { InMemoryUserStore } from './services/user-store.ts';
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadEnvFile(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env'));
|
||||||
|
} catch {
|
||||||
|
// no .env — rely on the ambient environment
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|
||||||
|
const userStore = process.env.DATABASE_URL
|
||||||
|
? new PostgresUserStore(new Pool({ connectionString: process.env.DATABASE_URL }))
|
||||||
|
: new InMemoryUserStore();
|
||||||
|
|
||||||
|
const imageManager = new ImageManager({
|
||||||
|
baseImage: process.env.WUR_IMAGE ?? 'wursor-base:latest',
|
||||||
|
webPort: Number(process.env.WUR_WEB_PORT ?? 8080),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sandboxManager =
|
||||||
|
process.env.WUR_ENABLE_SANDBOX === '1'
|
||||||
|
? new SandboxManager(new DockerodeClient(new Docker()), {
|
||||||
|
image: imageManager.imageRef(),
|
||||||
|
previewBaseUrl: process.env.PREVIEW_BASE_URL ?? 'http://localhost:8080',
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const llmClient = process.env.OPENROUTER_API_KEY
|
||||||
|
? new OpenRouterLlmClient({ apiKey: process.env.OPENROUTER_API_KEY, model: process.env.OPENROUTER_MODEL })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const toolExecutor =
|
||||||
|
process.env.WP_URL !== undefined && process.env.WP_USER !== undefined && process.env.WP_APP_PASSWORD !== undefined
|
||||||
|
? new WpRestExecutor({
|
||||||
|
baseUrl: process.env.WP_URL,
|
||||||
|
username: process.env.WP_USER,
|
||||||
|
appPassword: process.env.WP_APP_PASSWORD,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const app = await buildApp({ userStore, sandboxManager, llmClient, toolExecutor });
|
||||||
|
|
||||||
|
await app.listen({ port, host: '0.0.0.0' });
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { randomBytes, randomInt } from 'node:crypto';
|
||||||
|
|
||||||
|
const CODE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
const CODE_LENGTH = 8;
|
||||||
|
|
||||||
|
export function generatePairingCode(): string {
|
||||||
|
let code = '';
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i += 1) {
|
||||||
|
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToken(): string {
|
||||||
|
return randomBytes(32).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateHmacSecret(): string {
|
||||||
|
return randomBytes(32).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHttpsUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(value).protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const hash = scryptSync(password, salt, 32).toString('hex');
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [salt, hash] = stored.split(':');
|
||||||
|
if (salt === undefined || hash === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = scryptSync(password, salt, 32);
|
||||||
|
return timingSafeEqual(candidate, Buffer.from(hash, 'hex'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newToken(): string {
|
||||||
|
return randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import type { SessionStore } from '../services/session-store.ts';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSession(store: SessionStore) {
|
||||||
|
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
const token = header !== undefined && header.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
|
||||||
|
if (token === undefined) {
|
||||||
|
await reply.status(401).send({ error: 'unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = await store.findByToken(token);
|
||||||
|
if (session === undefined) {
|
||||||
|
await reply.status(401).send({ error: 'unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.userId = session.userId;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { hashPassword, newId } from '../lib/crypto.ts';
|
||||||
|
import type { SessionStore } from '../services/session-store.ts';
|
||||||
|
import type { UserStore } from '../services/user-store.ts';
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
type SignupBody = {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function authRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
store: UserStore,
|
||||||
|
sessionStore: SessionStore,
|
||||||
|
): Promise<void> {
|
||||||
|
app.post('/auth/signup', async (request, reply) => {
|
||||||
|
const { email: rawEmail, password } = request.body as SignupBody;
|
||||||
|
const email = rawEmail?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (email === undefined || email === '' || !EMAIL_RE.test(email)) {
|
||||||
|
return reply.status(400).send({ error: 'invalid_email' });
|
||||||
|
}
|
||||||
|
if (password === undefined || password.length < 8) {
|
||||||
|
return reply.status(400).send({ error: 'weak_password' });
|
||||||
|
}
|
||||||
|
if ((await store.findByEmail(email)) !== undefined) {
|
||||||
|
return reply.status(409).send({ error: 'email_exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await store.create({ id: newId(), email, passwordHash: hashPassword(password) });
|
||||||
|
const session = await sessionStore.create(user.id);
|
||||||
|
return reply.status(201).send({ user: { id: user.id, email: user.email }, sessionToken: session.token });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { runAgent } from '../agents/agent-loop.ts';
|
||||||
|
import { AGENT_SYSTEM_PROMPT } from '../agents/system-prompt.ts';
|
||||||
|
import type { LlmClient } from '../agents/llm-client.ts';
|
||||||
|
import type { ToolExecutor } from '../agents/tool-executor.ts';
|
||||||
|
import { requireSession } from '../middleware/auth.ts';
|
||||||
|
import type { SessionStore } from '../services/session-store.ts';
|
||||||
|
|
||||||
|
export type ChatRoutesDeps = {
|
||||||
|
sessionStore: SessionStore;
|
||||||
|
llmClient?: LlmClient;
|
||||||
|
toolExecutor?: ToolExecutor;
|
||||||
|
maxRounds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatBody = {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function chatRoutes(app: FastifyInstance, deps: ChatRoutesDeps): Promise<void> {
|
||||||
|
app.post('/chat', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
|
||||||
|
if (deps.llmClient === undefined || deps.toolExecutor === undefined) {
|
||||||
|
return reply.status(503).send({ error: 'agent_not_configured' });
|
||||||
|
}
|
||||||
|
const { message } = request.body as ChatBody;
|
||||||
|
if (message === undefined || message.trim() === '') {
|
||||||
|
return reply.status(400).send({ error: 'invalid_message' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runAgent(deps.llmClient, deps.toolExecutor, {
|
||||||
|
systemPrompt: AGENT_SYSTEM_PROMPT,
|
||||||
|
userPrompt: message,
|
||||||
|
maxRounds: deps.maxRounds ?? 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({ reply: result.finalContent, toolCalls: result.toolCalls, halted: result.halted });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { newId } from '../lib/crypto.ts';
|
||||||
|
import type { SandboxManager } from '../services/sandbox-manager.ts';
|
||||||
|
|
||||||
|
export async function sessionRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sandboxManager: SandboxManager | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
app.post('/sessions', async (_request, reply) => {
|
||||||
|
if (sandboxManager === undefined) {
|
||||||
|
return reply.status(503).send({ error: 'sandbox_not_configured' });
|
||||||
|
}
|
||||||
|
const { sandboxId, previewUrl } = await sandboxManager.start();
|
||||||
|
return reply.status(201).send({ sessionId: newId(), sandboxId, previewUrl });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { requireSession } from '../middleware/auth.ts';
|
||||||
|
import { newId } from '../lib/crypto.ts';
|
||||||
|
import type { PairingService } from '../services/pairing-service.ts';
|
||||||
|
import type { SessionStore } from '../services/session-store.ts';
|
||||||
|
import type { SiteStore } from '../services/site-store.ts';
|
||||||
|
|
||||||
|
export type SiteRoutesDeps = {
|
||||||
|
sessionStore: SessionStore;
|
||||||
|
pairingService: PairingService;
|
||||||
|
siteStore: SiteStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RedeemBody = {
|
||||||
|
code?: string;
|
||||||
|
siteUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function siteRoutes(app: FastifyInstance, deps: SiteRoutesDeps): Promise<void> {
|
||||||
|
app.post('/sites/pair', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
|
||||||
|
const { code, expiresAt } = deps.pairingService.issue(request.userId as string);
|
||||||
|
return reply.status(201).send({ code, expiresAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/sites/redeem', async (request, reply) => {
|
||||||
|
const { code, siteUrl } = request.body as RedeemBody;
|
||||||
|
if (code === undefined || siteUrl === undefined) {
|
||||||
|
return reply.status(400).send({ error: 'invalid' });
|
||||||
|
}
|
||||||
|
const result = deps.pairingService.redeem(code, siteUrl);
|
||||||
|
if (!result.ok) {
|
||||||
|
return reply.status(400).send({ error: result.error });
|
||||||
|
}
|
||||||
|
const site = await deps.siteStore.create({
|
||||||
|
id: newId(),
|
||||||
|
accountId: result.accountId,
|
||||||
|
siteUrl: result.siteUrl,
|
||||||
|
readToken: result.readToken,
|
||||||
|
deployToken: result.deployToken,
|
||||||
|
hmacSecret: result.hmacSecret,
|
||||||
|
connected: false,
|
||||||
|
});
|
||||||
|
return reply.status(201).send({
|
||||||
|
siteId: site.id,
|
||||||
|
readToken: site.readToken,
|
||||||
|
deployToken: site.deployToken,
|
||||||
|
hmacSecret: site.hmacSecret,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sites/:siteId', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
|
||||||
|
const site = await deps.siteStore.findById((request.params as { siteId: string }).siteId);
|
||||||
|
if (site === undefined || site.accountId !== request.userId) {
|
||||||
|
return reply.status(404).send({ error: 'not_found' });
|
||||||
|
}
|
||||||
|
return { id: site.id, siteUrl: site.siteUrl, connected: site.connected };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/sites/:siteId/confirm', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
|
||||||
|
const site = await deps.siteStore.findById((request.params as { siteId: string }).siteId);
|
||||||
|
if (site === undefined || site.accountId !== request.userId) {
|
||||||
|
return reply.status(404).send({ error: 'not_found' });
|
||||||
|
}
|
||||||
|
await deps.siteStore.setConnected(site.id, true);
|
||||||
|
return { connected: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type SandboxInfo = {
|
||||||
|
id: string;
|
||||||
|
status: 'running' | 'destroyed';
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DockerClient {
|
||||||
|
createSandbox(image: string): Promise<SandboxInfo>;
|
||||||
|
destroySandbox(id: string): Promise<void>;
|
||||||
|
status(id: string): Promise<SandboxInfo | undefined>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { DockerClient, SandboxInfo } from './docker-client.ts';
|
||||||
|
|
||||||
|
export type ContainerInspect = {
|
||||||
|
Id: string;
|
||||||
|
State: { Running: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContainerLike = {
|
||||||
|
start(): Promise<void>;
|
||||||
|
inspect(): Promise<ContainerInspect>;
|
||||||
|
remove(opts?: { force?: boolean }): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DockerEngine = {
|
||||||
|
createContainer(opts: { Image: string; name: string }): Promise<ContainerLike>;
|
||||||
|
getContainer(id: string): ContainerLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DockerodeClient implements DockerClient {
|
||||||
|
constructor(private readonly engine: DockerEngine = new Docker() as unknown as DockerEngine) {}
|
||||||
|
|
||||||
|
async createSandbox(image: string): Promise<SandboxInfo> {
|
||||||
|
const container = await this.engine.createContainer({ Image: image, name: `wursor-${randomUUID()}` });
|
||||||
|
await container.start();
|
||||||
|
const info = await container.inspect();
|
||||||
|
return { id: info.Id, status: info.State.Running ? 'running' : 'destroyed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySandbox(id: string): Promise<void> {
|
||||||
|
await this.engine.getContainer(id).remove({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(id: string): Promise<SandboxInfo | undefined> {
|
||||||
|
try {
|
||||||
|
const info = await this.engine.getContainer(id).inspect();
|
||||||
|
return { id: info.Id, status: info.State.Running ? 'running' : 'destroyed' };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type SandboxStatus = 'running' | 'paused' | 'destroyed';
|
||||||
|
|
||||||
|
export type SandboxState = {
|
||||||
|
status: SandboxStatus;
|
||||||
|
lastActiveAt: number;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GcAction = 'keep' | 'pause' | 'destroy';
|
||||||
|
|
||||||
|
export type GcOptions = {
|
||||||
|
idleMs: number;
|
||||||
|
hardMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decideGc(state: SandboxState, now: number, opts: GcOptions): GcAction {
|
||||||
|
if (state.status === 'destroyed') {
|
||||||
|
return 'keep';
|
||||||
|
}
|
||||||
|
if (now - state.createdAt >= opts.hardMs) {
|
||||||
|
return 'destroy';
|
||||||
|
}
|
||||||
|
if (state.status === 'running' && now - state.lastActiveAt >= opts.idleMs) {
|
||||||
|
return 'pause';
|
||||||
|
}
|
||||||
|
return 'keep';
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type ImageManagerOptions = {
|
||||||
|
baseImage: string;
|
||||||
|
webPort: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SandboxContainerSpec = {
|
||||||
|
image: string;
|
||||||
|
name: string;
|
||||||
|
ports: { container: number; host: number }[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ImageManager {
|
||||||
|
constructor(private readonly opts: ImageManagerOptions) {}
|
||||||
|
|
||||||
|
imageRef(): string {
|
||||||
|
return this.opts.baseImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
containerSpec(name: string): SandboxContainerSpec {
|
||||||
|
return {
|
||||||
|
image: this.opts.baseImage,
|
||||||
|
name,
|
||||||
|
ports: [{ container: 80, host: this.opts.webPort }],
|
||||||
|
labels: { 'wursor.managed': 'true' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type Manifest = Record<string, string>;
|
||||||
|
|
||||||
|
export type ManifestDiff = {
|
||||||
|
added: string[];
|
||||||
|
changed: string[];
|
||||||
|
removed: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
|
||||||
|
const added = Object.keys(after).filter((path) => !(path in before));
|
||||||
|
const removed = Object.keys(before).filter((path) => !(path in after));
|
||||||
|
const changed = Object.keys(after).filter((path) => path in before && before[path] !== after[path]);
|
||||||
|
return { added, changed, removed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function mediaProxyTarget(origin: string, path: string): string {
|
||||||
|
return `${origin}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMediaPath(origin: string, path: string, staged: ReadonlySet<string>): string {
|
||||||
|
return staged.has(path) ? path : mediaProxyTarget(origin, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageReplacement(_origin: string, path: string, _bytes: number): { copiedPaths: string[] } {
|
||||||
|
return { copiedPaths: [path] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Playbook, SiteExport, SubsetRequest, SubsetResult } from './types.ts';
|
||||||
|
|
||||||
|
const PLAYBOOK_TABLES: Record<Playbook, Set<string>> = {
|
||||||
|
content: new Set(['wp_posts', 'wp_postmeta', 'wp_options']),
|
||||||
|
design: new Set(['wp_posts', 'wp_postmeta', 'wp_options', 'wp_terms', 'wp_term_taxonomy', 'wp_term_relationships']),
|
||||||
|
plugin: new Set(['wp_options']),
|
||||||
|
};
|
||||||
|
|
||||||
|
const secret = /(_key|_secret|smtp_pass)$/;
|
||||||
|
|
||||||
|
export function exportDbSubset(dump: SiteExport, request: SubsetRequest): SubsetResult {
|
||||||
|
const wanted = PLAYBOOK_TABLES[request.playbook];
|
||||||
|
const tables = Object.keys(dump.tables).filter((name) => wanted.has(name));
|
||||||
|
const options = (dump.tables.wp_options ?? [])
|
||||||
|
.map((row) => String(row.option_name ?? ''))
|
||||||
|
.filter((name) => name !== '' && !secret.test(name));
|
||||||
|
|
||||||
|
return { tables, options };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type Playbook = 'content' | 'design' | 'plugin';
|
||||||
|
|
||||||
|
export type SiteExport = {
|
||||||
|
origin: string;
|
||||||
|
tables: Record<string, Array<Record<string, string | number>>>;
|
||||||
|
uploads: { path: string; bytes: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubsetRequest = {
|
||||||
|
playbook: Playbook;
|
||||||
|
postIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubsetResult = {
|
||||||
|
tables: string[];
|
||||||
|
options: string[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { generateHmacSecret, generatePairingCode, generateToken, isHttpsUrl } from '../lib/codes.ts';
|
||||||
|
|
||||||
|
export type PendingPairing = {
|
||||||
|
code: string;
|
||||||
|
accountId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
attempts: number;
|
||||||
|
locked: boolean;
|
||||||
|
consumed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedeemError = 'invalid' | 'expired' | 'locked' | 'consumed';
|
||||||
|
|
||||||
|
export type RedeemResult =
|
||||||
|
| { ok: true; accountId: string; siteUrl: string; readToken: string; deployToken: string; hmacSecret: string }
|
||||||
|
| { ok: false; error: RedeemError };
|
||||||
|
|
||||||
|
export type PairingServiceOptions = {
|
||||||
|
ttlMs?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
now?: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
export class PairingService {
|
||||||
|
private readonly pending = new Map<string, PendingPairing>();
|
||||||
|
private readonly now: () => number;
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly maxAttempts: number;
|
||||||
|
|
||||||
|
constructor(opts: PairingServiceOptions = {}) {
|
||||||
|
this.now = opts.now ?? Date.now;
|
||||||
|
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
issue(accountId: string): { code: string; expiresAt: number } {
|
||||||
|
const code = generatePairingCode();
|
||||||
|
const expiresAt = this.now() + this.ttlMs;
|
||||||
|
this.pending.set(code, { code, accountId, expiresAt, attempts: 0, locked: false, consumed: false });
|
||||||
|
return { code, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
redeem(code: string, siteUrl: string): RedeemResult {
|
||||||
|
const pairing = this.pending.get(code);
|
||||||
|
if (pairing === undefined) {
|
||||||
|
return { ok: false, error: 'invalid' };
|
||||||
|
}
|
||||||
|
if (pairing.consumed) {
|
||||||
|
return { ok: false, error: 'consumed' };
|
||||||
|
}
|
||||||
|
if (pairing.locked) {
|
||||||
|
return { ok: false, error: 'locked' };
|
||||||
|
}
|
||||||
|
if (this.now() > pairing.expiresAt) {
|
||||||
|
return { ok: false, error: 'expired' };
|
||||||
|
}
|
||||||
|
if (!isHttpsUrl(siteUrl)) {
|
||||||
|
pairing.attempts += 1;
|
||||||
|
if (pairing.attempts >= this.maxAttempts) {
|
||||||
|
pairing.locked = true;
|
||||||
|
}
|
||||||
|
return { ok: false, error: 'invalid' };
|
||||||
|
}
|
||||||
|
|
||||||
|
pairing.consumed = true;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
accountId: pairing.accountId,
|
||||||
|
siteUrl,
|
||||||
|
readToken: generateToken(),
|
||||||
|
deployToken: generateToken(),
|
||||||
|
hmacSecret: generateHmacSecret(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { createHash, createHmac } from 'node:crypto';
|
||||||
|
import { isHttpsUrl } from '../lib/codes.ts';
|
||||||
|
|
||||||
|
export type PluginCredentials = {
|
||||||
|
siteUrl: string;
|
||||||
|
readToken: string;
|
||||||
|
deployToken?: string;
|
||||||
|
hmacSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sha256hex(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly namespace: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly creds: PluginCredentials,
|
||||||
|
namespace = 'wursor/v1',
|
||||||
|
) {
|
||||||
|
if (!isHttpsUrl(creds.siteUrl)) {
|
||||||
|
throw new Error('Site URL must be https');
|
||||||
|
}
|
||||||
|
this.namespace = namespace;
|
||||||
|
this.baseUrl = `${creds.siteUrl.replace(/\/$/, '')}/wp-json/${namespace}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(path: string): Promise<unknown> {
|
||||||
|
return this.request('GET', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(path: string, body?: unknown): Promise<unknown> {
|
||||||
|
return this.request('POST', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||||
|
const bodyText = body === undefined ? '' : JSON.stringify(body);
|
||||||
|
const route = `/${this.namespace}${path}`;
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||||
|
const canonical = `${timestamp}\n${method}\n${route}\n${sha256hex(bodyText)}`;
|
||||||
|
const signature = createHmac('sha256', this.creds.hmacSecret).update(canonical).digest('hex');
|
||||||
|
|
||||||
|
const token = method === 'GET' ? this.creds.readToken : (this.creds.deployToken ?? this.creds.readToken);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Wursor-Timestamp': timestamp,
|
||||||
|
'X-Wursor-Signature': signature,
|
||||||
|
};
|
||||||
|
if (bodyText !== '') {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: bodyText === '' ? undefined : bodyText,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new Error('Authentication failed');
|
||||||
|
}
|
||||||
|
throw new Error(`Plugin HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { User, UserStore } from './user-store.ts';
|
||||||
|
|
||||||
|
export type Queryable = {
|
||||||
|
query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password_hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PostgresUserStore implements UserStore {
|
||||||
|
constructor(private readonly db: Queryable) {}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | undefined> {
|
||||||
|
const result = await this.db.query('SELECT id, email, password_hash FROM users WHERE email = $1', [email]);
|
||||||
|
const row = result.rows[0] as UserRow | undefined;
|
||||||
|
return row === undefined
|
||||||
|
? undefined
|
||||||
|
: { id: row.id, email: row.email, passwordHash: row.password_hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: User): Promise<User> {
|
||||||
|
await this.db.query('INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)', [
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.passwordHash,
|
||||||
|
]);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { DockerClient } from '../sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
export type SandboxManagerOptions = {
|
||||||
|
image: string;
|
||||||
|
previewBaseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SandboxManager {
|
||||||
|
constructor(
|
||||||
|
private readonly docker: DockerClient,
|
||||||
|
private readonly opts: SandboxManagerOptions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(image?: string): Promise<{ sandboxId: string; previewUrl: string }> {
|
||||||
|
const info = await this.docker.createSandbox(image ?? this.opts.image);
|
||||||
|
return { sandboxId: info.id, previewUrl: `${this.opts.previewBaseUrl}/${info.id}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(id: string): Promise<void> {
|
||||||
|
await this.docker.destroySandbox(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { generateToken } from '../lib/codes.ts';
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SessionStore {
|
||||||
|
create(userId: string): Promise<Session>;
|
||||||
|
findByToken(token: string): Promise<Session | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemorySessionStore implements SessionStore {
|
||||||
|
private byToken = new Map<string, Session>();
|
||||||
|
|
||||||
|
async create(userId: string): Promise<Session> {
|
||||||
|
const session = { token: generateToken(), userId, createdAt: Date.now() };
|
||||||
|
this.byToken.set(session.token, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByToken(token: string): Promise<Session | undefined> {
|
||||||
|
return this.byToken.get(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type Site = {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
siteUrl: string;
|
||||||
|
readToken: string;
|
||||||
|
deployToken: string;
|
||||||
|
hmacSecret: string;
|
||||||
|
connected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SiteStore {
|
||||||
|
create(site: Site): Promise<Site>;
|
||||||
|
findById(id: string): Promise<Site | undefined>;
|
||||||
|
setConnected(id: string, connected: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemorySiteStore implements SiteStore {
|
||||||
|
private byId = new Map<string, Site>();
|
||||||
|
|
||||||
|
async create(site: Site): Promise<Site> {
|
||||||
|
this.byId.set(site.id, site);
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Site | undefined> {
|
||||||
|
return this.byId.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setConnected(id: string, connected: boolean): Promise<void> {
|
||||||
|
const site = this.byId.get(id);
|
||||||
|
if (site !== undefined) {
|
||||||
|
this.byId.set(id, { ...site, connected });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
passwordHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserStore {
|
||||||
|
findByEmail(email: string): Promise<User | undefined>;
|
||||||
|
create(user: User): Promise<User>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemoryUserStore implements UserStore {
|
||||||
|
private byEmail = new Map<string, User>();
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | undefined> {
|
||||||
|
return this.byEmail.get(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: User): Promise<User> {
|
||||||
|
this.byEmail.set(user.email, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { DockerClient } from '../sandbox/docker-client.ts';
|
||||||
|
|
||||||
|
export function computeSpares(current: number, target: number): number {
|
||||||
|
return Math.max(0, target - current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WarmPoolOptions = {
|
||||||
|
image: string;
|
||||||
|
hotSpares: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WarmPool {
|
||||||
|
constructor(
|
||||||
|
private readonly docker: DockerClient,
|
||||||
|
private readonly opts: WarmPoolOptions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async topUp(currentRunning: number): Promise<{ created: number }> {
|
||||||
|
const need = computeSpares(currentRunning, this.opts.hotSpares);
|
||||||
|
for (let i = 0; i < need; i += 1) {
|
||||||
|
await this.docker.createSandbox(this.opts.image);
|
||||||
|
}
|
||||||
|
return { created: need };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 11. Fastify is the API server; React + Vite is the web shell
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
IMPLEMENTATION §1 names "Express/Fastify" for the backend and "React + TypeScript" for the frontend. Sprint 1 required committing to one server framework before the first route and test.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use **Fastify** for `api/`, and **React 19 + Vite + vitest + @testing-library/react** for `web/`.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- Express (matches the plan's pseudocode).
|
||||||
|
- Fastify (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Express — the plan allows either; Fastify ships built-in JSON-schema validation, native async handlers, and first-class TypeScript, which removes glue the plan would otherwise write by hand.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Route handlers return via Fastify's `reply` object and validate payloads schema-first (the signup route enforces email format and password length).
|
||||||
|
- Future routes should keep using Fastify schema validation at the boundary rather than hand-rolled checks.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 12. In-memory user store behind a UserStore interface; Postgres deferred
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
IMPLEMENTATION §1 names PostgreSQL for Wursor's own data. Sprint 1's first slice needed working sign-up/auth without standing up a database, migrations, or a connection pool on day one.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define a `UserStore` interface and ship `InMemoryUserStore` behind it. Passwords are hashed with `node:crypto` scrypt; session tokens are `crypto.randomBytes(32)` hex.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- Stand up Postgres now.
|
||||||
|
- In-memory store behind an interface (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Postgres now — adds infrastructure friction to the first slice for no behavioral gain; the interface confines the swap to `services/user-store.ts`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Auth data is not durable until Postgres lands; restarting the API clears users and sessions.
|
||||||
|
- The Postgres swap is a drop-in replacement of `InMemoryUserStore` implementing the same `UserStore` contract.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 13. Sandbox orchestration mocks the Docker boundary; real daemon client deferred
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The development machine has no Docker daemon (the Phase 0 spikes already hit this). Sprint 1 still had to build and test the sandbox services: DB subset, media proxy, manifest delta, GC, and orchestration.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Implement the pure-logic sandbox services (`subset`, `media-proxy`, `manifest`, `gc`) and test them directly. Define a `DockerClient` interface and a `SandboxManager` orchestrator that depends on it, tested with a fake client. Defer the real Docker daemon HTTP client.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- Block on a Docker host.
|
||||||
|
- Mock at the boundary (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Block on Docker — IMPLEMENTATION §7 already specifies "mocks at boundaries"; blocking would stall the slice for a reason that doesn't change the logic.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `subset` and `media-proxy` logic is promoted from the golden harness (`e2e/golden/src/`) into `api/src/sandbox/`.
|
||||||
|
- The Docker wire-up (`DockerClient` daemon implementation, `image-manager`, `warm-pool`) is an explicit Sprint 1 follow-up and remains unverified until a Docker host exists.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 14. Postgres user store via a Queryable boundary; schema in SQL migrations
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0012 deferred Postgres behind the `UserStore` interface. Sprint 1 now ships the real implementation. Postgres is not available in the dev environment, so the store had to be testable without a database.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`PostgresUserStore` uses `node-postgres` (`pg`) but depends on a minimal `Queryable` interface (`query(text, values)`) instead of `pg.Pool` directly. `UserStore` methods became async. The `users` table lives in `api/migrations/001_init.sql`. `index.ts` selects Postgres when `DATABASE_URL` is set, else the in-memory store.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- ORM (Prisma/Drizzle) with migrations.
|
||||||
|
- Raw `pg` behind a `Queryable` interface (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- ORM — adds tooling and a codegen step for a two-statement surface; the raw SQL is reviewable and the interface keeps tests database-free.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `InMemoryUserStore` and `PostgresUserStore` share the same async `UserStore` contract; swapping is env-driven.
|
||||||
|
- Auth data is durable when `DATABASE_URL` is configured; the migration must be applied before first use.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 15. Docker daemon client via dockerode behind an injected engine; sandbox gated by env
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0013 deferred the real Docker client. Sprint 1 now ships it. There is still no Docker daemon in the dev environment, so the client had to be testable without one.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`DockerodeClient` implements `DockerClient` using `dockerode`, but depends on an injected `DockerEngine` (a minimal `createContainer`/`getContainer` surface) instead of `dockerode` directly. The API enables it via `WUR_ENABLE_SANDBOX=1`; when off, `POST /sessions` returns 503. `index.ts` constructs `new Docker()` (dockerode auto-detects `DOCKER_HOST` or the unix socket).
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- Raw Docker Engine HTTP over the unix socket.
|
||||||
|
- dockerode behind an injected engine (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Raw HTTP — dockerode already handles unix sockets, TLS, and API version negotiation; reimplementing it is pure waste.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `DockerodeClient` is unit-tested with a fake engine; only the daemon wiring remains to be verified on a Docker host.
|
||||||
|
- Sandbox spin-up is opt-in, so local dev without Docker still boots and `auth`/`sessions` behave predictably (503 on sessions).
|
||||||
|
- Port mapping is a Sprint 1 placeholder (single fixed host port); dynamic port allocation and preview proxying are follow-ups.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 16. Pairing-code TTL/lockout lives on the Wursor API; the plugin enforces token/HMAC/scope
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The pairing threat model (`spikes/pairing-threat-model.md`) mandates that **Wursor generates** the pairing code and **the plugin redeems** it — explicitly rejecting the plugin-local generate/redeem sketch in IMPLEMENTATION. Its "Sprint 2 tests" section, however, still labels the pairing-code TTL/lockout/single-use tests under `plugin/__tests__/test-auth.php`, a leftover from that rejected sketch.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The pairing code lifecycle (issue, 5-minute TTL, 5-attempt lockout, single-use, `site_url` https check) is enforced in the API's `PairingService`. The plugin's `class-auth.php` enforces token hashing (SHA-256 + `hash_equals`), HMAC verification, `read` vs `deploy` scoping, and rotation.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- Follow the test-file labels literally (plugin enforces the pairing code).
|
||||||
|
- Follow the locked flow (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Literal labels — they contradict the "Wursor generates, plugin redeems" flow the same note mandates; pairing state can only live where the code is issued.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `api/__tests__/routes/sites-pair.test.ts` + `pairing-service.test.ts` cover TTL/lockout/single-use.
|
||||||
|
- `plugin/__tests__/test-auth.php` covers hashing, HMAC, scope, and rotation only.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 17. The agent is given a semantic, allowlisted tool schema — not a raw wp_cli surface
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
IMPLEMENTATION's Sprint 3 sketch exposed a single `wp_cli` tool and tested that `wp eval` / `wp config` / `DROP TABLE` / `wp plugin install http` never appear in the schema. The product is a general-purpose coding agent ("type anything, the agent does it"), which needs richer tools, and the safety guarantee is better served by *not exposing* a raw shell-like surface at all.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The agent's tool schema is a set of **semantic, allowlisted tools** — `read_page`, `update_post`, `update_option`, `create_page`, `update_theme_json` — each mapping to a constrained WordPress operation. There is no `wp_cli`, `eval`, `config`, or raw SQL tool.
|
||||||
|
|
||||||
|
### Options considered
|
||||||
|
|
||||||
|
- Raw `wp_cli` tool with a deny-list of subcommands.
|
||||||
|
- Semantic allowlisted tools (chosen).
|
||||||
|
|
||||||
|
### Rejected
|
||||||
|
|
||||||
|
- Raw `wp_cli` — a deny-list is fail-open by nature; a new dangerous subcommand is one miss away. An allowlist of semantic tools is fail-closed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `api/src/agents/tool-schemas.ts` is the single allowlist; new capability is a new semantic tool with its own executor mapping, reviewed on its own.
|
||||||
|
- This is the tool surface the real-WP spike (`e2e/agent/`) exercises.
|
||||||
@@ -24,6 +24,13 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
|
|||||||
| [0008](0008-empty-packages-not-stubs.md) | Workspace ships empty packages, not placeholder source | Accepted |
|
| [0008](0008-empty-packages-not-stubs.md) | Workspace ships empty packages, not placeholder source | Accepted |
|
||||||
| [0009](0009-repo-rename.md) | Repository renamed originmain → wursor | Accepted |
|
| [0009](0009-repo-rename.md) | Repository renamed originmain → wursor | Accepted |
|
||||||
| [0010](0010-openrouter-live-golden.md) | Golden harness scores live runs through a provider-agnostic LLM client (OpenRouter first) | Accepted |
|
| [0010](0010-openrouter-live-golden.md) | Golden harness scores live runs through a provider-agnostic LLM client (OpenRouter first) | Accepted |
|
||||||
|
| [0011](0011-fastify-react-stack.md) | Fastify is the API server; React + Vite is the web shell | Accepted |
|
||||||
|
| [0012](0012-in-memory-user-store.md) | In-memory user store behind a UserStore interface; Postgres deferred | Accepted |
|
||||||
|
| [0013](0013-docker-boundary-mock.md) | Sandbox orchestration mocks the Docker boundary; real daemon client deferred | Accepted |
|
||||||
|
| [0014](0014-postgres-queryable.md) | Postgres user store via a Queryable boundary; schema in SQL migrations | Accepted |
|
||||||
|
| [0015](0015-dockerode-engine-gating.md) | Docker daemon client via dockerode behind an injected engine; sandbox gated by env | Accepted |
|
||||||
|
| [0016](0016-pairing-code-ownership.md) | Pairing-code TTL/lockout lives on the Wursor API; the plugin enforces token/HMAC/scope | Accepted |
|
||||||
|
| [0017](0017-semantic-tool-schema.md) | The agent is given a semantic, allowlisted tool schema — not a raw wp_cli surface | Accepted |
|
||||||
|
|
||||||
## How to add one
|
## How to add one
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "spike-01",
|
||||||
|
"prompt": "Rebrand this site: set the site title to 'Acme Dental', set the tagline to 'Gentle care for every smile', and create a new page titled 'Services' with a paragraph about teeth whitening.",
|
||||||
|
"asserts": [
|
||||||
|
{ "type": "option", "key": "blogname", "value": "Acme Dental" },
|
||||||
|
{ "type": "option", "key": "blogdescription", "value": "Gentle care for every smile" },
|
||||||
|
{ "type": "page_title_exists", "title": "Services" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spike-02",
|
||||||
|
"prompt": "Create an 'About' page that introduces the practice in one paragraph and a 'Contact' page that lists the phone number (555-0123).",
|
||||||
|
"asserts": [
|
||||||
|
{ "type": "page_title_exists", "title": "About" },
|
||||||
|
{ "type": "page_title_exists", "title": "Contact" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spike-03",
|
||||||
|
"prompt": "Find the existing 'Sample Page' and rewrite its content to start with 'Welcome to our practice.'.",
|
||||||
|
"asserts": [{ "type": "page_content_contains", "slug": "sample-page", "text": "Welcome to our practice." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { loadEnvFile } from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { runAgent } from '../../api/src/agents/agent-loop.ts';
|
||||||
|
import { OpenRouterLlmClient } from '../../api/src/agents/openrouter-client.ts';
|
||||||
|
import { WpRestExecutor } from '../../api/src/agents/wp-executor.ts';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
|
try {
|
||||||
|
loadEnvFile(join(root, '.env'));
|
||||||
|
} catch {
|
||||||
|
// no .env
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You are an expert WordPress agent working inside a sandboxed copy of the user\u2019s site. ' +
|
||||||
|
'Use the provided tools to complete the request. Work step by step: read what you need, make the ' +
|
||||||
|
'changes, and verify. Never touch anything outside the sandbox. When finished, reply with a one-line ' +
|
||||||
|
'summary of what you changed.';
|
||||||
|
|
||||||
|
type Assert =
|
||||||
|
| { type: 'option'; key: string; value: string }
|
||||||
|
| { type: 'page_title_exists'; title: string }
|
||||||
|
| { type: 'page_content_contains'; slug: string; text: string };
|
||||||
|
|
||||||
|
type Prompt = { id: string; prompt: string; asserts: Assert[] };
|
||||||
|
|
||||||
|
const base = process.env.WP_URL;
|
||||||
|
const username = process.env.WP_USER;
|
||||||
|
const appPassword = process.env.WP_APP_PASSWORD;
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
|
||||||
|
if (base === undefined || username === undefined || appPassword === undefined || apiKey === undefined) {
|
||||||
|
process.stderr.write('Missing env: WP_URL, WP_USER, WP_APP_PASSWORD, OPENROUTER_API_KEY\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const executor = new WpRestExecutor({ baseUrl: base, username, appPassword });
|
||||||
|
const llm = new OpenRouterLlmClient({ apiKey, model: process.env.OPENROUTER_MODEL });
|
||||||
|
const auth = `Basic ${Buffer.from(`${username}:${appPassword}`).toString('base64')}`;
|
||||||
|
|
||||||
|
async function get(url: string): Promise<unknown> {
|
||||||
|
const res = await fetch(`${base}${url}`, { headers: { Authorization: auth } });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status} ${url}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(html: string): string {
|
||||||
|
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check(a: Assert): Promise<boolean> {
|
||||||
|
if (a.type === 'option') {
|
||||||
|
const settings = (await get('/wp-json/wp/v2/settings')) as Record<string, string>;
|
||||||
|
return settings[a.key] === a.value;
|
||||||
|
}
|
||||||
|
if (a.type === 'page_title_exists') {
|
||||||
|
const pages = (await get(
|
||||||
|
`/wp-json/wp/v2/pages?search=${encodeURIComponent(a.title)}&_fields=title`,
|
||||||
|
)) as Array<{ title: { rendered: string } }>;
|
||||||
|
return pages.some((p) => stripTags(p.title.rendered) === a.title);
|
||||||
|
}
|
||||||
|
const pages = (await get(
|
||||||
|
`/wp-json/wp/v2/pages?slug=${encodeURIComponent(a.slug)}&_fields=content`,
|
||||||
|
)) as Array<{ content: { rendered: string } }>;
|
||||||
|
return stripTags(pages[0]?.content.rendered ?? '').includes(a.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompts = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'prompts.json'), 'utf8')) as Prompt[];
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
results: [] as Array<{ id: string; elapsedMs: number; toolCalls: number; halted: boolean; assertions: Array<{ ok: boolean; assert: Assert }> }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
const started = Date.now();
|
||||||
|
const result = await runAgent(llm, executor, { systemPrompt: SYSTEM_PROMPT, userPrompt: prompt.prompt, maxRounds: 12 });
|
||||||
|
const assertions = await Promise.all(prompt.asserts.map(async (assert) => ({ assert, ok: await check(assert) })));
|
||||||
|
report.results.push({
|
||||||
|
id: prompt.id,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
toolCalls: result.toolCalls,
|
||||||
|
halted: result.halted,
|
||||||
|
assertions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = join(dirname(fileURLToPath(import.meta.url)), 'runs');
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
writeFileSync(join(outDir, 'latest.json'), `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
+3
-1
@@ -5,9 +5,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"golden": "tsx golden/src/run-golden.ts",
|
"golden": "tsx golden/src/run-golden.ts",
|
||||||
"mirror:time": "tsx golden/src/run-mirror-timing.ts"
|
"mirror:time": "tsx golden/src/run-mirror-timing.ts",
|
||||||
|
"agent:spike": "tsx agent/run-agent-spike.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'pnpm --filter @wursor/api start',
|
||||||
|
url: 'http://localhost:3000/health',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'pnpm --filter @wursor/web dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('user signs up and sees the chat interface', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(`e2e-${Date.now()}@example.com`);
|
||||||
|
await page.getByLabel('Password').fill('password123');
|
||||||
|
await page.getByRole('button', { name: 'Sign up' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.wursor-chat-input')).toBeVisible();
|
||||||
|
await expect(page.locator('.wursor-welcome')).toContainText('Describe what you want');
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Wursor — DevOps & Infrastructure Brief
|
||||||
|
|
||||||
|
This is the handoff document for the DevOps / infrastructure engineer. It bundles everything Docker and hosting-related in one place: what exists now, what must be built, and how the infrastructure interfaces with the application code (api / web / plugin).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The product in one paragraph
|
||||||
|
|
||||||
|
Wursor is an agentic WordPress management platform. A non-technical site owner describes a change in chat; Wursor spins up an **isolated cloud sandbox** (a copy of their WordPress site), has an AI agent make the change, shows a **live preview**, and on approval **deploys** the change to the real site through a WordPress plugin. The sandbox is the safety guarantee — the live site is never touched until explicit approval.
|
||||||
|
|
||||||
|
**Stack:** Node.js + TypeScript (api), React + Vite (web), PHP (plugin), PostgreSQL (Wursor data), Redis (SSE/queue), Docker (sandboxes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Runtime topology
|
||||||
|
|
||||||
|
```
|
||||||
|
User browser
|
||||||
|
│ HTTPS
|
||||||
|
▼
|
||||||
|
Web app (React/Vite static build) ──proxied──► API (Fastify, Node 22)
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────┼──────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
PostgreSQL (Wursor data) Redis (SSE/queue/cache) Docker host (VPS)
|
||||||
|
users / sites / sessions / deploys │
|
||||||
|
├─ Pre-baked WordPress image (read-only)
|
||||||
|
├─ Warm pool (paused images + 1–2 hot spares)
|
||||||
|
├─ Active sandboxes (image + overlayfs site layer)
|
||||||
|
├─ Media proxy (nginx rewrites /wp-content/uploads → origin)
|
||||||
|
└─ GC (idle → pause-to-disk, 24h hard timeout → destroy)
|
||||||
|
|
||||||
|
User's live WordPress site ◄──── deploy via plugin REST API (Sprint 2+)
|
||||||
|
```
|
||||||
|
|
||||||
|
Component ownership:
|
||||||
|
|
||||||
|
| Component | Language | Repo path | State today |
|
||||||
|
|---|---|---|---|
|
||||||
|
| API server | Node 22 + TypeScript (Fastify) | `api/` | auth + sessions routes live |
|
||||||
|
| Web app | React 19 + Vite | `web/` | sign-up + chat shell live |
|
||||||
|
| WordPress plugin | PHP | `plugin/` | empty until Sprint 2 |
|
||||||
|
| Sandbox services | TypeScript | `api/src/sandbox/`, `api/src/services/` | logic + Docker client live (see §3) |
|
||||||
|
| Docker assets | Dockerfile / compose | `infrastructure/docker/` | scaffold live |
|
||||||
|
| e2e | Playwright | `e2e/` | chat-flow test live |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What already exists (no DevOps work needed to understand the contract)
|
||||||
|
|
||||||
|
These are implemented and unit-tested (mocked at the Docker/Postgres boundary, since the dev machine has neither):
|
||||||
|
|
||||||
|
- **`DockerClient` contract** (`api/src/sandbox/docker-client.ts`): `createSandbox(image)`, `destroySandbox(id)`, `status(id)`.
|
||||||
|
- **`DockerodeClient`** (`api/src/sandbox/dockerode-client.ts`): real implementation using [dockerode](https://github.com/apocas/dockerode). Auto-detects `DOCKER_HOST` or the local unix socket.
|
||||||
|
- **`SandboxManager`** (`api/src/services/sandbox-manager.ts`): `start()` → `{ sandboxId, previewUrl }`, `destroy(id)`.
|
||||||
|
- **`ImageManager`** (`api/src/sandbox/image-manager.ts`): owns the base image ref (`WUR_IMAGE`) and the container spec (port 80 → host `WUR_WEB_PORT`, label `wursor.managed=true`).
|
||||||
|
- **`WarmPool`** (`api/src/services/warm-pool.ts`): tops the pool up to `WARM_POOL_HOT_SPARES`.
|
||||||
|
- **`gc.ts`** (`api/src/sandbox/gc.ts`): pure decision function — running + idle → `pause`; any sandbox past hard timeout → `destroy`.
|
||||||
|
- **`subset.ts` / `media-proxy.ts` / `manifest.ts`**: DB subset, media proxying, path→sha256 delta (logic only; not yet wired to a live DB/daemon).
|
||||||
|
- **`POST /sessions`** (`api/src/routes/sessions.ts`): spins up a sandbox and returns `{ sessionId, sandboxId, previewUrl }`; returns `503 { error: "sandbox_not_configured" }` when sandboxing is off.
|
||||||
|
- **`POST /auth/signup`** + `GET /health`.
|
||||||
|
- **Postgres store** (`api/src/services/postgres-user-store.ts`) + migration `api/migrations/001_init.sql`; selected when `DATABASE_URL` is set, otherwise in-memory.
|
||||||
|
|
||||||
|
**The API ↔ Docker interface is already defined.** DevOps owns making the daemon side of that interface real and reliable, not redesigning it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Environment contract (single source of truth: `.env.example`)
|
||||||
|
|
||||||
|
| Variable | Purpose | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | API listen port | default `3000` |
|
||||||
|
| `DATABASE_URL` | Wursor PostgreSQL DSN | set → Postgres store; unset → in-memory (dev only) |
|
||||||
|
| `REDIS_URL` | Redis DSN | used from Sprint 3 (SSE/queue) |
|
||||||
|
| `SESSION_SECRET` | session signing | must be a real secret in prod |
|
||||||
|
| `LLM_PROVIDER` | `grok` \| `openrouter` | model provider |
|
||||||
|
| `XAI_API_KEY` / `OPENROUTER_API_KEY` | model keys | `OPENROUTER_MODEL` selects the model |
|
||||||
|
| `DOCKER_HOST` | Docker daemon (dockerode) | optional; auto-detected locally |
|
||||||
|
| `WUR_ENABLE_SANDBOX` | `1` → enable sandbox spin-up | unset → `/sessions` returns 503 |
|
||||||
|
| `WUR_IMAGE` | pre-baked image tag | default `wursor-base:latest` |
|
||||||
|
| `WUR_WEB_PORT` | host port for sandbox HTTP | default `8080` |
|
||||||
|
| `PREVIEW_BASE_URL` | base URL for preview links | default `http://localhost:8080` |
|
||||||
|
| `WARM_POOL_HOT_SPARES` | hot-spare count | default `2` |
|
||||||
|
|
||||||
|
Secrets are read from `.env` (gitignored) or the environment; never committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DevOps work items
|
||||||
|
|
||||||
|
Ordered by dependency. Each has an acceptance criterion.
|
||||||
|
|
||||||
|
### A. Docker host
|
||||||
|
- Provision a VPS (or managed Docker runtime) running a Docker daemon reachable by the API.
|
||||||
|
- Set `DOCKER_HOST` (or use the local unix socket when co-located) and `WUR_ENABLE_SANDBOX=1`.
|
||||||
|
- **Accept:** `docker info` succeeds from the API host; `POST /sessions` returns `201` with a `sandboxId`.
|
||||||
|
|
||||||
|
### B. Pre-baked WordPress image (finalize)
|
||||||
|
- The scaffold `infrastructure/docker/Dockerfile.wordpress` uses the Apache-based `wordpress:6.7-php8.2` image + WP-CLI. This is a **placeholder**.
|
||||||
|
- The target base image (PRD §6.2) is: **WordPress + nginx + PHP 8.x + MySQL 8.x + WP-CLI + Redis**, with the WordPress install on a **read-only base layer** and site-specific changes on an **overlayfs layer**.
|
||||||
|
- Build and tag `wursor-base:latest`; wire into CI image builds.
|
||||||
|
- **Accept:** `docker build` succeeds; a container boots and serves WordPress on port 80; `wp` CLI works in-container.
|
||||||
|
|
||||||
|
### C. Sandbox runtime: warm pool + GC + overlayfs
|
||||||
|
- **Warm pool:** maintain `WARM_POOL_HOT_SPARES` hot spares plus paused images. Pause-to-disk on idle; resume in ~2s (PRD R8).
|
||||||
|
- **GC:** implement the container-level effect of `gc.ts` decisions — 15-min idle → pause/checkpoint; 24h hard timeout → destroy, no exceptions.
|
||||||
|
- **Overlayfs:** site layers as overlayfs on the shared read-only image so sandboxes are cheap and fast.
|
||||||
|
- **Accept:** a sandbox boots in ≤10s from the warm pool; an idle sandbox pauses and resumes; a 24h sandbox is destroyed automatically.
|
||||||
|
|
||||||
|
### D. Media proxy
|
||||||
|
- Sandbox nginx rewrites `/wp-content/uploads/*` to the live origin (or a signed Wursor proxy). Media is **never bulk-copied**; a file is copied only when the agent replaces it (ADR 0007).
|
||||||
|
- **Accept:** a sandbox page renders live-site images without downloading the uploads directory.
|
||||||
|
|
||||||
|
### E. Preview routing + TLS
|
||||||
|
- Map each sandbox to a reachable preview URL (subdomain or per-sandbox port) with HTTPS.
|
||||||
|
- Note: the current code emits `PREVIEW_BASE_URL/<sandboxId>` with a single fixed `WUR_WEB_PORT` — a Sprint 1 placeholder. DevOps must provide **dynamic per-sandbox routing** so concurrent sandboxes don't collide.
|
||||||
|
- **Accept:** two concurrent sandboxes each resolve to distinct, working preview URLs.
|
||||||
|
|
||||||
|
### F. Data services
|
||||||
|
- **PostgreSQL** for Wursor data. Apply `api/migrations/001_init.sql` on first deploy; add a migration mechanism for future changes.
|
||||||
|
- **Redis** for SSE streaming and queues (required from Sprint 3).
|
||||||
|
- **Accept:** `DATABASE_URL` set → sign-up persists across API restarts; `redis-cli ping` succeeds.
|
||||||
|
|
||||||
|
### G. CI/CD (finish)
|
||||||
|
- `ci.yml` exists but is minimal (install + test + lint). Complete it per IMPLEMENTATION §8: split api/web/plugin jobs, add coverage gates (api/web ≥ 90%, plugin ≥ 80%), add an e2e job running the Playwright suite against a Docker service.
|
||||||
|
- **Accept:** a green CI run on PR, including the Playwright e2e suite.
|
||||||
|
|
||||||
|
### H. Secrets management
|
||||||
|
- Store `SESSION_SECRET`, `DATABASE_URL`, `REDIS_URL`, `DOCKER_HOST`, and LLM keys in the deployment secret store (not `.env`), injected as environment variables.
|
||||||
|
- **Accept:** no secret value appears in the repo or logs.
|
||||||
|
|
||||||
|
### I. Observability
|
||||||
|
- Minimal telemetry (sign-up, connect, task start/approve/reject, deploy) — Sprint 8. Logs for API + sandbox lifecycle; GC and warm-pool metrics.
|
||||||
|
- **Accept:** sandbox create/destroy and GC actions are observable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. How DevOps works with the codebase
|
||||||
|
|
||||||
|
**Contracts, not re-implementation.** The app calls into infrastructure through three stable seams:
|
||||||
|
|
||||||
|
1. **`DockerClient`** — the API already codes against `createSandbox` / `destroySandbox` / `status`. DevOps makes the daemon side behave, not change the interface.
|
||||||
|
2. **Environment variables** — the only runtime configuration. There is no config file to maintain; changing behavior is changing env vars (§4).
|
||||||
|
3. **`/health` and `/sessions`** — the integration smoke tests. `/health` proves the app is up; `/sessions` proves the Docker path end-to-end.
|
||||||
|
|
||||||
|
**Testing boundaries (TDD rule 3):** unit tests mock Docker and Postgres, so CI runs without a daemon. Only the e2e/integration layer talks to real Docker — the e2e job must run on a Docker-enabled runner (`services: docker` with `--privileged`).
|
||||||
|
|
||||||
|
**The plugin (Sprint 2+) is not DevOps-owned** but is part of the same deploy path: it runs on the *user's* WordPress site and exposes REST endpoints for site-info and deploy. DevOps provides the cloud side (snapshot storage for rollback — the last 3 deploy snapshots live in Wursor's cloud, PRD R3) and the media proxy origin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Key risks DevOps must honor (from PRD §13)
|
||||||
|
|
||||||
|
- **R4 (large sites):** task-scoped mirror + media proxy, never a full clone. The sandbox must not pull the user's entire media library.
|
||||||
|
- **R8 (cost):** no large fleet of always-running WP+MySQL boxes. Warm pool = paused images + 1–2 hot spares. Idle sandboxes pause to disk.
|
||||||
|
- **R1 (agent breaks sandbox):** overlayfs copy-on-write checkpoints; sandboxes are disposable.
|
||||||
|
- **R3 (undo when site is down):** last 3 deploy snapshots stored in Wursor's cloud.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Definition of done for the DevOps handoff
|
||||||
|
|
||||||
|
- [ ] Docker host reachable; `WUR_ENABLE_SANDBOX=1` and `/sessions` returns `201`.
|
||||||
|
- [ ] Final `wursor-base:latest` image built (nginx + PHP 8 + MySQL 8 + WP-CLI + Redis) and built in CI.
|
||||||
|
- [ ] Warm pool + GC + overlayfs running; sandbox boot ≤10s, resume ≤2s, 24h hard destroy.
|
||||||
|
- [ ] Media proxy live (uploads proxied, not copied).
|
||||||
|
- [ ] Dynamic preview routing + TLS.
|
||||||
|
- [ ] PostgreSQL (with migration) and Redis provisioned and wired via env.
|
||||||
|
- [ ] CI green including Playwright e2e on a Docker runner; coverage gates enforced.
|
||||||
|
- [ ] Secrets injected, never committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. References
|
||||||
|
|
||||||
|
- `IMPLEMENTATION.md` — §1 architecture, §8 CI/CD pipeline.
|
||||||
|
- `PRD.md` — §6.2 sandbox, §13 risk register (R1/R3/R4/R8/R14).
|
||||||
|
- `docs/decisions/` — ADRs 0007 (media proxy), 0013–0015 (Docker boundary, Postgres, dockerode gating).
|
||||||
|
- `api/src/sandbox/`, `api/src/services/` — the interfaces DevOps integrates against.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
FROM wordpress:6.7-php8.2
|
||||||
|
|
||||||
|
RUN curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp \
|
||||||
|
&& chmod +x /usr/local/bin/wp
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: wordpress
|
||||||
|
MYSQL_USER: wordpress
|
||||||
|
MYSQL_PASSWORD: wordpress
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
|
||||||
|
wordpress:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.wordpress
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
WORDPRESS_DB_HOST: db
|
||||||
|
WORDPRESS_DB_USER: wordpress
|
||||||
|
WORDPRESS_DB_PASSWORD: wordpress
|
||||||
|
WORDPRESS_DB_NAME: wordpress
|
||||||
|
volumes:
|
||||||
|
- wp_data:/var/www/html
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
wp_data:
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Wursor_Auth_Test extends WP_UnitTestCase {
|
||||||
|
|
||||||
|
private function sign( $secret, $timestamp, $method, $route, $body ) {
|
||||||
|
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
|
||||||
|
return hash_hmac( 'sha256', $canonical, $secret );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tear_down() {
|
||||||
|
Wursor_Auth::clear_tokens();
|
||||||
|
parent::tear_down();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_tokens_hashes_tokens_not_plaintext() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
|
||||||
|
$this->assertNotEquals( 'read-token', get_option( Wursor_Auth::OPTION_READ_HASH ) );
|
||||||
|
$this->assertEquals( hash( 'sha256', 'read-token' ), get_option( Wursor_Auth::OPTION_READ_HASH ) );
|
||||||
|
$this->assertNotEquals( 'hmac-secret', get_option( Wursor_Auth::OPTION_HMAC_SECRET ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_verify_token_accepts_matching_token() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
$this->assertTrue( Wursor_Auth::verify_token( 'read-token', 'read' ) );
|
||||||
|
$this->assertTrue( Wursor_Auth::verify_token( 'deploy-token', 'deploy' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_verify_token_rejects_wrong_token() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
$this->assertFalse( Wursor_Auth::verify_token( 'wrong', 'read' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_read_token_does_not_verify_as_deploy() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
$this->assertFalse( Wursor_Auth::verify_token( 'read-token', 'deploy' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hmac_accepts_valid_signature() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
$ts = (string) time();
|
||||||
|
$route = '/wursor/v1/site-info';
|
||||||
|
$body = '';
|
||||||
|
$this->assertTrue( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hmac_rejects_stale_timestamp() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
$ts = (string) ( time() - 120 );
|
||||||
|
$route = '/wursor/v1/site-info';
|
||||||
|
$body = '';
|
||||||
|
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hmac_rejects_tampered_body() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
$ts = (string) time();
|
||||||
|
$route = '/wursor/v1/files';
|
||||||
|
$sig = $this->sign( 'hmac-secret', $ts, 'POST', $route, '{"a":1}' );
|
||||||
|
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'POST', $route, '{"a":2}', $sig ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hmac_rejects_missing_signature() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
$this->assertFalse( Wursor_Auth::verify_hmac( (string) time(), 'GET', '/wursor/v1/site-info', '', null ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rotated_tokens_invalidate_old_hashes() {
|
||||||
|
Wursor_Auth::store_tokens( 'old-read', 'old-deploy', 'old-secret' );
|
||||||
|
Wursor_Auth::store_tokens( 'new-read', 'new-deploy', 'new-secret' );
|
||||||
|
|
||||||
|
$this->assertFalse( Wursor_Auth::verify_token( 'old-read', 'read' ) );
|
||||||
|
$this->assertTrue( Wursor_Auth::verify_token( 'new-read', 'read' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disconnect_clears_tokens() {
|
||||||
|
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||||
|
Wursor_Auth::clear_tokens();
|
||||||
|
$this->assertFalse( Wursor_Auth::is_connected() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin settings page: paste the Wursor pairing code, connect, disconnect.
|
||||||
|
*/
|
||||||
|
class Wursor_Admin {
|
||||||
|
|
||||||
|
public static function register_menu() {
|
||||||
|
add_menu_page( 'Wursor', 'Wursor', 'manage_options', 'wursor', array( __CLASS__, 'render' ), 'dashicons-update' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render() {
|
||||||
|
self::handle_post();
|
||||||
|
$connected = Wursor_Auth::is_connected();
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Wursor</h1>
|
||||||
|
<?php if ( $connected ) : ?>
|
||||||
|
<p>This site is connected to Wursor.</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="wursor_disconnect" value="1" />
|
||||||
|
<?php submit_button( 'Disconnect' ); ?>
|
||||||
|
</form>
|
||||||
|
<?php else : ?>
|
||||||
|
<p>Paste the pairing code from Wursor to connect this site.</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="text" name="wursor_pairing_code" maxlength="8" autocomplete="off" placeholder="ABCD1234" />
|
||||||
|
<?php submit_button( 'Connect' ); ?>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function handle_post() {
|
||||||
|
if ( isset( $_POST['wursor_disconnect'] ) ) {
|
||||||
|
Wursor_Auth::clear_tokens();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( empty( $_POST['wursor_pairing_code'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = sanitize_text_field( wp_unslash( $_POST['wursor_pairing_code'] ) );
|
||||||
|
$api_url = get_option( 'wursor_api_url', 'https://api.wursor.dev' );
|
||||||
|
|
||||||
|
$response = wp_remote_post(
|
||||||
|
rtrim( $api_url, '/' ) . '/sites/redeem',
|
||||||
|
array(
|
||||||
|
'body' => wp_json_encode( array( 'code' => $code, 'siteUrl' => home_url( '/' ) ) ),
|
||||||
|
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||||
|
'timeout' => 15,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
if ( isset( $body['readToken'], $body['deployToken'], $body['hmacSecret'] ) ) {
|
||||||
|
Wursor_Auth::store_tokens( $body['readToken'], $body['deployToken'], $body['hmacSecret'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API routes under /wp-json/wursor/v1/.
|
||||||
|
* Every request requires a Bearer token with the right scope and a valid HMAC signature.
|
||||||
|
*/
|
||||||
|
class Wursor_API {
|
||||||
|
|
||||||
|
public static function register_routes() {
|
||||||
|
register_rest_route( 'wursor/v1', '/site-info', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => array( __CLASS__, 'get_site_info' ),
|
||||||
|
'permission_callback' => array( __CLASS__, 'authorize_read' ),
|
||||||
|
) );
|
||||||
|
register_rest_route( 'wursor/v1', '/files', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => array( __CLASS__, 'stub' ),
|
||||||
|
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
|
||||||
|
) );
|
||||||
|
register_rest_route( 'wursor/v1', '/db', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => array( __CLASS__, 'stub' ),
|
||||||
|
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
|
||||||
|
) );
|
||||||
|
register_rest_route( 'wursor/v1', '/wp-cli', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => array( __CLASS__, 'stub' ),
|
||||||
|
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function authorize_read( WP_REST_Request $request ) {
|
||||||
|
return self::authorize( $request, 'read' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function authorize_deploy( WP_REST_Request $request ) {
|
||||||
|
return self::authorize( $request, 'deploy' );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function authorize( WP_REST_Request $request, $scope ) {
|
||||||
|
$auth = $request->get_header( 'authorization' );
|
||||||
|
if ( ! is_string( $auth ) || 0 !== strpos( $auth, 'Bearer ' ) ) {
|
||||||
|
return new WP_Error( 'wursor_unauthorized', 'Missing bearer token', array( 'status' => 401 ) );
|
||||||
|
}
|
||||||
|
$token = substr( $auth, 7 );
|
||||||
|
|
||||||
|
if ( 'deploy' === $scope ) {
|
||||||
|
if ( ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
|
||||||
|
return new WP_Error( 'wursor_forbidden', 'Deploy token required', array( 'status' => 403 ) );
|
||||||
|
}
|
||||||
|
} elseif ( ! Wursor_Auth::verify_token( $token, 'read' ) && ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
|
||||||
|
return new WP_Error( 'wursor_unauthorized', 'Invalid token', array( 'status' => 401 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = $request->get_header( 'x-wursor-timestamp' );
|
||||||
|
$signature = $request->get_header( 'x-wursor-signature' );
|
||||||
|
$route = $request->get_route();
|
||||||
|
$body = $request->get_body();
|
||||||
|
|
||||||
|
if ( ! Wursor_Auth::verify_hmac( $timestamp, $request->get_method(), $route, $body, $signature ) ) {
|
||||||
|
return new WP_Error( 'wursor_bad_signature', 'Bad HMAC signature', array( 'status' => 401 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get_site_info( WP_REST_Request $request ) {
|
||||||
|
return Wursor_Site_Info::get_site_info();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function stub( WP_REST_Request $request ) {
|
||||||
|
return new WP_Error( 'wursor_not_implemented', 'Not implemented until Sprint 6', array( 'status' => 501 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token storage, verification, and request signing (HMAC).
|
||||||
|
*
|
||||||
|
* Tokens are stored only as SHA-256 hashes; the HMAC secret is stored encrypted
|
||||||
|
* with a key derived from the site's AUTH_KEY + AUTH_SALT. See spikes/pairing-threat-model.md.
|
||||||
|
*/
|
||||||
|
class Wursor_Auth {
|
||||||
|
const OPTION_READ_HASH = 'wursor_read_token_hash';
|
||||||
|
const OPTION_DEPLOY_HASH = 'wursor_deploy_token_hash';
|
||||||
|
const OPTION_HMAC_SECRET = 'wursor_hmac_secret';
|
||||||
|
const MAX_SKEW_SECONDS = 60;
|
||||||
|
|
||||||
|
public static function store_tokens( $read_token, $deploy_token, $hmac_secret ) {
|
||||||
|
update_option( self::OPTION_READ_HASH, hash( 'sha256', $read_token ), true );
|
||||||
|
update_option( self::OPTION_DEPLOY_HASH, hash( 'sha256', $deploy_token ), true );
|
||||||
|
update_option( self::OPTION_HMAC_SECRET, self::encrypt( $hmac_secret ), true );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clear_tokens() {
|
||||||
|
delete_option( self::OPTION_READ_HASH );
|
||||||
|
delete_option( self::OPTION_DEPLOY_HASH );
|
||||||
|
delete_option( self::OPTION_HMAC_SECRET );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function is_connected() {
|
||||||
|
return false !== get_option( self::OPTION_READ_HASH );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify_token( $token, $scope ) {
|
||||||
|
$option = 'deploy' === $scope ? self::OPTION_DEPLOY_HASH : self::OPTION_READ_HASH;
|
||||||
|
$stored = get_option( $option );
|
||||||
|
return is_string( $stored ) && hash_equals( $stored, hash( 'sha256', $token ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify_hmac( $timestamp, $method, $route, $body, $signature ) {
|
||||||
|
if ( ! is_string( $signature ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( abs( time() - intval( $timestamp ) ) > self::MAX_SKEW_SECONDS ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
|
||||||
|
$expected = hash_hmac( 'sha256', $canonical, self::hmac_secret() );
|
||||||
|
return hash_equals( $expected, $signature );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hmac_secret() {
|
||||||
|
$encrypted = get_option( self::OPTION_HMAC_SECRET );
|
||||||
|
return false === $encrypted ? '' : self::decrypt( $encrypted );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function encryption_key() {
|
||||||
|
return hash( 'sha256', wp_salt( 'auth' ) . wp_salt( 'auth_salt' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function encrypt( $value ) {
|
||||||
|
$iv = random_bytes( 16 );
|
||||||
|
$tag = '';
|
||||||
|
$ciphertext = openssl_encrypt( $value, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
|
||||||
|
if ( false === $ciphertext ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return base64_encode( $iv . $tag . $ciphertext );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function decrypt( $value ) {
|
||||||
|
$data = base64_decode( $value, true );
|
||||||
|
if ( false === $data || strlen( $data ) < 32 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$iv = substr( $data, 0, 16 );
|
||||||
|
$tag = substr( $data, 16, 16 );
|
||||||
|
$ciphertext = substr( $data, 32 );
|
||||||
|
$plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
|
||||||
|
return false === $plaintext ? '' : $plaintext;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site information provider: theme, plugins, versions, builder, capability tiers, preflight.
|
||||||
|
* Builder detection mirrors e2e/golden/src/builder-detect.ts (see ADR 0006).
|
||||||
|
*/
|
||||||
|
class Wursor_Site_Info {
|
||||||
|
|
||||||
|
public static function get_site_info() {
|
||||||
|
$theme = wp_get_theme();
|
||||||
|
return array(
|
||||||
|
'theme' => $theme->get_stylesheet(),
|
||||||
|
'plugins' => self::plugins(),
|
||||||
|
'wordpress_version' => get_bloginfo( 'version' ),
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
'builder' => self::detect_builder(),
|
||||||
|
'capabilities' => self::capabilities(),
|
||||||
|
'preflight' => self::preflight(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function plugins() {
|
||||||
|
if ( ! function_exists( 'get_plugins' ) ) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||||
|
}
|
||||||
|
$all = get_plugins();
|
||||||
|
$active = (array) get_option( 'active_plugins', array() );
|
||||||
|
$result = array();
|
||||||
|
foreach ( $all as $plugin_file => $data ) {
|
||||||
|
$result[] = array(
|
||||||
|
'slug' => self::slug_from_file( $plugin_file ),
|
||||||
|
'active' => in_array( $plugin_file, $active, true ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function active_slugs() {
|
||||||
|
$active = (array) get_option( 'active_plugins', array() );
|
||||||
|
return array_map( array( __CLASS__, 'slug_from_file' ), $active );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function slug_from_file( $file ) {
|
||||||
|
$dir = dirname( $file );
|
||||||
|
return '.' === $dir ? basename( $file, '.php' ) : $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function front_page_id() {
|
||||||
|
$front = (int) get_option( 'page_on_front' );
|
||||||
|
if ( $front > 0 ) {
|
||||||
|
return $front;
|
||||||
|
}
|
||||||
|
$pages = get_pages( array( 'number' => 1 ) );
|
||||||
|
return empty( $pages ) ? 0 : $pages[0]->ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function front_page_content() {
|
||||||
|
$id = self::front_page_id();
|
||||||
|
return $id > 0 ? (string) get_post_field( 'post_content', $id ) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function detect_builder() {
|
||||||
|
$theme = wp_get_theme()->get_stylesheet();
|
||||||
|
$active = self::active_slugs();
|
||||||
|
$id = self::front_page_id();
|
||||||
|
$content = self::front_page_content();
|
||||||
|
|
||||||
|
$elementor_mode = $id > 0 ? get_post_meta( $id, '_elementor_edit_mode', true ) : '';
|
||||||
|
$elementor_data = $id > 0 ? get_post_meta( $id, '_elementor_data', true ) : '';
|
||||||
|
$fl_builder = $id > 0 ? get_post_meta( $id, '_fl_builder_data', true ) : '';
|
||||||
|
$et_pb = $id > 0 ? get_post_meta( $id, '_et_pb_use_builder', true ) : '';
|
||||||
|
|
||||||
|
if ( in_array( 'elementor', $active, true ) && ( '' !== $elementor_mode || '' !== $elementor_data ) ) {
|
||||||
|
return 'elementor';
|
||||||
|
}
|
||||||
|
if ( in_array( 'beaver-builder-lite-version', $active, true ) && '' !== $fl_builder ) {
|
||||||
|
return 'beaver';
|
||||||
|
}
|
||||||
|
if ( false !== stripos( $theme, 'divi' ) && 'on' === $et_pb ) {
|
||||||
|
return 'divi';
|
||||||
|
}
|
||||||
|
if ( false !== strpos( $content, '<!-- wp:' ) ) {
|
||||||
|
return 'gutenberg';
|
||||||
|
}
|
||||||
|
return 'classic';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function capabilities() {
|
||||||
|
$full = version_compare( get_bloginfo( 'version' ), '6.1', '>=' ) && version_compare( PHP_VERSION, '8.0', '>=' );
|
||||||
|
$install_safe = ! defined( 'DISALLOW_FILE_MODS' ) || ! DISALLOW_FILE_MODS;
|
||||||
|
return array(
|
||||||
|
'content' => true,
|
||||||
|
'design' => $full,
|
||||||
|
'install' => $full && $install_safe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function preflight() {
|
||||||
|
return array(
|
||||||
|
'https' => is_ssl(),
|
||||||
|
'disallow_file_mods' => defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS,
|
||||||
|
'disk_free' => function_exists( 'disk_free_space' ) ? disk_free_space( ABSPATH ) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Wursor
|
||||||
|
* Description: Connects this WordPress site to Wursor for safe, previewed changes.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Requires PHP: 7.4
|
||||||
|
* Author: Wursor
|
||||||
|
* License: GPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/src/class-auth.php';
|
||||||
|
require_once __DIR__ . '/src/class-site-info.php';
|
||||||
|
require_once __DIR__ . '/src/class-api.php';
|
||||||
|
require_once __DIR__ . '/src/class-admin.php';
|
||||||
|
|
||||||
|
add_action('rest_api_init', array('Wursor_API', 'register_routes'));
|
||||||
|
add_action('admin_menu', array('Wursor_Admin', 'register_menu'));
|
||||||
Generated
+2003
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ApproveBar } from '../src/components/ApproveBar.tsx';
|
||||||
|
|
||||||
|
describe('ApproveBar', () => {
|
||||||
|
it('renders apply and reject buttons when visible', () => {
|
||||||
|
render(<ApproveBar visible onApprove={vi.fn()} onReject={vi.fn()} />);
|
||||||
|
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /reject/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onApprove and onReject', async () => {
|
||||||
|
const onApprove = vi.fn();
|
||||||
|
const onReject = vi.fn();
|
||||||
|
render(<ApproveBar visible onApprove={onApprove} onReject={onReject} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||||
|
expect(onApprove).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /reject/i }));
|
||||||
|
expect(onReject).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when not visible', () => {
|
||||||
|
const { container } = render(<ApproveBar visible={false} onApprove={vi.fn()} onReject={vi.fn()} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ChatPanel } from '../src/components/ChatPanel.tsx';
|
||||||
|
|
||||||
|
describe('ChatPanel', () => {
|
||||||
|
it('renders user and agent messages', () => {
|
||||||
|
render(
|
||||||
|
<ChatPanel
|
||||||
|
messages={[
|
||||||
|
{ id: '1', role: 'user', text: 'Change the heading' },
|
||||||
|
{ id: '2', role: 'agent', text: 'Done' },
|
||||||
|
]}
|
||||||
|
status="done"
|
||||||
|
onSend={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Change the heading')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSend with the typed message', async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
render(<ChatPanel messages={[]} status="idle" onSend={onSend} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('Describe what you want…'), 'Change the heading');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /send/i }));
|
||||||
|
|
||||||
|
expect(onSend).toHaveBeenCalledWith('Change the heading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a working indicator while the agent works', () => {
|
||||||
|
render(<ChatPanel messages={[]} status="working" onSend={vi.fn()} />);
|
||||||
|
expect(screen.getByText(/working/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { SignUp } from '../src/components/SignUp.tsx';
|
||||||
|
|
||||||
|
describe('SignUp', () => {
|
||||||
|
it('submits the email and password', async () => {
|
||||||
|
const onSignUp = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<SignUp onSignUp={onSignUp} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Email'), 'a@example.com');
|
||||||
|
await userEvent.type(screen.getByLabelText('Password'), 'password123');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
||||||
|
|
||||||
|
expect(onSignUp).toHaveBeenCalledWith('a@example.com', 'password123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error when signup fails', async () => {
|
||||||
|
const onSignUp = vi.fn().mockRejectedValue(new Error('email_exists'));
|
||||||
|
render(<SignUp onSignUp={onSignUp} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Email'), 'a@example.com');
|
||||||
|
await userEvent.type(screen.getByLabelText('Password'), 'password123');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('email_exists');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { SiteConnector } from '../src/components/SiteConnector.tsx';
|
||||||
|
|
||||||
|
describe('SiteConnector', () => {
|
||||||
|
it('shows the pairing code', () => {
|
||||||
|
render(<SiteConnector code="ABCD1234" checkConnected={vi.fn().mockResolvedValue({ connected: false })} />);
|
||||||
|
expect(screen.getByText('ABCD1234')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success state when connected', async () => {
|
||||||
|
render(<SiteConnector code="ABCD1234" checkConnected={vi.fn().mockResolvedValue({ connected: true })} />);
|
||||||
|
expect(await screen.findByText('Site connected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state when the check fails', async () => {
|
||||||
|
render(<SiteConnector code="ABCD1234" checkConnected={vi.fn().mockRejectedValue(new Error('nope'))} />);
|
||||||
|
expect(await screen.findByText('Connection failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls until the site is connected', async () => {
|
||||||
|
const checkConnected = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ connected: false })
|
||||||
|
.mockResolvedValueOnce({ connected: true });
|
||||||
|
render(<SiteConnector code="ABCD1234" checkConnected={checkConnected} pollIntervalMs={5} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Site connected')).toBeInTheDocument();
|
||||||
|
expect(checkConnected).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Wursor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+22
-1
@@ -2,5 +2,26 @@
|
|||||||
"name": "@wursor/web",
|
"name": "@wursor/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {}
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.4",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.2.1",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ApproveBar } from './components/ApproveBar.tsx';
|
||||||
|
import { ChatPanel } from './components/ChatPanel.tsx';
|
||||||
|
import { Preview } from './components/Preview.tsx';
|
||||||
|
import { SignUp } from './components/SignUp.tsx';
|
||||||
|
import { useChat } from './hooks/useChat.ts';
|
||||||
|
|
||||||
|
async function signUp(email: string, password: string): Promise<string> {
|
||||||
|
const res = await fetch('/auth/signup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new Error(body?.error ?? 'Sign up failed');
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { sessionToken: string };
|
||||||
|
return body.sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [sessionToken, setSessionToken] = useState<string | null>(null);
|
||||||
|
const [applied, setApplied] = useState(false);
|
||||||
|
const chat = useChat(sessionToken);
|
||||||
|
|
||||||
|
if (sessionToken === null) {
|
||||||
|
return (
|
||||||
|
<div className="auth-screen">
|
||||||
|
<SignUp onSignUp={async (email, password) => setSessionToken(await signUp(email, password))} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-topbar">
|
||||||
|
<span className="app-logo">Wursor</span>
|
||||||
|
<span className="app-badge">Preview sandbox</span>
|
||||||
|
</header>
|
||||||
|
<div className="app-body">
|
||||||
|
<ChatPanel messages={chat.messages} status={chat.status} onSend={chat.send} />
|
||||||
|
<Preview heading={chat.heading} status={chat.status} applied={applied} />
|
||||||
|
</div>
|
||||||
|
<ApproveBar
|
||||||
|
visible={chat.status === 'done' && !applied}
|
||||||
|
onApprove={() => setApplied(true)}
|
||||||
|
onReject={() => setApplied(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
type ApproveBarProps = {
|
||||||
|
visible: boolean;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApproveBar({ visible, onApprove, onReject }: ApproveBarProps) {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="approve-bar">
|
||||||
|
<span className="approve-copy">Looks good?</span>
|
||||||
|
<button className="wursor-approve-button" type="button" onClick={onApprove}>
|
||||||
|
Looks good → Apply
|
||||||
|
</button>
|
||||||
|
<button className="wursor-reject-button" type="button" onClick={onReject}>
|
||||||
|
Not right → Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { ChatMessage, ChatStatus } from '../hooks/useChat.ts';
|
||||||
|
|
||||||
|
type ChatPanelProps = {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
status: ChatStatus;
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatPanel({ messages, status, onSend }: ChatPanelProps) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = draft.trim();
|
||||||
|
if (text === '') return;
|
||||||
|
setDraft('');
|
||||||
|
onSend(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="chat">
|
||||||
|
<div className="chat-scroll">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="wursor-welcome">
|
||||||
|
<p className="welcome-kicker">Wursor</p>
|
||||||
|
<h2>Describe what you want.</h2>
|
||||||
|
<p className="welcome-sub">Change wording, colors, add a page — just say it. We preview it first.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="chat-list">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<li key={message.id} className={`message message-${message.role} wursor-message-${message.role}`}>
|
||||||
|
{message.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{status === 'working' && <div className="wursor-working">Working on it…</div>}
|
||||||
|
</div>
|
||||||
|
<form className="chat-composer" onSubmit={submit}>
|
||||||
|
<input
|
||||||
|
className="wursor-chat-input"
|
||||||
|
placeholder="Describe what you want…"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="wursor-chat-send" type="submit" aria-label="Send">
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ChatStatus } from '../hooks/useChat.ts';
|
||||||
|
|
||||||
|
type PreviewProps = {
|
||||||
|
heading: string;
|
||||||
|
status: ChatStatus;
|
||||||
|
applied: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Preview({ heading, status, applied }: PreviewProps) {
|
||||||
|
return (
|
||||||
|
<main className="preview">
|
||||||
|
<div className="browser">
|
||||||
|
<div className="browser-bar">
|
||||||
|
<span className="dot dot-red" />
|
||||||
|
<span className="dot dot-yellow" />
|
||||||
|
<span className="dot dot-green" />
|
||||||
|
<span className="browser-url">preview.wursor.dev</span>
|
||||||
|
{applied && <span className="preview-badge">Applied to your site ✓</span>}
|
||||||
|
</div>
|
||||||
|
<div className="browser-body">
|
||||||
|
{status === 'working' ? (
|
||||||
|
<div className="preview-working">Applying your change…</div>
|
||||||
|
) : (
|
||||||
|
<div className="mock-site">
|
||||||
|
<nav className="mock-nav">
|
||||||
|
<span className="mock-brand">Your Site</span>
|
||||||
|
<span>About</span>
|
||||||
|
<span>Contact</span>
|
||||||
|
</nav>
|
||||||
|
<div className="mock-hero">
|
||||||
|
<h1 className="wursor-preview-frame">{heading}</h1>
|
||||||
|
<p>This is a live preview of your sandbox — the real site stays untouched until you approve.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
type SignUpProps = {
|
||||||
|
onSignUp: (email: string, password: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SignUp({ onSignUp }: SignUpProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSignUp(email, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="wursor-signup" onSubmit={submit}>
|
||||||
|
<input name="email" type="email" aria-label="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
aria-label="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error ? <p role="alert">{error}</p> : null}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type SiteConnectorProps = {
|
||||||
|
code: string;
|
||||||
|
checkConnected: () => Promise<{ connected: boolean }>;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = 'pending' | 'connected' | 'error';
|
||||||
|
|
||||||
|
export function SiteConnector({ code, checkConnected, pollIntervalMs = 2000 }: SiteConnectorProps) {
|
||||||
|
const [state, setState] = useState<State>('pending');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const { connected } = await checkConnected();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (connected) {
|
||||||
|
setState('connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(poll, pollIntervalMs);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setState('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [checkConnected, pollIntervalMs]);
|
||||||
|
|
||||||
|
if (state === 'connected') {
|
||||||
|
return <div className="wursor-connected">Site connected</div>;
|
||||||
|
}
|
||||||
|
if (state === 'error') {
|
||||||
|
return <div className="wursor-error">Connection failed</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="wursor-pairing">
|
||||||
|
<p>Enter this code in your Wursor plugin:</p>
|
||||||
|
<code className="wursor-pairing-code">{code}</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export type ChatMessage = { id: string; role: 'user' | 'agent'; text: string };
|
||||||
|
export type ChatStatus = 'idle' | 'working' | 'done';
|
||||||
|
|
||||||
|
function extractHeading(text: string): string | undefined {
|
||||||
|
const match = text.match(/["“']([^"”']+)["”']/);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockReply(text: string, heading: string | undefined): string {
|
||||||
|
return heading !== undefined
|
||||||
|
? `Done — I updated the homepage heading to “${heading}”. Preview it below.`
|
||||||
|
: 'Done — your change is ready to preview.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat(sessionToken: string | null) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [status, setStatus] = useState<ChatStatus>('idle');
|
||||||
|
const [heading, setHeading] = useState('Welcome to our site');
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
const nextHeading = extractHeading(text);
|
||||||
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]);
|
||||||
|
setStatus('working');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(sessionToken !== null ? { Authorization: `Bearer ${sessionToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: text }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('chat unavailable');
|
||||||
|
const body = (await res.json()) as { reply?: string };
|
||||||
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: body.reply ?? 'Done' }]);
|
||||||
|
} catch {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: mockReply(text, nextHeading) }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextHeading !== undefined) {
|
||||||
|
setHeading(nextHeading);
|
||||||
|
}
|
||||||
|
setStatus('done');
|
||||||
|
},
|
||||||
|
[sessionToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, status, heading, send };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App.tsx';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
:root {
|
||||||
|
--bg-app: #0c0c10;
|
||||||
|
--bg-panel: #111115;
|
||||||
|
--bg-toolbar: #141418;
|
||||||
|
--bg-surface: #1c1c22;
|
||||||
|
--bg-input: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
--fg: rgba(255, 255, 255, 0.88);
|
||||||
|
--fg-2: rgba(255, 255, 255, 0.45);
|
||||||
|
--fg-3: rgba(255, 255, 255, 0.22);
|
||||||
|
|
||||||
|
--border: rgba(255, 255, 255, 0.055);
|
||||||
|
--border-2: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--accent: #3385ff;
|
||||||
|
--accent-bg: rgba(51, 133, 255, 0.14);
|
||||||
|
--accent-strong: #0066ff;
|
||||||
|
--success: #10b981;
|
||||||
|
|
||||||
|
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--sh-lg: 0 10px 24px rgba(0, 0, 0, 0.35), 0 4px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
--sh-product: 0 48px 96px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
--r-2: 8px;
|
||||||
|
--r-3: 12px;
|
||||||
|
--r-4: 16px;
|
||||||
|
--r-5: 20px;
|
||||||
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg-app);
|
||||||
|
color: var(--fg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Auth screen ---------- */
|
||||||
|
.auth-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(51, 133, 255, 0.08), transparent 60%), var(--bg-app);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-signup {
|
||||||
|
width: 360px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-5);
|
||||||
|
padding: 40px 32px;
|
||||||
|
box-shadow: var(--sh-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-signup input {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-signup input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-signup button {
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-signup button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 102, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-signup button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-signup [role='alert'] {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- App chrome ---------- */
|
||||||
|
.app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-topbar {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: var(--bg-toolbar);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-3);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Chat panel ---------- */
|
||||||
|
.chat {
|
||||||
|
width: 380px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-welcome h2 {
|
||||||
|
font-size: 1.625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.028em;
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-kicker {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-sub {
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--r-3);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
animation: pop 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-agent {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-working {
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 12px 2px;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-chat-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-chat-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-chat-send {
|
||||||
|
width: 44px;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-chat-send:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Preview ---------- */
|
||||||
|
.preview {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser {
|
||||||
|
width: min(1000px, 100%);
|
||||||
|
height: min(640px, 100%);
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: var(--r-5);
|
||||||
|
box-shadow: var(--sh-product);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f4f4f5;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-red {
|
||||||
|
background: #ff5f57;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-yellow {
|
||||||
|
background: #febc2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-green {
|
||||||
|
background: #28c840;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-url {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badge {
|
||||||
|
color: #047857;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-working {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #71717a;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-site {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 20px 40px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #52525b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-hero {
|
||||||
|
padding: 80px 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #0a0a0a;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
transition: opacity 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-hero p {
|
||||||
|
color: #52525b;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Approve bar ---------- */
|
||||||
|
.approve-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(28, 28, 34, 0.92);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.6);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 8px 10px 8px 20px;
|
||||||
|
box-shadow: var(--sh-lg);
|
||||||
|
animation: rise 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-copy {
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-approve-button {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-approve-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 102, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-reject-button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg-2);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wursor-reject-button:hover {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Motion ---------- */
|
||||||
|
@keyframes pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.app-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chat {
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
height: 50%;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user