Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
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
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,44 @@ kody search --target mock local
`/__kody/dev/events` (server-sent events). The page reloads after each swap
to a new build. Build errors, and runtime errors from the page's own
`fetch` calls, show an overlay; the page reloads on the next good build.
There is no HMR. Compressed HTML and `kody dev fetch` responses are left
untouched.
There is no HMR. Only requests that accept `text/html` (browser page loads)
get the script; compressed HTML, agent requests, and `kody dev fetch`
responses are left untouched.
- **Cleanup**: Ctrl-C, `SIGTERM`, and `SIGHUP` stop workerd and remove the
`kody-dev-*` temp dir. If the CLI is killed outright, a small reaper process
does the same. Each start also sweeps dirs and orphaned workerd processes
left behind by dead CLI runs, including older CLI versions.

### Driving `kody dev` from an agent

```bash
kody dev --json --ready-file .kody-dev.json & # NDJSON events on stdout
curl -s http://127.0.0.1:<port>/__kody/dev/status
kody dev fetch /scores # reuses the running server
```

- `--json` prints one JSON object per line on stdout, each with a `type` and
an ISO `ts`:
- `ready`: `url`, `port`, `basePath`, `pid`, `statusUrl`, `buildId`
- `rebuild_start`: `files`
- `rebuild_ok`: `ms`, `buildId`, `restarted`
- `build_error`: `file`, `line`, `col`, `message`, `diagnostics`
- `runtime_error`: `message`, `stack`, `request`
- `log`: `source`, `level`, `message`, `request`

Status lines stay on stderr.
- `GET /__kody/dev/status` returns `{ ok, state, buildId, url, port,
basePath, target, package, pid, lastRebuild, lastError }`. `state` is one of
`starting`, `building`, `ready`, or `build_error`. `lastError` holds the
current build error or the most recent runtime error.
- `--ready-file <path>` writes `{ url, port, basePath, pid, statusUrl }` once
the first build settles, and deletes the file on exit.
- `kody dev fetch` attaches to a running `kody dev` for the same package and
`--target` (found through the user cache dir and confirmed via the status
endpoint) instead of booting a second workerd. The request waits for any
in-progress rebuild, so fetching right after a save sees the new code. Pass
`--no-attach` to force a one-shot server.

## Local execute

