feat: cli launcher npx kandev (#39)
* feat: cli launcher * feat: improved debug
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require("../dist/cli.js");
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+311
@@ -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,4 +1,5 @@
|
||||
packages:
|
||||
- 'cli'
|
||||
- 'web'
|
||||
- 'landing'
|
||||
- 'packages/*'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__KANDEV_API_BASE_URL?: string;
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: [
|
||||
"nova",
|
||||
"nova:3000",
|
||||
|
||||
@@ -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/`.
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user