Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
16 changes: 16 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,22 @@ All tests should pass against the local PostgreSQL database.
| `pnpm stripe` | Start Stripe webhook forwarding to localhost |
| `pnpm test:e2e` | Run Playwright end-to-end tests |

## Dev CLI

The repo includes a Bun-based dev CLI that manages service dependencies and selectively starts what you need:

```bash
pnpm kilo up # Start Next.js + Postgres + Redis + migrations
pnpm kilo up kiloclaw # Start KiloClaw + all its dependencies
pnpm kilo up cloud-agent # Start Cloud Agent + dependencies
pnpm kilo status # Check what's running
pnpm kilo env check # Validate all .dev.vars files
pnpm kilo down # Stop Docker infrastructure
pnpm kilo logs # List all available services
```

The CLI resolves service dependencies automatically — `pnpm kilo up kiloclaw` will start Postgres, Redis, run migrations, start Next.js, then start the KiloClaw worker.

## Git Workflow

- Direct commits to `main` are blocked by a pre-commit hook. Always work on a feature branch.
Expand Down
19 changes: 19 additions & 0 deletions dev/cli/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions dev/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@kilocode/dev-cli",
"version": "0.0.1",
"private": true,
"type": "module",
"devDependencies": {
"bun-types": "^1.3.10"
}
}
8 changes: 8 additions & 0 deletions dev/cli/src/commands/down.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as docker from '../infra/docker';
import * as ui from '../utils/ui';

export async function down(root: string) {
ui.header('Stopping services');
await docker.stopAll(root);
ui.success('Docker services stopped');
}
63 changes: 63 additions & 0 deletions dev/cli/src/commands/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { services } from '../services/registry';
import { parseEnvFile, findMissingVars } from '../utils/env';
import * as ui from '../utils/ui';
import { join } from 'path';

export async function envCheck(root: string) {
ui.header('Environment Variable Check');

const envLocalPath = join(root, '.env.local');
const envLocalExists = await Bun.file(envLocalPath).exists();
if (envLocalExists) {
ui.success('.env.local exists');
} else {
ui.error('.env.local missing — run: vercel env pull');
}

const vercelProjectPath = join(root, '.vercel', 'project.json');
const vercelLinked = await Bun.file(vercelProjectPath).exists();
if (vercelLinked) {
ui.success('Vercel project linked');
} else {
ui.warn('Vercel project not linked — run: vercel link --project kilocode-app');
}

let allGood = envLocalExists && vercelLinked;

const servicesWithEnv = services.filter(s => s.envFile);

for (const svc of servicesWithEnv) {
const examplePath = join(root, svc.dir, svc.envFile!);
const actualPath = join(root, svc.dir, '.dev.vars');

const exampleExists = await Bun.file(examplePath).exists();
const actualExists = await Bun.file(actualPath).exists();

if (!actualExists) {
ui.warn(`${svc.name}: .dev.vars missing (copy from ${svc.envFile})`);
allGood = false;
continue;
}

if (exampleExists) {
const exampleContent = await Bun.file(examplePath).text();
const actualContent = await Bun.file(actualPath).text();
const example = parseEnvFile(exampleContent);
const actual = parseEnvFile(actualContent);
const missing = findMissingVars(example, actual);

if (missing.length > 0) {
ui.warn(`${svc.name}: placeholder values: ${missing.join(', ')}`);
allGood = false;
} else {
ui.success(`${svc.name}: .dev.vars OK`);
}
}
}

if (allGood) {
console.log(`\n ${ui.green('All environment checks passed!')}\n`);
} else {
console.log(`\n ${ui.yellow('Some checks need attention (see above)')}\n`);
}
}
36 changes: 36 additions & 0 deletions dev/cli/src/commands/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { services } from '../services/registry';
import * as ui from '../utils/ui';

export async function logs(args: string[], root: string) {
if (args.length === 0) {
ui.header('Available services');
for (const svc of services) {
const portInfo = svc.port ? ` (port ${svc.port})` : '';
console.log(` ${svc.name.padEnd(20)} ${ui.dim(svc.description)}${portInfo}`);
}
return;
}

const name = args[0];
const svc = services.find(s => s.name === name);
if (!svc) {
ui.error(`Unknown service: "${name}"`);
return;
}

if (svc.type === 'infra' && svc.devCommand?.startsWith('docker compose')) {
const proc = Bun.spawn(
['docker', 'compose', '-f', 'dev/docker-compose.yml', 'logs', '-f', svc.name],
{ stdout: 'inherit', stderr: 'inherit', cwd: root }
);
await proc.exited;
} else if (svc.type === 'infra') {
ui.warn(
`"${svc.name}" is not a Docker Compose service (runs: ${svc.devCommand ?? 'n/a'}).\n It does not produce persistent logs that can be tailed.`
);
} else {
ui.warn(
`Log tailing for running dev servers is not yet supported.\n Start the service with 'pnpm kilo dev up ${name}' to see its output.`
);
}
}
46 changes: 46 additions & 0 deletions dev/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { services } from '../services/registry';
import * as docker from '../infra/docker';
import * as ui from '../utils/ui';

