tilt-ts-core
Version:
A TypeScript implementation of a Tilt-like development tool for Kubernetes live development workflows
1,635 lines (1,626 loc) • 137 kB
JavaScript
import * as fs from 'fs';
import * as path5 from 'path';
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 * as fs12 from 'fs/promises';
import { mkdir, writeFile } from 'fs/promises';
import * as YAML from 'yaml';
import { set } from 'es-toolkit/compat';
import chalk from 'chalk';
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
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 __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// 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();
}
});
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((resolve5, reject) => {
const [command, ...args] = cmd;
const proc = spawn(command, args, {
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 {
resolve5();
}
});
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((resolve5, reject) => {
const [command, ...args] = cmd;
const proc = spawn(command, args, {
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 {
resolve5(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 default_registry(config) {
config.clusterUrl = config.clusterUrl ?? config.hostUrl;
globalRegistry = config;
logger.info("\u{1F3D7}\uFE0F Set default registry configuration", {
hostUrl: config.hostUrl,
clusterUrl: config.clusterUrl,
operation: "default_registry"
});
return config;
}
function get_default_registry() {
return globalRegistry;
}
function reset_default_registry() {
globalRegistry = null;
logger.info("\u{1F504} Reset default registry configuration", {
operation: "reset_default_registry"
});
}
// src/utils/image-correlation.ts
init_image_registry();
async function streamFileToPod(localFilePath, namespace, podName, remotePath, containerName) {
return new Promise((resolve5, 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 {
resolve5();
}
});
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((resolve5, 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 {
resolve5();
}
});
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 fs13 = await import('fs');
await fs13.promises.access(localFilePath, fs13.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((resolve5) => setTimeout(resolve5, 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,
delayMs: retryDelay,
error: errorMessage,
operation: "catchup_stream_retry"
});
await new Promise((resolve5) => setTimeout(resolve5, retryDelay));
}
}
logger.info("Catch-up synced file to pod", {
podName,
containerName,
namespace,
relativePath: fileState.relativePath,
destPath,
contentHash: fileState.contentHash.substring(0, 12),
operation: "catchup_file_synced_detail"
});
}
/**
* Check if a container needs catch-up sync
*/
needsCatchupSync(podName, containerName) {
const currentImage = this.syncStateManager.getCurrentImageState();
logger.debug("catchup::manager::check - current image state presence", {
podName,
containerName,
hasCurrentImage: !!currentImage,
operation: "catchup_check_image_presence"
});
logger.debug("catchup::manager::check - input pod/container", {
podName,
containerName,
operation: "catchup_check_inputs"
});
if (!currentImage) {
logger.debug("catchup::manager::check - no current image state", {
podName,
containerName,
operation: "catchup_check_no_image"
});
return false;
}
const syncedFileCount = Object.keys(currentImage.syncedFiles).length;
const syncedFileNames = Object.keys(currentImage.syncedFiles);
logger.debug("catchup::manager::check - synced files summary", {
podName,
containerName,
count: syncedFileCount,
files: syncedFileNames,
operation: "catchup_check_synced_summary"
});
return syncedFileCount > 0;
}
};
// src/core/ignore-patterns.ts
var DEFAULT_IGNORE_PATTERNS = [
"**/node_modules/**",
"**/.git/**",
"**/.idea/**",
"**/.devtool-*/**",
"**/.venv/**",
// Python virtual env
"**/venv/**",
// Alternative venv name
"**/__pycache__/**",
// Python cache
"**/.pytest_cache/**",
// Pytest cache
"**/site-packages/**"
// Direct site-packages
];
var globalIgnorePatterns = [...DEFAULT_IGNORE_PATTERNS];
function setGlobalIgnorePatterns(customPatterns) {
globalIgnorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...customPatterns];
}
function getGlobalIgnorePatterns() {
return [...globalIgnorePatterns];
}
function resetIgnorePatterns() {
globalIgnorePatterns = [...DEFAULT_IGNORE_PATTERNS];
}
function getCustomIgnorePatterns() {
return globalIgnorePatterns.slice(DEFAULT_IGNORE_PATTERNS.length);
}
// src/core/live-update.ts
function scanDirectoryRecursively(dirPath) {
const foundFiles = [];
try {
const items = fs.readdirSync(dirPath, { withFileTypes: true });
for (const item of items) {
const itemPath = path5.join(dirPath, item.name);
if (item.isFile()) {
foundFiles.push(itemPath);
} else if (item.isDirectory()) {
const subFiles = scanDirectoryRecursively(itemPath);
foundFiles.push(...subFiles);
}
}
} catch (error) {
logger.warn("Failed to scan directory during file discovery", {
dirPath,
error: error instanceof Error ? error.message : String(error),
operation: "scan_directory_error"
});
}
return foundFiles;
}
async function batchSyncFilesToPod(files, absSrc, namespace, podName, containerName) {
if (files.length === 0) return;
const tempDir = process.cwd();
const tarFileName = `tilt-batch-${Date.now()}-${Math.random().toString(36).slice(2)}.tar`;
const localTarPath = path5.join(tempDir, tarFileName);
const remoteTarPath = `/tmp/${tarFileName}`;
try {
const relativeFiles = files.map((f) => f.relPath);
await createTar(absSrc, relativeFiles, localTarPath);
logger.debug("Created tar archive for batch sync", {
fileCount: files.length,
tarPath: localTarPath,
operation: "batch_sync_tar_created"
});
await exec([
"kubectl",
"cp",
localTarPath,
`${namespace}/${podName}:${remoteTarPath}`,
...containerName ? ["-c", containerName] : []
]);
logger.debug("Uploaded tar to pod", {
podName,
remotePath: remoteTarPath,
operation: "batch_sync_uploaded"
});
const firstDestPath = files[0]?.destPath || "";
const firstRelPath = files[0]?.relPath || "";
const baseDestDir = firstDestPath.replace(firstRelPath, "").replace(/\/$/, "");
await exec([
"kubectl",
"exec",
"-n",
namespace,
podName,
...containerName ? ["-c", containerName] : [],
"--",
"tar",
"-xf",
remoteTarPath,
"-C",
baseDestDir
]);
logger.info("Batch sync completed successfully", {
fileCount: files.length,
podName,
baseDestDir,
operation: "batch_sync_completed"
});
await exec([
"kubectl",
"exec",
"-n",
namespace,
podName,
...containerName ? ["-c", containerName] : [],
"--",
"rm",
"-f",
remoteTarPath
]);
logger.debug("Batch synced files", {
files: files.map((f) => f.relPath),
operation: "batch_sync_files"
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("Batch sync failed", {
error: errorMessage,
fileCount: files.length,
podName,
operation: "batch_sync_error"
});
throw error;
} finally {
try {
if (fs.existsSync(localTarPath)) {
fs.unlinkSync(localTarPath);
}
} catch (cleanupError) {
logger.warn("Failed to cleanup local tar file", {
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
tarPath: localTarPath,
operation: "batch_sync_cleanup_failed"
});
}
}
}
async function live_update_binding(bind) {
const ns = bind.selector.namespace || "default";
const containerName = bind.selector.container;
const BATCH_SYNC_THRESHOLD = parseInt(process.env.TILT_BATCH_SYNC_THRESHOLD || "2");
const syncStateManager = new SyncStateManager();
const catchupSyncManager = new CatchupSyncManager(syncStateManager);
const podWatcher = new PodWatcher();
await podWatcher.start(bind.selector);
podWatcher.onContainerEvent(async (event) => {
if (event.type === "restarted") {
logger.info("Container restarted", {
podName: event.container.podName,
containerID: event.container.containerID.substring(0, 12),
operation: "container_restart"
});
} else if (event.type === "started") {
logger.info("New container detected", {
podName: event.container.podName,
containerID: event.container.containerID.substring(0, 12),
operation: "container_start"
});
const podName = event.container.podName;
const podWatcherNeedsCatchup = podWatcher.needsCatchupSync(podName, containerName);
const catchupManagerNeedsCatchup = catchupSyncManager.needsCatchupSync(podName, containerName);
if (podWatcherNeedsCatchup && catchupManagerNeedsCatchup) {
logger.info("Initiating catch-up sync for new container", {
podName,
operation: "catchup_sync_start"
});
const syncDestinations = syncSteps.map((s) => ({
src: s.src,
dest: s.dest
}));
try {
await catchupSyncManager.performCatchupSync({
namespace: ns,
podName,
containerName,
syncDestinations
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("Catch-up sync failed", {
podName,
error: errorMessage,
operation: "catchup_sync_error"
});
logger.error("Failed to perform catch-up sync for new container", {
podName,
containerName,
error: errorMessage
});
}
}
}
});
async function currentPods() {
const pods = podWatcher.getRunningPods();
if (pods.length > 0) {
logger.info("Found running pods for operations (cached)", {
runningPodCount: pods.length,
runningPods: pods,
operation: "current_pods_cached"
});
} else {
logger.warn("No running pods found for operations (cached)", {
operation: "current_pods_cached"
});
}
return pods;
}
const syncSteps = bind.steps.filter((s) => s.type === "sync");
const runSteps = bind.steps.filter((s) => s.type === "run");
const watchTargets = [
...syncSteps.map((s) => s.src),
...runSteps.flatMap((r) => r.whenFilesChanged || [])
];
const entries = buildWatchEntries(watchTargets);
for (const e of entries) {
logger.info("Setting up file watcher", {
root: e.root,
fileCount: e.files.size,
isDirectoryWatch: e.files.size === 0,
absoluteRoot: path5.resolve(e.root),
operation: "watch_setup"
});
}
const projectRoot = process.cwd();
const changedSet = /* @__PURE__ */ new Set();
let timer = null;
const createdDirs = /* @__PURE__ */ new Set();
async function flush() {
if (changedSet.size === 0) return;
const changes = Array.from(changedSet.values());
changedSet.clear();
let pods;
try {
pods = await currentPods();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("Failed to get pods", {
operation: "get_pods_error"
});
logger.warn("Failed to get current pods, skipping this sync cycle", {
error: errorMessage.replace(/^command failed \d+: /, ""),
changeCount: changes.length
});
return;
}
if (pods.length === 0) {
logger.warn("No running pods found", {
operation: "no_pods_warning"
});
logger.info("No running pods available, skipping sync cycle", {
changeCount: changes.length
});
return;
}
for (const s of syncSteps) {
const absSrc = path5.resolve(projectRoot, s.src);
const allRelativeChanges = changes.filter((p) => {
const rel = path5.relative(absSrc, p);
return rel && !rel.startsWith("..") && rel !== ".";
}).map((p) => path5.relative(absSrc, p));
const rels = allRelativeChanges.filter((p) => {
const fullPath = path5.join(absSrc, p);
return fs.existsSync(fullPath);
});
const deletedFiles = allRelativeChanges.filter((p) => {
const fullPath = path5.join(absSrc, p);
return !fs.existsSync(fullPath);
});
logger.debug("Filtering file changes for sync step", {
srcPath: s.src,
absSrc,
totalChanges: changes.length,
allChanges: changes.map((p) => path5.relative(projectRoot, p)),
allRelativeChanges,
matchingFiles: rels,
deletedFiles,
operation: "sync_filter_debug"
});
logger.info("Filtered files for sync", {
totalChanges: changes.length,
absSrc,
matchingFiles: rels,
deletedFiles,
allChanges: changes
});
if (deletedFiles.length > 0) {
for (const pod of pods) {
for (const deletedFile of deletedFiles) {
const destPath = `${s.dest}/${deletedFile}`;
logger.info("Deleti