repoimage 0.2.0

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.
Files changed (71) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/AGENTS.md +28 -0
  3. package/PROJECT-AGENTS.md +55 -0
  4. package/README.md +153 -0
  5. package/TODO.md +132 -0
  6. package/client/index.html +12 -0
  7. package/client/package.json +23 -0
  8. package/client/src/App.tsx +599 -0
  9. package/client/src/components/FsBrowser.tsx +210 -0
  10. package/client/src/components/Settings.tsx +81 -0
  11. package/client/src/index.css +797 -0
  12. package/client/src/lib/api.ts +69 -0
  13. package/client/src/lib/collect.ts +204 -0
  14. package/client/src/lib/format.ts +96 -0
  15. package/client/src/lib/session.ts +58 -0
  16. package/client/src/main.tsx +10 -0
  17. package/client/src/vite-env.d.ts +1 -0
  18. package/client/tsconfig.json +18 -0
  19. package/client/vite.config.ts +27 -0
  20. package/docs/README.md +28 -0
  21. package/docs/api/overview.md +65 -0
  22. package/docs/api/scan.md +188 -0
  23. package/docs/architecture.md +155 -0
  24. package/docs/design/invariants.md +19 -0
  25. package/docs/design/role-system.md +50 -0
  26. package/docs/development/README.md +94 -0
  27. package/docs/features/README.md +21 -0
  28. package/docs/features/compression-score.md +75 -0
  29. package/docs/features/exclusions.md +63 -0
  30. package/docs/features/session.md +64 -0
  31. package/package.json +37 -0
  32. package/server/dist/cli.d.ts +3 -0
  33. package/server/dist/cli.d.ts.map +1 -0
  34. package/server/dist/cli.js +54 -0
  35. package/server/dist/cli.js.map +1 -0
  36. package/server/dist/fs-list.d.ts +3 -0
  37. package/server/dist/fs-list.d.ts.map +1 -0
  38. package/server/dist/fs-list.js +73 -0
  39. package/server/dist/fs-list.js.map +1 -0
  40. package/server/dist/paths.d.ts +3 -0
  41. package/server/dist/paths.d.ts.map +1 -0
  42. package/server/dist/paths.js +12 -0
  43. package/server/dist/paths.js.map +1 -0
  44. package/server/dist/scan.d.ts +16 -0
  45. package/server/dist/scan.d.ts.map +1 -0
  46. package/server/dist/scan.js +158 -0
  47. package/server/dist/scan.js.map +1 -0
  48. package/server/dist/server.d.ts +6 -0
  49. package/server/dist/server.d.ts.map +1 -0
  50. package/server/dist/server.js +313 -0
  51. package/server/dist/server.js.map +1 -0
  52. package/server/package.json +22 -0
  53. package/server/src/cli.ts +63 -0
  54. package/server/src/fs-list.ts +70 -0
  55. package/server/src/paths.ts +6 -0
  56. package/server/src/scan.ts +203 -0
  57. package/server/src/server.ts +356 -0
  58. package/server/tsconfig.json +9 -0
  59. package/shared/package.json +10 -0
  60. package/shared/src/constants.ts +37 -0
  61. package/shared/src/index.ts +4 -0
  62. package/shared/src/role-guess.ts +103 -0
  63. package/shared/src/schema.ts +18 -0
  64. package/shared/src/types.ts +36 -0
  65. package/shared/tsconfig.json +9 -0
  66. package/test/cli.test.js +56 -0
  67. package/test/fs-list.test.js +39 -0
  68. package/test/role-guess.test.js +50 -0
  69. package/test/scan.test.js +150 -0
  70. package/test/server.test.js +308 -0
  71. package/tsconfig.base.json +14 -0
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const http = require('http');
7
+ const test = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+
10
+ const { createRepoimageServer } = require('../server/dist/server');
11
+
12
+ const MINI_PNG = Buffer.from(
13
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
14
+ 'base64'
15
+ );
16
+
17
+ function tempDir() {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'repoimage-srv-'));
19
+ }
20
+
21
+ function postJson(port, pathname, payload) {
22
+ const body = JSON.stringify(payload);
23
+ return new Promise((resolve, reject) => {
24
+ const req = http.request(
25
+ {
26
+ hostname: '127.0.0.1',
27
+ port,
28
+ path: pathname,
29
+ method: 'POST',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'Content-Length': Buffer.byteLength(body)
33
+ }
34
+ },
35
+ res => {
36
+ const chunks = [];
37
+ res.on('data', c => chunks.push(c));
38
+ res.on('end', () => {
39
+ const text = Buffer.concat(chunks).toString('utf8');
40
+ let json = null;
41
+ try {
42
+ json = JSON.parse(text);
43
+ } catch {
44
+ // leave json null
45
+ }
46
+ resolve({ status: res.statusCode, text, json });
47
+ });
48
+ }
49
+ );
50
+ req.on('error', reject);
51
+ req.write(body);
52
+ req.end();
53
+ });
54
+ }
55
+
56
+ function httpGet(port, pathname) {
57
+ return new Promise((resolve, reject) => {
58
+ http
59
+ .get({ hostname: '127.0.0.1', port, path: pathname }, res => {
60
+ const chunks = [];
61
+ res.on('data', c => chunks.push(c));
62
+ res.on('end', () => {
63
+ const body = Buffer.concat(chunks);
64
+ resolve({
65
+ status: res.statusCode,
66
+ text: body.toString('utf8'),
67
+ body
68
+ });
69
+ });
70
+ })
71
+ .on('error', reject);
72
+ });
73
+ }
74
+
75
+ test('POST /api/scan returns images for an existing folder', async () => {
76
+ const dir = tempDir();
77
+ fs.writeFileSync(path.join(dir, 'x.png'), MINI_PNG);
78
+
79
+ const server = createRepoimageServer();
80
+ await new Promise((resolve, reject) => {
81
+ server.listen(0, resolve);
82
+ server.on('error', reject);
83
+ });
84
+ const port = server.address().port;
85
+
86
+ try {
87
+ const { status, json } = await postJson(port, '/api/scan', { folders: [dir] });
88
+ assert.equal(status, 200);
89
+ assert.ok(json);
90
+ assert.equal(json.images.length, 1);
91
+ assert.equal(json.images[0].path, 'x.png');
92
+ assert.ok(Array.isArray(json.issues));
93
+ } finally {
94
+ await new Promise(resolve => server.close(resolve));
95
+ }
96
+ });
97
+
98
+ test('POST /api/scan with empty folders returns 400', async () => {
99
+ const server = createRepoimageServer();
100
+ await new Promise((resolve, reject) => {
101
+ server.listen(0, resolve);
102
+ server.on('error', reject);
103
+ });
104
+ const port = server.address().port;
105
+
106
+ try {
107
+ const { status, json } = await postJson(port, '/api/scan', { folders: [] });
108
+ assert.equal(status, 400);
109
+ assert.ok(json && json.error);
110
+ } finally {
111
+ await new Promise(resolve => server.close(resolve));
112
+ }
113
+ });
114
+
115
+ test('POST /api/scan accepts single "folder" string', async () => {
116
+ const dir = tempDir();
117
+ fs.writeFileSync(path.join(dir, 'y.png'), MINI_PNG);
118
+
119
+ const server = createRepoimageServer();
120
+ await new Promise((resolve, reject) => {
121
+ server.listen(0, resolve);
122
+ server.on('error', reject);
123
+ });
124
+ const port = server.address().port;
125
+
126
+ try {
127
+ const { status, json } = await postJson(port, '/api/scan', { folder: dir });
128
+ assert.equal(status, 200);
129
+ assert.equal(json.images.length, 1);
130
+ assert.equal(json.images[0].path, 'y.png');
131
+ } finally {
132
+ await new Promise(resolve => server.close(resolve));
133
+ }
134
+ });
135
+
136
+ test('POST /api/scan-entries enriches client-provided rows', async () => {
137
+ const server = createRepoimageServer();
138
+ await new Promise((resolve, reject) => {
139
+ server.listen(0, resolve);
140
+ server.on('error', reject);
141
+ });
142
+ const port = server.address().port;
143
+
144
+ try {
145
+ const { status, json } = await postJson(port, '/api/scan-entries', {
146
+ entries: [{ path: 'a/b.png', size: 100, width: 10, height: 10 }]
147
+ });
148
+ assert.equal(status, 200);
149
+ assert.equal(json.images.length, 1);
150
+ assert.equal(json.images[0].path, 'a/b.png');
151
+ assert.ok(json.images[0].compressionRatio != null);
152
+ } finally {
153
+ await new Promise(resolve => server.close(resolve));
154
+ }
155
+ });
156
+
157
+ test('POST /api/scan-entries with empty entries returns 400', async () => {
158
+ const server = createRepoimageServer();
159
+ await new Promise((resolve, reject) => {
160
+ server.listen(0, resolve);
161
+ server.on('error', reject);
162
+ });
163
+ const port = server.address().port;
164
+
165
+ try {
166
+ const { status, json } = await postJson(port, '/api/scan-entries', { entries: [] });
167
+ assert.equal(status, 400);
168
+ assert.ok(json && json.error);
169
+ } finally {
170
+ await new Promise(resolve => server.close(resolve));
171
+ }
172
+ });
173
+
174
+ test('GET /api/fs/home returns a path string', async () => {
175
+ const server = createRepoimageServer();
176
+ await new Promise((resolve, reject) => {
177
+ server.listen(0, '127.0.0.1', resolve);
178
+ server.on('error', reject);
179
+ });
180
+ const port = server.address().port;
181
+
182
+ try {
183
+ const { status, text } = await httpGet(port, '/api/fs/home');
184
+ const json = JSON.parse(text);
185
+ assert.equal(status, 200);
186
+ assert.ok(typeof json.path === 'string' && json.path.length > 0);
187
+ } finally {
188
+ await new Promise(resolve => server.close(resolve));
189
+ }
190
+ });
191
+
192
+ test('GET /api/fs/list returns directory entries', async () => {
193
+ const dir = tempDir();
194
+ fs.writeFileSync(path.join(dir, 'x.dat'), '');
195
+
196
+ const server = createRepoimageServer();
197
+ await new Promise((resolve, reject) => {
198
+ server.listen(0, '127.0.0.1', resolve);
199
+ server.on('error', reject);
200
+ });
201
+ const port = server.address().port;
202
+ const q = encodeURIComponent(dir);
203
+ try {
204
+ const { status, text } = await httpGet(port, `/api/fs/list?path=${q}`);
205
+ const json = JSON.parse(text);
206
+ assert.equal(status, 200);
207
+ assert.ok(json.entries.some(e => e.name === 'x.dat'));
208
+ } finally {
209
+ await new Promise(resolve => server.close(resolve));
210
+ }
211
+ });
212
+
213
+ test('POST /api/fs/list accepts path in JSON body', async () => {
214
+ const dir = tempDir();
215
+
216
+ const server = createRepoimageServer();
217
+ await new Promise((resolve, reject) => {
218
+ server.listen(0, '127.0.0.1', resolve);
219
+ server.on('error', reject);
220
+ });
221
+ const port = server.address().port;
222
+
223
+ try {
224
+ const { status, json } = await postJson(port, '/api/fs/list', { path: dir });
225
+ assert.equal(status, 200);
226
+ assert.ok(Array.isArray(json.entries));
227
+ } finally {
228
+ await new Promise(resolve => server.close(resolve));
229
+ }
230
+ });
231
+
232
+ test('GET /api/file serves an image by absolute path', async () => {
233
+ const dir = tempDir();
234
+ const imgPath = path.join(dir, 'thumb.png');
235
+ fs.writeFileSync(imgPath, MINI_PNG);
236
+
237
+ const server = createRepoimageServer();
238
+ await new Promise((resolve, reject) => {
239
+ server.listen(0, '127.0.0.1', resolve);
240
+ server.on('error', reject);
241
+ });
242
+ const port = server.address().port;
243
+
244
+ try {
245
+ const q = encodeURIComponent(imgPath);
246
+ const { status, body } = await httpGet(port, `/api/file?path=${q}`);
247
+ assert.equal(status, 200);
248
+ assert.equal(body.length, MINI_PNG.length);
249
+ assert.equal(body.compare(MINI_PNG), 0);
250
+ } finally {
251
+ await new Promise(resolve => server.close(resolve));
252
+ }
253
+ });
254
+
255
+ test('POST /api/scan with excludeCommonFolders filters results', async () => {
256
+ const dir = tempDir();
257
+ const nodeModulesDir = path.join(dir, 'node_modules');
258
+ const assetsDir = path.join(dir, 'assets');
259
+ fs.mkdirSync(nodeModulesDir, { recursive: true });
260
+ fs.mkdirSync(assetsDir, { recursive: true });
261
+
262
+ fs.writeFileSync(path.join(nodeModulesDir, 'dep.png'), MINI_PNG);
263
+ fs.writeFileSync(path.join(assetsDir, 'logo.png'), MINI_PNG);
264
+
265
+ const server = createRepoimageServer();
266
+ await new Promise((resolve, reject) => {
267
+ server.listen(0, resolve);
268
+ server.on('error', reject);
269
+ });
270
+ const port = server.address().port;
271
+
272
+ try {
273
+ const { status: status1, json: json1 } = await postJson(port, '/api/scan', {
274
+ folders: [dir],
275
+ excludeCommonFolders: false
276
+ });
277
+ const { status: status2, json: json2 } = await postJson(port, '/api/scan', {
278
+ folders: [dir],
279
+ excludeCommonFolders: true
280
+ });
281
+
282
+ assert.equal(status1, 200);
283
+ assert.equal(status2, 200);
284
+ assert.equal(json1.images.length, 2);
285
+ assert.equal(json2.images.length, 1);
286
+ assert.equal(json2.images[0].path, 'assets/logo.png');
287
+ } finally {
288
+ await new Promise(resolve => server.close(resolve));
289
+ }
290
+ });
291
+
292
+ test('GET / serves index.html from client dist', async () => {
293
+ const clientRoot = path.join(__dirname, '..', 'client', 'dist');
294
+ const server = createRepoimageServer({ clientRoot });
295
+ await new Promise((resolve, reject) => {
296
+ server.listen(0, resolve);
297
+ server.on('error', reject);
298
+ });
299
+ const port = server.address().port;
300
+
301
+ try {
302
+ const { status, text } = await httpGet(port, '/');
303
+ assert.equal(status, 200);
304
+ assert.match(text, /RepoImage/);
305
+ } finally {
306
+ await new Promise(resolve => server.close(resolve));
307
+ }
308
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "Node",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "sourceMap": true
13
+ }
14
+ }