diff --git a/.changeset/dokploy-deploy-adapter.md b/.changeset/dokploy-deploy-adapter.md new file mode 100644 index 0000000..14b8601 --- /dev/null +++ b/.changeset/dokploy-deploy-adapter.md @@ -0,0 +1,9 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Add `dokployTarget` — a second real `DeployTarget`, for self-hosted Dokploy. + +`dokployTarget({ serverUrl, applicationId })` triggers a deployment of a pre-configured Dokploy application over the Dokploy API (`POST /api/application.deploy`, `x-api-key` auth). Dokploy builds and serves the app server-side, so — unlike `cloudflareTarget`, which builds and uploads from the session — this target is a simple API trigger and takes no runner session. It never throws: a missing token, a bad response, or a network failure return `{ deployed: false, detail }`. Credentials come from `apiToken` or `DOKPLOY_AUTH_TOKEN` / `DOKPLOY_API_KEY`. + +Also fixes the spelling of the deploy target in `DEFAULT_DEPLOY_TARGETS`: `dockploy` → `dokploy` (the real product name). diff --git a/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts b/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts index 3630ded..3c18ee6 100644 --- a/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts +++ b/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts @@ -126,7 +126,7 @@ describe('Bootstrap — prototype scope', () => { describe('Bootstrap — deploy phase', () => { const deployStub = (): NonNullable => () => ({ - plan: { render: 'ssr', target: 'dockploy', reason: 'per-request data' }, + plan: { render: 'ssr', target: 'dokploy', reason: 'per-request data' }, result: { deployed: false, detail: 'plan only' }, }) @@ -136,13 +136,13 @@ describe('Bootstrap — deploy phase', () => { const result = await boot.run() assert.equal(result.deploy?.plan.render, 'ssr') - assert.equal(result.deploy?.plan.target, 'dockploy') + assert.equal(result.deploy?.plan.target, 'dokploy') assert.equal(result.deploy?.result.deployed, false) // deploy narration + event land after the checklist, before done const types = events.map(e => e.type) assert.deepEqual(types.slice(-3), ['narrate', 'deploy', 'done']) const deployEvent = events.find(e => e.type === 'deploy') - assert.equal(deployEvent?.type === 'deploy' && deployEvent.plan.target, 'dockploy') + assert.equal(deployEvent?.type === 'deploy' && deployEvent.plan.target, 'dokploy') }) it('is optional — no deploy step means no deploy on the result', async () => { diff --git a/packages/ai-autopilot/src/bootstrap/deploy.test.ts b/packages/ai-autopilot/src/bootstrap/deploy.test.ts index 219627a..bb56acb 100644 --- a/packages/ai-autopilot/src/bootstrap/deploy.test.ts +++ b/packages/ai-autopilot/src/bootstrap/deploy.test.ts @@ -15,7 +15,7 @@ const ctx = (over: Partial = {}): DeployContext => ({ describe('planOnlyTarget (v1 default seam)', () => { it('decides + narrates only — never reports a deploy', async () => { const target = planOnlyTarget() - const result = await target.deploy({ plan: { render: 'ssr', target: 'dockploy', reason: 'x' }, intent: 'a shop' }) + const result = await target.deploy({ plan: { render: 'ssr', target: 'dokploy', reason: 'x' }, intent: 'a shop' }) assert.equal(result.deployed, false) assert.match(result.detail ?? '', /infra-gated/) }) @@ -36,13 +36,13 @@ describe('agentDeploy (default step over an ai-sdk agent)', () => { const fake = AiFake.fake() try { fake.respondWithSequence([ - { text: JSON.stringify({ render: 'ssr', target: 'dockploy', reason: 'auth + per-request data' }) }, + { text: JSON.stringify({ render: 'ssr', target: 'dokploy', reason: 'auth + per-request data' }) }, ]) const target = new FakeDeployTarget() const step = agentDeploy(agent({ instructions: 'deployer' }), { target }) const outcome = await step(ctx()) - assert.deepEqual(outcome.plan, { render: 'ssr', target: 'dockploy', reason: 'auth + per-request data' }) + assert.deepEqual(outcome.plan, { render: 'ssr', target: 'dokploy', reason: 'auth + per-request data' }) assert.equal(outcome.result.deployed, true) assert.equal(target.deployed.length, 1) // the plan reached the target } finally { @@ -67,7 +67,7 @@ describe('agentDeploy (default step over an ai-sdk agent)', () => { try { fake.respondWithSequence([{ text: JSON.stringify({ render: 'ssr', target: 'heroku', reason: 'habit' }) }]) const outcome = await agentDeploy(agent({ instructions: 'deployer' }))(ctx()) - assert.equal(outcome.plan.target, DEFAULT_DEPLOY_TARGETS[0]) // 'heroku' is not allowed → 'dockploy' + assert.equal(outcome.plan.target, DEFAULT_DEPLOY_TARGETS[0]) // 'heroku' is not allowed → 'dokploy' } finally { fake.restore() } diff --git a/packages/ai-autopilot/src/bootstrap/deploy.ts b/packages/ai-autopilot/src/bootstrap/deploy.ts index a000c47..b7487af 100644 --- a/packages/ai-autopilot/src/bootstrap/deploy.ts +++ b/packages/ai-autopilot/src/bootstrap/deploy.ts @@ -22,7 +22,7 @@ import type { */ /** The target names the deploy step steers toward by default. */ -export const DEFAULT_DEPLOY_TARGETS = ['dockploy', 'cloudflare'] as const +export const DEFAULT_DEPLOY_TARGETS = ['dokploy', 'cloudflare'] as const const RENDER_MODES: readonly RenderMode[] = ['ssr', 'ssg', 'spa'] diff --git a/packages/ai-autopilot/src/bootstrap/dokploy.test.ts b/packages/ai-autopilot/src/bootstrap/dokploy.test.ts new file mode 100644 index 0000000..ffa7987 --- /dev/null +++ b/packages/ai-autopilot/src/bootstrap/dokploy.test.ts @@ -0,0 +1,116 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { dokployTarget, type FetchLike } from './dokploy.js' +import type { DeployPlan, DeployTargetContext } from './types.js' + +interface FetchCall { + url: string + init?: RequestInit +} + +/** A fake fetch that records calls and returns a canned response. */ +function fakeFetch(response: { status?: number; body?: string } = {}): FetchLike & { calls: FetchCall[] } { + const calls: FetchCall[] = [] + const fn = (async (url: string, init?: RequestInit) => { + calls.push({ url, ...(init ? { init } : {}) }) + const status = response.status ?? 200 + return { + ok: status >= 200 && status < 300, + status, + async text() { + return response.body ?? '' + }, + } as Response + }) as FetchLike & { calls: FetchCall[] } + fn.calls = calls + return fn +} + +const ctx: DeployTargetContext = { + plan: { render: 'ssr', target: 'dokploy', reason: 'per-request data' } as DeployPlan, + intent: 'an app', +} + +function bodyOf(init?: RequestInit): Record { + return JSON.parse(String(init?.body)) +} + +test('triggers a deploy for the configured application and reports success', async () => { + const fetch = fakeFetch() + const target = dokployTarget({ serverUrl: 'https://dok.example.com', applicationId: 'app_123', apiToken: 'tok', fetch }) + const res = await target.deploy(ctx) + assert.equal(res.deployed, true) + assert.match(res.detail ?? '', /app_123/) + assert.equal(fetch.calls.length, 1) + assert.equal(fetch.calls[0]!.url, 'https://dok.example.com/api/application.deploy') + const headers = fetch.calls[0]!.init?.headers as Record + assert.equal(headers['x-api-key'], 'tok') + assert.equal(bodyOf(fetch.calls[0]!.init).applicationId, 'app_123') +}) + +test('normalizes a serverUrl that already ends in /api or a slash', async () => { + const fetch = fakeFetch() + await dokployTarget({ serverUrl: 'https://dok.example.com/api/', applicationId: 'a', apiToken: 't', fetch }).deploy(ctx) + assert.equal(fetch.calls[0]!.url, 'https://dok.example.com/api/application.deploy') +}) + +test('redeploy hits the application.redeploy endpoint', async () => { + const fetch = fakeFetch() + await dokployTarget({ serverUrl: 'https://dok.example.com', applicationId: 'a', apiToken: 't', redeploy: true, fetch }).deploy(ctx) + assert.match(fetch.calls[0]!.url, /application\.redeploy$/) +}) + +test('a missing token short-circuits before any request', async () => { + const saved = { auth: process.env.DOKPLOY_AUTH_TOKEN, key: process.env.DOKPLOY_API_KEY } + delete process.env.DOKPLOY_AUTH_TOKEN + delete process.env.DOKPLOY_API_KEY + try { + const fetch = fakeFetch() + const res = await dokployTarget({ serverUrl: 'https://dok.example.com', applicationId: 'a', fetch }).deploy(ctx) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /DOKPLOY_AUTH_TOKEN/) + assert.equal(fetch.calls.length, 0) + } finally { + if (saved.auth !== undefined) process.env.DOKPLOY_AUTH_TOKEN = saved.auth + if (saved.key !== undefined) process.env.DOKPLOY_API_KEY = saved.key + } +}) + +test('the token falls back to DOKPLOY_AUTH_TOKEN in the environment', async () => { + const saved = process.env.DOKPLOY_AUTH_TOKEN + process.env.DOKPLOY_AUTH_TOKEN = 'env-tok' + try { + const fetch = fakeFetch() + await dokployTarget({ serverUrl: 'https://dok.example.com', applicationId: 'a', fetch }).deploy(ctx) + const headers = fetch.calls[0]!.init?.headers as Record + assert.equal(headers['x-api-key'], 'env-tok') + } finally { + if (saved === undefined) delete process.env.DOKPLOY_AUTH_TOKEN + else process.env.DOKPLOY_AUTH_TOKEN = saved + } +}) + +test('a non-2xx response surfaces the status and body', async () => { + const fetch = fakeFetch({ status: 401, body: 'invalid api key' }) + const res = await dokployTarget({ serverUrl: 'https://dok.example.com', applicationId: 'a', apiToken: 'bad', fetch }).deploy(ctx) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /401/) + assert.match(res.detail ?? '', /invalid api key/) +}) + +test('a network rejection is caught, not thrown', async () => { + const fetch = (async () => { + throw new TypeError('fetch failed') + }) as FetchLike + const res = await dokployTarget({ serverUrl: 'https://dok.example.com', applicationId: 'a', apiToken: 't', fetch }).deploy(ctx) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /request failed/) +}) + +test('a missing applicationId short-circuits', async () => { + const fetch = fakeFetch() + const res = await dokployTarget({ serverUrl: 'https://dok.example.com', applicationId: '', apiToken: 't', fetch }).deploy(ctx) + assert.equal(res.deployed, false) + assert.match(res.detail ?? '', /applicationId/) + assert.equal(fetch.calls.length, 0) +}) diff --git a/packages/ai-autopilot/src/bootstrap/dokploy.ts b/packages/ai-autopilot/src/bootstrap/dokploy.ts new file mode 100644 index 0000000..5c8b5b4 --- /dev/null +++ b/packages/ai-autopilot/src/bootstrap/dokploy.ts @@ -0,0 +1,85 @@ +import type { DeployResult, DeployTarget, DeployTargetContext } from './types.js' + +/** The `fetch` surface the adapter needs — global `fetch` satisfies it; tests pass a fake. */ +export type FetchLike = (input: string, init?: RequestInit) => Promise + +/** Options for {@link dokployTarget}. */ +export interface DokployTargetOptions { + /** Base URL of the Dokploy instance, e.g. `https://dokploy.example.com` (a trailing `/api` is fine). */ + serverUrl: string + /** The pre-configured Dokploy application to (re)deploy. */ + applicationId: string + /** API token. Falls back to `DOKPLOY_AUTH_TOKEN`, then `DOKPLOY_API_KEY`, in the environment. */ + apiToken?: string + /** Use `application.redeploy` (rebuild from scratch) instead of `application.deploy`. */ + redeploy?: boolean + /** `fetch` implementation, injectable for tests. Defaults to the global `fetch`. */ + fetch?: FetchLike + /** Target name, matched against {@link DeployPlan.target}. Default `'dokploy'`. */ + name?: string +} + +/** Normalize a base URL to `/api`, tolerating a trailing slash or an included `/api`. */ +function apiBase(serverUrl: string): string { + return `${serverUrl.replace(/\/+$/, '').replace(/\/api$/, '')}/api` +} + +/** + * A real {@link DeployTarget} that ships to a self-hosted [Dokploy](https://dokploy.com) + * instance. Dokploy builds and serves the app server-side from its own configured + * git source, so — unlike {@link cloudflareTarget}, which builds and uploads from + * the session — this target just triggers a deployment over the Dokploy API + * (`POST /api/application.deploy`) for a pre-configured application. + * + * It never throws: a missing token, a bad response, or a network failure come back + * as `{ deployed: false, detail }`. Dokploy does not return the app's public URL + * from the deploy trigger (the domain is configured on the Dokploy side), so a + * successful result reports the triggered deployment rather than a URL. + * + * Credentials come from `apiToken` (or `DOKPLOY_AUTH_TOKEN` / `DOKPLOY_API_KEY`). + * + * ```ts + * deploy: agentDeploy(deployer, { + * targets: ['dokploy'], + * target: dokployTarget({ serverUrl: 'https://dokploy.example.com', applicationId: 'app_123' }), + * }) + * ``` + */ +export function dokployTarget(options: DokployTargetOptions): DeployTarget { + const { serverUrl, applicationId, redeploy = false, name = 'dokploy' } = options + const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init)) + + return { + name, + async deploy(ctx: DeployTargetContext): Promise { + const apiToken = options.apiToken ?? process.env.DOKPLOY_AUTH_TOKEN ?? process.env.DOKPLOY_API_KEY + if (!apiToken) { + return { deployed: false, detail: 'no Dokploy API token — set DOKPLOY_AUTH_TOKEN or pass apiToken.' } + } + if (!serverUrl) return { deployed: false, detail: 'a Dokploy deploy needs a serverUrl.' } + if (!applicationId) return { deployed: false, detail: 'a Dokploy deploy needs an applicationId.' } + + const endpoint = `${apiBase(serverUrl)}/application.${redeploy ? 'redeploy' : 'deploy'}` + let res: Response + try { + res = await doFetch(endpoint, { + method: 'POST', + headers: { 'x-api-key': apiToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ applicationId, title: 'GemStack bootstrap deploy', description: ctx.plan.reason }), + ...(ctx.signal ? { signal: ctx.signal } : {}), + }) + } catch (cause) { + return { deployed: false, detail: `Dokploy request failed: ${cause instanceof Error ? cause.message : String(cause)}` } + } + + if (!res.ok) { + const body = await res.text().catch(() => '') + return { deployed: false, detail: `Dokploy deploy failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ''}` } + } + return { + deployed: true, + detail: `Triggered Dokploy ${redeploy ? 'redeploy' : 'deploy'} of application ${applicationId} on ${apiBase(serverUrl)}.`, + } + }, + } +} diff --git a/packages/ai-autopilot/src/bootstrap/index.ts b/packages/ai-autopilot/src/bootstrap/index.ts index 79cc537..67116d5 100644 --- a/packages/ai-autopilot/src/bootstrap/index.ts +++ b/packages/ai-autopilot/src/bootstrap/index.ts @@ -39,6 +39,7 @@ export { type CloudflareProduct, type DeployExecutor, } from './cloudflare.js' +export { dokployTarget, type DokployTargetOptions, type FetchLike } from './dokploy.js' export type { BootstrapScope, BootstrapPhase, diff --git a/packages/ai-autopilot/src/bootstrap/steps.test.ts b/packages/ai-autopilot/src/bootstrap/steps.test.ts index bb45b76..a3533e0 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.test.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.test.ts @@ -139,7 +139,7 @@ describe('Bootstrap end-to-end with the default steps (offline)', () => { }), }, { text: 'scaffolded the catalog page and orders schema' }, - { text: JSON.stringify({ render: 'ssr', target: 'dockploy', reason: 'per-request catalog + auth' }) }, + { text: JSON.stringify({ render: 'ssr', target: 'dokploy', reason: 'per-request catalog + auth' }) }, ]) // The full-fledged loop: first checklist has a blocker, second is clean. @@ -186,8 +186,8 @@ describe('Bootstrap end-to-end with the default steps (offline)', () => { assert.equal(result.productionGrade, true) assert.equal(improved, 1) // improved once, between the two checks assert.equal(ledger.size, 1) // architect choice recorded - // deploy ran last: decided SSR/dockploy and reached the (fake) target - assert.deepEqual(result.deploy?.plan, { render: 'ssr', target: 'dockploy', reason: 'per-request catalog + auth' }) + // deploy ran last: decided SSR/dokploy and reached the (fake) target + assert.deepEqual(result.deploy?.plan, { render: 'ssr', target: 'dokploy', reason: 'per-request catalog + auth' }) assert.equal(result.deploy?.result.url, 'https://bookstore.example') assert.equal(deployTarget.deployed.length, 1) // build events were forwarded into the narration diff --git a/packages/ai-autopilot/src/bootstrap/types.ts b/packages/ai-autopilot/src/bootstrap/types.ts index 846fb63..115fad5 100644 --- a/packages/ai-autopilot/src/bootstrap/types.ts +++ b/packages/ai-autopilot/src/bootstrap/types.ts @@ -49,7 +49,7 @@ export type RenderMode = 'ssr' | 'ssg' | 'spa' /** The deploy decision: how to render, where to ship, and why. */ export interface DeployPlan { render: RenderMode - /** The deploy target's name (e.g. "dockploy", "cloudflare"). */ + /** The deploy target's name (e.g. "dokploy", "cloudflare"). */ target: string /** One-line rationale, to narrate. */ reason: string diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 5bd9a85..e31cc0a 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -73,8 +73,8 @@ * - {@link agentArchitect} / {@link supervisorBuild} — the default step wirings * - {@link loopChecklist} / {@link loopImprove} — the full-fledged loop steps * - {@link agentDeploy} + the {@link DeployTarget} seam ({@link planOnlyTarget}, - * {@link FakeDeployTarget}, {@link cloudflareTarget}) — the final phase: decide - * SSR/SSG/SPA + target and narrate, then ship via a real adapter + * {@link FakeDeployTarget}, {@link cloudflareTarget}, {@link dokployTarget}) — the + * final phase: decide SSR/SSG/SPA + target and narrate, then ship via a real adapter * * Scale mode keeps a compact `CODE-OVERVIEW.md` the agent reads first in a large * repo, refreshed only on *material* change (build tooling, test framework, a @@ -229,10 +229,13 @@ export { serveCheck, mergeChecklists, cloudflareTarget, + dokployTarget, type ServeCheckOptions, type CloudflareTargetOptions, type CloudflareProduct, type DeployExecutor, + type DokployTargetOptions, + type FetchLike, type ArchitectAgentOptions, type SupervisorBuildOptions, type LoopStepOptions,