feat: cli launcher npx kandev (#39)

* feat: cli launcher

* feat: improved debug
This commit is contained in:
Carlos Florêncio
2026-01-18 20:42:03 +00:00
committed by GitHub
parent 16b3fb5b92
commit bc6a5f28d9
29 changed files with 1682 additions and 33 deletions
+108
View File
@@ -0,0 +1,108 @@
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
build-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: "9"
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install dependencies
run: pnpm -C apps install --frozen-lockfile
- name: Build web (standalone)
run: pnpm -C apps --filter @kandev/web build
- name: Package web bundle
run: bash scripts/release/package-web.sh
- uses: actions/upload-artifact@v4
with:
name: web-bundle
path: dist/web
build-bundles:
needs: build-web
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux-x64
- os: macos-13
platform: macos-x64
- os: macos-14
platform: macos-arm64
- os: windows-latest
platform: windows-x64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v6
with:
go-version-file: apps/backend/go.mod
cache-dependency-path: apps/backend/go.sum
- uses: actions/download-artifact@v4
with:
name: web-bundle
path: dist/web
- name: Build backend binaries (unix)
if: runner.os != 'Windows'
run: |
cd apps/backend
mkdir -p bin
go build -o bin/kandev ./cmd/kandev
go build -o bin/agentctl ./cmd/agentctl
- name: Build backend binaries (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Set-Location apps/backend
New-Item -ItemType Directory -Force -Path bin | Out-Null
go build -o bin/kandev.exe ./cmd/kandev
go build -o bin/agentctl.exe ./cmd/agentctl
- name: Package bundle (unix)
if: runner.os != 'Windows'
run: |
rm -rf dist/kandev
mkdir -p dist/kandev/bin
cp apps/backend/bin/kandev dist/kandev/bin/
cp apps/backend/bin/agentctl dist/kandev/bin/
mkdir -p dist/kandev/web
cp -R dist/web/. dist/kandev/web/
cd dist
zip -r "kandev-${{ matrix.platform }}.zip" kandev
shasum -a 256 "kandev-${{ matrix.platform }}.zip" > "kandev-${{ matrix.platform }}.zip.sha256"
- name: Package bundle (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Remove-Item -Recurse -Force dist/kandev -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path dist/kandev/bin | Out-Null
Copy-Item apps/backend/bin/kandev.exe dist/kandev/bin/
Copy-Item apps/backend/bin/agentctl.exe dist/kandev/bin/
New-Item -ItemType Directory -Force -Path dist/kandev/web | Out-Null
Copy-Item dist/web/* dist/kandev/web/ -Recurse -Force
Compress-Archive -Path dist/kandev -DestinationPath dist/kandev-${{ matrix.platform }}.zip -Force
$hash = Get-FileHash dist/kandev-${{ matrix.platform }}.zip -Algorithm SHA256
"$($hash.Hash) kandev-${{ matrix.platform }}.zip" | Out-File -FilePath dist/kandev-${{ matrix.platform }}.zip.sha256 -Encoding ascii
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
files: |
dist/kandev-${{ matrix.platform }}.zip
dist/kandev-${{ matrix.platform }}.zip.sha256
+4 -31
View File
@@ -23,10 +23,11 @@ help:
@echo "Kandev - AI Agent Kanban Board"
@echo ""
@echo "Development Commands:"
@echo " dev Run backend + web via CLI (auto ports)"
@echo " dev-backend Run backend in development mode (port 8080)"
@echo " dev-web Run web app in development mode (port 3000)"
@echo " dev-landing Run landing page in development mode (port 3001)"
@echo " dev Note: Run dev-backend and dev-web in separate terminals"
@echo " dev Note: Uses apps/cli launcher (auto ports)"
@echo ""
@echo "Build Commands:"
@echo " build Build backend, web app, and landing page"
@@ -64,36 +65,8 @@ help:
.PHONY: dev
dev:
@echo "╔════════════════════════════════════════════════════════════════╗"
@echo "║ TIP: For better log visibility, run in separate terminals: ║"
@echo "║ ║"
@echo "║ Terminal 1: make dev-backend ║"
@echo "║ Terminal 2: make dev-web ║"
@echo "╚════════════════════════════════════════════════════════════════╝"
@echo ""
@echo "Starting backend and web app..."
@echo "Backend: http://localhost:8080"
@echo "Web app: http://localhost:3000"
@echo ""
@trap 'stty sane 2>/dev/null || true' EXIT INT TERM; \
bash -c ' \
cleanup_called=0; \
cleanup() { \
if [ "$$cleanup_called" -eq 1 ]; then \
return; \
fi; \
cleanup_called=1; \
echo ""; \
echo "Shutting down..."; \
trap - EXIT INT TERM; \
kill 0 2>/dev/null || true; \
stty sane 2>/dev/null || true; \
exit 0; \
}; \
trap cleanup EXIT INT TERM; \
$(MAKE) -C $(BACKEND_DIR) run & \
cd $(APPS_DIR) && $(PNPM) --filter @kandev/web dev & \
wait'
@echo "Launching via CLI (auto ports)..."
@cd $(APPS_DIR) && $(PNPM) -C cli dev -- dev
.PHONY: dev-backend
dev-backend:
+10
View File
@@ -65,6 +65,16 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture docum
### Build and Run
#### NPX Launcher (recommended)
```bash
npx kandev
```
This downloads prebuilt backend + frontend bundles and starts them locally.
#### From Source
```bash
cd apps/backend
+66
View File
@@ -0,0 +1,66 @@
# Kandev Launcher (npx)
This package powers `npx kandev` by downloading prebuilt release bundles from GitHub Releases and running them locally. It:
- Detects OS/arch and fetches the matching bundle ZIP.
- Verifies the SHA256 checksum when available.
- Extracts the bundle into `~/.kandev/bin/<version>/<platform>/`.
- Starts the backend binary and waits for the `/health` endpoint.
- Starts the Next.js standalone server with runtime `KANDEV_API_BASE_URL`.
- Uses the latest GitHub Release by default, so runtime bundles update automatically.
It also supports local dev runs from a repo checkout.
## Updates
On `run`, the launcher checks npm for the latest `kandev` CLI version and prompts:
```
Update available: <current> -> <latest>. Update now? [y/N]
```
If you accept, it re-runs `npx kandev@latest` with the same arguments.
This check is skipped in `dev` mode.
Note: the runtime bundles are pulled from the latest GitHub Release by default, even if the CLI version is unchanged. So:
- **New runtime release without CLI publish**: users get new runtime automatically, but no update prompt.
- **New CLI publish**: users get an update prompt and then re-run with the new CLI.
You can disable the prompt with:
```bash
KANDEV_NO_UPDATE_PROMPT=1 npx kandev
```
## Usage
```bash
# Run the latest release bundle
npx kandev
# Run a specific release tag
npx kandev run --version v0.1.0
# Local dev (from repo root)
npx kandev dev
```
## Local Development
```bash
pnpm -C apps/cli dev
```
## Build / Publish
```bash
pnpm -C apps/cli build
npm publish --access public
```
The published package name is `kandev`, with a bin entry `kandev`.
## Environment Overrides
- `KANDEV_GITHUB_OWNER`, `KANDEV_GITHUB_REPO`: Override the GitHub repo to fetch releases from.
- `KANDEV_GITHUB_TOKEN`: Optional token for GitHub API rate limits.
- `KANDEV_NO_UPDATE_PROMPT=1`: Disable the update prompt.
- `KANDEV_SKIP_UPDATE=1`: Internal guard to avoid update loops.
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
require("../dist/cli.js");
+30
View File
@@ -0,0 +1,30 @@
{
"name": "kandev",
"version": "0.1.0",
"private": false,
"description": "NPX launcher for Kandev",
"license": "UNLICENSED",
"type": "commonjs",
"bin": {
"kandev": "bin/cli.js"
},
"main": "dist/cli.js",
"files": [
"bin",
"dist"
],
"dependencies": {
"adm-zip": "^0.5.16",
"tree-kill": "^1.2.2"
},
"devDependencies": {
"@types/node": "^20",
"tsx": "^4.15.7",
"typescript": "^5"
},
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsc -p tsconfig.json",
"prepublishOnly": "pnpm build"
}
}
+37
View File
@@ -0,0 +1,37 @@
import AdmZip from "adm-zip";
import fs from "node:fs";
import path from "node:path";
export function extractZip(zipPath: string, destDir: string): void {
const zip = new AdmZip(zipPath);
zip.extractAllTo(destDir, true);
}
export function ensureExtracted(zipPath: string, destDir: string): void {
const marker = path.join(destDir, ".extracted");
if (fs.existsSync(marker)) {
return;
}
extractZip(zipPath, destDir);
fs.writeFileSync(marker, "");
}
export function findBundleRoot(cacheDir: string): string {
const candidate = path.join(cacheDir, "kandev");
if (fs.existsSync(candidate)) {
return candidate;
}
return cacheDir;
}
export function resolveWebServerPath(bundleDir: string): string | null {
const direct = path.join(bundleDir, "web", "server.js");
if (fs.existsSync(direct)) {
return direct;
}
const nested = path.join(bundleDir, "web", "apps", "web", "server.js");
if (fs.existsSync(nested)) {
return nested;
}
return null;
}
+131
View File
@@ -0,0 +1,131 @@
import path from "node:path";
import fs from "node:fs";
import pkg from "../package.json";
import { runDev } from "./dev";
import { runRelease } from "./run";
import { ensureValidPort } from "./ports";
import { maybePromptForUpdate } from "./update";
type Command = "run" | "dev";
type CliOptions = {
command: Command;
version?: string;
backendPort?: number;
webPort?: number;
};
function printHelp() {
console.log(`kandev launcher
Usage:
kandev run [--version <tag>] [--backend-port <port>] [--web-port <port>]
kandev dev [--backend-port <port>] [--web-port <port>]
kandev [--version <tag>] [--backend-port <port>] [--web-port <port>]
kandev --dev [--backend-port <port>] [--web-port <port>]
Examples:
kandev
kandev run
kandev --dev
kandev dev
kandev --version v0.1.0
kandev --backend-port 18080 --web-port 13000
Options:
dev Use local repo for dev (make dev + next dev) if available.
run Use release bundles (default).
--dev Alias for "dev".
--version Release tag to install (default: latest).
--backend-port Override backend port.
--web-port Override web port.
--help, -h Show help.
`);
}
function parseArgs(argv: string[]): CliOptions {
const opts: CliOptions = { command: "run" };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
if (arg === "dev" || arg === "run") {
opts.command = arg;
continue;
}
if (arg === "--version") {
opts.version = argv[i + 1];
i += 1;
continue;
}
if (arg.startsWith("--version=")) {
opts.version = arg.split("=")[1];
continue;
}
if (arg === "--dev") {
opts.command = "dev";
continue;
}
if (arg === "--backend-port") {
opts.backendPort = Number(argv[i + 1]);
i += 1;
continue;
}
if (arg.startsWith("--backend-port=")) {
opts.backendPort = Number(arg.split("=")[1]);
continue;
}
if (arg === "--web-port") {
opts.webPort = Number(argv[i + 1]);
i += 1;
continue;
}
if (arg.startsWith("--web-port=")) {
opts.webPort = Number(arg.split("=")[1]);
continue;
}
}
return opts;
}
function findRepoRoot(startDir: string): string | null {
let current = path.resolve(startDir);
while (true) {
const backendDir = path.join(current, "apps", "backend");
const webDir = path.join(current, "apps", "web");
if (fs.existsSync(backendDir) && fs.existsSync(webDir)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
async function main(): Promise<void> {
const raw = parseArgs(process.argv.slice(2));
const backendPort = ensureValidPort(raw.backendPort, "backend port");
const webPort = ensureValidPort(raw.webPort, "web port");
if (raw.command === "dev") {
const repoRoot = findRepoRoot(process.cwd());
if (!repoRoot) {
throw new Error("Unable to locate repo root for dev. Run from the repo.");
}
await runDev({ repoRoot, backendPort, webPort });
return;
}
await maybePromptForUpdate(pkg.version, process.argv.slice(2));
await runRelease({ version: raw.version, backendPort, webPort });
}
main().catch((err) => {
console.error(`[kandev] ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+19
View File
@@ -0,0 +1,19 @@
import os from "node:os";
import path from "node:path";
// Default service ports (will auto-fallback if busy).
export const DEFAULT_BACKEND_PORT = 8080;
export const DEFAULT_WEB_PORT = 3000;
export const DEFAULT_AGENTCTL_PORT = 9999;
// Random fallback range for port selection.
export const RANDOM_PORT_MIN = 10000;
export const RANDOM_PORT_MAX = 60000;
export const RANDOM_PORT_RETRIES = 10;
// Backend healthcheck timeout during startup.
export const HEALTH_TIMEOUT_MS = 15000;
// Local user cache/data directories for release bundles and DB.
export const CACHE_DIR = path.join(os.homedir(), ".kandev", "bin");
export const DATA_DIR = path.join(os.homedir(), ".kandev", "data");
+75
View File
@@ -0,0 +1,75 @@
import { spawn } from "node:child_process";
import path from "node:path";
import {
DEFAULT_AGENTCTL_PORT,
DEFAULT_BACKEND_PORT,
DEFAULT_WEB_PORT,
HEALTH_TIMEOUT_MS,
} from "./constants";
import { waitForHealth } from "./health";
import { pickAvailablePort } from "./ports";
import { createProcessSupervisor } from "./process";
import { launchWebApp } from "./web";
export type DevOptions = {
repoRoot: string;
backendPort?: number;
webPort?: number;
};
export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise<void> {
const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT));
const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT));
const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT);
const backendUrl = `http://localhost:${actualBackendPort}`;
const backendEnv = {
...process.env,
KANDEV_SERVER_PORT: String(actualBackendPort),
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
};
const webEnv = {
...process.env,
KANDEV_API_BASE_URL: backendUrl,
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
PORT: String(actualWebPort),
NEXT_PUBLIC_KANDEV_DEBUG: "true",
};
console.log("[kandev] dev mode: using local repo");
console.log("[kandev] backend port:", actualBackendPort);
console.log("[kandev] web port:", actualWebPort);
console.log("[kandev] agentctl port:", agentctlPort);
const supervisor = createProcessSupervisor();
supervisor.attachSignalHandlers();
const backendProc = spawn(
"make",
["-C", path.join("apps", "backend"), "dev"],
{ cwd: repoRoot, env: backendEnv, stdio: "inherit" },
);
supervisor.children.push(backendProc);
backendProc.on("exit", (code, signal) => {
console.error(`[kandev] backend exited (code=${code}, signal=${signal})`);
const exitCode = signal ? 0 : code ?? 1;
void supervisor.shutdown("backend exit").then(() => process.exit(exitCode));
});
await waitForHealth(backendUrl, backendProc, HEALTH_TIMEOUT_MS);
console.log(`[kandev] backend ready at ${backendUrl}`);
const webUrl = `http://localhost:${actualWebPort}`;
launchWebApp({
command: "pnpm",
args: ["-C", "apps", "--filter", "@kandev/web", "dev"],
cwd: repoRoot,
env: webEnv,
url: webUrl,
supervisor,
label: "web",
});
console.log(`[kandev] web ready at ${webUrl}`);
}
+189
View File
@@ -0,0 +1,189 @@
import crypto from "node:crypto";
import fs from "node:fs";
import https from "node:https";
import path from "node:path";
import { CACHE_DIR } from "./constants";
// Allow overriding the GitHub repo for forks/testing.
const OWNER = process.env.KANDEV_GITHUB_OWNER || "kandev";
const REPO = process.env.KANDEV_GITHUB_REPO || "kandev";
const API_BASE = `https://api.github.com/repos/${OWNER}/${REPO}`;
type ReleaseAsset = {
name: string;
browser_download_url: string;
};
type ReleaseResponse = {
tag_name?: string;
assets?: ReleaseAsset[];
};
function requestJson<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{
headers: {
"User-Agent": "kandev-npx",
Accept: "application/vnd.github+json",
...(process.env.KANDEV_GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }
: {}),
},
},
(res) => {
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
}
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(body) as T);
} catch {
reject(new Error(`Failed to parse JSON from ${url}`));
}
});
},
);
req.setTimeout(5000, () => {
req.destroy(new Error(`Request timed out fetching ${url}`));
});
req.on("error", reject);
});
}
function downloadFile(
url: string,
destPath: string,
expectedSha256?: string | null,
onProgress?: (downloaded: number, total: number) => void,
): Promise<string> {
const tempPath = `${destPath}.tmp`;
return new Promise<string>((resolve, reject) => {
const file = fs.createWriteStream(tempPath);
const hash = crypto.createHash("sha256");
const cleanup = () => {
try {
fs.unlinkSync(tempPath);
} catch {}
};
const req = https.get(
url,
{
headers: {
"User-Agent": "kandev-npx",
Accept: "application/octet-stream",
...(process.env.KANDEV_GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }
: {}),
},
},
(res) => {
if (res.statusCode !== 200) {
file.close();
cleanup();
return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
}
const totalSize = parseInt(res.headers["content-length"] || "0", 10);
let downloadedSize = 0;
res.on("data", (chunk) => {
downloadedSize += chunk.length;
hash.update(chunk);
if (onProgress) onProgress(downloadedSize, totalSize);
});
res.pipe(file);
file.on("finish", () => {
file.close();
const actualSha256 = hash.digest("hex");
if (expectedSha256 && actualSha256 !== expectedSha256) {
cleanup();
return reject(
new Error(`Checksum mismatch: expected ${expectedSha256}, got ${actualSha256}`),
);
}
try {
fs.renameSync(tempPath, destPath);
resolve(destPath);
} catch (err) {
cleanup();
reject(err);
}
});
},
);
req.setTimeout(5000, () => {
req.destroy(new Error(`Request timed out downloading ${url}`));
});
req.on("error", (err) => {
file.close();
cleanup();
reject(err);
});
});
}
function findAsset(release: ReleaseResponse, name: string): ReleaseAsset | undefined {
return release.assets?.find((asset) => asset.name === name);
}
function readSha256(pathToSha: string): string | null {
if (!fs.existsSync(pathToSha)) {
return null;
}
const content = fs.readFileSync(pathToSha, "utf8").trim();
const first = content.split(/\s+/)[0];
return first || null;
}
export async function getRelease(version?: string): Promise<ReleaseResponse> {
if (version) {
return requestJson<ReleaseResponse>(`${API_BASE}/releases/tags/${version}`);
}
return requestJson<ReleaseResponse>(`${API_BASE}/releases/latest`);
}
export async function ensureAsset(
release: ReleaseResponse,
assetName: string,
cacheDir: string,
onProgress?: (downloaded: number, total: number) => void,
): Promise<string> {
const asset = findAsset(release, assetName);
if (!asset) {
throw new Error(`Release asset not found: ${assetName}`);
}
fs.mkdirSync(cacheDir, { recursive: true });
const destPath = path.join(cacheDir, assetName);
const shaPath = `${destPath}.sha256`;
let expectedSha = readSha256(shaPath);
if (!expectedSha) {
const shaAsset = findAsset(release, `${assetName}.sha256`);
if (shaAsset) {
await downloadFile(shaAsset.browser_download_url, shaPath);
expectedSha = readSha256(shaPath);
}
}
if (fs.existsSync(destPath)) {
if (!expectedSha) {
return destPath;
}
const hash = crypto.createHash("sha256");
const file = fs.readFileSync(destPath);
hash.update(file);
if (hash.digest("hex") === expectedSha) {
return destPath;
}
fs.unlinkSync(destPath);
}
await downloadFile(asset.browser_download_url, destPath, expectedSha, onProgress);
return destPath;
}
+27
View File
@@ -0,0 +1,27 @@
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitForHealth(
baseUrl: string,
proc: { exitCode: number | null },
timeoutMs: number,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
const healthUrl = `${baseUrl}/health`;
while (Date.now() < deadline) {
if (proc.exitCode !== null) {
throw new Error("Backend exited before healthcheck passed");
}
try {
const res = await fetch(healthUrl);
if (res.ok) {
return;
}
} catch {
// ignore until timeout
}
await delay(300);
}
throw new Error(`Backend healthcheck timed out after ${timeoutMs}ms`);
}
+48
View File
@@ -0,0 +1,48 @@
import { execSync } from "node:child_process";
const isWindows = process.platform === "win32";
export type PlatformArch = "x64" | "arm64";
export type PlatformDir =
| "linux-x64"
| "macos-x64"
| "macos-arm64"
| "windows-x64"
| "windows-arm64";
export function getBinaryName(base: string): string {
return isWindows ? `${base}.exe` : base;
}
function getEffectiveArch(): PlatformArch {
const platform = process.platform;
const nodeArch = process.arch;
if (platform === "darwin") {
if (nodeArch === "arm64") return "arm64";
try {
const translated = execSync("sysctl -in sysctl.proc_translated", {
encoding: "utf8",
}).trim();
if (translated === "1") return "arm64";
} catch {}
return "x64";
}
if (/arm/i.test(nodeArch)) return "arm64";
if (platform === "win32") {
const pa = process.env.PROCESSOR_ARCHITECTURE || "";
const paw = process.env.PROCESSOR_ARCHITEW6432 || "";
if (/arm/i.test(pa) || /arm/i.test(paw)) return "arm64";
}
return "x64";
}
export function getPlatformDir(): PlatformDir {
const platform = process.platform;
const arch = getEffectiveArch();
if (platform === "linux" && arch === "x64") return "linux-x64";
if (platform === "darwin" && arch === "x64") return "macos-x64";
if (platform === "darwin" && arch === "arm64") return "macos-arm64";
if (platform === "win32" && arch === "x64") return "windows-x64";
if (platform === "win32" && arch === "arm64") return "windows-arm64";
throw new Error(`Unsupported platform: ${platform}-${arch}`);
}
+81
View File
@@ -0,0 +1,81 @@
import crypto from "node:crypto";
import net from "node:net";
import { RANDOM_PORT_MAX, RANDOM_PORT_MIN, RANDOM_PORT_RETRIES } from "./constants";
export function ensureValidPort(port: number | undefined, name: string): number | undefined {
if (port === undefined) {
return undefined;
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`${name} must be an integer between 1 and 65535`);
}
return port;
}
function isPortAvailable(port: number, host = "0.0.0.0"): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
resolve(false);
} else {
resolve(false);
}
});
server.listen(port, host, () => {
server.close(() => resolve(true));
});
});
}
async function reserveSpecificPort(port: number, host = "127.0.0.1"): Promise<net.Server | null> {
return new Promise((resolve) => {
const server = net.createServer();
server.on("error", () => resolve(null));
server.listen(port, host, () => resolve(server));
});
}
export async function pickAvailablePort(
preferred: number,
retries = RANDOM_PORT_RETRIES,
): Promise<number> {
if (await isPortAvailable(preferred)) {
return preferred;
}
for (let i = 0; i < retries; i += 1) {
const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1);
if (await isPortAvailable(candidate)) {
return candidate;
}
}
throw new Error(`Unable to find a free port after ${retries + 1} attempts`);
}
export async function pickAndReservePort(
preferred: number,
retries = RANDOM_PORT_RETRIES,
): Promise<{ port: number; release: () => Promise<void> }> {
const reservedPreferred = await reserveSpecificPort(preferred);
if (reservedPreferred) {
return {
port: preferred,
release: () => new Promise((resolve) => reservedPreferred.close(() => resolve())),
};
}
for (let i = 0; i < retries; i += 1) {
const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1);
const reserved = await reserveSpecificPort(candidate);
if (reserved) {
return {
port: candidate,
release: () => new Promise((resolve) => reserved.close(() => resolve())),
};
}
}
throw new Error(`Unable to reserve a free port after ${retries + 1} attempts`);
}
+34
View File
@@ -0,0 +1,34 @@
import kill from "tree-kill";
export type ChildLike = { pid?: number | undefined };
export function createProcessSupervisor(): {
children: ChildLike[];
shutdown: (reason: string) => Promise<void>;
attachSignalHandlers: () => void;
} {
let shuttingDown = false;
const children: ChildLike[] = [];
const shutdown = async (reason: string) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[kandev] shutting down (${reason})...`);
await Promise.all(
children
.filter((child) => child.pid)
.map((child) => new Promise<void>((resolve) => kill(child.pid as number, "SIGTERM", () => resolve()))),
);
};
const onSignal = (signal: NodeJS.Signals) => {
void shutdown(`signal ${signal}`).then(() => process.exit(0));
};
const attachSignalHandlers = () => {
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
};
return { children, shutdown, attachSignalHandlers };
}
+152
View File
@@ -0,0 +1,152 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { ensureExtracted, findBundleRoot, resolveWebServerPath } from "./bundle";
import {
DATA_DIR,
DEFAULT_AGENTCTL_PORT,
DEFAULT_BACKEND_PORT,
DEFAULT_WEB_PORT,
HEALTH_TIMEOUT_MS,
} from "./constants";
import { CACHE_DIR, ensureAsset, getRelease } from "./github";
import { waitForHealth } from "./health";
import { getBinaryName, getPlatformDir } from "./platform";
import { pickAvailablePort } from "./ports";
import { createProcessSupervisor } from "./process";
import { launchWebApp } from "./web";
export type RunOptions = {
version?: string;
backendPort?: number;
webPort?: number;
};
type PreparedRelease = {
bundleDir: string;
backendBin: string;
backendUrl: string;
backendEnv: NodeJS.ProcessEnv;
webEnv: NodeJS.ProcessEnv;
webPort: number;
agentctlPort: number;
};
async function prepareReleaseBundle({
version,
backendPort,
webPort,
}: RunOptions): Promise<PreparedRelease> {
const platformDir = getPlatformDir();
const release = await getRelease(version);
const tag = release.tag_name || "latest";
const assetName = `kandev-${platformDir}.zip`;
const cacheDir = path.join(CACHE_DIR, tag, platformDir);
const zipPath = await ensureAsset(release, assetName, cacheDir, (downloaded, total) => {
const percent = total ? Math.round((downloaded / total) * 100) : 0;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?";
process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`);
});
process.stderr.write("\n");
ensureExtracted(zipPath, cacheDir);
const bundleDir = findBundleRoot(cacheDir);
const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev"));
if (!fs.existsSync(backendBin)) {
throw new Error(`Backend binary not found at ${backendBin}`);
}
const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl"));
if (!fs.existsSync(agentctlBin)) {
throw new Error(`agentctl binary not found at ${agentctlBin}`);
}
const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT));
const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT));
const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT);
const backendUrl = `http://localhost:${actualBackendPort}`;
fs.mkdirSync(DATA_DIR, { recursive: true });
const dbPath = path.join(DATA_DIR, "kandev.db");
const backendEnv = {
...process.env,
KANDEV_SERVER_PORT: String(actualBackendPort),
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
KANDEV_DB_PATH: dbPath,
};
const webEnv = {
...process.env,
KANDEV_API_BASE_URL: backendUrl,
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
PORT: String(actualWebPort),
NODE_ENV: "production",
};
return {
bundleDir,
backendBin,
backendUrl,
backendEnv,
webEnv,
webPort: actualWebPort,
agentctlPort,
};
}
function launchReleaseApps(prepared: PreparedRelease): {
supervisor: ReturnType<typeof createProcessSupervisor>;
backendProc: ReturnType<typeof spawn>;
webServerPath: string;
} {
console.log("[kandev] backend port:", prepared.backendEnv.KANDEV_SERVER_PORT);
console.log("[kandev] web port:", prepared.webPort);
console.log("[kandev] agentctl port:", prepared.agentctlPort);
const supervisor = createProcessSupervisor();
supervisor.attachSignalHandlers();
const backendProc = spawn(prepared.backendBin, [], {
cwd: path.dirname(prepared.backendBin),
env: prepared.backendEnv,
stdio: "inherit",
});
supervisor.children.push(backendProc);
backendProc.on("exit", (code, signal) => {
console.error(`[kandev] backend exited (code=${code}, signal=${signal})`);
void supervisor.shutdown("backend exit").then(() => process.exit(code || 1));
});
const webServerPath = resolveWebServerPath(prepared.bundleDir);
if (!webServerPath) {
throw new Error("Web server entry (server.js) not found in bundle");
}
const webUrl = `http://localhost:${prepared.webPort}`;
const webProc = launchWebApp({
command: "node",
args: [webServerPath],
cwd: path.dirname(webServerPath),
env: prepared.webEnv,
url: webUrl,
supervisor,
label: "web",
});
return { supervisor, backendProc, webServerPath, webProc };
}
export async function runRelease({ version, backendPort, webPort }: RunOptions) {
const prepared = await prepareReleaseBundle({ version, backendPort, webPort });
const { backendProc } = launchReleaseApps(prepared);
// Wait for backend before announcing the web URL.
await waitForHealth(prepared.backendUrl, backendProc, HEALTH_TIMEOUT_MS);
console.log(`[kandev] backend ready at ${prepared.backendUrl}`);
console.log(`[kandev] web ready at http://localhost:${prepared.webPort}`);
}
+98
View File
@@ -0,0 +1,98 @@
import { spawn } from "node:child_process";
import https from "node:https";
import readline from "node:readline";
function requestJson<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{ headers: { "User-Agent": "kandev-npx" } },
(res) => {
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
}
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(body) as T);
} catch {
reject(new Error(`Failed to parse JSON from ${url}`));
}
});
},
);
req.setTimeout(5000, () => {
req.destroy(new Error(`Request timed out fetching ${url}`));
});
req.on("error", reject);
});
}
async function getLatestNpmVersion(): Promise<string | undefined> {
const data = await requestJson<{ "dist-tags"?: { latest?: string } }>(
"https://registry.npmjs.org/kandev",
);
return data?.["dist-tags"]?.latest;
}
function compareVersions(a: string, b: string): number {
const pa = String(a).replace(/^v/, "").split(".").map(Number);
const pb = String(b).replace(/^v/, "").split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const av = pa[i] ?? 0;
const bv = pb[i] ?? 0;
if (av > bv) return 1;
if (av < bv) return -1;
}
return 0;
}
function promptYesNo(question: string, defaultYes = false): Promise<boolean> {
return new Promise<boolean>((resolve) => {
if (!process.stdin.isTTY) {
resolve(false);
return;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
rl.question(`${question} ${suffix} `, (answer: string) => {
rl.close();
const normalized = String(answer || "").trim().toLowerCase();
if (!normalized) {
resolve(Boolean(defaultYes));
return;
}
resolve(normalized === "y" || normalized === "yes");
});
});
}
export async function maybePromptForUpdate(currentVersion: string, args: string[]) {
// Allow disabling update checks for CI or automation.
if (process.env.KANDEV_SKIP_UPDATE === "1" || process.env.KANDEV_NO_UPDATE_PROMPT === "1") {
return;
}
try {
const latest = await getLatestNpmVersion();
if (!latest) return;
if (compareVersions(latest, currentVersion) <= 0) return;
const wantsUpdate = await promptYesNo(
`Update available: ${currentVersion} -> ${latest}. Update now?`,
false,
);
if (!wantsUpdate) return;
const env = { ...process.env, KANDEV_SKIP_UPDATE: "1" };
const child = spawn("npx", ["kandev@latest", ...args], {
stdio: "inherit",
env,
});
child.on("exit", (code) => process.exit(code || 0));
await new Promise(() => {});
} catch {
// ignore update errors
}
}
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from "node:child_process";
import { createProcessSupervisor } from "./process";
export type WebLaunchOptions = {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
url: string;
supervisor: ReturnType<typeof createProcessSupervisor>;
label: string;
};
export function openBrowser(url: string) {
if (process.env.KANDEV_NO_BROWSER === "1") {
return;
}
const opener =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "cmd"
: "xdg-open";
const args =
process.platform === "win32"
? ["/c", "start", "", url]
: [url];
try {
const child = spawn(opener, args, { stdio: "ignore", detached: true });
child.unref();
} catch {
// ignore browser launch errors
}
}
export function launchWebApp({
command,
args,
cwd,
env,
url,
supervisor,
label,
}: WebLaunchOptions) {
const proc = spawn(command, args, { cwd, env, stdio: "inherit" });
supervisor.children.push(proc);
proc.on("exit", (code, signal) => {
console.error(`[kandev] ${label} exited (code=${code}, signal=${signal})`);
const exitCode = signal ? 0 : code ?? 1;
void supervisor.shutdown(`${label} exit`).then(() => process.exit(exitCode));
});
openBrowser(url);
return proc;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+311
View File
@@ -15,6 +15,25 @@ importers:
specifier: ^5
version: 5.9.3
cli:
dependencies:
adm-zip:
specifier: ^0.5.16
version: 0.5.16
tree-kill:
specifier: ^1.2.2
version: 1.2.2
devDependencies:
'@types/node':
specifier: ^20
version: 20.19.28
tsx:
specifier: ^4.15.7
version: 4.21.0
typescript:
specifier: ^5
version: 5.9.3
landing:
dependencies:
'@kandev/theme':
@@ -664,138 +683,294 @@ packages:
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2552,6 +2727,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -3134,6 +3313,11 @@ packages:
engines: {node: '>=12'}
hasBin: true
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -5111,6 +5295,10 @@ packages:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -5136,6 +5324,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@@ -5937,72 +6130,150 @@ snapshots:
'@esbuild/aix-ppc64@0.21.5':
optional: true
'@esbuild/aix-ppc64@0.27.2':
optional: true
'@esbuild/android-arm64@0.21.5':
optional: true
'@esbuild/android-arm64@0.27.2':
optional: true
'@esbuild/android-arm@0.21.5':
optional: true
'@esbuild/android-arm@0.27.2':
optional: true
'@esbuild/android-x64@0.21.5':
optional: true
'@esbuild/android-x64@0.27.2':
optional: true
'@esbuild/darwin-arm64@0.21.5':
optional: true
'@esbuild/darwin-arm64@0.27.2':
optional: true
'@esbuild/darwin-x64@0.21.5':
optional: true
'@esbuild/darwin-x64@0.27.2':
optional: true
'@esbuild/freebsd-arm64@0.21.5':
optional: true
'@esbuild/freebsd-arm64@0.27.2':
optional: true
'@esbuild/freebsd-x64@0.21.5':
optional: true
'@esbuild/freebsd-x64@0.27.2':
optional: true
'@esbuild/linux-arm64@0.21.5':
optional: true
'@esbuild/linux-arm64@0.27.2':
optional: true
'@esbuild/linux-arm@0.21.5':
optional: true
'@esbuild/linux-arm@0.27.2':
optional: true
'@esbuild/linux-ia32@0.21.5':
optional: true
'@esbuild/linux-ia32@0.27.2':
optional: true
'@esbuild/linux-loong64@0.21.5':
optional: true
'@esbuild/linux-loong64@0.27.2':
optional: true
'@esbuild/linux-mips64el@0.21.5':
optional: true
'@esbuild/linux-mips64el@0.27.2':
optional: true
'@esbuild/linux-ppc64@0.21.5':
optional: true
'@esbuild/linux-ppc64@0.27.2':
optional: true
'@esbuild/linux-riscv64@0.21.5':
optional: true
'@esbuild/linux-riscv64@0.27.2':
optional: true
'@esbuild/linux-s390x@0.21.5':
optional: true
'@esbuild/linux-s390x@0.27.2':
optional: true
'@esbuild/linux-x64@0.21.5':
optional: true
'@esbuild/linux-x64@0.27.2':
optional: true
'@esbuild/netbsd-arm64@0.27.2':
optional: true
'@esbuild/netbsd-x64@0.21.5':
optional: true
'@esbuild/netbsd-x64@0.27.2':
optional: true
'@esbuild/openbsd-arm64@0.27.2':
optional: true
'@esbuild/openbsd-x64@0.21.5':
optional: true
'@esbuild/openbsd-x64@0.27.2':
optional: true
'@esbuild/openharmony-arm64@0.27.2':
optional: true
'@esbuild/sunos-x64@0.21.5':
optional: true
'@esbuild/sunos-x64@0.27.2':
optional: true
'@esbuild/win32-arm64@0.21.5':
optional: true
'@esbuild/win32-arm64@0.27.2':
optional: true
'@esbuild/win32-ia32@0.21.5':
optional: true
'@esbuild/win32-ia32@0.27.2':
optional: true
'@esbuild/win32-x64@0.21.5':
optional: true
'@esbuild/win32-x64@0.27.2':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
dependencies:
eslint: 9.39.2(jiti@2.6.1)
@@ -7758,6 +8029,8 @@ snapshots:
acorn@8.15.0: {}
adm-zip@0.5.16: {}
agent-base@7.1.4: {}
ajv-formats@3.0.1(ajv@8.17.1):
@@ -8417,6 +8690,35 @@ snapshots:
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
escalade@3.2.0: {}
escape-html@1.0.3: {}
@@ -11073,6 +11375,8 @@ snapshots:
dependencies:
tldts: 7.0.19
tree-kill@1.2.2: {}
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -11101,6 +11405,13 @@ snapshots:
tslib@2.8.1: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.2
get-tsconfig: 4.13.0
optionalDependencies:
fsevents: 2.3.3
tw-animate-css@1.4.0: {}
type-check@0.4.0:
+1
View File
@@ -1,4 +1,5 @@
packages:
- 'cli'
- 'web'
- 'landing'
- 'packages/*'
+9
View File
@@ -16,9 +16,18 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const runtimeApiBaseUrl = process.env.KANDEV_API_BASE_URL ?? "";
return (
<html lang="en" suppressHydrationWarning>
<body className="antialiased font-sans">
{runtimeApiBaseUrl ? (
// Inject runtime API base URL for production bundles where ports are chosen at launch.
<script
dangerouslySetInnerHTML={{
__html: `window.__KANDEV_API_BASE_URL = ${JSON.stringify(runtimeApiBaseUrl)};`,
}}
/>
) : null}
<StateProvider>
<ThemeProvider>
<TooltipProvider>
+7
View File
@@ -0,0 +1,7 @@
export {};
declare global {
interface Window {
__KANDEV_API_BASE_URL?: string;
}
}
+6 -2
View File
@@ -3,8 +3,7 @@ export type AppConfig = {
};
const DEFAULT_API_BASE_URL = 'http://localhost:8080';
export const DEBUG_UI = true
// export const DEBUG_UI = process.env.NEXT_PUBLIC_KANDEV_DEBUG === 'true';
export const DEBUG_UI = process.env.NEXT_PUBLIC_KANDEV_DEBUG === 'true';
export function getBackendConfig(): AppConfig {
if (typeof window === 'undefined') {
@@ -12,6 +11,11 @@ export function getBackendConfig(): AppConfig {
apiBaseUrl: process.env.KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
};
}
if (window.__KANDEV_API_BASE_URL) {
return {
apiBaseUrl: window.__KANDEV_API_BASE_URL,
};
}
return {
apiBaseUrl: process.env.NEXT_PUBLIC_KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
};
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: [
"nova",
"nova:3000",
+67
View File
@@ -0,0 +1,67 @@
# Releases
This repo ships two things:
- **Kandev runtime bundles** (backend + web) published to GitHub Releases.
- **NPX launcher** (`kandev` on npm) that downloads and runs those bundles.
## Packaging the web bundle
The release workflow calls:
```bash
scripts/release/package-web.sh
```
It packages the Next.js standalone output into `dist/web/`, which is then bundled into each platform ZIP.
## Release flow (runtime bundles)
1) Pick a version tag:
```bash
git tag v0.1.0
git push origin v0.1.0
```
Or use the helper script (creates and pushes the tag):
```bash
scripts/release/publish-release.sh 0.1.0
```
2) GitHub Actions will:
- Build the web app in standalone mode.
- Build `kandev` + `agentctl` binaries for each platform.
- Package and upload `kandev-<platform>.zip` and `.sha256` to the release.
## Publish the NPX launcher
The launcher package lives at `apps/cli` and is published as `kandev`.
```bash
cd apps/cli
pnpm build
npm publish --access public
```
Or use the helper script:
```bash
scripts/release/publish-launcher.sh 0.1.0
```
## Releasing a new Kandev version
1) Update versions:
- Bump `apps/cli/package.json` (npm package version).
- The `publish-release.sh` script will error if this doesn't match the tag.
2) Tag and push the release (`vX.Y.Z`).
3) Publish the launcher to npm (same version or higher).
4) Verify:
- `npx kandev@latest` downloads the new release bundle.
- `npx kandev` prompts to update if an older launcher is installed.
## Notes
- Release assets are downloaded to `~/.kandev/bin/<version>/<platform>/`.
- Local data is stored under `~/.kandev/data/`.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Used by .github/workflows/release.yml to package the Next.js standalone output.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
WEB_DIR="$ROOT_DIR/apps/web"
OUT_DIR="$ROOT_DIR/dist/web"
echo "Packaging Next.js standalone output for release..."
if [ ! -d "$WEB_DIR/.next/standalone" ]; then
echo "Missing standalone output at $WEB_DIR/.next/standalone"
echo "Run: pnpm -C apps --filter @kandev/web build"
exit 1
fi
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
cp -R "$WEB_DIR/.next/standalone/." "$OUT_DIR/"
mkdir -p "$OUT_DIR/.next"
cp -R "$WEB_DIR/.next/static" "$OUT_DIR/.next/"
if [ -d "$WEB_DIR/public" ]; then
cp -R "$WEB_DIR/public" "$OUT_DIR/public"
fi
echo "Web bundle packaged at $OUT_DIR"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Publish the npm launcher package (apps/cli) with a specific version.
# Usage: scripts/release/publish-launcher.sh <version>
set -euo pipefail
if [ $# -lt 1 ]; then
echo "Usage: $0 <version>"
echo "Example: $0 0.1.2"
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
VERSION="$1"
if [[ "$VERSION" == v* ]]; then
VERSION="${VERSION#v}"
echo "Stripped leading 'v', using version $VERSION"
fi
echo "Updating launcher version to $VERSION..."
(cd "$ROOT_DIR/apps/cli" && npm version --no-git-tag-version "$VERSION")
ACTUAL_VERSION="$(node -p "require('./apps/cli/package.json').version")"
if [ "$ACTUAL_VERSION" != "$VERSION" ]; then
echo "Failed to set launcher version to $VERSION (found $ACTUAL_VERSION)."
exit 1
fi
echo "Installing workspace dependencies..."
pnpm -C "$ROOT_DIR/apps" install --frozen-lockfile
echo "Building launcher (TypeScript)..."
pnpm -C "$ROOT_DIR/apps/cli" build
echo "Publishing launcher to npm..."
(cd "$ROOT_DIR/apps/cli" && npm publish --access public)
echo "Done. This only published the npm CLI package."
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Tag and push a release version to trigger the GitHub Actions bundle build.
# Usage: scripts/release/publish-release.sh <version>
set -euo pipefail
if [ $# -lt 1 ]; then
echo "Usage: $0 <version>"
echo "Example: $0 0.1.2"
exit 1
fi
VERSION="$1"
LAUNCHER_VERSION="$(node -p "require('./apps/cli/package.json').version")"
if [ "$LAUNCHER_VERSION" != "$VERSION" ]; then
echo "Launcher version mismatch: apps/cli/package.json is $LAUNCHER_VERSION"
echo "Run scripts/release/publish-launcher.sh $VERSION first."
exit 1
fi
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "Tag v$VERSION already exists."
exit 1
fi
echo "Creating tag v$VERSION..."
git tag "v$VERSION"
echo "Pushing tag v$VERSION..."
git push origin "v$VERSION"
echo "Done. GitHub Actions will build and upload runtime bundles."