第一章:Go文件操作黄金法则总览
Go 语言的文件操作以简洁、安全和显式错误处理为核心,其标准库 os 和 io/ioutil(已逐步被 os 中更细粒度函数替代)共同构成稳健的底层支撑。开发者应始终遵循“显式打开、及时关闭、严格校验”的三原则,避免资源泄漏与竞态隐患。
文件路径处理需平台无关
使用 filepath.Join() 构建路径,而非字符串拼接,确保在 Windows/macOS/Linux 下行为一致:
// ✅ 推荐:自动适配路径分隔符
path := filepath.Join("data", "config", "app.json")
// ❌ 避免:硬编码 "/" 可能在 Windows 上失效
// path := "data/config/app.json"
打开文件必须检查错误并确保关闭
os.Open 或 os.OpenFile 返回的 *os.File 必须调用 Close(),推荐搭配 defer 使用,但需注意 defer 在函数返回前才执行,若需立即释放资源应手动调用:
f, err := os.Open("input.txt")
if err != nil {
log.Fatal("无法打开文件:", err) // 不可忽略 err
}
defer f.Close() // 确保函数退出时关闭
读写操作优先选用流式接口
避免一次性加载超大文件到内存。io.Copy() 是高效复制的标准方式,底层自动使用 32KB 缓冲区:
src, _ := os.Open("large.zip")
dst, _ := os.Create("backup.zip")
_, err := io.Copy(dst, src) // 自动分块读写,内存友好
if err != nil {
log.Fatal(err)
}
src.Close()
dst.Close()
权限与模式需显式声明
创建文件或目录时,权限位必须明确指定(如 0644 表示用户可读写、组和其他人只读),Go 不提供默认掩码推导:
| 操作 | 推荐函数 | 关键参数示例 |
|---|---|---|
| 创建带权限文件 | os.CreateFile() |
0644 |
| 创建多级目录 | os.MkdirAll() |
0755 |
| 安全重命名(原子) | os.Rename() |
跨文件系统可能失败 |
所有文件操作均应基于 error 判断成败,绝不假设调用必然成功。
第二章:基础写入方案——os.Create + io.WriteString 的经典组合
2.1 os.Create底层原理与文件描述符生命周期分析
os.Create本质是调用syscall.Open以O_CREAT|O_WRONLY|O_TRUNC标志打开文件:
// Go 源码简化逻辑(src/os/file_unix.go)
fd, err := syscall.Open(name, syscall.O_CREAT|syscall.O_WRONLY|syscall.O_TRUNC, 0666)
O_CREAT:文件不存在时创建O_WRONLY:仅写入权限,禁止读取O_TRUNC:若存在则清空内容0666:由进程umask掩码修正后生效
文件描述符(fd)在*os.File结构体中持久化,其生命周期绑定于Go对象:
- 创建时由内核分配唯一fd整数
Close()触发syscall.Close(fd)释放内核资源- GC不自动关闭,未显式关闭将导致fd泄漏
fd 状态流转示意
graph TD
A[os.Create] --> B[syscall.Open → fd]
B --> C[fd存入 &os.File.Fd]
C --> D[Write/Read操作]
D --> E[Close → syscall.Close]
E --> F[fd归还内核表]
常见陷阱对照表
| 场景 | 是否释放fd | 原因 |
|---|---|---|
| defer f.Close() | ✅ | 正常作用域退出时执行 |
| 忘记Close() | ❌ | fd持续占用,直至进程终止 |
| 多次Close() | ⚠️ | 第二次调用返回EBADF错误 |
2.2 io.WriteString的缓冲机制与UTF-8边界安全写入实践
io.WriteString 并非直接系统调用,而是经由 bufio.Writer 缓冲层中转(若底层 Writer 实现了 WriteString),其核心优势在于避免小字符串反复 syscall。
UTF-8 边界风险场景
当向未对齐的缓冲区尾部写入多字节 UTF-8 字符(如 é → 0xC3 0xA9)时,若缓冲区恰好满,可能被截断为孤立字节,导致解码错误。
安全写入实践要点
- 始终确保
bufio.Writer容量 ≥ 最长单个 UTF-8 码点(4 字节) - 避免在
WriteString后立即Flush()——应复用缓冲批次 - 对不确定来源的字符串,先用
utf8.ValidString()校验
// 安全写入示例:显式检查并刷新
buf := bufio.NewWriterSize(w, 4096)
if _, err := io.WriteString(buf, "Hello 世界"); err != nil {
return err // 错误处理
}
return buf.Flush() // 确保完整 UTF-8 序列落盘
逻辑分析:
io.WriteString内部调用w.Write([]byte(s));bufio.Writer在缓冲区剩余空间不足时自动 flush 并重试。参数w必须支持WriteString或具备足够缓冲容量,否则退化为逐字节写入。
| 缓冲策略 | 是否保障 UTF-8 完整性 | 触发条件 |
|---|---|---|
默认 bufio |
✅(自动跨块拼接) | 缓冲区满时自动 flush |
os.File 直写 |
❌(无边界感知) | 每次 syscall 独立提交 |
2.3 文件权限缺失导致创建失败的12种真实错误码诊断
当进程尝试创建文件或目录时,内核在 open() 或 mkdir() 系统调用路径中会逐级校验父目录的 w+x 权限(写+执行,后者用于进入目录)。任一环节缺失即触发特定 errno。
常见错误码映射表
| 错误码 | errno 名称 | 触发场景示例 |
|---|---|---|
| 13 | EACCES | 父目录无写权限(但有读权) |
| 2 | ENOENT | 父目录不存在(常被误判为权限问题) |
| 20 | ENOTDIR | 中间路径段是文件而非目录 |
典型诊断代码片段
#include <sys/stat.h>
#include <errno.h>
// 检查父目录是否可写+可执行
struct stat st;
if (stat("/var/log/app/", &st) == 0) {
if (!(st.st_mode & S_IWUSR) || !(st.st_mode & S_IXUSR)) {
errno = EACCES; // 显式模拟权限不足
}
}
逻辑分析:stat() 获取父目录元数据;S_IWUSR 判定用户写权限,S_IXUSR 判定用户执行权限(即“进入”能力);二者缺一即无法 creat() 子项。
权限校验流程
graph TD
A[open\path/to/file\ O_CREAT] --> B{父目录 /path/to/ 可访问?}
B -->|否| C[EACCES/ENOENT]
B -->|是| D{父目录 st_mode & S_IWUSR & S_IXUSR?}
D -->|否| E[EACCES]
D -->|是| F[成功创建]
2.4 并发场景下Create竞态条件复现与sync.Once规避方案
竞态复现:多次初始化风险
以下代码模拟高并发下 Create() 被重复调用的问题:
var instance *Service
func Create() *Service {
if instance == nil {
instance = &Service{} // 非原子操作:读-判-写三步
}
return instance
}
逻辑分析:
instance == nil检查与赋值非原子;若两个 goroutine 同时通过判断,将各自创建新实例,导致数据不一致、资源泄漏。instance为全局指针,无同步机制时完全暴露于竞态。
sync.Once 安全初始化
var once sync.Once
var instance *Service
func CreateSafe() *Service {
once.Do(func() {
instance = &Service{}
})
return instance
}
参数说明:
once.Do(f)内部使用atomic.LoadUint32+ CAS 保证f仅执行一次,无论多少 goroutine 并发调用。
方案对比
| 方案 | 线程安全 | 初始化次数 | 实现复杂度 |
|---|---|---|---|
| 原生判空 | ❌ | 可能多次 | 低 |
| sync.Once | ✅ | 严格一次 | 极低 |
graph TD
A[goroutine A] -->|检查 instance==nil| C[进入临界区]
B[goroutine B] -->|同时检查| C
C --> D{once.m atomic load}
D -->|first call| E[执行初始化]
D -->|already done| F[直接返回]
2.5 模拟磁盘满、只读挂载等极端环境的单元测试编写
在存储敏感型服务(如日志写入、缓存落盘)中,必须验证系统对 ENOSPC(磁盘满)和 EROFS(只读文件系统)等错误码的健壮响应。
核心模拟策略
- 使用
tmpfs挂载并限制大小模拟磁盘满 - 利用
mount --bind -o ro创建只读挂载点 - 通过
LD_PRELOAD注入write()/open()系统调用失败
示例:磁盘满场景测试(Go)
func TestWriteWithENOSPC(t *testing.T) {
// 创建受限 tmpfs:仅 1KB 可用
cmd := exec.Command("sh", "-c", "mkdir -p /tmp/testfs && mount -t tmpfs -o size=1K tmpfs /tmp/testfs")
cmd.Run()
defer exec.Command("umount", "/tmp/testfs").Run()
f, err := os.OpenFile("/tmp/testfs/overflow.log", os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
t.Fatal(err) // 应触发 ENOSPC
}
_, err = f.Write(make([]byte, 2048)) // 超出 1KB 容量
if !errors.Is(err, syscall.ENOSPC) {
t.Errorf("expected ENOSPC, got %v", err)
}
}
逻辑分析:
tmpfs严格按size=参数限制内存用量;Write()写入超限时内核返回ENOSPC;errors.Is()确保错误类型匹配而非字符串比较,提升可移植性。
常见错误码映射表
| 错误场景 | Linux 错误码 | Go 标准库常量 |
|---|---|---|
| 磁盘空间不足 | ENOSPC |
syscall.ENOSPC |
| 文件系统只读 | EROFS |
syscall.EROFS |
| 权限拒绝写入 | EACCES |
os.ErrPermission |
graph TD
A[触发写操作] --> B{内核检查文件系统状态}
B -->|可用空间 < 写入量| C[返回 ENOSPC]
B -->|挂载选项含 ro| D[返回 EROFS]
C & D --> E[应用层捕获并降级处理]
第三章:高效写入方案——ioutil.WriteFile 的封装陷阱与替代演进
3.1 ioutil.WriteFile被弃用后go1.16+标准库迁移路径详解
Go 1.16 正式将 ioutil.WriteFile 标记为弃用,其功能已完全整合至 os.WriteFile。
替代方案对比
| 旧方式(ioutil) | 新方式(os) | 变化点 |
|---|---|---|
ioutil.WriteFile(path, data, perm) |
os.WriteFile(path, data, perm) |
签名一致,仅包路径变更 |
迁移示例
// ✅ Go 1.16+ 推荐写法
err := os.WriteFile("config.json", []byte(`{"mode":"prod"}`), 0644)
if err != nil {
log.Fatal(err)
}
逻辑分析:
os.WriteFile内部复用os.OpenFile+Write+Close,但自动处理临时文件、权限校验与原子性保障;perm参数仍需显式传入(如0644),不可省略。
关键注意事项
os.WriteFile不再隐式创建父目录(需搭配os.MkdirAll)- 错误类型保持兼容(
*fs.PathError),无需修改错误处理逻辑
graph TD
A[调用 os.WriteFile] --> B[检查路径有效性]
B --> C[以 O_CREATE\|O_TRUNC\|O_WRONLY 打开文件]
C --> D[写入字节流]
D --> E[关闭文件句柄]
3.2 os.WriteFile原子性保障机制与临时文件交换策略实战
os.WriteFile 并非原子操作——它底层调用 os.OpenFile + Write + Close,若中途崩溃可能导致文件截断或脏写。
数据同步机制
为保障原子性,Go 生态普遍采用「临时文件+原子重命名」策略:
- 先写入唯一命名的临时文件(如
config.json.tmp-12345) - 调用
os.Rename将其覆盖目标文件(Linux/macOS 下rename(2)是原子的)
func atomicWrite(path string, data []byte) error {
tmpPath := path + ".tmp-" + strconv.FormatInt(time.Now().UnixNano(), 16)
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return err
}
return os.Rename(tmpPath, path) // 原子交换,失败则保留原文件
}
os.Rename在同一文件系统内是原子的;若跨设备会返回syscall.EXDEV,需降级为拷贝+删除。参数path必须为绝对路径以避免相对路径歧义,0644确保权限可控。
关键保障维度对比
| 维度 | 直接 WriteFile | 临时文件+Rename |
|---|---|---|
| 崩溃一致性 | ❌ 可能残留半写文件 | ✅ 原文件始终完整 |
| 权限继承 | ✅ 依赖父目录umask | ✅ 由 WriteFile 显式控制 |
| 文件系统要求 | 无限制 | 同一挂载点(否则 Rename 失败) |
graph TD
A[生成唯一临时路径] --> B[写入临时文件]
B --> C{os.Rename 成功?}
C -->|是| D[原子替换完成]
C -->|否| E[清理临时文件<br>返回错误]
3.3 大文件写入时内存暴涨的pprof定位与流式分块写入改造
数据同步机制
某日志导出服务在写入 2GB JSON 文件时,RSS 内存飙升至 4.8GB,触发 OOM kill。通过 pprof 抓取 heap profile:
curl -s "http://localhost:6060/debug/pprof/heap?debug=1" > heap.pb
go tool pprof heap.pb
发现 json.MarshalIndent 累积占 92% 堆分配——整文件序列化导致对象树全驻内存。
流式分块改造
改用 json.Encoder 分块写入,每 1000 条记录 flush 一次:
enc := json.NewEncoder(f)
for i, item := range items {
if i%1000 == 0 && i > 0 {
f.Sync() // 强制刷盘,防缓冲区膨胀
}
enc.Encode(item) // 流式编码,无中间 []byte
}
Encode() 直接写入 io.Writer,避免 Marshal() 的临时字节切片;Sync() 控制内核页缓存压力。
性能对比(2GB 文件)
| 指标 | 原方案 | 流式分块 |
|---|---|---|
| 峰值 RSS | 4.8 GB | 196 MB |
| GC pause avg | 127 ms | 1.3 ms |
graph TD
A[读取数据源] --> B{缓冲区满 1000 条?}
B -->|否| C[Encode 单条]
B -->|是| D[Sync 刷盘]
C --> B
D --> B
第四章:生产级写入方案——bufio.Writer + os.O_CREATE|os.O_WRONLY|os.O_TRUNC 组合
4.1 文件打开标志位组合的语义冲突图谱(O_APPEND vs O_TRUNC)
当 O_APPEND 与 O_TRUNC 同时传入 open() 系统调用时,POSIX 明确允许该组合,但语义存在隐式时序依赖:O_TRUNC 在文件描述符创建前截断,而 O_APPEND 在每次 write() 前自动寻址至末尾。
冲突本质
O_TRUNC是一次性元数据操作(清空内容、重置 i_size)O_APPEND是运行时写入策略(每次 write 自动 lseek(SEEK_END))
典型误用示例
int fd = open("log.txt", O_WRONLY | O_APPEND | O_TRUNC, 0644);
// ❌ 表面意图:清空后追加;实际效果:先截断→获得空文件fd→后续write仍追加(无问题)
// ✅ 但若期望“覆盖式追加”(如循环日志头),此组合无法达成
逻辑分析:open() 内部先执行 truncation(原子完成),再设置 file 结构体的 f_flags & FMODE_APPEND。后续 write() 调用 generic_file_write_iter() 时,因 FMODE_APPEND 置位,强制 pos = i_size_read(inode),故始终追加到新末尾——截断与追加不矛盾,但“覆盖后追加”的业务语义被消解。
组合行为对照表
| 标志组合 | 截断时机 | 写入位置策略 | 典型用途 |
|---|---|---|---|
O_WRONLY \| O_TRUNC |
open 时立即执行 | 从 offset=0 开始 | 完全覆盖写入 |
O_WRONLY \| O_APPEND |
不截断 | 每次 write 前 seek 到 EOF | 日志追加 |
O_WRONLY \| O_APPEND \| O_TRUNC |
open 时截断 | 每次 write 前 seek 到(新)EOF | 清空后开启新日志流 |
graph TD
A[open path, flags] --> B{flags & O_TRUNC?}
B -->|Yes| C[truncate inode to 0]
B -->|No| D[skip truncation]
C & D --> E{flags & O_APPEND?}
E -->|Yes| F[set FMODE_APPEND in file*]
E -->|No| G[leave append flag unset]
F --> H[write → auto-seek-to-EOF before copy]
4.2 bufio.Writer刷新时机控制与panic recovery双保险设计
数据同步机制
bufio.Writer 的刷新行为直接影响数据持久化可靠性。默认缓冲区满(4KB)或显式调用 Flush() 触发写入,但网络抖动或 I/O 阻塞可能导致延迟。
panic 恢复防护
在高并发写入场景中,底层 io.Writer 实现若 panic(如 socket 关闭后继续写),需双重兜底:
func safeWrite(w *bufio.Writer, data []byte) error {
// 第一重:延迟刷新 + 错误捕获
if _, err := w.Write(data); err != nil {
return fmt.Errorf("write failed: %w", err)
}
// 第二重:recover 包裹 Flush,避免 panic 波及主流程
defer func() {
if r := recover(); r != nil {
log.Printf("Flush panicked: %v", r)
}
}()
return w.Flush()
}
逻辑分析:
Write先入缓冲区,不触发系统调用;Flush才真正落盘/发包。recover仅捕获Flush可能引发的 panic(如底层连接已关闭),保障服务连续性。
| 控制维度 | 方式 | 风险覆盖点 |
|---|---|---|
| 刷新时机 | Flush() + Write 分离 |
缓冲区滞留、OOM |
| panic 恢复 | defer recover() 包裹 Flush |
底层 writer 异常中断 |
graph TD
A[Write data] --> B{Buffer full?}
B -->|Yes| C[Auto Flush]
B -->|No| D[Data in buffer]
C --> E[Syscall write]
E --> F{Success?}
F -->|No| G[Return error]
F -->|Yes| H[Done]
D --> I[Manual Flush]
I --> E
4.3 文件系统同步(fsync)在数据持久化中的必要性验证实验
数据同步机制
fsync() 强制将文件数据与元数据刷写至底层存储设备,绕过页缓存与块设备队列,是 POSIX 持久化语义的核心保障。
实验对比设计
- 不调用
fsync():进程退出后断电 → 数据丢失率接近100% - 调用
fsync()后断电 → 数据完整性可达99.98%(基于 ext4 + SSD 测试)
关键验证代码
int fd = open("log.bin", O_WRONLY | O_CREAT, 0644);
write(fd, buf, len); // 仅写入页缓存
// fsync(fd); // 注释掉即触发风险路径
close(fd); // 缓存可能未落盘!
write()仅保证数据进入内核页缓存;fsync()才触发submit_bio()至设备驱动层。参数fd必须为合法文件描述符,且文件需以可写模式打开。
性能与可靠性权衡
| 场景 | 平均延迟 | 持久化保障 |
|---|---|---|
| 无 fsync | ~0.02 ms | ❌ |
| 同步写 + fsync | ~1.8 ms | ✅ |
graph TD
A[write syscall] --> B[Page Cache]
B --> C{fsync called?}
C -->|Yes| D[Block Layer Queue]
C -->|No| E[Cache Reclaim/Power Loss → Data Lost]
D --> F[Storage Device]
4.4 基于context.Context实现带超时与取消的可中断写入流程
在高并发写入场景中,硬性阻塞可能引发 goroutine 泄漏与资源耗尽。context.Context 提供了优雅的生命周期控制能力。
核心控制模式
context.WithTimeout():设定绝对截止时间context.WithCancel():显式触发取消ctx.Done():监听取消信号(返回<-chan struct{})
超时写入示例
func writeWithTimeout(dst io.Writer, data []byte, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() // 防止泄漏
// 传入 context 到支持 cancel 的写操作(如 net.Conn.WriteContext)
if w, ok := dst.(interface{ WriteContext(context.Context, []byte) (int, error) }); ok {
_, err := w.WriteContext(ctx, data)
return err
}
// 回退:用 goroutine + select 模拟(生产环境应使用原生支持的接口)
done := make(chan error, 1)
go func() { done <- io.WriteString(dst, string(data)) }()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err() // 返回 context.Canceled 或 context.DeadlineExceeded
}
}
该实现通过 select 在写入完成与上下文结束间竞争,确保调用方能及时感知中断原因(Canceled 或 DeadlineExceeded),且 defer cancel() 保障资源释放。
| 场景 | ctx.Err() 值 | 语义含义 |
|---|---|---|
主动调用 cancel() |
context.Canceled |
用户/系统主动中止 |
| 超时自动触发 | context.DeadlineExceeded |
写入耗时超出预设阈值 |
graph TD
A[开始写入] --> B{是否支持 Context 接口?}
B -->|是| C[调用 WriteContext]
B -->|否| D[启动 goroutine 写入]
D --> E[select 等待完成或 ctx.Done]
C --> F[返回结果]
E --> F
第五章:终极避坑指南与工程化建议
配置漂移的静默陷阱
在 Kubernetes 多环境部署中,ConfigMap 与 Secret 的 YAML 文件常被手动修改后未同步至 Git 仓库,导致生产环境配置与 IaC 代码长期不一致。某电商团队曾因 staging 环境临时调整数据库连接池大小(maxPoolSize: 20 → 50)未提交,两周后 CI 流水线回滚时意外将错误配置推至 prod,引发连接耗尽雪崩。解决方案:强制启用 kubeseal 加密 Secret 并绑定 GitOps 工具链,在 Argo CD 中配置 syncPolicy.automated.prune: true 与 selfHeal: true,确保集群状态每 3 分钟与 Git 仓库强制对齐。
日志格式碎片化导致可观测性断裂
微服务日志字段命名不统一(如 user_id / userId / uid)、时间戳缺失时区、错误堆栈被截断,使 Loki 查询效率下降 70%。实际案例:某支付网关 12 个服务中仅 3 个输出结构化 JSON,其余为自由文本,SRE 团队需编写 8 类正则解析器才能聚合交易失败率。工程化对策:在构建阶段注入统一日志中间件(如 pino + pino-pretty 生产模式),通过 Dockerfile 复写 ENTRYPOINT 强制添加 --log-format json --log-timezone utc 参数,并在 CI 中运行 jq -e '.timestamp, .level, .service' < /dev/stdin 校验日志结构。
数据库迁移的原子性幻觉
使用 flyway migrate 命令直接在生产环境执行 SQL 脚本,看似原子实则存在事务边界漏洞——Flyway 默认对每个脚本开启独立事务,但 DDL 操作(如 ALTER TABLE ... ADD COLUMN)在 MySQL 5.7+ 中会隐式提交,导致部分列添加成功而后续索引创建失败时无法回滚。真实故障:某 SaaS 平台升级中 users 表新增 last_login_at 字段成功,但 CREATE INDEX idx_users_last_login ON users(last_login_at) 因磁盘满失败,遗留不一致表结构。修复方案:改用 liquibase 的 changelog XML 定义完整变更单元,并在 K8s Job 中封装如下逻辑:
initContainers:
- name: migration-check
image: postgres:14
command: ["sh", "-c"]
args: ["psql -h $DB_HOST -U $DB_USER $DB_NAME -c 'SELECT COUNT(*) FROM databasechangelog' | grep -q '0'"]
依赖版本锁死失效链
package-lock.json 在 monorepo 中被 .gitignore 错误排除,导致不同开发者本地安装 lodash@4.17.21 与 4.17.22,后者引入了 Array.prototype.flatMap 的 polyfill 冲突,引发前端白屏。验证数据:该问题在 3 个发布周期内造成 17% 的用户会话异常退出。根治措施:在 pre-commit 钩子中执行 npm ci --no-save && git status --porcelain | grep 'package-lock.json' || exit 1,并配置 GitHub Actions 矩阵测试:
| Node.js 版本 | npm 版本 | lockfileVersion |
|---|---|---|
| 18.18.2 | 9.8.1 | 2 |
| 20.11.0 | 10.2.4 | 3 |
CI 缓存污染的隐蔽传播
GitHub Actions 的 actions/cache 在跨分支复用时未校验 yarn.lock SHA256,导致 feature/login 分支缓存的 react@18.2.0 被 main 分支误用,而后者已升级至 18.2.1(含关键安全补丁)。监控显示该漏洞在缓存命中率 92% 的流水线中持续暴露 47 小时。工程实践:改用 actions/setup-node 的 cache-dependency-path 参数,显式指定 yarn.lock 路径,并添加校验步骤:
echo "yarn.lock hash: $(sha256sum yarn.lock | cut -d' ' -f1)" >> $GITHUB_STEP_SUMMARY
构建产物路径硬编码反模式
Dockerfile 中 COPY dist/ /app/ 导致多阶段构建时若 dist/ 目录不存在则静默跳过,容器启动后报错 ENOENT: no such file or directory, open '/app/index.js'。某 IoT 平台因此在 ARM64 构建中因 tsc 输出路径配置差异,连续 3 次发布失败却无构建日志告警。解决路径:在 Dockerfile 中插入防御性检查:
RUN if [ ! -f dist/index.js ]; then echo "FATAL: dist/index.js missing! Check tsconfig.json outDir"; exit 1; fi 