Skip to content

Commit b8ac9d5

Browse files
committed
feat(docs): add docs sync system to keep docs/ in sync with database (#106)
Adds a CLI tool (pnpm sync:docs) that recursively syncs every .md file under docs/ into content_items as a note with source='docs-sync', tagged #project:shadowbrain, #docs, and a path-derived category tag. - src/lib/docs-sync.ts: syncDocsDirectory engine (idempotent upsert, tag self-healing, prune of deleted files, --force/--dry-run) - scripts/sync-docs.ts: CLI entry point mirroring import-markdown.ts - 27 tests covering idempotency, tags, cleanup, dry-run, force, and the hidden/private re-sync regression - version 0.8.0 -> 0.9.0 (new user-facing capability)
1 parent 65d13cf commit b8ac9d5

5 files changed

Lines changed: 1151 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
# ShadowBrain
77

8-
[![Version](https://img.shields.io/badge/version-0.8.0-yellow)](CHANGELOG.md)
8+
[![Version](https://img.shields.io/badge/version-0.9.0-yellow)](CHANGELOG.md)
99

1010
</div>
1111

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "shadowbrain",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"private": true,
55
"scripts": {
66
"dev": "next dev",
@@ -14,6 +14,7 @@
1414
"test:db:reset": "node scripts/test-db-reset.js",
1515
"test:db:cleanup": "node scripts/test-db-cleanup.js",
1616
"import:markdown": "node --env-file=.env --import tsx scripts/import-markdown.ts",
17+
"sync:docs": "node --env-file=.env --import tsx scripts/sync-docs.ts",
1718
"migrate:journal-shadows": "node --env-file=.env --import tsx scripts/migrate-journal-shadows.ts",
1819
"hash:password": "tsx scripts/hash-password.ts",
1920
"test": "vitest",

scripts/sync-docs.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* CLI entry point for the docs sync system.
3+
*
4+
* Reads the `docs/` directory at the project root and upserts every
5+
* `.md` file as a `content_item` with `type='note'` and
6+
* `source='docs-sync'`, tagged `#project:shadowbrain`, `#docs`, and a
7+
* path-derived category tag. Files removed from disk are pruned from the
8+
* database. Re-running is safe and idempotent.
9+
*
10+
* Usage:
11+
* pnpm sync:docs
12+
* pnpm sync:docs --dir /path/to/docs
13+
* pnpm sync:docs --force # re-write every file
14+
* pnpm sync:docs --dry-run # preview without writing
15+
*
16+
* Exits non-zero when at least one file failed to sync, or when a
17+
* transaction-level failure aborts the run. The summary is printed for
18+
* per-file failures; a crash short-circuits to the error handler.
19+
*/
20+
import { resolve } from "path";
21+
import { getDb, closeDb } from "@/db/index";
22+
import { syncDocsDirectory, formatDocsSyncResult } from "@/lib/docs-sync";
23+
import { log } from "@/lib/logger";
24+
25+
interface CliArgs {
26+
dir: string;
27+
force: boolean;
28+
dryRun: boolean;
29+
}
30+
31+
function parseArgs(argv: string[]): CliArgs {
32+
let dir: string | null = null;
33+
let force = false;
34+
let dryRun = false;
35+
for (let i = 0; i < argv.length; i += 1) {
36+
const arg = argv[i];
37+
if (arg === "--dir" || arg === "-d") {
38+
const next = argv[i + 1];
39+
if (!next) {
40+
throw new Error("--dir requires a path argument");
41+
}
42+
dir = next;
43+
i += 1;
44+
} else if (arg.startsWith("--dir=")) {
45+
dir = arg.slice("--dir=".length);
46+
} else if (arg === "--force") {
47+
force = true;
48+
} else if (arg === "--dry-run") {
49+
dryRun = true;
50+
} else if (arg === "--help" || arg === "-h") {
51+
printHelp();
52+
process.exit(0);
53+
} else {
54+
throw new Error(`Unknown argument: ${arg}`);
55+
}
56+
}
57+
return {
58+
dir: dir ?? resolve(process.cwd(), "docs"),
59+
force,
60+
dryRun,
61+
};
62+
}
63+
64+
function printHelp(): void {
65+
console.log(`Usage: pnpm sync:docs [--dir <path>] [--force] [--dry-run]
66+
67+
Reads .md files from <path> (default: ./docs) and upserts each as a
68+
content_item with type='note' and source='docs-sync'. Each doc is tagged
69+
#project:shadowbrain, #docs, and a category tag derived from its path.
70+
Files removed from disk are pruned. Re-runs are idempotent.
71+
72+
Options:
73+
-d, --dir <path> Directory to sync (default: ./docs)
74+
--force Re-write every file even if the stored content
75+
matches the on-disk version.
76+
--dry-run Preview changes without writing to the database.
77+
-h, --help Show this help
78+
`);
79+
}
80+
81+
async function main(): Promise<void> {
82+
let args: CliArgs;
83+
try {
84+
args = parseArgs(process.argv.slice(2));
85+
} catch (err) {
86+
const message = err instanceof Error ? err.message : String(err);
87+
console.error(`Argument error: ${message}`);
88+
printHelp();
89+
process.exit(2);
90+
}
91+
92+
const db = getDb();
93+
try {
94+
const result = await syncDocsDirectory(db, args.dir, {
95+
skipUnchanged: !args.force,
96+
dryRun: args.dryRun,
97+
});
98+
console.log(formatDocsSyncResult(result));
99+
log("info", "docs sync complete", {
100+
event: "docs.sync.complete",
101+
directory: result.directory,
102+
created: result.created,
103+
updated: result.updated,
104+
skipped: result.skipped,
105+
deleted: result.deleted,
106+
failed: result.failed,
107+
force: args.force,
108+
dryRun: args.dryRun,
109+
});
110+
if (result.failed > 0) {
111+
process.exitCode = 1;
112+
}
113+
} finally {
114+
closeDb();
115+
}
116+
}
117+
118+
main().catch((err) => {
119+
const message = err instanceof Error ? err.message : String(err);
120+
console.error(`Docs sync failed: ${message}`);
121+
log("error", "docs sync crashed", {
122+
event: "docs.sync.crash",
123+
error: err instanceof Error ? { message, stack: err.stack } : message,
124+
});
125+
process.exit(1);
126+
});

0 commit comments

Comments
 (0)