mrmd-server 0.2.6 → 0.2.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-server",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "HTTP server for mrmd - run mrmd in any browser, access from anywhere",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -47,7 +47,6 @@
47
47
  "chokidar": "^3.6.0",
48
48
  "fzf": "^0.5.2",
49
49
  "mrmd-project": "^0.1.2",
50
- "mrmd-electron": "^0.4.0",
51
50
  "mrmd-sync": "^0.3.3"
52
51
  }
53
52
  }
package/src/services.js CHANGED
@@ -1,38 +1,38 @@
1
1
  /**
2
- * Re-export services from mrmd-electron
2
+ * Re-export services (vendored from mrmd-electron)
3
3
  *
4
4
  * These services are pure Node.js (no Electron dependencies)
5
- * and can be used directly by mrmd-server.
5
+ * and are bundled directly so mrmd-server works standalone.
6
6
  */
7
7
 
8
8
  export {
9
9
  default as ProjectService,
10
- } from 'mrmd-electron/src/services/project-service.js';
10
+ } from './vendor/services/project-service.js';
11
11
 
12
12
  export {
13
13
  default as RuntimeService,
14
- } from 'mrmd-electron/src/services/runtime-service.js';
14
+ } from './vendor/services/runtime-service.js';
15
15
 
16
16
  export {
17
17
  default as FileService,
18
- } from 'mrmd-electron/src/services/file-service.js';
18
+ } from './vendor/services/file-service.js';
19
19
 
20
20
  export {
21
21
  default as AssetService,
22
- } from 'mrmd-electron/src/services/asset-service.js';
22
+ } from './vendor/services/asset-service.js';
23
23
 
24
24
  export {
25
25
  default as SettingsService,
26
- } from 'mrmd-electron/src/services/settings-service.js';
26
+ } from './vendor/services/settings-service.js';
27
27
 
28
28
  export {
29
29
  default as RuntimePreferencesService,
30
- } from 'mrmd-electron/src/services/runtime-preferences-service.js';
30
+ } from './vendor/services/runtime-preferences-service.js';
31
31
 
32
32
  export {
33
33
  default as LanguageToolService,
34
- } from 'mrmd-electron/src/services/languagetool-service.js';
34
+ } from './vendor/services/languagetool-service.js';
35
35
 
36
36
  export {
37
37
  default as LanguageToolPreferencesService,
38
- } from 'mrmd-electron/src/services/languagetool-preferences-service.js';
38
+ } from './vendor/services/languagetool-preferences-service.js';
@@ -15,9 +15,9 @@ import fs from 'fs';
15
15
  import os from 'os';
16
16
  import { fileURLToPath } from 'url';
17
17
 
18
- // Import utilities from mrmd-electron
19
- import { findFreePort, waitForPort, isProcessAlive } from 'mrmd-electron/src/utils/index.js';
20
- import { SYNC_SERVER_MEMORY_MB, DIR_HASH_LENGTH } from 'mrmd-electron/src/config.js';
18
+ // Import utilities (vendored from mrmd-electron)
19
+ import { findFreePort, waitForPort, isProcessAlive } from './vendor/utils/index.js';
20
+ import { SYNC_SERVER_MEMORY_MB, DIR_HASH_LENGTH } from './vendor/config.js';
21
21
 
22
22
  // Relay bridge for cloud mode
