Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/dokploy-deploy-adapter.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 3 additions & 3 deletions packages/ai-autopilot/src/bootstrap/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('Bootstrap — prototype scope', () => {

describe('Bootstrap — deploy phase', () => {
const deployStub = (): NonNullable<BootstrapSteps['deploy']> => () => ({
plan: { render: 'ssr', target: 'dockploy', reason: 'per-request data' },
plan: { render: 'ssr', target: 'dokploy', reason: 'per-request data' },
result: { deployed: false, detail: 'plan only' },
})

Expand All @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/ai-autopilot/src/bootstrap/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const ctx = (over: Partial<DeployContext> = {}): 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/)
})
Expand All @@ -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 {
Expand All @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-autopilot/src/bootstrap/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
116 changes: 116 additions & 0 deletions packages/ai-autopilot/src/bootstrap/dokploy.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, string>
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<string, string>
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)
})
85 changes: 85 additions & 0 deletions packages/ai-autopilot/src/bootstrap/dokploy.ts
Original file line number Diff line number Diff line change
@@ -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<Response>

/** 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 `<origin>/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<DeployResult> {
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)}.`,
}
},
}
}
1 change: 1 addition & 0 deletions packages/ai-autopilot/src/bootstrap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/ai-autopilot/src/bootstrap/steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-autopilot/src/bootstrap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading