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
JavaScript
#!/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,