23
23
  import { RelayBridge } from './relay-bridge.js';
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Centralized configuration for mrmd-electron
3
+ *
4
+ * All magic numbers, timeouts, paths, and other configuration values
5
+ * should be defined here for easy modification and documentation.
6
+ */
7
+
8
+ import os from 'os';
9
+ import path from 'path';
10
+ import {
11
+ isWin,
12
+ getConfigDir,
13
+ getDataDir,
14
+ getUvInstallDir,
15
+ getSystemPythonPaths,
16
+ getUvPaths,
17
+ getCondaPaths,
18
+ } from './utils/platform.js';
19
+
20
+ // ============================================================================
21
+ // PATHS
22
+ // ============================================================================
23
+
24
+ /**
25
+ * User configuration directory
26
+ * Windows: %APPDATA%/mrmd
27
+ * macOS: ~/Library/Application Support/mrmd
28
+ * Linux: ~/.config/mrmd (or XDG_CONFIG_HOME)
29
+ */
30
+ export const CONFIG_DIR = getConfigDir();
31
+
32
+ /**
33
+ * Recent files/venvs persistence file
34
+ */
35
+ export const RECENT_FILE = path.join(CONFIG_DIR, 'recent.json');
36
+
37
+ /**
38
+ * User settings file (API keys, model mappings, custom commands)
39
+ */
40
+ export const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.json');
41
+
42
+ /**
43
+ * Legacy runtimes directory (for old-style runtime registration)
44
+ */
45
+ export const RUNTIMES_DIR = path.join(os.homedir(), '.mrmd', 'runtimes');
46
+
47
+ /**
48
+ * Session registry directory (for new session service)
49
+ */
50
+ export const SESSIONS_DIR = path.join(os.homedir(), '.mrmd', 'sessions');
51
+
52
+ /**
53
+ * Asset directory name within projects
54
+ */
55
+ export const ASSETS_DIR_NAME = '_assets';
56
+
57
+ /**
58
+ * Asset manifest filename
59
+ */
60
+ export const ASSET_MANIFEST_NAME = '.manifest.json';
61
+
62
+ // ============================================================================
63
+ // NETWORK
64
+ // ============================================================================
65
+
66
+ /**
67
+ * Default host for all network servers
68
+ * Using 127.0.0.1 for security (localhost only)
69
+ * TODO: Make configurable for remote/container setups
70
+ */
71
+ export const DEFAULT_HOST = '127.0.0.1';
72
+
73
+ /**
74
+ * Timeout for waiting for a port to become available (ms)
75
+ */
76
+ export const PORT_WAIT_TIMEOUT = 10000;
77
+
78
+ /**
79
+ * Interval between port checks (ms)
80
+ */
81
+ export const PORT_CHECK_INTERVAL = 200;
82
+
83
+ /**
84
+ * Socket connection timeout (ms)
85
+ */
86
+ export const SOCKET_TIMEOUT = 500;
87
+
88
+ // ============================================================================
89
+ // SYNC SERVER
90
+ // ============================================================================
91
+
92
+ /**
93
+ * Memory limit for sync server (MB)
94
+ * Limited to 512MB to fail fast instead of consuming all system memory
95
+ * and crashing unpredictably after hours. Better to restart early than
96
+ * lose hours of work.
97
+ */
98
+ export const SYNC_SERVER_MEMORY_MB = 512;
99
+
100
+ /**
101
+ * Watchdog interval for periodic backups (ms)
102
+ */
103
+ export const WATCHDOG_INTERVAL = 60000;
104
+
105
+ /**
106
+ * WebSocket ping interval for connection health checks (ms)
107
+ */
108
+ export const WEBSOCKET_PING_INTERVAL = 30000;
109
+
110
+ /**
111
+ * WebSocket pong timeout - if no pong received in this time,
112
+ * consider connection dead (ms)
113
+ */
114
+ export const WEBSOCKET_PONG_TIMEOUT = 5000;
115
+
116
+ // ============================================================================
117
+ // FILE SCANNING
118
+ // ============================================================================
119
+
120
+ /**
121
+ * Maximum directory depth for file scanning
122
+ */
123
+ export const FILE_SCAN_MAX_DEPTH = 6;
124
+
125
+ /**
126
+ * Maximum directory depth for venv discovery
127
+ */
128
+ export const VENV_SCAN_MAX_DEPTH = 4;
129
+
130
+ /**
131
+ * Maximum directory depth for project file scanning
132
+ */
133
+ export const PROJECT_SCAN_MAX_DEPTH = 10;
134
+
135
+ // ============================================================================
136
+ // LIMITS
137
+ // ============================================================================
138
+
139
+ /**
140
+ * Maximum recent files to keep
141
+ */
142
+ export const MAX_RECENT_FILES = 50;
143
+
144
+ /**
145
+ * Maximum recent venvs to keep
146
+ */
147
+ export const MAX_RECENT_VENVS = 20;
148
+
149
+ /**
150
+ * Hash length for asset deduplication (characters)
151
+ */
152
+ export const ASSET_HASH_LENGTH = 16;
153
+
154
+ /**
155
+ * Hash length for directory hashing (characters)
156
+ */
157
+ export const DIR_HASH_LENGTH = 12;
158
+
159
+ // ============================================================================
160
+ // WINDOW
161
+ // ============================================================================
162
+
163
+ /**
164
+ * Default window dimensions
165
+ */
166
+ export const DEFAULT_WINDOW_WIDTH = 1000;
167
+ export const DEFAULT_WINDOW_HEIGHT = 750;
168
+
169
+ /**
170
+ * Default background color (dark theme)
171
+ */
172
+ export const DEFAULT_BACKGROUND_COLOR = '#ffffff';
173
+
174
+ // ============================================================================
175
+ // PYTHON PATHS
176
+ // ============================================================================
177
+
178
+ /**
179
+ * Common system Python paths to check
180
+ * Platform-aware: includes Windows paths on Windows, Unix paths on Unix
181
+ */
182
+ export const SYSTEM_PYTHON_PATHS = getSystemPythonPaths();
183
+
184
+ /**
185
+ * Common conda installation paths (relative to home)
186
+ * Platform-aware: uses backslashes on Windows
187
+ */
188
+ export const CONDA_PATHS = getCondaPaths();
189
+
190
+ /**
191
+ * Common uv installation paths
192
+ * Platform-aware: includes Windows paths on Windows
193
+ */
194
+ export const UV_PATHS = getUvPaths();
195
+
196
+ // ============================================================================
197
+ // SPECIAL FILES
198
+ // ============================================================================
199
+
200
+ /**
201
+ * Files that should never have FSML order prefixes
202
+ */
203
+ export const UNORDERED_FILES = new Set([
204
+ 'readme.md',
205
+ 'readme.qmd',
206
+ 'readme',
207
+ 'license.md',
208
+ 'license.qmd',
209
+ 'license',
210
+ 'license.txt',
211
+ 'changelog.md',
212
+ 'changelog.qmd',
213
+ 'changelog',
214
+ 'contributing.md',
215
+ 'contributing.qmd',
216
+ 'contributing',
217
+ 'mrmd.md',
218
+ 'index.md',
219
+ 'index.qmd',
220
+ '.gitignore',
221
+ '.gitattributes',
222
+ ]);
223
+
224
+ // ============================================================================
225
+ // VERSION COMPATIBILITY MATRIX
226
+ // ============================================================================
227
+ // Defines which Python package versions are compatible with this electron app.
228
+ // Updated on each release. Uses pip version specifiers.
229
+
230
+ /**
231
+ * Current mrmd-electron version
232
+ */
233
+ export const APP_VERSION = '0.3.1';
234
+
235
+ /**
236
+ * Python package version requirements for this electron version.
237
+ * These are installed via uv/pip when user sets up a venv.
238
+ */
239
+ export const PYTHON_DEPS = {
240
+ // Core runtime - required
241
+ 'mrmd-python': '>=0.3.7,<0.5',
242
+
243
+ // AI features - required for full experience
244
+ 'mrmd-ai': '>=0.1.0,<0.2',
245
+
246
+ // Bash runtime - for ```bash blocks
247
+ 'mrmd-bash': '>=0.1.0,<0.2',
248
+
249
+ // PTY runtime - for ```term blocks
250
+ 'mrmd-pty': '>=0.1.0,<0.2',
251
+
252
+ // Orchestrator (optional, for advanced multi-runtime setups)
253
+ // 'mrmd': '>=0.2.0,<0.3',
254
+ };
255
+
256
+ /**
257
+ * Get pip install args for all required Python packages
258
+ * @returns {string[]} Array of package specifiers
259
+ */
260
+ export function getPythonInstallArgs() {
261
+ return Object.entries(PYTHON_DEPS).map(
262
+ ([pkg, version]) => `${pkg}${version}`
263
+ );
264
+ }
265
+
266
+ // ============================================================================
267
+ // UV AUTO-INSTALL
268
+ // ============================================================================
269
+
270
+ /**
271
+ * uv download URLs by platform
272
+ * Updated from: https://github.com/astral-sh/uv/releases
273
+ */
274
+ export const UV_DOWNLOAD_URLS = {
275
+ 'linux-x64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz',
276
+ 'linux-arm64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-unknown-linux-gnu.tar.gz',
277
+ 'darwin-x64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz',
278
+ 'darwin-arm64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz',
279
+ 'win32-x64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip',
280
+ };
281
+
282
+ /**
283
+ * Default uv install location (user-local)
284
+ * Windows: %LOCALAPPDATA%/uv/bin
285
+ * Unix: ~/.local/bin
286
+ */
287
+ export const UV_INSTALL_DIR = getUvInstallDir();
288
+
289
+ /**
290
+ * Path where uv will be installed
291
+ */
292
+ export const UV_INSTALL_PATH = path.join(
293
+ UV_INSTALL_DIR,
294
+ isWin ? 'uv.exe' : 'uv'
295
+ );
@@ -0,0 +1,332 @@
1
+ /**
2
+ * AssetService - Asset management with deduplication
3
+ *
4
+ * Manages assets in the _assets/ directory.
5
+ * Handles saving with hash-based deduplication, orphan detection, etc.
6
+ *
7
+ * Uses mrmd-project for path computation.
8
+ */
9
+
10
+ import { Assets } from 'mrmd-project';
11
+ import crypto from 'crypto';
12
+ import fs from 'fs';
13
+ import fsPromises from 'fs/promises';
14
+ import path from 'path';
15
+ import { ASSETS_DIR_NAME, ASSET_MANIFEST_NAME, ASSET_HASH_LENGTH } from '../config.js';
16
+
17
+ // MIME type mapping
18
+ const MIME_TYPES = {
19
+ '.png': 'image/png',
20
+ '.jpg': 'image/jpeg',
21
+ '.jpeg': 'image/jpeg',
22
+ '.gif': 'image/gif',
23
+ '.webp': 'image/webp',
24
+ '.svg': 'image/svg+xml',
25
+ '.pdf': 'application/pdf',
26
+ '.csv': 'text/csv',
27
+ '.json': 'application/json',
28
+ '.mp4': 'video/mp4',
29
+ '.webm': 'video/webm',
30
+ '.mp3': 'audio/mpeg',
31
+ '.wav': 'audio/wav',
32
+ };
33
+
34
+ class AssetService {
35
+ /**
36
+ * @param {FileService} fileService - Reference to FileService for scanning
37
+ */
38
+ constructor(fileService) {
39
+ this.fileService = fileService;
40
+ }
41
+
42
+ /**
43
+ * List all assets in a project
44
+ *
45
+ * @param {string} projectRoot - Project root path
46
+ * @returns {Promise<AssetInfo[]>}
47
+ */
48
+ async list(projectRoot) {
49
+ const assetsDir = path.join(projectRoot, ASSETS_DIR_NAME);
50
+
51
+ if (!fs.existsSync(assetsDir)) {
52
+ return [];
53
+ }
54
+
55
+ const manifest = await this.loadManifest(projectRoot);
56
+ const assets = [];
57
+
58
+ const walk = async (dir) => {
59
+ let entries;
60
+ try {
61
+ entries = await fsPromises.readdir(dir, { withFileTypes: true });
62
+ } catch {
63
+ return;
64
+ }
65
+
66
+ for (const entry of entries) {
67
+ // Skip hidden files
68
+ if (entry.name.startsWith('.')) continue;
69
+
70
+ const fullPath = path.join(dir, entry.name);
71
+ const relativePath = path.relative(assetsDir, fullPath);
72
+
73
+ if (entry.isDirectory()) {
74
+ await walk(fullPath);
75
+ } else {
76
+ const stat = await fsPromises.stat(fullPath);
77
+
78
+ // Get hash from manifest or compute it
79
+ let hash = manifest[relativePath]?.hash;
80
+ if (!hash) {
81
+ hash = await this.computeFileHash(fullPath);
82
+ }
83
+
84
+ assets.push({
85
+ path: relativePath,
86
+ fullPath,
87
+ hash,
88
+ size: stat.size,
89
+ mimeType: this.getMimeType(entry.name),
90
+ usedIn: manifest[relativePath]?.usedIn || [],
91
+ });
92
+ }
93
+ }
94
+ };
95
+
96
+ await walk(assetsDir);
97
+ return assets;
98
+ }
99
+
100
+ /**
101
+ * Save an asset (handles deduplication)
102
+ *
103
+ * @param {string} projectRoot - Project root path
104
+ * @param {Buffer} file - File content
105
+ * @param {string} filename - Desired filename
106
+ * @returns {Promise<{ path: string, deduplicated: boolean }>}
107
+ */
108
+ async save(projectRoot, file, filename) {
109
+ const assetsDir = path.join(projectRoot, ASSETS_DIR_NAME);
110
+ const hash = this.computeHashFromBuffer(file);
111
+
112
+ // Load manifest to check for duplicates
113
+ const manifest = await this.loadManifest(projectRoot);
114
+
115
+ // Check for duplicate by hash
116
+ for (const [assetPath, info] of Object.entries(manifest)) {
117
+ if (info.hash === hash) {
118
+ // Found duplicate
119
+ return { path: assetPath, deduplicated: true };
120
+ }
121
+ }
122
+
123
+ // Also check files not in manifest
124
+ const existingAssets = await this.list(projectRoot);
125
+ for (const asset of existingAssets) {
126
+ if (asset.hash === hash) {
127
+ return { path: asset.path, deduplicated: true };
128
+ }
129
+ }
130
+
131
+ // Save new file
132
+ const assetPath = await this.uniquePath(assetsDir, filename);
133
+ const fullPath = path.join(assetsDir, assetPath);
134
+
135
+ await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
136
+ await fsPromises.writeFile(fullPath, file);
137
+
138
+ // Update manifest
139
+ manifest[assetPath] = {
140
+ hash,
141
+ addedAt: new Date().toISOString(),
142
+ usedIn: [],
143
+ };
144
+ await this.saveManifest(projectRoot, manifest);
145
+
146
+ return { path: assetPath, deduplicated: false };
147
+ }
148
+
149
+ /**
150
+ * Get relative path from a document to an asset
151
+ *
152
+ * @param {string} assetPath - Asset path relative to _assets/
153
+ * @param {string} documentPath - Document path relative to project root
154
+ * @returns {string}
155
+ */
156
+ getRelativePath(assetPath, documentPath) {
157
+ // Use mrmd-project for computation
158
+ return Assets.computeRelativePath(documentPath, path.join(ASSETS_DIR_NAME, assetPath));
159
+ }
160
+
161
+ /**
162
+ * Find orphaned assets (not referenced by any document)
163
+ *
164
+ * @param {string} projectRoot - Project root path
165
+ * @returns {Promise<string[]>} Orphaned asset paths
166
+ */
167
+ async findOrphans(projectRoot) {
168
+ const assets = await this.list(projectRoot);
169
+
170
+ if (assets.length === 0) return [];
171
+
172
+ // Get all markdown files
173
+ const files = await this.fileService.scan(projectRoot);
174
+
175
+ // Extract all asset references from all documents
176
+ const usedAssets = new Set();
177
+
178
+ for (const file of files) {
179
+ try {
180
+ const fullPath = path.join(projectRoot, file);
181
+ const content = await fsPromises.readFile(fullPath, 'utf8');
182
+ const refs = Assets.extractPaths(content);
183
+
184
+ for (const ref of refs) {
185
+ // Normalize the path to extract just the asset name
186
+ // Paths might be: _assets/img.png, ../_assets/img.png, etc.
187
+ const normalized = this.normalizeAssetRef(ref.path);
188
+ if (normalized) {
189
+ usedAssets.add(normalized);
190
+ }
191
+ }
192
+ } catch {
193
+ // Skip files that can't be read
194
+ }
195
+ }
196
+
197
+ // Find assets not in usedAssets
198
+ return assets
199
+ .filter(a => !usedAssets.has(a.path))
200
+ .map(a => a.path);
201
+ }
202
+
203
+ /**
204
+ * Delete an asset
205
+ *
206
+ * @param {string} projectRoot - Project root path
207
+ * @param {string} assetPath - Asset path relative to _assets/
208
+ */
209
+ async delete(projectRoot, assetPath) {
210
+ const fullPath = path.join(projectRoot, ASSETS_DIR_NAME, assetPath);
211
+
212
+ await fsPromises.unlink(fullPath);
213
+
214
+ // Update manifest
215
+ const manifest = await this.loadManifest(projectRoot);
216
+ delete manifest[assetPath];
217
+ await this.saveManifest(projectRoot, manifest);
218
+
219
+ // Clean up empty directories
220
+ await this.removeEmptyDirs(
221
+ path.dirname(fullPath),
222
+ path.join(projectRoot, ASSETS_DIR_NAME)
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Compute hash of a file
228
+ */
229
+ async computeFileHash(filePath) {
230
+ const content = await fsPromises.readFile(filePath);
231
+ return this.computeHashFromBuffer(content);
232
+ }
233
+
234
+ /**
235
+ * Compute hash from buffer
236
+ */
237
+ computeHashFromBuffer(buffer) {
238
+ return crypto.createHash('sha256').update(buffer).digest('hex').slice(0, ASSET_HASH_LENGTH);
239
+ }
240
+
241
+ /**
242
+ * Get MIME type from filename
243
+ */
244
+ getMimeType(filename) {
245
+ const ext = path.extname(filename).toLowerCase();
246
+ return MIME_TYPES[ext] || 'application/octet-stream';
247
+ }
248
+
249
+ /**
250
+ * Generate a unique path for a new asset.
251
+ * Preserves directory structure (e.g., 'generated/plot.png' stays in 'generated/').
252
+ */
253
+ async uniquePath(assetsDir, filename) {
254
+ const ext = path.extname(filename);
255
+ const dir = path.dirname(filename);
256
+ const base = path.basename(filename, ext);
257
+
258
+ // Preserve directory structure
259
+ const prefix = dir !== '.' ? dir + '/' : '';
260
+
261
+ let candidate = filename;
262
+ let counter = 1;
263
+
264
+ while (fs.existsSync(path.join(assetsDir, candidate))) {
265
+ candidate = `${prefix}${base}-${counter}${ext}`;
266
+ counter++;
267
+ }
268
+
269
+ return candidate;
270
+ }
271
+
272
+ /**
273
+ * Normalize an asset reference path
274
+ */
275
+ normalizeAssetRef(refPath) {
276
+ // Handle: _assets/img.png, ../_assets/img.png, ../../_assets/img.png
277
+ const match = refPath.match(/(?:\.\.\/)*_assets\/(.+)/);
278
+ if (match) {
279
+ return match[1];
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Load manifest file
286
+ */
287
+ async loadManifest(projectRoot) {
288
+ const manifestPath = path.join(projectRoot, ASSETS_DIR_NAME, ASSET_MANIFEST_NAME);
289
+ try {
290
+ const content = await fsPromises.readFile(manifestPath, 'utf8');
291
+ return JSON.parse(content);
292
+ } catch (e) {
293
+ console.warn(`[asset] Could not load manifest: ${e.message}`);
294
+ return {};
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Save manifest file
300
+ */
301
+ async saveManifest(projectRoot, manifest) {
302
+ const assetsDir = path.join(projectRoot, ASSETS_DIR_NAME);
303
+ const manifestPath = path.join(assetsDir, ASSET_MANIFEST_NAME);
304
+
305
+ await fsPromises.mkdir(assetsDir, { recursive: true });
306
+ await fsPromises.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
307
+ }
308
+
309
+ /**
310
+ * Remove empty directories
311
+ */
312
+ async removeEmptyDirs(dir, stopAt) {
313
+ while (dir !== stopAt && dir.startsWith(stopAt)) {
314
+ try {
315
+ const entries = await fsPromises.readdir(dir);
316
+ // Don't count manifest file as a real entry
317
+ const realEntries = entries.filter(e => e !== ASSET_MANIFEST_NAME);
318
+ if (realEntries.length === 0) {
319
+ await fsPromises.rmdir(dir);
320
+ dir = path.dirname(dir);
321
+ } else {
322
+ break;
323
+ }
324
+ } catch (e) {
325
+ console.warn(`[asset] Error removing empty directory ${dir}: ${e.message}`);
326
+ break;
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ export default AssetService;