|
1 | 1 | import * as fs from "node:fs/promises"; |
| 2 | +import os from "node:os"; |
| 3 | +import * as path from "node:path"; |
| 4 | + |
| 5 | +import type { Logger } from "../logging/logger"; |
2 | 6 |
|
3 | 7 | const transientRenameCodes: ReadonlySet<string> = new Set([ |
4 | 8 | "EPERM", |
@@ -82,3 +86,111 @@ export async function writeAtomically<T>( |
82 | 86 | throw err; |
83 | 87 | } |
84 | 88 | } |
| 89 | + |
| 90 | +export interface FileCleanupCandidate { |
| 91 | + name: string; |
| 92 | + mtime: number; |
| 93 | + size: number; |
| 94 | +} |
| 95 | + |
| 96 | +export interface FileCleanupOptions { |
| 97 | + /** Label for log messages, e.g. "telemetry file". */ |
| 98 | + label: string; |
| 99 | + /** Cheap name-based predicate; non-matching entries are skipped before stat. */ |
| 100 | + filter?: (name: string) => boolean; |
| 101 | + /** From the stat'd survivors of `filter`, returns the files to delete. */ |
| 102 | + select: ( |
| 103 | + files: FileCleanupCandidate[], |
| 104 | + now: number, |
| 105 | + ) => Array<{ name: string }>; |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * Lists files in `dir`, applies `filter` to names, stats the survivors, and |
| 110 | + * unlinks whatever `select` returns. ENOENT is swallowed so concurrent |
| 111 | + * deletes are safe. Never throws; failures go to `logger.debug`. |
| 112 | + */ |
| 113 | +export async function cleanupFiles( |
| 114 | + dir: string, |
| 115 | + logger: Logger, |
| 116 | + options: FileCleanupOptions, |
| 117 | +): Promise<void> { |
| 118 | + const { label, filter, select } = options; |
| 119 | + const now = Date.now(); |
| 120 | + let names: string[]; |
| 121 | + try { |
| 122 | + names = await fs.readdir(dir); |
| 123 | + } catch (error) { |
| 124 | + // ENOENT just means the dir hasn't been created yet; anything else |
| 125 | + // (EACCES, EMFILE, ...) is a real failure worth surfacing. |
| 126 | + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { |
| 127 | + logger.debug(`Failed to read ${label} directory ${dir}`, error); |
| 128 | + } |
| 129 | + return; |
| 130 | + } |
| 131 | + const candidates = filter ? names.filter(filter) : names; |
| 132 | + |
| 133 | + const withStats = await Promise.all( |
| 134 | + candidates.map(async (name) => { |
| 135 | + try { |
| 136 | + const stats = await fs.stat(path.join(dir, name)); |
| 137 | + return { |
| 138 | + name, |
| 139 | + mtime: stats.mtime.getTime(), |
| 140 | + size: stats.size, |
| 141 | + }; |
| 142 | + } catch (error) { |
| 143 | + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { |
| 144 | + logger.debug(`Failed to stat ${label} ${name}`, error); |
| 145 | + } |
| 146 | + return null; |
| 147 | + } |
| 148 | + }), |
| 149 | + ); |
| 150 | + |
| 151 | + const toDelete = select( |
| 152 | + withStats.filter((f) => f !== null), |
| 153 | + now, |
| 154 | + ); |
| 155 | + |
| 156 | + const deleted = await Promise.all( |
| 157 | + toDelete.map(async (file) => { |
| 158 | + // Basename only; never let `select` escape `dir`. |
| 159 | + const safeName = path.basename(file.name); |
| 160 | + try { |
| 161 | + await fs.unlink(path.join(dir, safeName)); |
| 162 | + return safeName; |
| 163 | + } catch (error) { |
| 164 | + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { |
| 165 | + logger.debug(`Failed to delete ${label} ${safeName}`, error); |
| 166 | + } |
| 167 | + return null; |
| 168 | + } |
| 169 | + }), |
| 170 | + ); |
| 171 | + |
| 172 | + const successful = deleted.filter((name) => name !== null); |
| 173 | + if (successful.length > 0) { |
| 174 | + logger.debug( |
| 175 | + `Cleaned up ${successful.length} ${label}(s): ${successful.join(", ")}`, |
| 176 | + ); |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +/** |
| 181 | + * Substitute `${env:VAR}` with `process.env.VAR` (unset → empty string), |
| 182 | + * `${userHome}` (anywhere) with `os.homedir()`, and a leading `~` with |
| 183 | + * `os.homedir()`. Env substitution runs first so env values can themselves |
| 184 | + * contain `~` or `${userHome}`. |
| 185 | + */ |
| 186 | +export function expandPath(input: string): string { |
| 187 | + const expanded = input.replace( |
| 188 | + /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, |
| 189 | + (_, name: string) => process.env[name] ?? "", |
| 190 | + ); |
| 191 | + const userHome = os.homedir(); |
| 192 | + const tildeExpanded = expanded.startsWith("~") |
| 193 | + ? userHome + expanded.substring("~".length) |
| 194 | + : expanded; |
| 195 | + return tildeExpanded.replaceAll("${userHome}", userHome); |
| 196 | +} |
0 commit comments