export async function status(root: string) {
ui.header('Service Status');

const pgHealthy = await docker.isHealthy(root, 'postgres');
const redisHealthy = await docker.isHealthy(root, 'redis');

console.log(
` ${pgHealthy ? ui.green('●') : ui.red('●')} postgres ${pgHealthy ? 'running' : 'stopped'}`
);
console.log(
` ${redisHealthy ? ui.green('●') : ui.red('●')} redis ${redisHealthy ? 'running' : 'stopped'}`
);

const portServices = services.filter(s => s.port && s.type !== 'infra');
for (const svc of portServices) {
const listening = await isPortListening(svc.port!);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Port-only checks misreport services that share a port

This status view assumes one service per port, but the new registry assigns 8792 to both auto-fix and db-proxy, and 8795 to both kiloclaw and git-token. If either process is running, this loop marks the sibling service as running too, so pnpm kilo status produces false positives.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed. Port-only checks do misreport when multiple services share a port. Fix in #1302: status now resolves by process name (or PID) rather than relying solely on the port, so co-tenanted ports no longer cause false positives.

console.log(
` ${listening ? ui.green('●') : ui.dim('○')} ${svc.name.padEnd(12)} ${listening ? `port ${svc.port}` : ui.dim('not running')}`
);
}

console.log();
}

async function isPortListening(port: number): Promise<boolean> {
try {
const socket = await Bun.connect({
hostname: '127.0.0.1',
port,
socket: {
data() {},
open(s) {
s.end();
},
error() {},
},
});
return true;
} catch {
return false;
}
}
29 changes: 29 additions & 0 deletions dev/cli/src/commands/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { startQuickTunnel, startNamedTunnel, updateDevVarsUrl } from '../infra/tunnel';
import * as ui from '../utils/ui';
import { join } from 'path';

export async function tunnel(args: string[], root: string) {
const nameIdx = args.indexOf('--name');
const tunnelName = nameIdx !== -1 ? args[nameIdx + 1] : undefined;
const port = 3000;

if (tunnelName) {
ui.header(`Starting named tunnel: ${tunnelName}`);
startNamedTunnel(tunnelName);
} else {
ui.header('Starting quick tunnel');
const result = await startQuickTunnel(port);
if (result.url) {
ui.success(`Tunnel URL: ${result.url}`);
const devVarsPath = join(root, 'kiloclaw', '.dev.vars');
if (await Bun.file(devVarsPath).exists()) {
await updateDevVarsUrl(devVarsPath, result.url);
}
} else {
ui.warn('Could not capture tunnel URL within 30s');
ui.warn('Check cloudflared output and manually update .dev.vars');
}
}

await new Promise(() => {});
}
78 changes: 78 additions & 0 deletions dev/cli/src/commands/up.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { resolve } from '../services/resolver';
import { getServiceNames, type ServiceDef } from '../services/registry';
import * as docker from '../infra/docker';
import { spawnService, run } from '../utils/process';
import * as ui from '../utils/ui';
import { join } from 'path';

export async function up(args: string[], root: string) {
const targets = args.length > 0 ? args : ['nextjs'];

const validNames = getServiceNames();
for (const name of targets) {
if (!validNames.includes(name)) {
ui.error(`Unknown service: "${name}"`);
console.log(`\nAvailable services: ${validNames.join(', ')}`);
process.exit(1);
}
}

const plan = resolve(targets);

ui.header('Starting services');
console.log(` ${plan.map(s => s.name).join(' → ')}\n`);

const infraServices = plan.filter(s => s.type === 'infra');
const appServices = plan.filter(s => s.type !== 'infra');

for (const svc of infraServices) {
await startInfraService(svc, root);
}

if (appServices.length === 0) {
ui.success('Infrastructure is ready. No app services to start.');
return;
}

ui.header('Starting dev servers');

for (const svc of appServices) {
if (!svc.devCommand) continue;
const cwd = join(root, svc.dir);
const portInfo = svc.port ? ` (port ${svc.port})` : '';
console.log(` Starting ${ui.bold(svc.name)}${portInfo}...`);
spawnService({ name: svc.name, command: svc.devCommand, cwd });
}

console.log(`\n ${ui.dim('Press Ctrl+C to stop all services')}\n`);
await new Promise(() => {});
}

async function startInfraService(svc: ServiceDef, root: string) {
ui.header(`Starting ${svc.name}`);

if (!svc.devCommand) return;

const ok = await run({
command: svc.devCommand,
cwd: join(root, svc.dir),
label: svc.devCommand,
});

if (!ok) {
ui.error(`Failed to start ${svc.name}`);
process.exit(1);
}

if (svc.name === 'postgres' || svc.name === 'redis') {
console.log(` Waiting for ${svc.name} to be healthy...`);
const healthy = await docker.waitForHealthy(root, svc.name);
if (healthy) {
ui.success(`${svc.name} is ready`);
} else {
ui.warn(`${svc.name} health check timed out — continuing anyway`);
}
} else {
ui.success(`${svc.name} complete`);
}
}
Loading
Loading