@hanna84/mcp-writing 1.5.2 → 1.6.1
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/index.js +133 -21
- package/package.json +1 -1
- package/sync.js +153 -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.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.6.0...v1.6.1)
|
|
9
|
+
|
|
10
|
+
- fix: preflight prose writes and make canonical sheet creation idempotent [`#46`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/46)
|
|
12
|
+
|
|
13
|
+
#### [v1.6.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.5.2...v1.6.0)
|
|
15
|
+
|
|
16
|
+
> 19 April 2026
|
|
17
|
+
|
|
18
|
+
- feat: add permission preflight diagnostics and ops contract [`#45`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/45)
|
|
20
|
+
- Release 1.6.0 [`bfab6d2`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/bfab6d205f4c60bf9e59cb4097796fc7cef63158)
|
|
22
|
+
|
|
7
23
|
#### [v1.5.2](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.5.1...v1.5.2)
|
|
9
25
|
|
|
26
|
+
> 19 April 2026
|
|
27
|
+
|
|
10
28
|
- fix: avoid nested scenes/projects paths on import [`#44`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/44)
|
|
30
|
+
- Release 1.5.2 [`47f7c6c`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/47f7c6c9ddbc4244b8a62b1dc37c86eb914aafbc)
|
|
12
32
|
|
|
13
33
|
#### [v1.5.1](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.5.0...v1.5.1)
|
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/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, getFileWriteDiagnostics, 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";
|
|
@@ -180,28 +180,72 @@ function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId,
|
|
|
180
180
|
const { dir } = resolveWorldEntityDir({ kind, projectId, universeId, name });
|
|
181
181
|
const prosePath = path.join(dir, "sheet.md");
|
|
182
182
|
const metaPath = sidecarPath(prosePath);
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
const hadProse = fs.existsSync(prosePath);
|
|
184
|
+
const hadMeta = fs.existsSync(metaPath);
|
|
185
|
+
|
|
186
|
+
let shouldWriteMeta = !hadMeta;
|
|
187
|
+
let payload;
|
|
188
|
+
const derivedId = `${prefix}-${slug}`;
|
|
189
|
+
if (hadMeta) {
|
|
190
|
+
let parsedMeta;
|
|
191
|
+
try {
|
|
192
|
+
parsedMeta = yaml.load(fs.readFileSync(metaPath, "utf8"));
|
|
193
|
+
} catch (err) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Existing metadata sidecar is invalid YAML at ${metaPath}: ${err.message}`,
|
|
196
|
+
{ cause: err }
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (parsedMeta != null && (typeof parsedMeta !== "object" || Array.isArray(parsedMeta))) {
|
|
201
|
+
throw new Error(`Existing metadata sidecar must be a YAML mapping at ${metaPath}.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const existingMeta = parsedMeta ?? {};
|
|
205
|
+
|
|
206
|
+
const backfilledId = existingMeta[idKey] ?? derivedId;
|
|
207
|
+
const backfilledName = existingMeta.name ?? name;
|
|
208
|
+
shouldWriteMeta = existingMeta[idKey] == null || existingMeta.name == null;
|
|
209
|
+
payload = shouldWriteMeta
|
|
210
|
+
? {
|
|
211
|
+
...existingMeta,
|
|
212
|
+
[idKey]: backfilledId,
|
|
213
|
+
name: backfilledName,
|
|
214
|
+
}
|
|
215
|
+
: existingMeta;
|
|
216
|
+
} else {
|
|
217
|
+
payload = {
|
|
218
|
+
[idKey]: derivedId,
|
|
219
|
+
name,
|
|
220
|
+
...(meta ?? {}),
|
|
221
|
+
};
|
|
185
222
|
}
|
|
186
223
|
|
|
187
224
|
fs.mkdirSync(dir, { recursive: true });
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
225
|
+
|
|
226
|
+
if (!hadProse) {
|
|
227
|
+
const defaultSheet = kind === "character"
|
|
228
|
+
? renderCharacterSheetTemplate(name)
|
|
229
|
+
: renderPlaceSheetTemplate(name);
|
|
230
|
+
const body = notes?.trim() ?? defaultSheet;
|
|
231
|
+
fs.writeFileSync(prosePath, `${body}${body ? "\n" : ""}`, "utf8");
|
|
232
|
+
}
|
|
233
|
+
|
|
192
234
|
if (kind === "character") {
|
|
193
|
-
|
|
235
|
+
const arcPath = path.join(dir, "arc.md");
|
|
236
|
+
if (!fs.existsSync(arcPath)) {
|
|
237
|
+
fs.writeFileSync(arcPath, `${renderCharacterArcTemplate(name)}\n`, "utf8");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (shouldWriteMeta) {
|
|
242
|
+
fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
|
|
194
243
|
}
|
|
195
|
-
const payload = {
|
|
196
|
-
[idKey]: `${prefix}-${slug}`,
|
|
197
|
-
name,
|
|
198
|
-
...meta,
|
|
199
|
-
};
|
|
200
|
-
fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
|
|
201
244
|
|
|
202
245
|
syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
203
246
|
|
|
204
247
|
return {
|
|
248
|
+
created: !hadProse && !hadMeta,
|
|
205
249
|
id: payload[idKey],
|
|
206
250
|
prose_path: prosePath,
|
|
207
251
|
meta_path: metaPath,
|
|
@@ -220,6 +264,7 @@ process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
|
|
|
220
264
|
|
|
221
265
|
// Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
|
|
222
266
|
const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
|
|
267
|
+
const SYNC_OWNERSHIP_DIAGNOSTICS = getSyncOwnershipDiagnostics(SYNC_DIR);
|
|
223
268
|
if (!SYNC_DIR_WRITABLE) {
|
|
224
269
|
process.stderr.write(`[mcp-writing] WARNING: sync dir is not writable — sidecar auto-migration and metadata write-back will be unavailable\n`);
|
|
225
270
|
}
|
|
@@ -263,6 +308,24 @@ function getRuntimeDiagnostics() {
|
|
|
263
308
|
recommendations.push("If running in Docker/OpenClaw, verify volume ownership and permissions for the container user.");
|
|
264
309
|
}
|
|
265
310
|
|
|
311
|
+
if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths > 0) {
|
|
312
|
+
warnings.push(
|
|
313
|
+
`OWNERSHIP_MISMATCH: ${SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths} sampled path(s) are not owned by runtime UID ${SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid}.`
|
|
314
|
+
);
|
|
315
|
+
recommendations.push(
|
|
316
|
+
`Repair ownership once on host: sudo chown -R "$(id -u):$(id -g)" "${SYNC_DIR_ABS}"`
|
|
317
|
+
);
|
|
318
|
+
recommendations.push(
|
|
319
|
+
"For Docker, run container as host user (compose: user: \"${UID:-1000}:${GID:-1000}\"). Optionally set UID/GID explicitly in a .env file."
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths > 0) {
|
|
324
|
+
warnings.push(
|
|
325
|
+
`ROOT_OWNED_PATHS: ${SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths} sampled path(s) are owned by UID 0 (root).`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
266
329
|
if (!GIT_AVAILABLE) {
|
|
267
330
|
warnings.push("GIT_NOT_FOUND: git is not available on PATH; snapshot/edit tools are unavailable.");
|
|
268
331
|
recommendations.push("Install git in the runtime image/environment.");
|
|
@@ -396,13 +459,14 @@ function createMcpServer() {
|
|
|
396
459
|
// ---- get_runtime_config --------------------------------------------------
|
|
397
460
|
s.tool(
|
|
398
461
|
"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.",
|
|
462
|
+
"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
463
|
{},
|
|
401
464
|
async () => {
|
|
402
465
|
return jsonResponse({
|
|
403
466
|
sync_dir: SYNC_DIR_ABS,
|
|
404
467
|
db_path: DB_PATH_DISPLAY,
|
|
405
468
|
sync_dir_writable: SYNC_DIR_WRITABLE,
|
|
469
|
+
permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
|
|
406
470
|
git_available: GIT_AVAILABLE,
|
|
407
471
|
git_enabled: GIT_ENABLED,
|
|
408
472
|
http_port: HTTP_PORT,
|
|
@@ -691,7 +755,7 @@ function createMcpServer() {
|
|
|
691
755
|
// ---- create_character_sheet ---------------------------------------------
|
|
692
756
|
s.tool(
|
|
693
757
|
"create_character_sheet",
|
|
694
|
-
"Create a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately.
|
|
758
|
+
"Create or reuse a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
|
|
695
759
|
{
|
|
696
760
|
name: z.string().describe("Display name of the character (e.g. 'Mira Nystrom')."),
|
|
697
761
|
project_id: z.string().optional().describe("Project scope for a book-local character (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
|
|
@@ -713,7 +777,7 @@ function createMcpServer() {
|
|
|
713
777
|
}
|
|
714
778
|
|
|
715
779
|
try {
|
|
716
|
-
const
|
|
780
|
+
const result = createCanonicalWorldEntity({
|
|
717
781
|
kind: "character",
|
|
718
782
|
name,
|
|
719
783
|
notes,
|
|
@@ -722,7 +786,7 @@ function createMcpServer() {
|
|
|
722
786
|
meta: fields ?? {},
|
|
723
787
|
});
|
|
724
788
|
|
|
725
|
-
return jsonResponse({ ok: true, action: "created", kind: "character", ...
|
|
789
|
+
return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "character", ...result });
|
|
726
790
|
} catch (err) {
|
|
727
791
|
return errorResponse("IO_ERROR", `Failed to create character sheet: ${err.message}`);
|
|
728
792
|
}
|
|
@@ -757,7 +821,7 @@ function createMcpServer() {
|
|
|
757
821
|
// ---- create_place_sheet -------------------------------------------------
|
|
758
822
|
s.tool(
|
|
759
823
|
"create_place_sheet",
|
|
760
|
-
"Create a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately.
|
|
824
|
+
"Create or reuse a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
|
|
761
825
|
{
|
|
762
826
|
name: z.string().describe("Display name of the place (e.g. 'University Hospital')."),
|
|
763
827
|
project_id: z.string().optional().describe("Project scope for a book-local place (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
|
|
@@ -777,7 +841,7 @@ function createMcpServer() {
|
|
|
777
841
|
}
|
|
778
842
|
|
|
779
843
|
try {
|
|
780
|
-
const
|
|
844
|
+
const result = createCanonicalWorldEntity({
|
|
781
845
|
kind: "place",
|
|
782
846
|
name,
|
|
783
847
|
notes,
|
|
@@ -786,7 +850,7 @@ function createMcpServer() {
|
|
|
786
850
|
meta: fields ?? {},
|
|
787
851
|
});
|
|
788
852
|
|
|
789
|
-
return jsonResponse({ ok: true, action: "created", kind: "place", ...
|
|
853
|
+
return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "place", ...result });
|
|
790
854
|
} catch (err) {
|
|
791
855
|
return errorResponse("IO_ERROR", `Failed to create place sheet: ${err.message}`);
|
|
792
856
|
}
|
|
@@ -1363,6 +1427,54 @@ function createMcpServer() {
|
|
|
1363
1427
|
}
|
|
1364
1428
|
|
|
1365
1429
|
try {
|
|
1430
|
+
const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
|
|
1431
|
+
if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
|
|
1432
|
+
return errorResponse(
|
|
1433
|
+
"PROSE_FILE_NOT_WRITABLE",
|
|
1434
|
+
"Scene prose file cannot be accessed by the current runtime user.",
|
|
1435
|
+
{
|
|
1436
|
+
indexed_path: proposal.scene_file_path,
|
|
1437
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1438
|
+
}
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (proseWriteDiagnostics.stat_error_code && proseWriteDiagnostics.stat_error_code !== "ENOENT" && proseWriteDiagnostics.stat_error_code !== "ENOTDIR") {
|
|
1443
|
+
return errorResponse(
|
|
1444
|
+
"IO_ERROR",
|
|
1445
|
+
"Failed to inspect scene prose path before writing.",
|
|
1446
|
+
{
|
|
1447
|
+
indexed_path: proposal.scene_file_path,
|
|
1448
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1449
|
+
}
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (!proseWriteDiagnostics.exists) {
|
|
1454
|
+
return errorResponse("STALE_PATH", "Prose file not found at indexed path.", {
|
|
1455
|
+
indexed_path: proposal.scene_file_path,
|
|
1456
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (!proseWriteDiagnostics.is_file) {
|
|
1461
|
+
return errorResponse("INVALID_PROSE_PATH", "Indexed prose path is not a regular file.", {
|
|
1462
|
+
indexed_path: proposal.scene_file_path,
|
|
1463
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
if (!proseWriteDiagnostics.writable) {
|
|
1468
|
+
return errorResponse(
|
|
1469
|
+
"PROSE_FILE_NOT_WRITABLE",
|
|
1470
|
+
"Scene prose file is not writable by the current runtime user.",
|
|
1471
|
+
{
|
|
1472
|
+
indexed_path: proposal.scene_file_path,
|
|
1473
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1474
|
+
}
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1366
1478
|
// Reconstruct file content, preserving frontmatter only if the original had it
|
|
1367
1479
|
const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
|
|
1368
1480
|
const content = hasFrontmatter
|
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,142 @@ 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
|
+
|
|
300
|
+
export function getFileWriteDiagnostics(filePath) {
|
|
301
|
+
const runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
302
|
+
const resolvedPath = path.resolve(filePath);
|
|
303
|
+
const parentDir = path.dirname(resolvedPath);
|
|
304
|
+
const diagnostics = {
|
|
305
|
+
path: resolvedPath,
|
|
306
|
+
parent_dir: parentDir,
|
|
307
|
+
exists: false,
|
|
308
|
+
is_file: false,
|
|
309
|
+
writable: false,
|
|
310
|
+
parent_dir_writable: false,
|
|
311
|
+
supported: runtimeUid !== null,
|
|
312
|
+
runtime_uid: runtimeUid,
|
|
313
|
+
owner_uid: null,
|
|
314
|
+
root_owned: false,
|
|
315
|
+
stat_error_code: null,
|
|
316
|
+
stat_error_message: null,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
fs.accessSync(parentDir, fs.constants.W_OK);
|
|
321
|
+
diagnostics.parent_dir_writable = true;
|
|
322
|
+
} catch {
|
|
323
|
+
diagnostics.parent_dir_writable = false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const stat = fs.statSync(resolvedPath);
|
|
328
|
+
diagnostics.exists = true;
|
|
329
|
+
diagnostics.is_file = stat.isFile();
|
|
330
|
+
diagnostics.owner_uid = typeof stat.uid === "number" ? stat.uid : null;
|
|
331
|
+
diagnostics.root_owned = stat.uid === 0;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
diagnostics.stat_error_code = typeof err?.code === "string" ? err.code : null;
|
|
334
|
+
diagnostics.stat_error_message = typeof err?.message === "string" ? err.message : String(err);
|
|
335
|
+
return diagnostics;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
fs.accessSync(resolvedPath, fs.constants.W_OK);
|
|
340
|
+
diagnostics.writable = diagnostics.is_file;
|
|
341
|
+
} catch {
|
|
342
|
+
diagnostics.writable = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return diagnostics;
|
|
346
|
+
}
|
|
347
|
+
|
|
207
348
|
// ---------------------------------------------------------------------------
|
|
208
349
|
// DB-dependent sync (takes db + syncDir as arguments for testability)
|
|
209
350
|
// ---------------------------------------------------------------------------
|
|
@@ -368,9 +509,18 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
368
509
|
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
369
510
|
const warnings = [];
|
|
370
511
|
|
|
512
|
+
const scanFiles = [];
|
|
513
|
+
for (const file of files) {
|
|
514
|
+
if (isNestedMirrorPath(syncDir, file)) {
|
|
515
|
+
warnings.push(`Ignored nested mirror path: ${path.relative(syncDir, file)}`);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
scanFiles.push(file);
|
|
519
|
+
}
|
|
520
|
+
|
|
371
521
|
// --- Pass 1: world files (characters/places must be indexed before scenes
|
|
372
522
|
// so that character name → ID resolution in scene_characters works) ---
|
|
373
|
-
for (const file of
|
|
523
|
+
for (const file of scanFiles) {
|
|
374
524
|
if (!isWorldFile(syncDir, file)) continue;
|
|
375
525
|
try {
|
|
376
526
|
const { meta } = readMeta(file, syncDir, { writable });
|
|
@@ -386,7 +536,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
386
536
|
}
|
|
387
537
|
|
|
388
538
|
// --- Pass 2: scene files ---
|
|
389
|
-
for (const file of
|
|
539
|
+
for (const file of scanFiles) {
|
|
390
540
|
if (isWorldFile(syncDir, file)) continue;
|
|
391
541
|
try {
|
|
392
542
|
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
|
|
@@ -431,7 +581,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
431
581
|
}
|
|
432
582
|
|
|
433
583
|
// --- Orphaned sidecar detection ---
|
|
434
|
-
const sidecars = walkSidecars(syncDir);
|
|
584
|
+
const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
|
|
435
585
|
for (const sidecar of sidecars) {
|
|
436
586
|
const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
|
|
437
587
|
const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
|