UNPKG

tilt-ts-core

Version:

A TypeScript implementation of a Tilt-like development tool for Kubernetes live development workflows

1,479 lines (1,470 loc) 146 kB
#!/usr/bin/env node import * as fs from 'fs'; import { existsSync } from 'fs'; import * as path5 from 'path'; import { resolve } from 'path'; import React, { useState, useEffect } from 'react'; import { render, useApp, useInput, Box, Text } from 'ink'; import { spawn } from 'child_process'; import { logs, SeverityNumber } from '@opentelemetry/api-logs'; import * as watcher from '@parcel/watcher'; import * as crypto from 'crypto'; import { EventEmitter } from 'events'; import fg from 'fast-glob'; import ignore from 'ignore'; import { mkdir, writeFile } from 'fs/promises'; import * as YAML from 'yaml'; import { set } from 'es-toolkit/compat'; import chalk from 'chalk'; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __commonJS = (cb, mod) => function __require2() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. __defProp(target, "default", { value: mod, enumerable: true }) , mod )); // src/core/image-registry.ts var image_registry_exports = {}; __export(image_registry_exports, { imageRegistry: () => imageRegistry }); var ImageRegistry, imageRegistry; var init_image_registry = __esm({ "src/core/image-registry.ts"() { ImageRegistry = class { images = /* @__PURE__ */ new Map(); stateFile; constructor() { const sessionId = process.env.TILT_TS_SESSION_ID || "default"; this.stateFile = path5.join(process.cwd(), ".tilt-ts", `image-registry-${sessionId}.json`); this.loadState(); } saveState() { const stateDir = path5.dirname(this.stateFile); if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true }); } const serializedImages = []; for (const [key, entry] of this.images) { serializedImages.push([key, entry]); } fs.writeFileSync(this.stateFile, JSON.stringify(serializedImages, null, 2)); } loadState() { if (!fs.existsSync(this.stateFile)) { return; } try { const serializedImages = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); this.images.clear(); for (const [key, entry] of serializedImages || []) { this.images.set(key, entry); } } catch (error) { } } /** * Register a built image with its logical name and live update config */ register(logicalName, builtImage, registryConfig) { this.images.set(logicalName, { builtImage, registryConfig }); this.saveState(); } /** * Get a registered image by logical name */ get(logicalName) { return this.images.get(logicalName); } /** * Get all registered images */ getAll() { return Array.from(this.images.values()); } /** * Check if a logical name is registered */ has(logicalName) { return this.images.has(logicalName); } /** * Clear all registered images (useful for testing) */ clear() { this.images.clear(); } /** * Force reload state from disk (useful when called from different module contexts) */ forceReloadState(sessionId) { this.images.clear(); if (sessionId) { this.stateFile = path5.join(process.cwd(), ".tilt-ts", `image-registry-${sessionId}.json`); } this.loadState(); } /** * Clean up state file (call at session end) */ cleanup() { if (fs.existsSync(this.stateFile)) { fs.unlinkSync(this.stateFile); } } /** * Get all images that have live update configurations */ getLiveUpdateImages() { return this.getAll().filter((entry) => entry.builtImage.live_update && entry.builtImage.live_update.length > 0); } }; imageRegistry = new ImageRegistry(); } }); // package.json var require_package = __commonJS({ "package.json"(exports, module) { module.exports = { name: "tilt-ts-core", version: "3.0.2", type: "module", engines: { bun: ">=1.1.0" }, description: "A TypeScript implementation of a Tilt-like development tool for Kubernetes live development workflows", main: "dist/index.cjs", module: "dist/index.js", types: "dist/index.d.ts", bin: { tilt4: "./bin/tilt4.js", "tilt-ts": "./bin/tilt-ts.js" }, exports: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js", require: "./dist/index.cjs" }, "./instrumentation": { types: "./dist/instrumentation/telemetry.d.ts", import: "./dist/instrumentation/telemetry.js", require: "./dist/instrumentation/telemetry.cjs" } }, files: [ "dist", "bin", "README.md" ], scripts: { dev: "tsup --watch", up: "bun run tiltfile.ts", debug: "SEMRESATTRS_SERVICE_NAME=tilt-ts-core bun run -r ./src/instrumentation/telementry.ts tiltfile.ts", build: "tsup", typecheck: "tsc --noEmit", prepublishOnly: "bun run build", otel: "docker compose -f ./observability/docker-compose.yml up -d", logs: "open http://localhost:3000/d/tilt-logs/tilt-development-logs" }, keywords: [ "kubernetes", "development", "live-update", "docker", "tilt", "typescript" ], author: "Frank", license: "MIT", repository: { type: "git", url: "git+https://github.com/7frank/tilt-ts-4.git" }, bugs: { url: "https://github.com/7frank/tilt-ts-4/issues" }, homepage: "https://github.com/7frank/tilt-ts-4#readme", dependencies: { "@opentelemetry/api-logs": "^0.203.0", "@opentelemetry/auto-instrumentations-node": "^0.62.0", "@opentelemetry/sdk-logs": "^0.203.0", "@parcel/watcher": "^2.4.1", chalk: "4.1.2", "cmd-ts": "0.13.0", "es-toolkit": "^1.39.9", "fast-glob": "^3.3.3", ignore: "^7.0.5", ink: "^6.3.0", react: "^19.1.1", yaml: "^2.8.1", zod: "^4.1.11" }, devDependencies: { "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-node": "^0.203.0", "@opentelemetry/sdk-trace-base": "^2.0.1", "@opentelemetry/semantic-conventions": "^1.36.0", "@types/react": "^19.0.21", "bun-types": "^1.1.10", tsup: "^8.5.0", typescript: "^5.5.4" }, packageManager: "bun@1.1.10" }; } }); function getStatusColor(status) { switch (status) { case "ready": return "green"; case "building": return "yellow"; case "error": return "red"; case "pending": return "dim"; default: return "white"; } } function getIcon(type, status) { const icons = { docker_build: { pending: "\u{1F4E6}", building: "\u{1F528}", ready: "\u2705", error: "\u274C" }, k8s_file: { pending: "\u{1F4C4}", building: "\u2699\uFE0F", ready: "\u{1F680}", error: "\u274C" }, k8s_yaml: { pending: "\u{1F4CB}", building: "\u2699\uFE0F", ready: "\u{1F680}", error: "\u274C" } }; return icons[type][status] || "\u2753"; } function formatBuildTime(buildTime) { if (!buildTime) return ""; if (buildTime < 1e3) return `${buildTime}ms`; if (buildTime < 6e4) return `${(buildTime / 1e3).toFixed(1)}s`; return `${(buildTime / 6e4).toFixed(1)}m`; } var ResourceList = ({ resources, selectedIndex, focused }) => { if (resources.length === 0) { return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: focused ? "blue" : void 0 }, "Resources (0)"), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "No resources tracked yet..."), focused && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Panel focused - use Tab to switch")); } const selectedResource = resources[selectedIndex]; return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: focused ? "blue" : void 0 }, "Resources (", resources.length, ")"), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, focused ? "\u2191/\u2193 select \xB7 r reload \xB7 c clear cache" : "Press Tab to focus"), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, resources.map((resource, index) => /* @__PURE__ */ React.createElement( Box, { key: resource.id, backgroundColor: index === selectedIndex && focused ? "blue" : void 0, paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, { color: getStatusColor(resource.status) }, getIcon(resource.type, resource.status), " ", resource.name), resource.buildTime && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " (", formatBuildTime(resource.buildTime), ")") ))), selectedResource && /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Details"), /* @__PURE__ */ React.createElement(Text, null, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Status:"), /* @__PURE__ */ React.createElement(Text, { color: getStatusColor(selectedResource.status) }, " ", selectedResource.status)), /* @__PURE__ */ React.createElement(Text, null, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Type:"), " ", selectedResource.type), selectedResource.path && /* @__PURE__ */ React.createElement(Text, null, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Path:"), " ", selectedResource.path), selectedResource.context && /* @__PURE__ */ React.createElement(Text, null, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Context:"), " ", selectedResource.context), selectedResource.imageRef && /* @__PURE__ */ React.createElement(Text, null, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Image:"), " ", selectedResource.imageRef), selectedResource.error && /* @__PURE__ */ React.createElement(Text, { color: "red" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Error:"), " ", selectedResource.error), selectedResource.dependencies && selectedResource.dependencies.length > 0 && /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Dependencies:"), selectedResource.dependencies.map((dep) => /* @__PURE__ */ React.createElement(Text, { key: dep, dimColor: true }, " \u2192 ", dep))), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Updated: ", selectedResource.lastUpdate instanceof Date ? selectedResource.lastUpdate.toLocaleTimeString() : new Date(selectedResource.lastUpdate).toLocaleTimeString()))); }; var LogPane = ({ logs: logs2, maxLines = 50, height = 10, scrollOffset = 0, focused = false }) => { const displayLogs = logs2.slice(-maxLines); const visibleLines = height - 2; const totalLogs = displayLogs.length; const startIndex = Math.max(0, totalLogs - visibleLines - scrollOffset); const endIndex = totalLogs - scrollOffset; const visibleLogs = displayLogs.slice(startIndex, endIndex); return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", height }, /* @__PURE__ */ React.createElement(Box, { flexDirection: "row", justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: focused ? "blue" : void 0 }, "Logs (", logs2.length, ")"), focused && totalLogs > visibleLines && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, scrollOffset > 0 ? `\u2191${scrollOffset}` : "", totalLogs - endIndex > 0 ? ` \u2193${totalLogs - endIndex}` : "")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column", height: visibleLines }, visibleLogs.map((log, index) => /* @__PURE__ */ React.createElement( Text, { key: index, dimColor: log.level === "debug", color: log.level === "error" ? "red" : void 0 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "[", log.timestamp.toLocaleTimeString(), "]"), log.source && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " ", log.source, ":"), /* @__PURE__ */ React.createElement(Text, null, " ", log.message) ))), logs2.length === 0 && /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: visibleLines }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "No logs yet..."), focused && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Panel focused - use Tab to switch")), focused && logs2.length > 0 && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\u2191/\u2193 scroll \xB7 PgUp/PgDn faster \xB7 Home/End jump")); }; var StatusBar = ({ resources, isRunning }) => { const stats = { total: resources.length, ready: resources.filter((r) => r.status === "ready").length, building: resources.filter((r) => r.status === "building").length, error: resources.filter((r) => r.status === "error").length }; return /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between", borderStyle: "single", paddingX: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Status: ", /* @__PURE__ */ React.createElement(Text, { color: isRunning ? "green" : "red" }, isRunning ? "Running" : "Stopped")), /* @__PURE__ */ React.createElement(Text, null, "Resources: ", /* @__PURE__ */ React.createElement(Text, { color: "green" }, stats.ready), stats.building > 0 && /* @__PURE__ */ React.createElement(Text, null, " \xB7 ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, stats.building, " building")), stats.error > 0 && /* @__PURE__ */ React.createElement(Text, null, " \xB7 ", /* @__PURE__ */ React.createElement(Text, { color: "red" }, stats.error, " errors")))); }; var Logger = class { logger = logs.getLogger("tilt-ts", "1.0.0"); debug(message, attributes) { this.logger.emit({ severityText: "DEBUG", severityNumber: SeverityNumber.DEBUG, body: message, attributes }); } info(message, attributes) { this.logger.emit({ severityText: "INFO", severityNumber: SeverityNumber.INFO, body: message, attributes }); } warn(message, attributes) { this.logger.emit({ severityText: "WARN", severityNumber: SeverityNumber.WARN, body: message, attributes }); } error(message, attributes) { this.logger.emit({ severityText: "ERROR", severityNumber: SeverityNumber.ERROR, body: message, attributes }); } }; var logger = new Logger(); // src/utils/process.ts async function exec(cmd, opts = {}) { if (!cmd[0]?.includes("kubectl")) { logger.info("Running command", { command: cmd.join(" "), cwd: opts.cwd }); } return new Promise((resolve6, reject) => { const [command, ...args2] = cmd; const proc = spawn(command, args2, { cwd: opts.cwd, env: { ...process.env, ...opts.env || {} }, stdio: [ opts.stdin === "inherit" ? "inherit" : "ignore", "inherit", "inherit" ] }); proc.on("close", (code) => { if (code !== 0) { reject(new Error(`command failed ${code}: ${cmd.join(" ")}`)); } else { resolve6(); } }); proc.on("error", (err) => { reject(new Error(`command error: ${err.message}`)); }); }); } async function execCapture(cmd, opts = {}) { if (!cmd[0]?.includes("kubectl")) { logger.info("Running command (capture)", { command: cmd.join(" "), cwd: opts.cwd }); } return new Promise((resolve6, reject) => { const [command, ...args2] = cmd; const proc = spawn(command, args2, { cwd: opts.cwd, env: { ...process.env, ...opts.env || {} }, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; if (proc.stdout) { proc.stdout.on("data", (data) => { stdout += data.toString(); }); } if (proc.stderr) { proc.stderr.on("data", (data) => { stderr += data.toString(); }); } proc.on("close", (code) => { if (code !== 0) { reject(new Error(`command failed ${code}: ${cmd.join(" ")} ${stderr || stdout}`)); } else { resolve6(stdout); } }); proc.on("error", (err) => { reject(new Error(`command error: ${err.message}`)); }); }); } // src/utils/registry.ts function registryRef(registry, image, tag) { const reg = registry || "localhost:36269"; const repo = image.includes("/") ? image : `dev/${image}`; return `${reg}/${repo}:${tag}`; } // src/core/docker.ts init_image_registry(); // src/core/registry.ts var globalRegistry = null; function get_default_registry() { return globalRegistry; } // src/utils/image-correlation.ts init_image_registry(); async function streamFileToPod(localFilePath, namespace, podName, remotePath, containerName) { return new Promise((resolve6, reject) => { const kubectlArgs = [ "exec", "-i", "-n", namespace, podName, ...containerName ? ["-c", containerName] : [], "--", "tee", remotePath ]; const kubectlProc = spawn("kubectl", kubectlArgs, { stdio: ["pipe", "pipe", "pipe"] }); const readStream = fs.createReadStream(localFilePath); readStream.pipe(kubectlProc.stdin); kubectlProc.stdout.on("data", () => { }); let stderr = ""; kubectlProc.stderr.on("data", (data) => { stderr += data.toString(); }); readStream.on("error", (err) => { kubectlProc.kill(); reject(new Error(`file read error: ${err.message}`)); }); kubectlProc.on("close", (code) => { if (code !== 0) { reject(new Error(`kubectl exec failed ${code}: ${stderr}`)); } else { resolve6(); } }); kubectlProc.on("error", (err) => { reject(new Error(`kubectl exec error: ${err.message}`)); }); }); } function pathIsDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } function buildWatchEntries(pathsIn) { const absRequested = Array.from(new Set(pathsIn.map((p) => path5.resolve(process.cwd(), p)))); const map = /* @__PURE__ */ new Map(); for (const p of absRequested) { const isDir = pathIsDir(p); const root = isDir ? p : path5.dirname(p); const set2 = map.get(root) ?? /* @__PURE__ */ new Set(); if (!isDir) set2.add(p); map.set(root, set2); } const roots = Array.from(map.keys()).sort((a, b) => a.length - b.length); const kept = []; for (const r of roots) { const filesInRoot = map.get(r); const isDirWatch = filesInRoot.size === 0; const shouldKeep = isDirWatch || !kept.some((k) => r.startsWith(k + path5.sep)); if (shouldKeep) kept.push(r); } return kept.map((r) => ({ root: r, files: map.get(r) })); } async function createTar(root, relFiles, outTarPath) { const fileListPath = path5.join(root, `.devtool-files-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); fs.writeFileSync(fileListPath, relFiles.map((f) => f.replaceAll("\\", "/")).join("\n"), "utf8"); try { await new Promise((resolve6, reject) => { const proc = spawn("tar", ["-C", root, "-cf", outTarPath, "-T", fileListPath], { stdio: ["ignore", "inherit", "inherit"] }); proc.on("close", (code) => { if (code !== 0) { reject(new Error(`tar failed with code ${code}`)); } else { resolve6(); } }); proc.on("error", (err) => { reject(new Error(`tar error: ${err.message}`)); }); }); } finally { fs.unlinkSync(fileListPath); } } function shQ(s) { if (/^[A-Za-z0-9._\-\/:@]+$/.test(s)) return s; return `'${s.replaceAll("'", "'\\''")}'`; } var PodWatcher = class { readyPods = /* @__PURE__ */ new Set(); runningPods = /* @__PURE__ */ new Set(); watchProcess; selector; trackedContainers = /* @__PURE__ */ new Map(); containerEventHandlers = []; /** * Start watching pods for a specific resource selector */ async start(selector) { this.selector = selector; const namespace = selector.namespace || "default"; logger.info("Starting pod watcher", { kind: selector.kind, name: selector.name, namespace, operation: "pod_watcher_start" }); try { const labelSelector = await this.getLabelSelector(selector); logger.info("Pod watcher label selector configured", { labelSelector, operation: "pod_watcher_label_selector" }); this.watchProcess = spawn("kubectl", [ "get", "pods", "-n", namespace, "-l", labelSelector, "--watch=true", "--output-watch-events=true", "-o", "json" ], { stdio: ["ignore", "pipe", "pipe"] }); if (this.watchProcess.stdout) { this.watchProcess.stdout.setEncoding("utf8"); } let buffer = ""; this.watchProcess.stdout?.on("data", (data) => { logger.debug("Pod watcher raw data received", { dataLength: data.length, dataPreview: data.substring(0, 200), operation: "pod_watcher_raw_data" }); buffer += data; const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine) { logger.debug("Processing pod watch line", { linePreview: trimmedLine.substring(0, 150), operation: "pod_watcher_process_line" }); try { const event = JSON.parse(trimmedLine); logger.debug("Pod watch event received", { eventType: event.type, podName: event.object.metadata.name, operation: "pod_watcher_event" }); this.handlePodEvent(event); } catch (e) { logger.warn("Failed to parse pod watch JSON", { error: e instanceof Error ? e.message : String(e), linePreview: trimmedLine.substring(0, 100), operation: "pod_watcher_parse_error" }); } } } }); this.watchProcess.stderr?.on("data", (data) => { logger.warn("Pod watcher stderr output", { stderr: data.toString(), operation: "pod_watcher_stderr" }); }); this.watchProcess.on("exit", (code) => { logger.info("Pod watcher process exited", { exitCode: code, operation: "pod_watcher_exit" }); if (code !== 0) { setTimeout(() => this.restart(), 5e3); } }); await this.getInitialPods(namespace, labelSelector); logger.info("Pod watcher started successfully", { kind: selector.kind, name: selector.name, namespace, labelSelector, operation: "pod_watcher_start" }); } catch (error) { logger.error("Failed to start pod watcher", { error: String(error), selector, operation: "pod_watcher_start" }); throw error; } } /** * Get label selector from the deployment/statefulset/etc */ async getLabelSelector(selector) { try { const resourceJson = await execCapture([ "kubectl", "get", selector.kind.toLowerCase(), selector.name, "-n", selector.namespace || "default", "-o", "json" ]); const resource = JSON.parse(resourceJson); let labels = {}; switch (selector.kind.toLowerCase()) { case "deployment": labels = resource.spec?.template?.metadata?.labels || {}; break; case "statefulset": labels = resource.spec?.template?.metadata?.labels || {}; break; case "daemonset": labels = resource.spec?.template?.metadata?.labels || {}; break; default: labels = resource.spec?.selector?.matchLabels || {}; } if (Object.keys(labels).length === 0) { throw new Error(`No labels found for ${selector.kind}/${selector.name}`); } return Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(","); } catch (error) { logger.error("Failed to get label selector", { error: String(error), selector, operation: "get_label_selector" }); throw error; } } /** * Get initial list of ready pods */ async getInitialPods(namespace, labelSelector) { try { const podsJson = await execCapture([ "kubectl", "get", "pods", "-n", namespace, "-l", labelSelector, "-o", "json" ]); const response = JSON.parse(podsJson); const pods = response.items || []; for (const pod of pods) { if (this.isPodReady(pod)) { this.readyPods.add(pod.metadata.name); logger.debug("Initial ready pod found", { podName: pod.metadata.name, operation: "pod_watcher_initial_ready" }); } } logger.info("Initial pod scan completed", { readyPodsCount: this.readyPods.size, readyPods: Array.from(this.readyPods), operation: "pod_watcher_initial_scan" }); } catch (error) { logger.warn("Failed to get initial pods", { error: String(error), namespace, labelSelector, operation: "get_initial_pods" }); } } /** * Handle pod events from the watch stream */ handlePodEvent(event) { const pod = event.object; const podName = pod.metadata.name; const wasTracked = this.readyPods.has(podName); const isReady = this.isPodReady(pod); const isRunning = this.isPodRunning(pod); logger.debug("Handling pod event", { eventType: event.type, podName, wasTracked, isReady, isRunning, currentCachedPods: Array.from(this.readyPods), currentRunningPods: Array.from(this.runningPods), operation: "pod_watcher_handle_event" }); switch (event.type) { case "ADDED": case "MODIFIED": if (isReady) { if (!this.readyPods.has(podName)) { this.readyPods.add(podName); logger.info("Pod became ready", { podName, operation: "pod_ready" }); } } else { if (this.readyPods.has(podName)) { this.readyPods.delete(podName); logger.info("Pod no longer ready", { podName, operation: "pod_not_ready" }); } } if (isRunning) { if (!this.runningPods.has(podName)) { this.runningPods.add(podName); logger.info("Pod became running", { podName, operation: "pod_running" }); } } else { if (this.runningPods.has(podName)) { this.runningPods.delete(podName); logger.info("Pod no longer running", { podName, operation: "pod_not_running" }); } } this.handleContainerEvents(pod); break; case "DELETED": if (this.readyPods.has(podName)) { this.readyPods.delete(podName); logger.info("Pod deleted from ready cache", { podName, operation: "pod_deleted_ready" }); } else { logger.debug("Pod deleted but was not in ready cache", { podName, operation: "pod_deleted_not_cached" }); } if (this.runningPods.has(podName)) { this.runningPods.delete(podName); logger.info("Pod deleted from running cache", { podName, operation: "pod_deleted_running" }); } this.cleanupContainersForPod(podName); break; } logger.debug("Pod watcher cache state updated", { readyPods: Array.from(this.readyPods), runningPods: Array.from(this.runningPods), operation: "pod_watcher_cache_updated" }); logger.debug("Pod event handled", { event: event.type, pod: podName, ready: isReady, totalReadyPods: this.readyPods.size, operation: "handle_pod_event" }); } /** * Check if a pod is ready for operations */ isPodReady(pod) { const phase = pod.status?.phase; const conditions = pod.status?.conditions || []; const ready = conditions.find((c) => c.type === "Ready")?.status === "True"; const running = phase === "Running"; return running && ready; } /** * Get list of ready pods (instant - no kubectl calls) */ getReadyPods() { return Array.from(this.readyPods); } /** * Check if a pod is truly running and stable (stricter than ready) */ isPodRunning(pod) { const phase = pod.status?.phase; const conditions = pod.status?.conditions || []; const ready = conditions.find((c) => c.type === "Ready")?.status === "True"; const isRunning = phase === "Running"; const notDeleting = !pod.metadata?.deletionTimestamp; const containerStatuses = pod.status?.containerStatuses || []; const allContainersRunning = containerStatuses.every( (status) => status.ready && status.state?.running ); return isRunning && ready && notDeleting && allContainersRunning; } /** * Get list of truly running pods (instant - no kubectl calls) */ getRunningPods() { return Array.from(this.runningPods); } /** * Stop the pod watcher */ stop() { if (this.watchProcess) { logger.info("Killing pod watcher process", { operation: "pod_watcher_killing" }); this.watchProcess.kill("SIGTERM"); setTimeout(() => { if (this.watchProcess && !this.watchProcess.killed) { logger.warn("Force killing pod watcher process", { operation: "pod_watcher_force_kill" }); this.watchProcess.kill("SIGKILL"); } }, 500); this.watchProcess = void 0; } this.readyPods.clear(); logger.info("Pod watcher stopped", { selector: this.selector, operation: "pod_watcher_stop" }); } /** * Restart the pod watcher */ async restart() { logger.info("Restarting pod watcher", { operation: "pod_watcher_restart" }); if (this.selector) { try { await this.start(this.selector); } catch (error) { logger.error("Failed to restart pod watcher", { error: String(error), operation: "pod_watcher_restart_failed" }); setTimeout(() => this.restart(), 1e4); } } } /** * Handle container lifecycle events from pod status */ handleContainerEvents(pod) { const podName = pod.metadata.name; const containerStatuses = pod.status?.containerStatuses || []; for (const status of containerStatuses) { const containerName = status.name; const key = `${podName}:${containerName}`; const existing = this.trackedContainers.get(key); let startTime = Date.now(); if (status.state?.running?.startedAt) { startTime = new Date(status.state.running.startedAt).getTime(); } const container = { podName, containerName, containerID: status.containerID || "", restartCount: status.restartCount || 0, startTime }; if (!existing) { this.trackedContainers.set(key, container); this.notifyContainerEvent({ type: "started", container }); logger.info("New container detected", { podName: container.podName, containerName: container.containerName, containerID: container.containerID.substring(0, 12), startTime: container.startTime }); } else if (container.containerID !== existing.containerID || container.restartCount > existing.restartCount) { this.trackedContainers.set(key, container); this.notifyContainerEvent({ type: "restarted", container }); logger.info("Container restart detected", { podName: container.podName, containerName: container.containerName, oldContainerID: existing.containerID.substring(0, 12), newContainerID: container.containerID.substring(0, 12), oldRestartCount: existing.restartCount, newRestartCount: container.restartCount }); } } } /** * Clean up tracked containers for a deleted pod */ cleanupContainersForPod(podName) { const keysToDelete = []; for (const [key, container] of this.trackedContainers) { if (container.podName === podName) { keysToDelete.push(key); } } for (const key of keysToDelete) { this.trackedContainers.delete(key); } } /** * Notify all container event handlers */ notifyContainerEvent(event) { for (const handler of this.containerEventHandlers) { try { handler(event); } catch (error) { logger.error("Error in container event handler", { error: error instanceof Error ? error.message : String(error) }); } } } /** * Add an event handler for container lifecycle events */ onContainerEvent(handler) { this.containerEventHandlers.push(handler); } /** * Get tracked container info for a specific pod/container */ getTrackedContainer(podName, containerName) { const key = `${podName}:${containerName || "default"}`; return this.trackedContainers.get(key); } /** * Check if a container needs catch-up sync based on our tracking */ needsCatchupSync(podName, containerName) { const tracked = this.getTrackedContainer(podName, containerName); if (!tracked) { return true; } const now = Date.now(); const recentStart = now - tracked.startTime < 3e4; return recentStart; } /** * Get current status for debugging */ getStatus() { return { isWatching: !!this.watchProcess, readyPodCount: this.readyPods.size, readyPods: this.getReadyPods(), runningPodCount: this.runningPods.size, runningPods: this.getRunningPods(), trackedContainerCount: this.trackedContainers.size, selector: this.selector }; } }; var SyncStateManager = class { stateDir; stateFile; state; constructor(projectRoot = process.cwd()) { this.stateDir = path5.join(projectRoot, ".tilt-ts"); this.stateFile = path5.join(this.stateDir, "sync-state.json"); this.state = this.loadState(); } loadState() { try { if (fs.existsSync(this.stateFile)) { const data = fs.readFileSync(this.stateFile, "utf8"); const parsed = JSON.parse(data); logger.info("Loaded sync state", { version: parsed.version, imageCount: Object.keys(parsed.images).length, currentImage: parsed.currentImage }); return parsed; } } catch (error) { logger.warn("Failed to load sync state, starting fresh", { error: error instanceof Error ? error.message : String(error) }); } return { version: "1.0.0", images: {} }; } saveState() { try { if (!fs.existsSync(this.stateDir)) { fs.mkdirSync(this.stateDir, { recursive: true }); } fs.writeFileSync(this.stateFile, JSON.stringify(this.state, null, 2)); logger.info("Saved sync state", { imageCount: Object.keys(this.state.images).length, stateFile: this.stateFile }); } catch (error) { logger.error("Failed to save sync state", { error: error instanceof Error ? error.message : String(error), stateFile: this.stateFile }); } } /** * Track a new Docker image build, resetting sync state for this image */ trackImageBuild(imageTag, imageHash) { const buildTimestamp = Date.now(); this.state.images[imageTag] = { imageTag, imageHash, buildTimestamp, syncedFiles: {}, containers: {} }; this.state.currentImage = imageTag; this.saveState(); logger.info("Tracked new image build", { imageTag, imageHash: imageHash.substring(0, 12), buildTimestamp }); } /** * Track a file that was synced to containers */ trackFileSync(relativePath, localFilePath, sourceDirectory) { logger.debug("trackFileSync - currentImage check", { currentImage: this.state.currentImage }); if (!this.state.currentImage) { logger.debug( "trackFileSync - no current image, skipping file tracking" ); logger.warn("No current image set, cannot track file sync"); return; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { logger.warn("Current image not found in state", { currentImage: this.state.currentImage }); return; } try { const content = fs.readFileSync(localFilePath); const contentHash = crypto.createHash("sha256").update(content).digest("hex"); const size = content.length; const fileState = { relativePath, contentHash, syncTimestamp: Date.now(), size, sourceDirectory }; imageState.syncedFiles[relativePath] = fileState; logger.info("trackFileSync - added file", { relativePath, totalFiles: Object.keys(imageState.syncedFiles).length }); this.saveState(); logger.info("Tracked file sync", { relativePath, contentHash: contentHash.substring(0, 12), size }); } catch (error) { logger.error("Failed to track file sync", { relativePath, localFilePath, error: error instanceof Error ? error.message : String(error) }); } } /** * Track a container's state for lifecycle detection */ trackContainer(podName, containerID, startTime) { if (!this.state.currentImage) { return; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { return; } const containerKey = `${podName}:${containerID}`; imageState.containers[containerKey] = { podName, containerID, startTime, lastSyncCheck: Date.now() }; this.saveState(); logger.info("Tracked container", { podName, containerID: containerID.substring(0, 12), startTime }); } /** * Check if a container needs catch-up sync (is new since last sync) */ needsCatchupSync(podName, containerID, startTime) { if (!this.state.currentImage) { return false; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { return false; } const containerKey = `${podName}:${containerID}`; const existingContainer = imageState.containers[containerKey]; if (!existingContainer) { return true; } if (startTime > existingContainer.startTime) { return true; } return false; } /** * Get all synced files for the current image that need to be re-synced */ getFilesForCatchupSync() { if (!this.state.currentImage) { return []; } const imageState = this.state.images[this.state.currentImage]; if (!imageState) { return []; } return Object.values(imageState.syncedFiles); } /** * Get the current image information */ getCurrentImageState() { if (!this.state.currentImage) { return void 0; } return this.state.images[this.state.currentImage]; } /** * Clear sync state for debugging or reset */ clearState() { this.state = { version: "1.0.0", images: {} }; this.saveState(); logger.info("Cleared sync state"); } }; var CatchupSyncManager = class { syncStateManager; constructor(syncStateManager) { this.syncStateManager = syncStateManager || new SyncStateManager(); } /** * Perform catch-up sync for a container that needs it */ async performCatchupSync(options) { const { namespace, podName, containerName, syncDestinations } = options; logger.info("Starting catch-up sync for container", { podName, containerName, namespace, operation: "catchup_sync_start" }); logger.info("catchup - starting file recovery", { podName, containerName, namespace, operation: "catchup_start" }); const filesToSync = this.syncStateManager.getFilesForCatchupSync(); if (filesToSync.length === 0) { logger.info("catchup - no files to sync", { podName, containerName, namespace, operation: "catchup_no_files" }); return; } logger.info("catchup - files to recover", { podName, containerName, namespace, count: filesToSync.length, operation: "catchup_list_count" }); const existingFiles = []; const projectRoot = process.cwd(); for (const fileState of filesToSync) { const localFilePath = path5.join(projectRoot, fileState.sourceDirectory, fileState.relativePath); try { const fs11 = await import('fs'); await fs11.promises.access(localFilePath, fs11.constants.F_OK); existingFiles.push(fileState); } catch { logger.warn("catchup::skip - file no longer exists, skipping", { podName, containerName, namespace, relativePath: fileState.relativePath, operation: "catchup_skip_missing" }); } } if (existingFiles.length === 0) { logger.info("catchup - no existing files to sync", { podName, containerName, namespace, operation: "catchup_no_existing" }); return; } logger.info("catchup - existing files to recover", { podName, containerName, namespace, existingCount: existingFiles.length, skippedCount: filesToSync.length - existingFiles.length, operation: "catchup_existing_count" }); let syncedCount = 0; let errorCount = 0; const filesByDestination = this.groupFilesByDestination(existingFiles, syncDestinations); for (const [destBase, files] of filesByDestination) { try { await this.ensureDestinationDirectory(namespace, podName, containerName, destBase); for (const fileState of files) { try { await this.syncSingleFile(namespace, podName, containerName, fileState, destBase); syncedCount++; logger.info("catchup::create - file synced", { podName, containerName, namespace, relativePath: fileState.relativePath, operation: "catchup_file_synced" }); } catch (error) { errorCount++; const errorMessage = error instanceof Error ? error.message : String(error); logger.warn("catchup::error - failed to sync file", { podName, containerName, namespace, relativePath: fileState.relativePath, error: errorMessage, operation: "catchup_file_error" }); } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("catchup::error - failed to prepare destination", { podName, containerName, namespace, destination: destBase, error: errorMessage, operation: "catchup_prepare_dest_error" }); } } if (syncedCount > 0) { logger.info("catchup - recovered files", { podName, containerName, namespace, count: syncedCount, operation: "catchup_recovered_summary" }); } if (errorCount > 0) { logger.warn("catchup - files failed", { podName, containerName, namespace, count: errorCount, operation: "catchup_failed_summary" }); } logger.info("Completed catch-up sync for container", { podName, containerName, syncedCount, errorCount, totalFiles: filesToSync.length, operation: "catchup_sync_complete" }); } /** * Group files by their destination base path */ groupFilesByDestination(files, syncDestinations) { const groups = /* @__PURE__ */ new Map(); for (const file of files) { let destBase = syncDestinations.length > 0 ? syncDestinations[0].dest : "/tmp"; if (!groups.has(destBase)) { groups.set(destBase, []); } groups.get(destBase).push(file); } return groups; } /** * Ensure destination directory exists in the pod with retry for container readiness */ async ensureDestinationDirectory(namespace, podName, containerName, destBase) { const maxRetries = 5; const retryDelay = 1e3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await exec([ "kubectl", "exec", "-n", namespace, podName, ...containerName ? ["-c", containerName] : [], "--", "mkdir", "-p", destBase ]); return; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (attempt === maxRetries) { throw new Error(`Failed to create directory after ${maxRetries} attempts: ${errorMessage}`); } logger.warn("catchup::retry - mkdir failed, will retry", { podName, containerName, namespace, destination: destBase, attempt, maxRetries, delayMs: retryDelay, error: errorMessage, operation: "catchup_mkdir_retry" }); await new Promise((resolve6) => setTimeout(resolve6, retryDelay)); } } } /** * Sync a single file to the pod */ async syncSingleFile(namespace, podName, containerName, fileState, destBase) { const projectRoot = process.cwd(); const localFilePath = path5.join(projectRoot, fileState.sourceDirectory, fileState.relativePath); logger.debug("catchup::file - resolving local path", { podName, containerName, namespace, relativePath: fileState.relativePath, sourceDirectory: fileState.sourceDirectory, localFilePath, operation: "catchup_file_resolve" }); const destPath = path5.join(destBase, fileState.relativePath); const parentDir = path5.dirname(destPath); if (parentDir !== destBase) { await this.ensureDestinationDirectory(namespace, podName, containerName, parentDir); } const maxRetries = 5; const retryDelay = 1e3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await streamFileToPod(localFilePath, namespace, podName, destPath, containerName); break; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (attempt === maxRetries) { throw new Error(`Failed to stream file after ${maxRetries} attempts: ${errorMessage}`); } logger.warn("catchup::retry - file stream failed, will retry", { podName, containerName, namespace, relativePath: fileState.relativePath, destPath, attempt, maxRetries,