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,69 @@
1
+ import type { ImageRow, ScanIssue } from '@repoimage/shared';
2
+
3
+ const FILE_PROTOCOL_ERR =
4
+ 'This page was opened as a local file (file://). Folder browse and server scan need the RepoImage GUI. Run npm run dev or npm run gui from the repo, then open the URL it prints.';
5
+
6
+ export function resolveApiOrigin(): string | null {
7
+ const p = location.protocol;
8
+ if (p === 'http:' || p === 'https:') return '';
9
+ return null;
10
+ }
11
+
12
+ export const API_ORIGIN = resolveApiOrigin();
13
+
14
+ export function apiUrl(resourcePath: string): string {
15
+ const rel = resourcePath.startsWith('/') ? resourcePath : `/${resourcePath}`;
16
+ if (API_ORIGIN === null) throw new Error(FILE_PROTOCOL_ERR);
17
+ return API_ORIGIN ? `${API_ORIGIN}${rel}` : rel;
18
+ }
19
+
20
+ export const FILE_PROTOCOL_ERROR = FILE_PROTOCOL_ERR;
21
+
22
+ async function parseJsonResponse(res: Response): Promise<Record<string, unknown>> {
23
+ const text = await res.text();
24
+ let data: Record<string, unknown>;
25
+ try {
26
+ data = JSON.parse(text) as Record<string, unknown>;
27
+ } catch {
28
+ throw new Error(
29
+ text.trim().slice(0, 200) || `HTTP ${res.status} — is the API server running?`
30
+ );
31
+ }
32
+ if (!res.ok) {
33
+ throw new Error((data.error as string) || `HTTP ${res.status}`);
34
+ }
35
+ return data;
36
+ }
37
+
38
+ export async function fetchApiJson(url: string): Promise<Record<string, unknown>> {
39
+ const res = await fetch(apiUrl(url));
40
+ return parseJsonResponse(res);
41
+ }
42
+
43
+ export async function fetchScanFolder(
44
+ folder: string,
45
+ options: { excludeCommonFolders?: boolean } = {}
46
+ ): Promise<{
47
+ images: ImageRow[];
48
+ issues: ScanIssue[];
49
+ }> {
50
+ const res = await fetch(apiUrl('/api/scan'), {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ folder, excludeCommonFolders: options.excludeCommonFolders })
54
+ });
55
+ const data = await parseJsonResponse(res);
56
+ return data as { images: ImageRow[]; issues: ScanIssue[] };
57
+ }
58
+
59
+ export async function fetchScanEntries(
60
+ entries: Array<{ path: string; size: number; width?: number | null; height?: number | null }>
61
+ ): Promise<{ images: ImageRow[]; issues: ScanIssue[] }> {
62
+ const res = await fetch(apiUrl('/api/scan-entries'), {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({ entries })
66
+ });
67
+ const data = await parseJsonResponse(res);
68
+ return data as { images: ImageRow[]; issues: ScanIssue[] };
69
+ }
@@ -0,0 +1,204 @@
1
+ import { IMAGE_EXTENSIONS } from '@repoimage/shared';
2
+
3
+ export const DIM_BATCH = 8;
4
+ export const WALK_REPORT_EVERY = 48;
5
+ export const FILELIST_CHUNK = 120;
6
+
7
+ export interface PickedFile {
8
+ path: string;
9
+ file: File;
10
+ }
11
+
12
+ export function yieldToPaint(): Promise<void> {
13
+ return new Promise(resolve => {
14
+ requestAnimationFrame(() => {
15
+ setTimeout(() => {
16
+ requestAnimationFrame(() => resolve());
17
+ }, 0);
18
+ });
19
+ });
20
+ }
21
+
22
+ export function isImageFileName(name: string): boolean {
23
+ const lower = name.toLowerCase();
24
+ const dot = lower.lastIndexOf('.');
25
+ if (dot === -1) return false;
26
+ return (IMAGE_EXTENSIONS as readonly string[]).includes(lower.slice(dot));
27
+ }
28
+
29
+ export async function collectFromDirectoryHandle(
30
+ dirHandle: FileSystemDirectoryHandle,
31
+ basePath: string,
32
+ onProgress?: (visited: number, imageCount: number) => void
33
+ ): Promise<PickedFile[]> {
34
+ const out: PickedFile[] = [];
35
+ let visited = 0;
36
+
37
+ if (onProgress) {
38
+ onProgress(0, 0);
39
+ await yieldToPaint();
40
+ }
41
+
42
+ async function walk(dh: FileSystemDirectoryHandle, bp: string): Promise<void> {
43
+ for await (const entry of dh.values()) {
44
+ visited++;
45
+ if (
46
+ onProgress &&
47
+ (visited % WALK_REPORT_EVERY === 0 || (out.length > 0 && out.length % 24 === 0))
48
+ ) {
49
+ onProgress(visited, out.length);
50
+ await yieldToPaint();
51
+ }
52
+
53
+ const name = entry.name;
54
+ const rel = bp ? `${bp}/${name}` : name;
55
+ if (entry.kind === 'directory') {
56
+ await walk(entry, rel);
57
+ } else if (entry.kind === 'file' && isImageFileName(name)) {
58
+ const file = await entry.getFile();
59
+ out.push({ path: rel.split('\\').join('/'), file });
60
+ if (onProgress && out.length % 28 === 0) {
61
+ onProgress(visited, out.length);
62
+ await yieldToPaint();
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ await walk(dirHandle, basePath);
69
+ if (onProgress) onProgress(visited, out.length);
70
+ return out;
71
+ }
72
+
73
+ export async function collectFromFileInputListAsync(
74
+ fileList: FileList,
75
+ onProgress?: (processed: number, total: number, imageCount: number) => void
76
+ ): Promise<PickedFile[]> {
77
+ const out: PickedFile[] = [];
78
+ const n = fileList.length;
79
+ await yieldToPaint();
80
+ for (let i = 0; i < n; i += FILELIST_CHUNK) {
81
+ const end = Math.min(i + FILELIST_CHUNK, n);
82
+ for (let j = i; j < end; j++) {
83
+ const file = fileList[j];
84
+ const rel = (file.webkitRelativePath || file.name).split('\\').join('/');
85
+ const seg = rel.split('/');
86
+ const baseName = seg[seg.length - 1] || file.name;
87
+ if (!isImageFileName(baseName)) continue;
88
+ out.push({ path: rel, file });
89
+ }
90
+ if (onProgress) onProgress(end, n, out.length);
91
+ await yieldToPaint();
92
+ }
93
+ return out;
94
+ }
95
+
96
+ export async function readImageDimensions(
97
+ file: File
98
+ ): Promise<{ width: number | null; height: number | null }> {
99
+ try {
100
+ const bmp = await createImageBitmap(file);
101
+ try {
102
+ return { width: bmp.width, height: bmp.height };
103
+ } finally {
104
+ bmp.close();
105
+ }
106
+ } catch {
107
+ return new Promise(resolve => {
108
+ const url = URL.createObjectURL(file);
109
+ const img = new Image();
110
+ img.onload = () => {
111
+ URL.revokeObjectURL(url);
112
+ resolve({
113
+ width: img.naturalWidth || null,
114
+ height: img.naturalHeight || null
115
+ });
116
+ };
117
+ img.onerror = () => {
118
+ URL.revokeObjectURL(url);
119
+ resolve({ width: null, height: null });
120
+ };
121
+ img.src = url;
122
+ });
123
+ }
124
+ }
125
+
126
+ export async function buildEntriesFromPicked(
127
+ picked: PickedFile[],
128
+ onProgress?: (done: number, total: number) => void
129
+ ): Promise<
130
+ Array<{ path: string; size: number; width: number | null; height: number | null }>
131
+ > {
132
+ const entries: Array<{
133
+ path: string;
134
+ size: number;
135
+ width: number | null;
136
+ height: number | null;
137
+ }> = [];
138
+ const total = picked.length;
139
+ let done = 0;
140
+ for (let i = 0; i < picked.length; i += DIM_BATCH) {
141
+ const slice = picked.slice(i, i + DIM_BATCH);
142
+ const batch = await Promise.all(
143
+ slice.map(async ({ path: relPath, file }) => {
144
+ const dims = await readImageDimensions(file);
145
+ return {
146
+ path: relPath.split('\\').join('/'),
147
+ size: file.size,
148
+ width: dims.width,
149
+ height: dims.height
150
+ };
151
+ })
152
+ );
153
+ entries.push(...batch);
154
+ done += slice.length;
155
+ if (onProgress) onProgress(done, total);
156
+ await yieldToPaint();
157
+ }
158
+ return entries;
159
+ }
160
+
161
+ export function pickerRootLabelFromPaths(paths: PickedFile[]): string {
162
+ if (!paths.length) return '';
163
+ const first = paths[0].path.split('/')[0];
164
+ return first || '';
165
+ }
166
+
167
+ export function pathNorm(p: string): string {
168
+ return String(p || '').replace(/\\/g, '/');
169
+ }
170
+
171
+ export function pathDirname(norm: string): string {
172
+ const trimmed = norm.replace(/\/+$/, '');
173
+ const i = trimmed.lastIndexOf('/');
174
+ if (i === -1) return '.';
175
+ if (i === 0) return '/';
176
+ return trimmed.slice(0, i);
177
+ }
178
+
179
+ export function commonAncestorDir(absPaths: string[]): string {
180
+ const dirs = absPaths.map(pathNorm).map(pathDirname);
181
+ let r = dirs[0];
182
+ if (!r) return '';
183
+ for (let i = 1; i < dirs.length; i++) {
184
+ let d = dirs[i];
185
+ while (d && r && d !== r && !d.startsWith(r + '/')) {
186
+ const next = pathDirname(r);
187
+ if (next === r) break;
188
+ r = next;
189
+ }
190
+ }
191
+ return r || '';
192
+ }
193
+
194
+ export function tryDeriveAbsoluteRootFromPicked(list: PickedFile[]): string {
195
+ const abs: string[] = [];
196
+ for (const item of list) {
197
+ const f = item.file as File & { path?: string };
198
+ if (f.path && f.path.length) {
199
+ abs.push(f.path);
200
+ }
201
+ }
202
+ if (!abs.length) return '';
203
+ return commonAncestorDir(abs);
204
+ }
@@ -0,0 +1,96 @@
1
+ import type { ImageRow } from '@repoimage/shared';
2
+
3
+ export function formatFileSize(bytes: number | null | undefined): string {
4
+ if (bytes == null) return '—';
5
+ if (bytes === 0) return '0 B';
6
+ const k = 1024;
7
+ const units = ['B', 'KB', 'MB', 'GB'];
8
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
9
+ return `${parseFloat((bytes / k ** i).toFixed(2))} ${units[i]}`;
10
+ }
11
+
12
+ export function scoreClass(score: string | null): string {
13
+ if (score == null) return 'score--na';
14
+ const n = parseFloat(score);
15
+ if (n >= 3.5) return 'score--good';
16
+ if (n >= 2.0) return 'score--mid';
17
+ return 'score--bad';
18
+ }
19
+
20
+ export function barFillClass(score: string | null): string {
21
+ if (score == null) return '';
22
+ const n = parseFloat(score);
23
+ if (n >= 3.5) return 'bar__fill--good';
24
+ if (n >= 2.0) return 'bar__fill--mid';
25
+ return 'bar__fill--bad';
26
+ }
27
+
28
+ export function splitPath(p: string): { dir: string; file: string } {
29
+ const lastSlash = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
30
+ if (lastSlash === -1) return { dir: '', file: p };
31
+ return { dir: p.substring(0, lastSlash + 1), file: p.substring(lastSlash + 1) };
32
+ }
33
+
34
+ export function escapeHtml(str: string): string {
35
+ return str
36
+ .replace(/&/g, '&amp;')
37
+ .replace(/</g, '&lt;')
38
+ .replace(/>/g, '&gt;')
39
+ .replace(/"/g, '&quot;');
40
+ }
41
+
42
+ export type SortKey =
43
+ | 'path-asc'
44
+ | 'path-desc'
45
+ | 'size-desc'
46
+ | 'size-asc'
47
+ | 'score-desc'
48
+ | 'score-asc'
49
+ | 'dimensions-desc'
50
+ | 'dimensions-asc';
51
+
52
+ export function applyFilters(
53
+ images: ImageRow[],
54
+ filterText: string,
55
+ minScore: string,
56
+ maxScore: string
57
+ ): ImageRow[] {
58
+ const ft = filterText.trim().toLowerCase();
59
+ const min = minScore !== '' ? parseFloat(minScore) : null;
60
+ const max = maxScore !== '' ? parseFloat(maxScore) : null;
61
+
62
+ return images.filter(img => {
63
+ if (ft && !img.path.toLowerCase().includes(ft)) return false;
64
+ const s = img.compressionRatio != null ? parseFloat(img.compressionRatio) : null;
65
+ if (min != null && (s == null || s < min)) return false;
66
+ if (max != null && (s == null || s > max)) return false;
67
+ return true;
68
+ });
69
+ }
70
+
71
+ export function applySort(images: ImageRow[], key: SortKey): ImageRow[] {
72
+ const sorted = [...images];
73
+ sorted.sort((a, b) => {
74
+ switch (key) {
75
+ case 'path-asc':
76
+ return a.path.localeCompare(b.path);
77
+ case 'path-desc':
78
+ return b.path.localeCompare(a.path);
79
+ case 'size-desc':
80
+ return (b.size || 0) - (a.size || 0);
81
+ case 'size-asc':
82
+ return (a.size || 0) - (b.size || 0);
83
+ case 'score-desc':
84
+ return (parseFloat(b.compressionRatio ?? '') || 0) - (parseFloat(a.compressionRatio ?? '') || 0);
85
+ case 'score-asc':
86
+ return (parseFloat(a.compressionRatio ?? '') || 0) - (parseFloat(b.compressionRatio ?? '') || 0);
87
+ case 'dimensions-desc':
88
+ return (b.width || 0) * (b.height || 0) - (a.width || 0) * (a.height || 0);
89
+ case 'dimensions-asc':
90
+ return (a.width || 0) * (a.height || 0) - (b.width || 0) * (b.height || 0);
91
+ default:
92
+ return 0;
93
+ }
94
+ });
95
+ return sorted;
96
+ }
@@ -0,0 +1,58 @@
1
+ import type { ImageRow } from '@repoimage/shared';
2
+
3
+ export interface SessionState {
4
+ folderInput: string;
5
+ excludeCommonFolders: boolean;
6
+ cachedScanResults: ImageRow[];
7
+ }
8
+
9
+ const KEYS = {
10
+ folderInput: 'folderInput',
11
+ excludeCommonFolders: 'excludeCommonFolders',
12
+ cachedScanResults: 'cachedScanResults'
13
+ };
14
+
15
+ export function loadSessionState(): Partial<SessionState> {
16
+ const state: Partial<SessionState> = {};
17
+
18
+ const folder = localStorage.getItem(KEYS.folderInput);
19
+ if (folder) {
20
+ state.folderInput = folder;
21
+ }
22
+
23
+ const exclude = localStorage.getItem(KEYS.excludeCommonFolders);
24
+ if (exclude === 'true') {
25
+ state.excludeCommonFolders = true;
26
+ }
27
+
28
+ const cached = localStorage.getItem(KEYS.cachedScanResults);
29
+ if (cached) {
30
+ try {
31
+ state.cachedScanResults = JSON.parse(cached) as ImageRow[];
32
+ } catch {
33
+ // ignore invalid cache
34
+ }
35
+ }
36
+
37
+ return state;
38
+ }
39
+
40
+ export function saveSessionFolder(folder: string): void {
41
+ localStorage.setItem(KEYS.folderInput, folder);
42
+ }
43
+
44
+ export function saveSessionExclude(exclude: boolean): void {
45
+ localStorage.setItem(KEYS.excludeCommonFolders, String(exclude));
46
+ }
47
+
48
+ export function saveSessionScanResults(images: ImageRow[]): void {
49
+ if (images.length > 0) {
50
+ localStorage.setItem(KEYS.cachedScanResults, JSON.stringify(images));
51
+ }
52
+ }
53
+
54
+ export function clearSessionData(): void {
55
+ localStorage.removeItem(KEYS.folderInput);
56
+ localStorage.removeItem(KEYS.excludeCommonFolders);
57
+ localStorage.removeItem(KEYS.cachedScanResults);
58
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+ import './index.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>
10
+ );
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "jsx": "react-jsx",
10
+ "noEmit": true,
11
+ "isolatedModules": true,
12
+ "paths": {
13
+ "@repoimage/shared": ["../shared/src/index.ts"]
14
+ }
15
+ },
16
+ "include": ["src"],
17
+ "references": [{ "path": "../shared" }]
18
+ }
@@ -0,0 +1,27 @@
1
+ import path from 'path';
2
+ import { defineConfig } from 'vite';
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ const apiPort = process.env.REPOIMAGE_API_PORT || '3847';
6
+
7
+ export default defineConfig({
8
+ plugins: [react()],
9
+ resolve: {
10
+ alias: {
11
+ '@repoimage/shared': path.resolve(__dirname, '../shared/src/index.ts')
12
+ }
13
+ },
14
+ server: {
15
+ port: 5173,
16
+ proxy: {
17
+ '/api': {
18
+ target: `http://127.0.0.1:${apiPort}`,
19
+ changeOrigin: true
20
+ }
21
+ }
22
+ },
23
+ build: {
24
+ outDir: 'dist',
25
+ emptyOutDir: true
26
+ }
27
+ });
package/docs/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # RepoImage Documentation
2
+
3
+ Welcome to the RepoImage developer documentation. This folder contains guides for understanding the architecture, working with the API, and developing features.
4
+
5
+ ## Quick Links
6
+
7
+ - **[Architecture](architecture.md)** — Workspace layout, data flow, package responsibilities
8
+ - **[API Reference](api/)** — HTTP endpoints, parameters, and response formats
9
+ - **[Development](development/)** — Local setup, testing strategies, and feature development
10
+ - **[Features](features/)** — User-facing feature documentation
11
+ - **[Design](design/)** — Architectural decisions and global invariants
12
+
13
+ ## Key Concepts
14
+
15
+ **Image Roles:** Every image can have a suggested role (`hero`, `content`, `thumbnail`, `unknown`) based on heuristics, and an optional confirmed role set by users in `.repoimage.json`.
16
+
17
+ **Compression Score:** A 0–10 scale where higher scores indicate larger-than-expected file size relative to image dimensions. Used to identify poorly compressed assets.
18
+
19
+ **Partial Catalog:** The sidecar file (`.repoimage.json`) stores only manually confirmed images, not the full scan results. It's a user-curated subset.
20
+
21
+ **Path Exclusion:** Users can toggle exclusion of common dependency folders (`node_modules`, `dist`, `.git`, etc.) to focus on asset images.
22
+
23
+ ## For Contributors
24
+
25
+ - Start with [Architecture](architecture.md) to understand the codebase structure
26
+ - See [PROJECT-AGENTS.md](../PROJECT-AGENTS.md) for project-specific development policies
27
+ - Review [design/invariants.md](design/invariants.md) for architectural constraints that must always hold
28
+ - Follow the testing and documentation requirements in [PROJECT-AGENTS.md](../PROJECT-AGENTS.md)
@@ -0,0 +1,65 @@
1
+ # API Reference
2
+
3
+ RepoImage provides an HTTP API for scanning images, listing files, and serving image thumbnails. The API server runs on port 3847 (configurable via `PORT` env var).
4
+
5
+ ## Endpoints
6
+
7
+ ### Scan Endpoints
8
+
9
+ - **[POST /api/scan](scan.md)** — Scan one or more directories for images
10
+ - **POST /api/scan-entries** — Enrich client-provided rows with compression metrics
11
+
12
+ ### File Browser
13
+
14
+ - **GET /api/fs/home** — Get user's home directory path
15
+ - **GET /api/fs/list** — List directory contents (directory browser)
16
+ - **POST /api/fs/list** — Alternative: send path in JSON body
17
+
18
+ ### Image Serving
19
+
20
+ - **GET /api/file** — Serve an image file by absolute path
21
+
22
+ ### Static Files
23
+
24
+ - **GET /** — Serve the React app (index.html and assets)
25
+
26
+ ## Response Format
27
+
28
+ All endpoints return JSON. Successful responses include:
29
+
30
+ ```json
31
+ {
32
+ "images": [...], // or entries, or single result
33
+ "issues": [...] // if applicable
34
+ }
35
+ ```
36
+
37
+ Error responses:
38
+
39
+ ```json
40
+ {
41
+ "error": "string",
42
+ "detail": "optional detail message"
43
+ }
44
+ ```
45
+
46
+ ## Security
47
+
48
+ - **Path validation** — All file paths are validated; `..` and escapes outside project root are rejected.
49
+ - **Loopback restriction** — Filesystem APIs (`/api/fs/*`, `/api/file`) are restricted to loopback clients (127.0.0.1, ::1) by default. Set `REPOIMAGE_FS_TRUST_REMOTE=1` to allow remote clients (not recommended).
50
+
51
+ ## Environment Variables
52
+
53
+ | Variable | Default | Purpose |
54
+ |----------|---------|---------|
55
+ | `PORT` | `3847` | HTTP server port |
56
+ | `REPOIMAGE_BIND` | `127.0.0.1` | Bind address (e.g., `0.0.0.0` for all interfaces) |
57
+ | `REPOIMAGE_FS_TRUST_REMOTE` | (unset) | Set to `1` to allow non-loopback clients to use `/api/fs/*` |
58
+ | `REPOIMAGE_API_PORT` | `3847` | API port targeted by the Vite dev proxy |
59
+
60
+ ## Future Endpoints
61
+
62
+ As features are implemented, new endpoints will be added:
63
+
64
+ - **POST /api/repoimage** — Save confirmed roles to `.repoimage.json`
65
+ - **GET /api/repoimage** — Load and validate config file