@bike4mind/cli 0.2.6-fix-cli-mcp-type-mismatch.17119 → 0.2.7-feat-cli-rewind-command.17128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ import "./chunk-PDX44BCA.js";
3
+
4
+ // src/storage/ImageStore.ts
5
+ import { createHash } from "crypto";
6
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs";
7
+ import { join, extname, resolve } from "path";
8
+ import { homedir } from "os";
9
+ import Database from "better-sqlite3";
10
+ import sharp from "sharp";
11
+ var ImageStore = class _ImageStore {
12
+ static {
13
+ this.BASE_DIR = join(homedir(), ".bike4mind", "cli");
14
+ }
15
+ static {
16
+ this.IMAGES_DIR = join(_ImageStore.BASE_DIR, "images");
17
+ }
18
+ static {
19
+ this.DB_PATH = join(_ImageStore.BASE_DIR, "images.db");
20
+ }
21
+ static {
22
+ this.RETENTION_DAYS = 7;
23
+ }
24
+ static {
25
+ // Target max size before base64 encoding. Base64 adds ~33% overhead.
26
+ // To stay under 1MB after base64, we need images < 750KB
27
+ this.MAX_RAW_SIZE = 750 * 1024;
28
+ }
29
+ static {
30
+ // 750KB
31
+ // Maximum size before compression to prevent OOM with very large images
32
+ this.MAX_PRECOMPRESS_SIZE = 50 * 1024 * 1024;
33
+ }
34
+ constructor() {
35
+ this.ensureDirectories();
36
+ this.db = new Database(_ImageStore.DB_PATH);
37
+ this.initDatabase();
38
+ try {
39
+ this.cleanupOldImages();
40
+ } catch (err) {
41
+ console.warn("Failed to cleanup old images:", err);
42
+ }
43
+ }
44
+ ensureDirectories() {
45
+ mkdirSync(_ImageStore.BASE_DIR, { recursive: true });
46
+ mkdirSync(_ImageStore.IMAGES_DIR, { recursive: true });
47
+ }
48
+ initDatabase() {
49
+ this.db.exec(`
50
+ CREATE TABLE IF NOT EXISTS images (
51
+ hash TEXT PRIMARY KEY,
52
+ format TEXT NOT NULL,
53
+ size INTEGER NOT NULL,
54
+ timestamp INTEGER NOT NULL,
55
+ uploaded INTEGER NOT NULL DEFAULT 0,
56
+ s3_url TEXT,
57
+ original_filename TEXT
58
+ )
59
+ `);
60
+ }
61
+ /**
62
+ * Store an image locally with content-based hashing for deduplication
63
+ */
64
+ async store(imageData, originalFilename) {
65
+ const { buffer: processedData, format: processedFormat } = await this.processImage(imageData, originalFilename);
66
+ const hash = this.generateHash(processedData);
67
+ const format = processedFormat;
68
+ const ext = format.toLowerCase();
69
+ const localPath = join(_ImageStore.IMAGES_DIR, `${hash}.${ext}`);
70
+ const existing = this.getMetadata(hash);
71
+ if (existing) {
72
+ return {
73
+ hash,
74
+ placeholder: "",
75
+ // Will be set by caller
76
+ localPath,
77
+ metadata: existing
78
+ };
79
+ }
80
+ writeFileSync(localPath, processedData);
81
+ const metadata = {
82
+ hash,
83
+ format,
84
+ size: processedData.length,
85
+ timestamp: Date.now(),
86
+ uploaded: false,
87
+ originalFilename
88
+ };
89
+ this.db.prepare(
90
+ `INSERT INTO images (hash, format, size, timestamp, uploaded, original_filename)
91
+ VALUES (?, ?, ?, ?, ?, ?)`
92
+ ).run(hash, format, metadata.size, metadata.timestamp, 0, originalFilename || null);
93
+ return {
94
+ hash,
95
+ placeholder: "",
96
+ localPath,
97
+ metadata
98
+ };
99
+ }
100
+ /**
101
+ * Get image metadata by hash
102
+ */
103
+ getMetadata(hash) {
104
+ const row = this.db.prepare("SELECT * FROM images WHERE hash = ?").get(hash);
105
+ if (!row) return null;
106
+ return {
107
+ hash: row.hash,
108
+ format: row.format,
109
+ size: row.size,
110
+ timestamp: row.timestamp,
111
+ uploaded: Boolean(row.uploaded),
112
+ s3Url: row.s3_url,
113
+ originalFilename: row.original_filename
114
+ };
115
+ }
116
+ /**
117
+ * Read image data from local cache
118
+ */
119
+ readImage(hash) {
120
+ const metadata = this.getMetadata(hash);
121
+ if (!metadata) return null;
122
+ const localPath = join(_ImageStore.IMAGES_DIR, `${hash}.${metadata.format.toLowerCase()}`);
123
+ const normalizedPath = resolve(localPath);
124
+ if (!normalizedPath.startsWith(resolve(_ImageStore.IMAGES_DIR))) {
125
+ throw new Error("Invalid image path");
126
+ }
127
+ if (!existsSync(localPath)) return null;
128
+ return readFileSync(localPath);
129
+ }
130
+ /**
131
+ * Generate content hash for deduplication
132
+ */
133
+ generateHash(data) {
134
+ return createHash("sha256").update(data).digest("hex").substring(0, 16);
135
+ }
136
+ /**
137
+ * Process image: compress if needed to stay under size limit
138
+ */
139
+ async processImage(data, originalFilename) {
140
+ if (data.length > _ImageStore.MAX_PRECOMPRESS_SIZE) {
141
+ throw new Error(
142
+ `Image too large to process (${Math.round(data.length / 1024 / 1024)}MB). Maximum size is ${_ImageStore.MAX_PRECOMPRESS_SIZE / 1024 / 1024}MB.`
143
+ );
144
+ }
145
+ const originalFormat = this.detectFormat(data, originalFilename);
146
+ if (data.length <= _ImageStore.MAX_RAW_SIZE) {
147
+ return { buffer: data, format: originalFormat };
148
+ }
149
+ let image = sharp(data);
150
+ const metadata = await image.metadata();
151
+ if (!metadata.width || !metadata.height) {
152
+ throw new Error("Unable to read image dimensions");
153
+ }
154
+ const maxDimension = 2048;
155
+ let width = metadata.width;
156
+ let height = metadata.height;
157
+ if (width > maxDimension || height > maxDimension) {
158
+ if (width > height) {
159
+ height = Math.round(height * maxDimension / width);
160
+ width = maxDimension;
161
+ } else {
162
+ width = Math.round(width * maxDimension / height);
163
+ height = maxDimension;
164
+ }
165
+ }
166
+ image = image.resize(width, height);
167
+ let quality = 85;
168
+ let buffer = await image.jpeg({ quality }).toBuffer();
169
+ while (buffer.length > _ImageStore.MAX_RAW_SIZE && quality > 30) {
170
+ quality -= 10;
171
+ buffer = await image.jpeg({ quality }).toBuffer();
172
+ }
173
+ if (buffer.length > _ImageStore.MAX_RAW_SIZE) {
174
+ throw new Error(`Unable to compress image below ${_ImageStore.MAX_RAW_SIZE / 1024}KB limit`);
175
+ }
176
+ return { buffer, format: "jpg" };
177
+ }
178
+ /**
179
+ * Detect image format from buffer or filename
180
+ */
181
+ detectFormat(data, filename) {
182
+ if (data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) {
183
+ return "png";
184
+ }
185
+ if (data[0] === 255 && data[1] === 216 && data[2] === 255) {
186
+ return "jpg";
187
+ }
188
+ if (data[0] === 71 && data[1] === 73 && data[2] === 70) {
189
+ return "gif";
190
+ }
191
+ if (data[0] === 82 && data[1] === 73 && data[2] === 70 && data[3] === 70) {
192
+ return "webp";
193
+ }
194
+ if (filename) {
195
+ const ext = extname(filename).substring(1).toLowerCase();
196
+ if (["png", "jpg", "jpeg", "gif", "webp"].includes(ext)) {
197
+ return ext === "jpeg" ? "jpg" : ext;
198
+ }
199
+ }
200
+ return "png";
201
+ }
202
+ /**
203
+ * Clean up images older than retention period
204
+ */
205
+ cleanupOldImages() {
206
+ const cutoffTime = Date.now() - _ImageStore.RETENTION_DAYS * 24 * 60 * 60 * 1e3;
207
+ const oldImages = this.db.prepare("SELECT hash, format FROM images WHERE timestamp < ?").all(cutoffTime);
208
+ for (const { hash, format } of oldImages) {
209
+ const localPath = join(_ImageStore.IMAGES_DIR, `${hash}.${format.toLowerCase()}`);
210
+ if (existsSync(localPath)) {
211
+ unlinkSync(localPath);
212
+ }
213
+ }
214
+ this.db.prepare("DELETE FROM images WHERE timestamp < ?").run(cutoffTime);
215
+ }
216
+ /**
217
+ * Close database connection
218
+ */
219
+ close() {
220
+ this.db.close();
221
+ }
222
+ };
223
+ export {
224
+ ImageStore
225
+ };