feat(server): auto-update UI via remote manifest

This commit is contained in:
Shantur Rathore
2026-01-22 15:12:32 +00:00
parent db3c13c463
commit a014ce555a
9 changed files with 1441 additions and 56 deletions

View File

@@ -167,7 +167,6 @@ export type WorkspaceEventType =
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -180,7 +179,6 @@ export type WorkspaceEventPayload =
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress {
ip: string
@@ -198,6 +196,19 @@ export interface LatestReleaseInfo {
notes?: string
}
export interface UiMeta {
version?: string
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
}
export interface SupportMeta {
supported: boolean
message?: string
minServerVersion?: string
latestServerVersion?: string
latestServerUrl?: string
}
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string
@@ -215,8 +226,9 @@ export interface ServerMeta {
workspaceRoot: string
/** Reachable addresses for this server, external first. */
addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */
latestRelease?: LatestReleaseInfo
serverVersion?: string
ui?: UiMeta
support?: SupportMeta
}
export type BackgroundProcessStatus = "running" | "stopped" | "error"

View File

@@ -29,7 +29,6 @@ export class EventBus extends EventEmitter {
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
@@ -41,7 +40,6 @@ export class EventBus extends EventEmitter {
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
}
}
}

View File

@@ -17,7 +17,7 @@ import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
const require = createRequire(import.meta.url)
@@ -37,6 +37,9 @@ interface CliOptions {
logDestination?: string
uiStaticDir: string
uiDevServer?: string
uiAutoUpdate: boolean
uiNoUpdate: boolean
uiManifestUrl?: string
launch: boolean
authUsername: string
authPassword?: string
@@ -66,6 +69,9 @@ function parseCliOptions(argv: string[]): CliOptions {
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
.addOption(
new Option("--username <username>", "Username for server authentication")
@@ -91,6 +97,9 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination?: string
uiDir: string
uiDevServer?: string
uiNoUpdate?: boolean
uiAutoUpdate?: string
uiManifestUrl?: string
launch?: boolean
username: string
password?: string
@@ -101,6 +110,9 @@ function parseCliOptions(argv: string[]): CliOptions {
const normalizedHost = resolveHost(parsed.host)
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
return {
port: parsed.port,
host: normalizedHost,
@@ -111,6 +123,9 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
uiAutoUpdate,
uiNoUpdate: Boolean(parsed.uiNoUpdate),
uiManifestUrl: parsed.uiManifestUrl,
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
@@ -141,6 +156,10 @@ function resolveHost(input: string | undefined): string {
return trimmed
}
function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag)
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
@@ -205,19 +224,36 @@ async function main() {
logger: logger.child({ component: "instance-events" }),
})
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
}
},
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
const uiResolution = await resolveUi({
serverVersion: packageJson.version,
bundledUiDir: DEFAULT_UI_STATIC_DIR,
autoUpdate: autoUpdateEnabled,
overrideUiDir: uiDirOverride,
uiDevServerUrl: options.uiDevServer,
manifestUrl: options.uiManifestUrl,
logger: logger.child({ component: "ui" }),
})
serverMeta.serverVersion = packageJson.version
serverMeta.ui = {
version: uiResolution.uiVersion,
source: uiResolution.source,
}
serverMeta.support = {
supported: uiResolution.supported,
message: uiResolution.message,
latestServerVersion: uiResolution.latestServerVersion,
latestServerUrl: uiResolution.latestServerUrl,
minServerVersion: uiResolution.minServerVersion,
}
const server = createHttpServer({
host: options.host,
port: options.port,
@@ -229,8 +265,8 @@ async function main() {
serverMeta,
instanceStore,
authManager,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
@@ -266,7 +302,7 @@ async function main() {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
releaseMonitor.stop()
// no-op: remote UI manifest replaces GitHub release monitor
logger.info("Exiting process")
process.exit(0)

View File

@@ -0,0 +1,535 @@
import { createHash } from "crypto"
import fs from "fs"
import { promises as fsp } from "fs"
import os from "os"
import path from "path"
import { Readable } from "stream"
import { fetch } from "undici"
import yauzl from "yauzl"
import type { Logger } from "../logger"
export interface RemoteUiManifest {
minServerVersion: string
latestUIVersion: string
uiPackageURL: string
sha256: string
latestServerVersion?: string
latestServerUrl?: string
}
export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
export interface UiResolution {
uiStaticDir?: string
uiDevServerUrl?: string
source: UiSource
uiVersion?: string
supported: boolean
message?: string
latestServerVersion?: string
latestServerUrl?: string
minServerVersion?: string
}
export interface RemoteUiOptions {
serverVersion: string
bundledUiDir: string
autoUpdate: boolean
overrideUiDir?: string
uiDevServerUrl?: string
manifestUrl?: string
configDir?: string
logger: Logger
}
const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json"
const MANIFEST_TIMEOUT_MS = 5_000
const ZIP_TIMEOUT_MS = 30_000
export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution> {
const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL
if (options.uiDevServerUrl) {
return {
uiDevServerUrl: options.uiDevServerUrl,
source: "dev-proxy",
supported: true,
}
}
if (options.overrideUiDir) {
const resolved = await resolveStaticUiDir(options.overrideUiDir)
return {
uiStaticDir: resolved ?? options.overrideUiDir,
source: "override",
uiVersion: await readUiVersion(resolved ?? options.overrideUiDir),
supported: true,
}
}
const uiRoot = resolveUiCacheRoot(options.configDir)
const currentDir = path.join(uiRoot, "current")
const previousDir = path.join(uiRoot, "previous")
if (!options.autoUpdate) {
const local = await resolveStaticUiDir(currentDir)
if (local) {
return {
uiStaticDir: local,
source: "downloaded",
uiVersion: await readUiVersion(local),
supported: true,
}
}
const bundled = await resolveStaticUiDir(options.bundledUiDir)
return {
uiStaticDir: bundled ?? options.bundledUiDir,
source: bundled ? "bundled" : "missing",
uiVersion: bundled ? await readUiVersion(bundled) : undefined,
supported: true,
}
}
let manifest: RemoteUiManifest | null = null
try {
manifest = await fetchManifest(manifestUrl, options.logger)
} catch (error) {
options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI")
}
if (!manifest) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
})
}
const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0
if (!supported) {
const message = "Upgrade App to use latest features"
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: false,
message,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
const currentVersion = await readUiVersion(currentDir)
if (currentVersion && currentVersion === manifest.latestUIVersion) {
const currentResolved = await resolveStaticUiDir(currentDir)
if (currentResolved) {
return {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: currentVersion,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
}
}
}
try {
await installRemoteUi({
manifest,
uiRoot,
currentDir,
previousDir,
logger: options.logger,
})
} catch (error) {
options.logger.warn({ err: error }, "Failed to install remote UI; falling back")
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
const installed = await resolveStaticUiDir(currentDir)
if (installed) {
return {
uiStaticDir: installed,
source: "downloaded",
uiVersion: await readUiVersion(installed),
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
}
}
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
function resolveUiCacheRoot(configDir?: string): string {
if (configDir) {
return path.join(configDir, "ui")
}
return path.join(os.homedir(), ".config", "codenomad", "ui")
}
async function resolveFromCacheOrBundled(args: {
logger: Logger
bundledUiDir: string
currentDir: string
previousDir: string
supported: boolean
message?: string
latestServerVersion?: string
latestServerUrl?: string
minServerVersion?: string
}): Promise<UiResolution> {
const currentResolved = await resolveStaticUiDir(args.currentDir)
if (currentResolved) {
return {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
const previousResolved = await resolveStaticUiDir(args.previousDir)
if (previousResolved) {
return {
uiStaticDir: previousResolved,
source: "previous",
uiVersion: await readUiVersion(previousResolved),
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
if (bundledResolved) {
return {
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found")
return {
uiStaticDir: args.bundledUiDir,
source: "missing",
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
try {
const indexPath = path.join(uiDir, "index.html")
await fsp.access(indexPath, fs.constants.R_OK)
return uiDir
} catch {
return null
}
}
interface UiVersionFile {
uiVersion?: string
version?: string
}
async function readUiVersion(uiDir: string): Promise<string | undefined> {
try {
const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8")
const parsed = JSON.parse(content) as UiVersionFile
return parsed.uiVersion ?? parsed.version
} catch {
return undefined
}
}
async function fetchManifest(url: string, logger: Logger): Promise<RemoteUiManifest> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Manifest responded with ${response.status}`)
}
const json = (await response.json()) as RemoteUiManifest
validateManifest(json)
return json
} catch (error) {
logger.debug({ err: error, url }, "Failed to fetch remote UI manifest")
throw error
} finally {
clearTimeout(timeout)
}
}
function validateManifest(manifest: RemoteUiManifest) {
const required: Array<keyof RemoteUiManifest> = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"]
for (const key of required) {
const value = manifest[key]
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Manifest missing ${key}`)
}
}
if (!/^https:\/\//i.test(manifest.uiPackageURL)) {
throw new Error("uiPackageURL must be https")
}
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) {
throw new Error("sha256 must be 64 hex chars")
}
}
async function installRemoteUi(args: {
manifest: RemoteUiManifest
uiRoot: string
currentDir: string
previousDir: string
logger: Logger
}) {
await fsp.mkdir(args.uiRoot, { recursive: true })
const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`)
const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`)
try {
await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger)
const digest = await sha256File(zipPath)
if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) {
throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`)
}
await extractZip(zipPath, tmpDir)
const indexPath = path.join(tmpDir, "index.html")
if (!fs.existsSync(indexPath)) {
throw new Error("Extracted UI missing index.html")
}
await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger })
fs.rmSync(args.currentDir, { recursive: true, force: true })
fs.renameSync(tmpDir, args.currentDir)
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
fs.rmSync(zipPath, { force: true })
}
}
async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) {
try {
if (fs.existsSync(args.previousDir)) {
fs.rmSync(args.previousDir, { recursive: true, force: true })
}
if (fs.existsSync(args.currentDir)) {
fs.renameSync(args.currentDir, args.previousDir)
}
} catch (error) {
args.logger.warn({ err: error }, "Failed to rotate UI cache directories")
}
}
async function downloadFile(url: string, targetPath: string, logger: Logger) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS)
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/octet-stream",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok || !response.body) {
throw new Error(`UI zip download failed with ${response.status}`)
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
const fileStream = fs.createWriteStream(targetPath)
const body = response.body
if (!body) {
throw new Error("UI zip response missing body")
}
const nodeStream = Readable.fromWeb(body as any)
await new Promise<void>((resolve, reject) => {
nodeStream.pipe(fileStream)
nodeStream.on("error", reject)
fileStream.on("error", reject)
fileStream.on("finish", () => resolve())
})
logger.debug({ url, targetPath }, "Downloaded remote UI bundle")
} finally {
clearTimeout(timeout)
}
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash("sha256")
const stream = fs.createReadStream(filePath)
await new Promise<void>((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk))
stream.on("error", reject)
stream.on("end", () => resolve())
})
return hash.digest("hex")
}
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
await fsp.mkdir(targetDir, { recursive: true })
await new Promise<void>((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => {
if (openErr || !zipfile) {
reject(openErr ?? new Error("Unable to open zip"))
return
}
const root = path.resolve(targetDir)
const closeWithError = (error: unknown) => {
try {
zipfile.close()
} catch {
// ignore
}
reject(error)
}
zipfile.readEntry()
zipfile.on("entry", (entry) => {
// Normalize and guard against zip-slip.
const entryPath = entry.fileName.replace(/\\/g, "/")
const segments = entryPath.split("/").filter(Boolean)
if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) {
closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`))
return
}
const destination = path.resolve(targetDir, entryPath)
if (!destination.startsWith(root + path.sep) && destination !== root) {
closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`))
return
}
const isDirectory = entry.fileName.endsWith("/")
if (isDirectory) {
fsp
.mkdir(destination, { recursive: true })
.then(() => zipfile.readEntry())
.catch((error) => closeWithError(error))
return
}
fsp
.mkdir(path.dirname(destination), { recursive: true })
.then(() => {
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
closeWithError(streamErr ?? new Error("Unable to read zip entry"))
return
}
const writeStream = fs.createWriteStream(destination)
const cleanup = (error?: unknown) => {
readStream.destroy()
writeStream.destroy()
if (error) {
closeWithError(error)
}
}
readStream.on("error", cleanup)
writeStream.on("error", cleanup)
writeStream.on("finish", () => zipfile.readEntry())
readStream.pipe(writeStream)
})
})
.catch((error) => closeWithError(error))
})
zipfile.on("end", () => {
zipfile.close()
resolve()
})
zipfile.on("error", (error) => closeWithError(error))
})
})
}
function compareSemverCore(a: string, b: string): number {
const pa = parseSemverCore(a)
const pb = parseSemverCore(b)
if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
return 0
}
function parseSemverCore(value: string): { major: number; minor: number; patch: number } {
const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0"
const parts = core.split(".")
const parsePart = (input: string | undefined) => {
const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10)
return Number.isFinite(n) ? n : 0
}
return {
major: parsePart(parts[0]),
minor: parsePart(parts[1]),
patch: parsePart(parts[2]),
}
}