`kody execute --local` runs the same execute module (default export called
Expand Down
19 changes: 19 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ function parseKnown(args: Array<string>) {
port: { type: 'string' },
'app-base-path': { type: 'string' },
'no-live-reload': { type: 'boolean' },
'ready-file': { type: 'string' },
'no-attach': { type: 'boolean' },
method: { type: 'string' },
body: { type: 'string' },
clients: { type: 'string' },
Expand Down Expand Up @@ -1066,6 +1068,7 @@ async function dispatch(
runDevJob,
runDevServe,
runDevWebhook,
tryAttachedDevFetch,
} = await import('./dev-command.js')
const target = resolveTarget({
command: 'dev',
Expand Down Expand Up @@ -1157,6 +1160,18 @@ async function dispatch(
}
}
}
if (subcommand === 'fetch' && parsed.values['no-attach'] !== true && rest[0]) {
const attached = await tryAttachedDevFetch({
target,
path: rest[0],
method:
typeof parsed.values.method === 'string' ? parsed.values.method : undefined,
body: bodyPath ? await readFile(bodyPath, 'utf8') : undefined,
json,
io,
})
if (attached !== null) return attached
}
const token =
target === 'prod'
? await resolveLocalExecuteBearer({
Expand Down Expand Up @@ -1227,6 +1242,10 @@ async function dispatch(
appBasePath,
allowPrivateNetwork,
liveReload: parsed.values['no-live-reload'] !== true,
json,
...(typeof parsed.values['ready-file'] === 'string'
? { readyFile: parsed.values['ready-file'] }
: {}),
io,
})
case 'fetch': {
Expand Down
149 changes: 137 additions & 12 deletions src/dev-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* against the local package), webhooks, schedules (job once), and subscriptions.
*/

import { access, readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import {
detectCredentialedFetchInSource,
mocksSuggestionDetail,
Expand All @@ -26,6 +26,12 @@ import type { KodyTarget } from './target.js'
import { formatToolResult } from './tool-result.js'
import { bundleLocalUserModule } from './local-bundle.js'
import { formatDevEvent, type DevEvent } from './dev/dev-events.js'
import {
devStatusPath,
findRunningDevServer,
removeDevRegistration,
writeDevRegistration,
} from './dev/dev-registry.js'

export type DevIo = {
stdout: (text: string) => void
Expand Down Expand Up @@ -111,12 +117,19 @@ export async function runDevServe(input: {
passthroughHosts?: ReadonlyArray<string>
/** Inject the live-reload client into HTML responses (default true). */
liveReload?: boolean
/** NDJSON events on stdout instead of human output. */
json?: boolean
/** Write `{ url, port, basePath, pid, statusUrl }` here once the first build settles. */
readyFile?: string
cwd?: string
io: DevIo
/** Injected for tests — when set, serve returns immediately after start. */
serveOnce?: boolean
}): Promise<number> {
const manifest = await loadPackageManifest(input.cwd)
const onEvent = input.json
? (event: DevEvent) => writeJsonEvent(input.io, event)
: (event: DevEvent) => writeDevEvent(input.io, event)
const server = await startPackageAppDev({
manifest,
token: input.auth.token,
Expand All @@ -135,25 +148,71 @@ export async function runDevServe(input: {
liveReload: input.liveReload ?? true,
onStatus: (message) => input.io.stderr(`${message}\n`),
onError: (message) => input.io.stderr(`${message}\n`),
onEvent: (event) => writeDevEvent(input.io, event),
onEvent,
})
input.io.stdout(
`kody dev listening on ${server.hostedUrl}${server.appBasePath || '/'}\n` +
`target: ${input.target}\n` +
`package: ${manifest.name} (${manifest.kodyId})\n` +
`Agents: kody dev fetch /path | job <name> | webhook <name> --body file.json | emit <event> --body file.json | check\n`,
)
if (input.serveOnce) {
const shutdown = async () => {
await server.close()
await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {})
if (input.readyFile) await removeOwnedReadyFile(input.readyFile, process.pid)
await input.auth.closeMock?.()
}
try {
const status = server.status()
const statusUrl = `${server.hostedUrl}${devStatusPath}`
const ready = {
url: status.url,
port: server.port,
basePath: server.appBasePath,
pid: process.pid,
statusUrl,
}
await writeDevRegistration({
pid: process.pid,
origin: server.hostedUrl,
port: server.port,
basePath: server.appBasePath,
target: input.target,
root: manifest.root,
}).catch(() => {})
if (input.readyFile) {
await mkdir(dirname(input.readyFile), { recursive: true })
await writeFile(input.readyFile, `${JSON.stringify(ready, null, 2)}\n`)
}
if (input.json) {
input.io.stdout(
`${JSON.stringify({
type: 'ready',
ts: new Date().toISOString(),
...ready,
target: input.target,
package: status.package,
buildId: status.buildId,
state: status.state,
})}\n`,
)
} else {
input.io.stdout(
`kody dev listening on ${status.url}\n` +
`target: ${input.target}\n` +
`package: ${manifest.name} (${manifest.kodyId})\n` +
`status: ${statusUrl}\n` +
`Agents: kody dev fetch /path | job <name> | webhook <name> --body file.json | emit <event> --body file.json | check\n`,
)
}
} catch (error) {
// Ready-file / registration failures must not leave the listen handles up.
await shutdown().catch(() => {})
throw error
}
if (input.serveOnce) {
await shutdown()
return 0
}
await new Promise<void>((resolveWait) => {
const signals: Array<NodeJS.Signals> = ['SIGINT', 'SIGTERM', 'SIGHUP']
const stop = async () => {
for (const signal of signals) process.off(signal, onSig)
await server.close()
await input.auth.closeMock?.()
await shutdown()
resolveWait()
}
const onSig = () => {
Expand All @@ -164,6 +223,54 @@ export async function runDevServe(input: {
return 0
}

/**
* `kody dev fetch` against an already running `kody dev` for this package and
* target, if there is one. Returns null when nothing is running so the caller
* can boot a one-shot server.
*/
export async function tryAttachedDevFetch(input: {
target: KodyTarget
path: string
method?: string
body?: string
cwd?: string
json?: boolean
io: DevIo
}): Promise<number | null> {
let root: string
try {
root = (await loadPackageManifest(input.cwd)).root
} catch {
return null
}
const running = await findRunningDevServer({ root, target: input.target })
if (!running) return null
const { registration } = running
const rest = input.path.startsWith('/') ? input.path : `/${input.path}`
const url = new URL(
`${registration.basePath}${rest === '/' && registration.basePath ? '' : rest}` || '/',
registration.origin,
)
input.io.stderr(`Attached to running kody dev (pid ${registration.pid}) at ${registration.origin}\n`)
const response = await fetch(url, {
method: input.method ?? 'GET',
headers: input.body ? { 'content-type': 'application/json' } : undefined,
body: input.body,
})
Comment thread
cursor[bot] marked this conversation as resolved.
const body = await response.text()
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})
const result = { status: response.status, headers, body }
if (input.json) {
input.io.stdout(`${JSON.stringify(result, null, 2)}\n`)
} else {
input.io.stdout(`HTTP ${result.status}\n${result.body}\n`)
}
return result.status >= 400 ? 1 : 0
}

export async function runDevFetch(input: {
target: KodyTarget
auth: DevAuth
Expand Down Expand Up @@ -648,6 +755,10 @@ async function printDevDryRun(input: {
}
}

function writeJsonEvent(io: DevIo, event: DevEvent): void {
io.stdout(`${JSON.stringify({ ...event, ts: new Date().toISOString() })}\n`)
}

function writeDevEvent(io: DevIo, event: DevEvent): void {
const { stream, text } = formatDevEvent(event)
io[stream](`${text}\n`)
Expand Down Expand Up @@ -677,4 +788,18 @@ export function parseDevSubcommand(positionals: Array<string>): {
}
}

/**
* Remove `--ready-file` only when it still names this process. A successor
* `kody dev` may have rewritten the same path before our shutdown finishes.
*/
export async function removeOwnedReadyFile(path: string, ownerPid: number): Promise<void> {
try {
const parsed = JSON.parse(await readFile(path, 'utf8')) as { pid?: unknown }
if (parsed.pid !== ownerPid) return
await rm(path, { force: true })
} catch {
// Missing or unreadable — nothing to clean up.
}
}

void (null as unknown as PackageAppDevServer)
Loading
Loading