@hanna84/mcp-writing 1.5.1 → 1.6.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.
- package/CHANGELOG.md +20 -0
- package/README.md +21 -0
- package/importer.js +78 -10
- package/index.js +22 -2
- package/package.json +1 -1
- package/sync.js +105 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,31 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v1.6.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.5.2...v1.6.0)
|
|
9
|
+
|
|
10
|
+
- feat: add permission preflight diagnostics and ops contract [`#45`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/45)
|
|
12
|
+
|
|
13
|
+
#### [v1.5.2](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.5.1...v1.5.2)
|
|
15
|
+
|
|
16
|
+
> 19 April 2026
|
|
17
|
+
|
|
18
|
+
- fix: avoid nested scenes/projects paths on import [`#44`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/44)
|
|
20
|
+
- Release 1.5.2 [`47f7c6c`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/47f7c6c9ddbc4244b8a62b1dc37c86eb914aafbc)
|
|
22
|
+
|
|
7
23
|
#### [v1.5.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.5.0...v1.5.1)
|
|
9
25
|
|
|
26
|
+
> 19 April 2026
|
|
27
|
+
|
|
10
28
|
- chore: switch license from MIT to AGPL v3 [`#43`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/43)
|
|
30
|
+
- Release 1.5.1 [`73b233f`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/73b233f2f210d435386f1235aa15ebc3c945542b)
|
|
12
32
|
|
|
13
33
|
#### [v1.5.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.4.8...v1.5.0)
|
package/README.md
CHANGED
|
@@ -34,6 +34,24 @@ npm --version # should be 8.0.0 or later
|
|
|
34
34
|
git --version # should be installed
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
## Permission Contract (Recommended For All Users)
|
|
38
|
+
|
|
39
|
+
To keep MCP write tools reliable across local runs, Docker, and AI agents, use this contract:
|
|
40
|
+
|
|
41
|
+
1. The same non-root user should own and write the sync directory.
|
|
42
|
+
2. Containerized runs should use host UID/GID, not root.
|
|
43
|
+
3. If ownership drifts (for example root-owned files), repair once on host and continue.
|
|
44
|
+
|
|
45
|
+
Repair commands (host):
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
sudo chown -R "$(id -u):$(id -g)" /path/to/sync-dir
|
|
49
|
+
find /path/to/sync-dir -type d -exec chmod u+rwx {} +
|
|
50
|
+
find /path/to/sync-dir -type f -exec chmod u+rw {} +
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
You can also inspect ownership/writability status at runtime via `get_runtime_config`.
|
|
54
|
+
|
|
37
55
|
## First-time setup path (recommended)
|
|
38
56
|
|
|
39
57
|
If this is your first time, follow these steps in order:
|
|
@@ -321,6 +339,7 @@ Paginated tools (`find_scenes`, `get_arc`, `list_threads`, `get_thread_arc`, `se
|
|
|
321
339
|
# docker-compose.yml snippet
|
|
322
340
|
writing-mcp:
|
|
323
341
|
build: .
|
|
342
|
+
user: "${UID:-1000}:${GID:-1000}"
|
|
324
343
|
environment:
|
|
325
344
|
WRITING_SYNC_DIR: /sync
|
|
326
345
|
DB_PATH: /data/writing.db
|
|
@@ -338,6 +357,8 @@ volumes:
|
|
|
338
357
|
writing-mcp-data:
|
|
339
358
|
```
|
|
340
359
|
|
|
360
|
+
If you want explicit host mapping, set `UID` and `GID` in your shell or a `.env` file next to `docker-compose.yml`.
|
|
361
|
+
|
|
341
362
|
Then register in your OpenClaw config:
|
|
342
363
|
|
|
343
364
|
```json
|
package/importer.js
CHANGED
|
@@ -129,6 +129,57 @@ function removeIfExists(filePath) {
|
|
|
129
129
|
if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
function resolveSyncRootFromPrefix(prefix, syncDirAbs) {
|
|
133
|
+
const parsedRoot = path.parse(syncDirAbs).root;
|
|
134
|
+
|
|
135
|
+
if (!prefix) {
|
|
136
|
+
return parsedRoot ? path.resolve(parsedRoot) : path.resolve(syncDirAbs);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// On Windows, a regex prefix like "C:" would resolve relative to cwd on drive C.
|
|
140
|
+
// Use the true drive root instead (e.g., "C:\\").
|
|
141
|
+
if (/^[a-zA-Z]:$/.test(prefix)) {
|
|
142
|
+
return parsedRoot || `${prefix}${path.sep}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return path.resolve(prefix);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function detectScopedSyncDir(syncDirAbs) {
|
|
149
|
+
const normalized = syncDirAbs.split(path.sep).join("/");
|
|
150
|
+
|
|
151
|
+
const universeMatch = normalized.match(/^(.*)\/universes\/([^/]+)\/([^/]+)(?:\/scenes)?$/);
|
|
152
|
+
if (universeMatch) {
|
|
153
|
+
const prefix = universeMatch[1];
|
|
154
|
+
const universeId = universeMatch[2];
|
|
155
|
+
const projectSlug = universeMatch[3];
|
|
156
|
+
const syncRoot = resolveSyncRootFromPrefix(prefix, syncDirAbs);
|
|
157
|
+
const projectRoot = path.join(syncRoot, "universes", universeId, projectSlug);
|
|
158
|
+
return {
|
|
159
|
+
projectId: `${universeId}/${projectSlug}`,
|
|
160
|
+
scope: "universe",
|
|
161
|
+
syncRoot,
|
|
162
|
+
projectRoot,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const projectMatch = normalized.match(/^(.*)\/projects\/([^/]+)(?:\/scenes)?$/);
|
|
167
|
+
if (projectMatch) {
|
|
168
|
+
const prefix = projectMatch[1];
|
|
169
|
+
const projectSlug = projectMatch[2];
|
|
170
|
+
const syncRoot = resolveSyncRootFromPrefix(prefix, syncDirAbs);
|
|
171
|
+
const projectRoot = path.join(syncRoot, "projects", projectSlug);
|
|
172
|
+
return {
|
|
173
|
+
projectId: projectSlug,
|
|
174
|
+
scope: "project",
|
|
175
|
+
syncRoot,
|
|
176
|
+
projectRoot,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
132
183
|
export function importScrivenerSync({
|
|
133
184
|
scrivenerDir,
|
|
134
185
|
mcpSyncDir,
|
|
@@ -138,31 +189,48 @@ export function importScrivenerSync({
|
|
|
138
189
|
}) {
|
|
139
190
|
const scrivenerDirAbs = path.resolve(scrivenerDir);
|
|
140
191
|
const mcpSyncDirAbs = path.resolve(mcpSyncDir);
|
|
192
|
+
const scopedSyncDir = detectScopedSyncDir(mcpSyncDirAbs);
|
|
193
|
+
const fallbackProjectId = path.basename(mcpSyncDirAbs).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
141
194
|
const resolvedProjectId = projectId
|
|
142
195
|
? projectId
|
|
143
|
-
:
|
|
196
|
+
: scopedSyncDir?.projectId ?? fallbackProjectId;
|
|
144
197
|
|
|
145
198
|
const projectIdCheck = validateProjectId(resolvedProjectId);
|
|
146
199
|
if (!projectIdCheck.ok) {
|
|
147
200
|
throw new Error(`Invalid project_id '${resolvedProjectId}': ${projectIdCheck.reason}`);
|
|
148
201
|
}
|
|
149
202
|
|
|
203
|
+
if (scopedSyncDir && projectId && projectId !== scopedSyncDir.projectId) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`project_id '${projectId}' does not match WRITING_SYNC_DIR scope '${scopedSyncDir.projectId}'. `
|
|
206
|
+
+ "Set WRITING_SYNC_DIR to the sync root or use the matching project_id."
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
150
210
|
if (!fs.existsSync(scrivenerDirAbs)) {
|
|
151
211
|
throw new Error(`Scrivener sync dir not found: ${scrivenerDirAbs}`);
|
|
152
212
|
}
|
|
153
213
|
|
|
154
|
-
// Route universe/project IDs to universes/<universe>/<project>/scenes,
|
|
155
|
-
// matching the convention used by inferProjectAndUniverse in sync.js.
|
|
156
|
-
const segments = resolvedProjectId.split("/");
|
|
157
214
|
let scenesDir;
|
|
158
215
|
let scenesBoundaryRoot;
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
if (scopedSyncDir) {
|
|
217
|
+
scenesBoundaryRoot = path.join(
|
|
218
|
+
scopedSyncDir.syncRoot,
|
|
219
|
+
scopedSyncDir.scope === "universe" ? "universes" : "projects"
|
|
220
|
+
);
|
|
221
|
+
scenesDir = path.join(scopedSyncDir.projectRoot, "scenes");
|
|
163
222
|
} else {
|
|
164
|
-
|
|
165
|
-
|
|
223
|
+
// Route universe/project IDs to universes/<universe>/<project>/scenes,
|
|
224
|
+
// matching the convention used by inferProjectAndUniverse in sync.js.
|
|
225
|
+
const segments = resolvedProjectId.split("/");
|
|
226
|
+
if (segments.length === 2) {
|
|
227
|
+
const [universeId, projectSlug] = segments;
|
|
228
|
+
scenesBoundaryRoot = path.join(mcpSyncDirAbs, "universes");
|
|
229
|
+
scenesDir = path.resolve(scenesBoundaryRoot, universeId, projectSlug, "scenes");
|
|
230
|
+
} else {
|
|
231
|
+
scenesBoundaryRoot = path.join(mcpSyncDirAbs, "projects");
|
|
232
|
+
scenesDir = path.resolve(scenesBoundaryRoot, resolvedProjectId, "scenes");
|
|
233
|
+
}
|
|
166
234
|
}
|
|
167
235
|
|
|
168
236
|
const relFromBoundary = path.relative(scenesBoundaryRoot, scenesDir);
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import matter from "gray-matter";
|
|
|
7
7
|
import yaml from "js-yaml";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { openDb } from "./db.js";
|
|
10
|
-
import { syncAll, isSyncDirWritable, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
|
|
10
|
+
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
|
|
11
11
|
import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
|
|
12
12
|
import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
|
|
13
13
|
import { importScrivenerSync, validateProjectId } from "./importer.js";
|
|
@@ -220,6 +220,7 @@ process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
|
|
|
220
220
|
|
|
221
221
|
// Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
|
|
222
222
|
const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
|
|
223
|
+
const SYNC_OWNERSHIP_DIAGNOSTICS = getSyncOwnershipDiagnostics(SYNC_DIR);
|
|
223
224
|
if (!SYNC_DIR_WRITABLE) {
|
|
224
225
|
process.stderr.write(`[mcp-writing] WARNING: sync dir is not writable — sidecar auto-migration and metadata write-back will be unavailable\n`);
|
|
225
226
|
}
|
|
@@ -263,6 +264,24 @@ function getRuntimeDiagnostics() {
|
|
|
263
264
|
recommendations.push("If running in Docker/OpenClaw, verify volume ownership and permissions for the container user.");
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths > 0) {
|
|
268
|
+
warnings.push(
|
|
269
|
+
`OWNERSHIP_MISMATCH: ${SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths} sampled path(s) are not owned by runtime UID ${SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid}.`
|
|
270
|
+
);
|
|
271
|
+
recommendations.push(
|
|
272
|
+
`Repair ownership once on host: sudo chown -R "$(id -u):$(id -g)" "${SYNC_DIR_ABS}"`
|
|
273
|
+
);
|
|
274
|
+
recommendations.push(
|
|
275
|
+
"For Docker, run container as host user (compose: user: \"${UID:-1000}:${GID:-1000}\"). Optionally set UID/GID explicitly in a .env file."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths > 0) {
|
|
280
|
+
warnings.push(
|
|
281
|
+
`ROOT_OWNED_PATHS: ${SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths} sampled path(s) are owned by UID 0 (root).`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
266
285
|
if (!GIT_AVAILABLE) {
|
|
267
286
|
warnings.push("GIT_NOT_FOUND: git is not available on PATH; snapshot/edit tools are unavailable.");
|
|
268
287
|
recommendations.push("Install git in the runtime image/environment.");
|
|
@@ -396,13 +415,14 @@ function createMcpServer() {
|
|
|
396
415
|
// ---- get_runtime_config --------------------------------------------------
|
|
397
416
|
s.tool(
|
|
398
417
|
"get_runtime_config",
|
|
399
|
-
"Show the active runtime paths and capabilities for this server instance (sync dir, database path, writability, and git availability). Use this to verify which manuscript location is currently connected.",
|
|
418
|
+
"Show the active runtime paths and capabilities for this server instance (sync dir, database path, writability, permission diagnostics, and git availability). Use this to verify which manuscript location is currently connected.",
|
|
400
419
|
{},
|
|
401
420
|
async () => {
|
|
402
421
|
return jsonResponse({
|
|
403
422
|
sync_dir: SYNC_DIR_ABS,
|
|
404
423
|
db_path: DB_PATH_DISPLAY,
|
|
405
424
|
sync_dir_writable: SYNC_DIR_WRITABLE,
|
|
425
|
+
permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
|
|
406
426
|
git_available: GIT_AVAILABLE,
|
|
407
427
|
git_enabled: GIT_ENABLED,
|
|
408
428
|
http_port: HTTP_PORT,
|
package/package.json
CHANGED
package/sync.js
CHANGED
|
@@ -53,6 +53,11 @@ export function walkSidecars(dir, fileList = []) {
|
|
|
53
53
|
return fileList;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function isNestedMirrorPath(syncDir, filePath) {
|
|
57
|
+
const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
|
|
58
|
+
return rel.includes("/scenes/projects/") || rel.includes("/scenes/universes/");
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
export function sidecarPath(filePath) {
|
|
57
62
|
return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
|
|
58
63
|
}
|
|
@@ -204,6 +209,94 @@ export function isSyncDirWritable(syncDir) {
|
|
|
204
209
|
}
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
function collectOwnershipSample(rootDir, limit = 200) {
|
|
213
|
+
const samples = [];
|
|
214
|
+
const stack = [rootDir];
|
|
215
|
+
|
|
216
|
+
while (stack.length && samples.length < limit) {
|
|
217
|
+
const current = stack.pop();
|
|
218
|
+
samples.push(current);
|
|
219
|
+
|
|
220
|
+
let entries;
|
|
221
|
+
try {
|
|
222
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (samples.length + stack.length >= limit) break;
|
|
229
|
+
if (entry.isSymbolicLink()) continue;
|
|
230
|
+
const full = path.join(current, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
stack.push(full);
|
|
233
|
+
} else {
|
|
234
|
+
samples.push(full);
|
|
235
|
+
if (samples.length >= limit) break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return samples;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getSyncOwnershipDiagnostics(syncDir, { sampleLimit = 200 } = {}) {
|
|
244
|
+
const runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
245
|
+
let syncDirPathExists;
|
|
246
|
+
let syncDirIsDirectory;
|
|
247
|
+
try {
|
|
248
|
+
const stat = fs.statSync(syncDir);
|
|
249
|
+
syncDirPathExists = true;
|
|
250
|
+
syncDirIsDirectory = stat.isDirectory();
|
|
251
|
+
} catch {
|
|
252
|
+
syncDirPathExists = false;
|
|
253
|
+
syncDirIsDirectory = false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const diagnostics = {
|
|
257
|
+
sync_dir: path.resolve(syncDir),
|
|
258
|
+
sync_dir_path_exists: syncDirPathExists,
|
|
259
|
+
sync_dir_is_directory: syncDirIsDirectory,
|
|
260
|
+
// Backwards-compatible: "exists" now means "exists and is a directory".
|
|
261
|
+
sync_dir_exists: syncDirIsDirectory,
|
|
262
|
+
supported: runtimeUid !== null,
|
|
263
|
+
runtime_uid: runtimeUid,
|
|
264
|
+
sampled_paths: 0,
|
|
265
|
+
sample_limit: sampleLimit,
|
|
266
|
+
root_owned_paths: 0,
|
|
267
|
+
non_runtime_owned_paths: 0,
|
|
268
|
+
unreadable_paths: 0,
|
|
269
|
+
root_owned_examples: [],
|
|
270
|
+
non_runtime_owned_examples: [],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if (!diagnostics.sync_dir_is_directory || runtimeUid === null) {
|
|
274
|
+
return diagnostics;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const sample = collectOwnershipSample(syncDir, sampleLimit);
|
|
278
|
+
diagnostics.sampled_paths = sample.length;
|
|
279
|
+
|
|
280
|
+
for (const filePath of sample) {
|
|
281
|
+
try {
|
|
282
|
+
const stat = fs.statSync(filePath);
|
|
283
|
+
const rel = path.relative(syncDir, filePath) || ".";
|
|
284
|
+
if (stat.uid === 0) {
|
|
285
|
+
diagnostics.root_owned_paths++;
|
|
286
|
+
if (diagnostics.root_owned_examples.length < 5) diagnostics.root_owned_examples.push(rel);
|
|
287
|
+
}
|
|
288
|
+
if (stat.uid !== runtimeUid) {
|
|
289
|
+
diagnostics.non_runtime_owned_paths++;
|
|
290
|
+
if (diagnostics.non_runtime_owned_examples.length < 5) diagnostics.non_runtime_owned_examples.push(rel);
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
diagnostics.unreadable_paths++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return diagnostics;
|
|
298
|
+
}
|
|
299
|
+
|
|
207
300
|
// ---------------------------------------------------------------------------
|
|
208
301
|
// DB-dependent sync (takes db + syncDir as arguments for testability)
|
|
209
302
|
// ---------------------------------------------------------------------------
|
|
@@ -368,9 +461,18 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
368
461
|
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
369
462
|
const warnings = [];
|
|
370
463
|
|
|
464
|
+
const scanFiles = [];
|
|
465
|
+
for (const file of files) {
|
|
466
|
+
if (isNestedMirrorPath(syncDir, file)) {
|
|
467
|
+
warnings.push(`Ignored nested mirror path: ${path.relative(syncDir, file)}`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
scanFiles.push(file);
|
|
471
|
+
}
|
|
472
|
+
|
|
371
473
|
// --- Pass 1: world files (characters/places must be indexed before scenes
|
|
372
474
|
// so that character name → ID resolution in scene_characters works) ---
|
|
373
|
-
for (const file of
|
|
475
|
+
for (const file of scanFiles) {
|
|
374
476
|
if (!isWorldFile(syncDir, file)) continue;
|
|
375
477
|
try {
|
|
376
478
|
const { meta } = readMeta(file, syncDir, { writable });
|
|
@@ -386,7 +488,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
386
488
|
}
|
|
387
489
|
|
|
388
490
|
// --- Pass 2: scene files ---
|
|
389
|
-
for (const file of
|
|
491
|
+
for (const file of scanFiles) {
|
|
390
492
|
if (isWorldFile(syncDir, file)) continue;
|
|
391
493
|
try {
|
|
392
494
|
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
|
|
@@ -431,7 +533,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
431
533
|
}
|
|
432
534
|
|
|
433
535
|
// --- Orphaned sidecar detection ---
|
|
434
|
-
const sidecars = walkSidecars(syncDir);
|
|
536
|
+
const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
|
|
435
537
|
for (const sidecar of sidecars) {
|
|
436
538
|
const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
|
|
437
539
|
const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
|