From a6683e183e3d53d16386ab8788415b2ab6b00a5f Mon Sep 17 00:00:00 2001 From: enieuwy Date: Sun, 12 Apr 2026 11:03:23 +0800 Subject: [PATCH 1/3] Add headless YAOS CLI --- package-lock.json | 66 ++- package.json | 3 + packages/cli/README.md | 55 ++ packages/cli/package.json | 23 + packages/cli/src/config.ts | 217 ++++++++ packages/cli/src/index.ts | 168 ++++++ packages/cli/src/nodeDiskMirror.ts | 804 +++++++++++++++++++++++++++++ packages/cli/src/nodeVaultSync.ts | 187 +++++++ packages/cli/src/shims.d.ts | 2 + packages/cli/tests/config.test.ts | 119 +++++ packages/cli/tsconfig.json | 13 + src/sync/exclude.ts | 16 +- src/sync/snapshotClient.ts | 8 +- src/sync/vaultSync.ts | 69 ++- src/utils/normalizeVaultPath.ts | 7 + 15 files changed, 1713 insertions(+), 44 deletions(-) create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/config.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/nodeDiskMirror.ts create mode 100644 packages/cli/src/nodeVaultSync.ts create mode 100644 packages/cli/src/shims.d.ts create mode 100644 packages/cli/tests/config.test.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 src/utils/normalizeVaultPath.ts diff --git a/package-lock.json b/package-lock.json index c1d6eb2..6ce41cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "yaos", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yaos", - "version": "1.5.0", + "version": "1.5.1", "license": "0-BSD", + "workspaces": [ + "packages/*" + ], "dependencies": { "fast-diff": "^1.3.0", "fflate": "^0.8.2", @@ -1029,6 +1032,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@yaos/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/acorn": { "version": "8.15.0", "dev": true, @@ -1087,7 +1094,6 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -1357,6 +1363,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -1382,6 +1403,15 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -3159,7 +3189,6 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3762,6 +3791,19 @@ "dev": true, "license": "MIT" }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "dev": true, @@ -4907,6 +4949,22 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "packages/cli": { + "name": "@yaos/cli", + "version": "0.0.0", + "dependencies": { + "chokidar": "^4.0.3", + "commander": "^14.0.0" + }, + "bin": { + "yaos-cli": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^16.11.6", + "esbuild": "0.25.5", + "typescript": "^5.8.3" + } } } } diff --git a/package.json b/package.json index a196dac..79a0853 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "A zero-terminal, real-time sync engine powered by your own Cloudflare Worker.", "main": "main.js", "type": "module", + "workspaces": [ + "packages/*" + ], "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..4ca3b0d --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,55 @@ +# YAOS CLI + +Headless YAOS client for mirroring a Markdown vault directory to the YAOS CRDT room. + +## Commands + +```bash +yaos-cli daemon --host --token --vault-id --dir +yaos-cli sync --host --token --vault-id --dir +yaos-cli status --host --token --vault-id +``` + +- `daemon` performs startup reconciliation, starts the filesystem watcher, and stays running. +- `sync` performs one reconciliation pass and exits. +- `status` connects to YAOS and prints current connection/cache state as JSON. + +## Configuration precedence + +1. CLI flags +2. Environment variables +3. `~/.config/yaos/cli.json` (or `$XDG_CONFIG_HOME/yaos/cli.json`) + +## Environment variables + +- `YAOS_HOST` +- `YAOS_TOKEN` +- `YAOS_VAULT_ID` +- `YAOS_DIR` +- `YAOS_DEVICE_NAME` +- `YAOS_DEBUG` +- `YAOS_EXCLUDE_PATTERNS` +- `YAOS_MAX_FILE_SIZE_KB` +- `YAOS_EXTERNAL_EDIT_POLICY` +- `YAOS_FRONTMATTER_GUARD` +- `YAOS_CONFIG_DIR` + +## Config file example + +```json +{ + "host": "https://sync.example.com", + "token": "...", + "vaultId": "vault-123", + "dir": "/srv/vault", + "deviceName": "n100-headless", + "debug": false, + "excludePatterns": "templates/,scratch/", + "maxFileSizeKB": 2048, + "externalEditPolicy": "always", + "frontmatterGuardEnabled": true, + "configDir": ".obsidian" +} +``` + +Use file mode `0600` if you store the token in this file. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..3c45626 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,23 @@ +{ + "name": "@yaos/cli", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "yaos-cli": "dist/index.cjs" + }, + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "node --import jiti/register --test tests/*.ts" + }, + "dependencies": { + "chokidar": "^4.0.3", + "commander": "^14.0.0" + }, + "devDependencies": { + "@types/node": "^16.11.6", + "esbuild": "0.25.5", + "typescript": "^5.8.3" + } +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..0c7ed59 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,217 @@ +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as nodePath from "node:path"; +import type { ExternalEditPolicy } from "../../../src/settings"; + +const DEFAULT_CONFIG_DIR = ".obsidian"; + +export interface CliCommandOptions { + host?: string; + token?: string; + vaultId?: string; + dir?: string; + deviceName?: string; + debug?: boolean; + excludePatterns?: string; + maxFileSizeKB?: number; + externalEditPolicy?: ExternalEditPolicy; + frontmatterGuardEnabled?: boolean; + configDir?: string; +} + +export interface CliFileConfig { + host?: string; + token?: string; + vaultId?: string; + dir?: string; + deviceName?: string; + debug?: boolean; + excludePatterns?: string; + maxFileSizeKB?: number; + externalEditPolicy?: ExternalEditPolicy; + frontmatterGuardEnabled?: boolean; + configDir?: string; +} + +export interface ResolvedCliConfig { + host?: string; + token?: string; + vaultId?: string; + dir?: string; + deviceName: string; + debug: boolean; + excludePatterns: string; + maxFileSizeKB: number; + externalEditPolicy: ExternalEditPolicy; + frontmatterGuardEnabled: boolean; + configDir: string; + configPath: string; +} + +export interface RuntimeCliConfig extends ResolvedCliConfig { + host: string; + token: string; + vaultId: string; + dir: string; +} + +const DEFAULTS = { + deviceName: os.hostname(), + debug: false, + excludePatterns: "", + maxFileSizeKB: 2048, + externalEditPolicy: "always" as ExternalEditPolicy, + frontmatterGuardEnabled: true, + configDir: DEFAULT_CONFIG_DIR, +}; + +export function getDefaultConfigPath(): string { + const baseDir = process.env.XDG_CONFIG_HOME + ? nodePath.resolve(process.env.XDG_CONFIG_HOME) + : nodePath.join(os.homedir(), ".config"); + return nodePath.join(baseDir, "yaos", "cli.json"); +} + +export async function resolveCliConfig(options: CliCommandOptions): Promise { + const configPath = getDefaultConfigPath(); + const fileConfig = await readConfigFile(configPath); + const envConfig = readEnvConfig(process.env); + + return { + ...DEFAULTS, + ...fileConfig, + ...envConfig, + ...pickDefined(options), + configPath, + }; +} + +export function requireRuntimeConfig( + config: ResolvedCliConfig, + requirements: { requireDir: boolean }, +): RuntimeCliConfig { + const missing = [ + config.host ? null : "host", + config.token ? null : "token", + config.vaultId ? null : "vaultId", + requirements.requireDir && !config.dir ? "dir" : null, + ].filter((value): value is string => value !== null); + + if (missing.length > 0) { + throw new Error(`Missing required configuration: ${missing.join(", ")}`); + } + + return { + ...config, + host: config.host!, + token: config.token!, + vaultId: config.vaultId!, + dir: config.dir!, + }; +} + +async function readConfigFile(configPath: string): Promise { + try { + const raw = await fs.readFile(configPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("config file must contain a JSON object"); + } + return sanitizeFileConfig(parsed as Record); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {}; + } + throw new Error(`Failed to load ${configPath}: ${(error as Error).message}`); + } +} + +function sanitizeFileConfig(value: Record): CliFileConfig { + const config: CliFileConfig = {}; + assignString(config, "host", value.host); + assignString(config, "token", value.token); + assignString(config, "vaultId", value.vaultId); + assignString(config, "dir", value.dir); + assignString(config, "deviceName", value.deviceName); + assignBoolean(config, "debug", value.debug); + assignString(config, "excludePatterns", value.excludePatterns); + assignNumber(config, "maxFileSizeKB", value.maxFileSizeKB); + assignExternalEditPolicy(config, value.externalEditPolicy); + assignBoolean(config, "frontmatterGuardEnabled", value.frontmatterGuardEnabled); + assignString(config, "configDir", value.configDir); + return config; +} + +function readEnvConfig(env: NodeJS.ProcessEnv): CliFileConfig { + const config: CliFileConfig = {}; + assignString(config, "host", env.YAOS_HOST); + assignString(config, "token", env.YAOS_TOKEN); + assignString(config, "vaultId", env.YAOS_VAULT_ID); + assignString(config, "dir", env.YAOS_DIR); + assignString(config, "deviceName", env.YAOS_DEVICE_NAME); + assignBoolean(config, "debug", parseBooleanEnv(env.YAOS_DEBUG)); + assignString(config, "excludePatterns", env.YAOS_EXCLUDE_PATTERNS); + assignNumber(config, "maxFileSizeKB", parseNumberEnv(env.YAOS_MAX_FILE_SIZE_KB)); + assignExternalEditPolicy(config, env.YAOS_EXTERNAL_EDIT_POLICY); + assignBoolean(config, "frontmatterGuardEnabled", parseBooleanEnv(env.YAOS_FRONTMATTER_GUARD)); + assignString(config, "configDir", env.YAOS_CONFIG_DIR); + return config; +} + +function pickDefined(value: T): Partial { + return Object.fromEntries( + Object.entries(value as Record).filter(([, entry]) => entry !== undefined), + ) as Partial; +} + +function assignString( + target: CliFileConfig, + key: K, + value: unknown, +): void { + if (typeof value === "string" && value.length > 0) { + target[key] = value as CliFileConfig[K]; + } +} + +function assignBoolean( + target: CliFileConfig, + key: K, + value: unknown, +): void { + if (typeof value === "boolean") { + target[key] = value as CliFileConfig[K]; + } +} + +function assignNumber( + target: CliFileConfig, + key: K, + value: unknown, +): void { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + target[key] = value as CliFileConfig[K]; + } +} + +function assignExternalEditPolicy( + target: CliFileConfig, + value: unknown, +): void { + if (value === "always" || value === "closed-only" || value === "never") { + target.externalEditPolicy = value; + } +} + +function parseBooleanEnv(value: string | undefined): boolean | undefined { + if (value == null) return undefined; + if (value === "1" || value.toLowerCase() === "true") return true; + if (value === "0" || value.toLowerCase() === "false") return false; + return undefined; +} + +function parseNumberEnv(value: string | undefined): number | undefined { + if (value == null || value.length === 0) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..ccd7f9e --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +import { Command, InvalidOptionArgumentError, Option } from "commander"; +import type { CliCommandOptions, ResolvedCliConfig } from "./config"; +import { requireRuntimeConfig, resolveCliConfig } from "./config"; +import { createNodeVaultSync, HeadlessYaosClient } from "./nodeVaultSync"; + +const program = new Command(); + +program + .name("yaos-cli") + .description("Headless YAOS client for filesystem-backed Markdown vaults") + .showHelpAfterError(); + +addCommonOptions( + program + .command("daemon") + .description("Run the headless YAOS client and keep watching the vault") + .action(async (options: CliCommandOptions) => { + const resolved = await resolveCliConfig(options); + const runtime = requireRuntimeConfig(resolved, { requireDir: true }); + const client = new HeadlessYaosClient(runtime); + const startup = await client.startup({ watch: true }); + console.log(JSON.stringify({ + mode: "daemon", + config: summarizeConfig(runtime), + startup, + status: client.getStatus(), + }, null, 2)); + await waitForShutdown(client); + }), + { includeDir: true }, +); + +addCommonOptions( + program + .command("sync") + .description("Perform one reconciliation pass and exit") + .action(async (options: CliCommandOptions) => { + const resolved = await resolveCliConfig(options); + const runtime = requireRuntimeConfig(resolved, { requireDir: true }); + const client = new HeadlessYaosClient(runtime); + try { + const startup = await client.startup({ watch: false }); + console.log(JSON.stringify({ + mode: "sync", + config: summarizeConfig(runtime), + startup, + status: client.getStatus(), + }, null, 2)); + } finally { + await client.stop(); + } + }), + { includeDir: true }, +); + +addCommonOptions( + program + .command("status") + .description("Show current connection and local-cache status") + .action(async (options: CliCommandOptions) => { + const resolved = await resolveCliConfig(options); + const vaultSync = createNodeVaultSync(resolved); + try { + const localLoaded = await vaultSync.waitForLocalPersistence(); + const providerSynced = await vaultSync.waitForProviderSync(); + console.log(JSON.stringify({ + mode: "status", + config: summarizeConfig(resolved), + localLoaded, + providerSynced, + connected: vaultSync.connected, + localReady: vaultSync.localReady, + connectionGeneration: vaultSync.connectionGeneration, + storedSchemaVersion: vaultSync.storedSchemaVersion, + safeReconcileMode: vaultSync.getSafeReconcileMode(), + fatalAuthError: vaultSync.fatalAuthError, + fatalAuthCode: vaultSync.fatalAuthCode, + activeMarkdownPaths: vaultSync.getActiveMarkdownPaths().length, + }, null, 2)); + } finally { + vaultSync.destroy(); + } + }), + { includeDir: false }, +); + +void program.parseAsync(process.argv); + +function addCommonOptions( + command: T, + options: { includeDir: boolean }, +): T { + command + .option("--host ", "YAOS server URL") + .option("--token ", "YAOS sync token") + .option("--vault-id ", "YAOS vault ID") + .option("--device-name ", "Device name reported to YAOS") + .option("--debug", "Enable verbose logging") + .option("--exclude-patterns ", "Comma-separated path prefixes to exclude") + .addOption( + new Option("--max-file-size-kb ", "Maximum markdown file size to sync") + .argParser(parsePositiveInteger), + ) + .addOption( + new Option("--external-edit-policy ", "How to treat local filesystem edits") + .choices(["always", "closed-only", "never"]), + ); + + if (options.includeDir) { + command.option("--dir ", "Vault directory to mirror"); + } + + return command; +} + +function parsePositiveInteger(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new InvalidOptionArgumentError(`Expected a positive integer, received ${value}`); + } + return parsed; +} + +function summarizeConfig(config: ResolvedCliConfig): Record { + return { + host: config.host ?? null, + vaultId: config.vaultId ?? null, + dir: config.dir ?? null, + deviceName: config.deviceName, + debug: config.debug, + excludePatterns: config.excludePatterns, + maxFileSizeKB: config.maxFileSizeKB, + externalEditPolicy: config.externalEditPolicy, + frontmatterGuardEnabled: config.frontmatterGuardEnabled, + configDir: config.configDir, + configPath: config.configPath, + }; +} + +async function waitForShutdown(client: HeadlessYaosClient): Promise { + await new Promise((resolve, reject) => { + const finish = async () => { + cleanup(); + try { + await client.stop(); + resolve(); + } catch (error) { + reject(error); + } + }; + + const onSigint = () => { + void finish(); + }; + const onSigterm = () => { + void finish(); + }; + const cleanup = () => { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + }; + + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + }); +} diff --git a/packages/cli/src/nodeDiskMirror.ts b/packages/cli/src/nodeDiskMirror.ts new file mode 100644 index 0000000..ccbd9b6 --- /dev/null +++ b/packages/cli/src/nodeDiskMirror.ts @@ -0,0 +1,804 @@ +import chokidar, { type FSWatcher } from "chokidar"; +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import type { Dirent, Stats } from "node:fs"; +import * as nodePath from "node:path"; +import * as Y from "yjs"; +import type { ExternalEditPolicy } from "../../../src/settings"; +import { applyDiffToYText } from "../../../src/sync/diff"; +import { isExcluded } from "../../../src/sync/exclude"; +import { + isFrontmatterBlocked, + validateFrontmatterTransition, +} from "../../../src/sync/frontmatterGuard"; +import type { ReconcileMode, ReconcileResult, VaultSync } from "../../../src/sync/vaultSync"; +import { isMarkdownSyncable, ORIGIN_SEED } from "../../../src/types"; +import { normalizeVaultPath } from "../../../src/utils/normalizeVaultPath"; + +const WRITE_DEBOUNCE_MS = 300; +const WRITE_DEBOUNCE_BURST_MS = 1_000; +const WRITE_BURST_THRESHOLD = 20; +const MARKDOWN_DIRTY_SETTLE_MS = 350; +const SUPPRESS_MS = 500; +const MAX_CONCURRENT_WRITES = 5; +const WATCHER_STABILITY_MS = 200; +const WATCHER_POLL_MS = 50; + +const LOCAL_STRING_ORIGINS = new Set([ + ORIGIN_SEED, + "disk-sync", +]); + +type DirtyReason = "create" | "modify"; + +interface SuppressionEntry { + kind: "write" | "delete"; + expiresAt: number; + expectedBytes?: number; + expectedHash?: string; +} + +interface ScannedDiskState { + contents: Map; + presentPaths: Set; +} + +interface DirtyFile { + path: string; + reason: DirtyReason; + content: string; + stats: Stats | null; +} + +export interface NodeDiskMirrorOptions { + rootDir: string; + deviceName: string; + debug: boolean; + excludePatterns: string[]; + maxFileSizeKB: number; + externalEditPolicy: ExternalEditPolicy; + frontmatterGuardEnabled: boolean; + configDir?: string; +} + +export interface NodeDiskMirrorDebugSnapshot { + watcherReady: boolean; + dirtyCount: number; + deletedCount: number; + queuedWrites: string[]; + suppressedCount: number; +} + +function isLocalOrigin(origin: unknown, provider: unknown): boolean { + if (origin === provider) return false; + if (typeof origin === "string") return LOCAL_STRING_ORIGINS.has(origin); + if (origin == null) return true; + return true; +} + +export class NodeDiskMirror { + private readonly rootDir: string; + private readonly configDir: string; + private readonly maxFileSize: number; + private watcher: FSWatcher | null = null; + private watcherReady = false; + private mapObserverCleanups: Array<() => void> = []; + private dirtyMarkdownPaths = new Map(); + private deletedMarkdownPaths = new Set(); + private markdownDrainPromise: Promise | null = null; + private markdownDrainTimer: ReturnType | null = null; + private lastMarkdownDirtyAt = 0; + private suppressedPaths = new Map(); + private writeQueue = new Set(); + private forcedWritePaths = new Set(); + private debounceTimers = new Map>(); + private writeDrainPromise: Promise | null = null; + private pathWriteLocks = new Map>(); + + constructor( + private readonly vaultSync: VaultSync, + private readonly options: NodeDiskMirrorOptions, + ) { + this.rootDir = nodePath.resolve(options.rootDir); + this.configDir = options.configDir ?? ".obsidian"; + this.maxFileSize = options.maxFileSizeKB * 1024; + } + + startMapObservers(): void { + if (this.mapObserverCleanups.length > 0) return; + + const metaObserver = (event: Y.YMapEvent) => { + if (isLocalOrigin(event.transaction.origin, this.vaultSync.provider)) { + return; + } + event.changes.keys.forEach((change, fileId) => { + const oldMeta = change.oldValue as import("../../../src/types").FileMeta | undefined; + const newMeta = this.vaultSync.meta.get(fileId); + const oldPath = typeof oldMeta?.path === "string" ? normalizeVaultPath(oldMeta.path) : null; + const newPath = typeof newMeta?.path === "string" ? normalizeVaultPath(newMeta.path) : null; + const wasDeleted = this.vaultSync.isFileMetaDeleted(oldMeta); + const isDeleted = this.vaultSync.isFileMetaDeleted(newMeta); + + if (newPath && isDeleted && !wasDeleted) { + void this.handleRemoteDelete(newPath); + return; + } + + if (newPath && !isDeleted && wasDeleted) { + this.scheduleWrite(newPath); + return; + } + + if (oldPath && newPath && oldPath !== newPath && !isDeleted) { + void this.handleRemoteRename(oldPath, newPath); + return; + } + + if ((change.action === "add" || change.action === "update") && newPath && !isDeleted) { + this.scheduleWrite(newPath); + } + }); + }; + + this.vaultSync.meta.observe(metaObserver); + this.mapObserverCleanups.push(() => this.vaultSync.meta.unobserve(metaObserver)); + + const afterTxnHandler = (txn: Y.Transaction) => { + if (isLocalOrigin(txn.origin, this.vaultSync.provider)) return; + + for (const [changedType] of txn.changed) { + if (!(changedType instanceof Y.Text)) continue; + const fileId = this.vaultSync.getFileIdForText(changedType); + if (!fileId) continue; + const meta = this.vaultSync.meta.get(fileId); + if (!meta || this.vaultSync.isFileMetaDeleted(meta)) continue; + this.scheduleWrite(meta.path); + } + }; + + this.vaultSync.ydoc.on("afterTransaction", afterTxnHandler); + this.mapObserverCleanups.push(() => this.vaultSync.ydoc.off("afterTransaction", afterTxnHandler)); + } + + async reconcileFromDisk(mode: ReconcileMode): Promise { + const disk = await this.scanMarkdownFiles(); + const result = this.vaultSync.reconcileVault( + disk.contents, + disk.presentPaths, + mode, + this.options.deviceName, + ); + + for (const path of result.createdOnDisk) { + this.queueImmediateWrite(path, `reconcile-create:${mode}`, true); + } + for (const path of result.updatedOnDisk) { + this.queueImmediateWrite(path, `reconcile-update:${mode}`, true); + } + await this.kickWriteDrain(); + return result; + } + + async startWatching(): Promise { + if (this.watcher) return; + + const watcher = chokidar.watch(".", { + cwd: this.rootDir, + persistent: true, + ignoreInitial: true, + alwaysStat: true, + awaitWriteFinish: { + stabilityThreshold: WATCHER_STABILITY_MS, + pollInterval: WATCHER_POLL_MS, + }, + ignored: (rawPath, stats) => this.shouldIgnoreWatchPath(rawPath, stats ?? null), + }); + + watcher + .on("add", (rawPath, stats) => this.onDiskAdd(rawPath, stats ?? null)) + .on("change", (rawPath, stats) => this.onDiskChange(rawPath, stats ?? null)) + .on("unlink", (rawPath) => this.onDiskDelete(rawPath)) + .on("error", (error) => { + console.error("[yaos-cli] chokidar watcher error:", error); + }); + + this.watcher = watcher; + await new Promise((resolve, reject) => { + watcher.once("ready", () => { + this.watcherReady = true; + resolve(); + }); + watcher.once("error", reject); + }); + } + + async stop(): Promise { + if (this.markdownDrainTimer) { + clearTimeout(this.markdownDrainTimer); + this.markdownDrainTimer = null; + } + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + await this.markdownDrainPromise; + await this.writeDrainPromise; + if (this.watcher) { + await this.watcher.close(); + this.watcher = null; + } + this.watcherReady = false; + for (const cleanup of this.mapObserverCleanups) { + cleanup(); + } + this.mapObserverCleanups = []; + this.dirtyMarkdownPaths.clear(); + this.deletedMarkdownPaths.clear(); + this.writeQueue.clear(); + this.forcedWritePaths.clear(); + this.suppressedPaths.clear(); + this.pathWriteLocks.clear(); + } + + getDebugSnapshot(): NodeDiskMirrorDebugSnapshot { + return { + watcherReady: this.watcherReady, + dirtyCount: this.dirtyMarkdownPaths.size, + deletedCount: this.deletedMarkdownPaths.size, + queuedWrites: Array.from(this.writeQueue), + suppressedCount: this.suppressedPaths.size, + }; + } + + private onDiskAdd(rawPath: string, stats: Stats | null): void { + const path = this.normalizeEventPath(rawPath); + if (!path || this.shouldIgnoreNormalizedPath(path, stats)) return; + this.markMarkdownDirty(path, "create"); + } + + private onDiskChange(rawPath: string, stats: Stats | null): void { + const path = this.normalizeEventPath(rawPath); + if (!path || this.shouldIgnoreNormalizedPath(path, stats)) return; + this.markMarkdownDirty(path, "modify"); + } + + private onDiskDelete(rawPath: string): void { + const path = this.normalizeEventPath(rawPath); + if (!path || !this.isMarkdownPathSyncable(path)) return; + this.deletedMarkdownPaths.add(path); + this.dirtyMarkdownPaths.delete(path); + this.lastMarkdownDirtyAt = Date.now(); + this.scheduleMarkdownDrain(); + } + + private markMarkdownDirty(path: string, reason: DirtyReason): void { + const previous = this.dirtyMarkdownPaths.get(path); + if (previous !== "create") { + this.dirtyMarkdownPaths.set(path, reason); + } + this.deletedMarkdownPaths.delete(path); + this.lastMarkdownDirtyAt = Date.now(); + this.scheduleMarkdownDrain(); + } + + private scheduleMarkdownDrain(): void { + if (this.markdownDrainTimer) { + clearTimeout(this.markdownDrainTimer); + } + + this.markdownDrainTimer = setTimeout(() => { + this.markdownDrainTimer = null; + const sinceLastDirty = Date.now() - this.lastMarkdownDirtyAt; + if (sinceLastDirty < MARKDOWN_DIRTY_SETTLE_MS) { + this.scheduleMarkdownDrain(); + return; + } + void this.kickMarkdownDrain(); + }, MARKDOWN_DIRTY_SETTLE_MS); + } + + private kickMarkdownDrain(): Promise { + if (this.markdownDrainPromise) return this.markdownDrainPromise; + this.markdownDrainPromise = this.drainDirtyMarkdownPaths().finally(() => { + this.markdownDrainPromise = null; + if (this.dirtyMarkdownPaths.size > 0 || this.deletedMarkdownPaths.size > 0) { + this.scheduleMarkdownDrain(); + } + }); + return this.markdownDrainPromise; + } + + private async drainDirtyMarkdownPaths(): Promise { + if (this.dirtyMarkdownPaths.size === 0 && this.deletedMarkdownPaths.size === 0) return; + + const batchDirty = Array.from(this.dirtyMarkdownPaths.entries()); + const batchDeletes = Array.from(this.deletedMarkdownPaths); + this.dirtyMarkdownPaths.clear(); + this.deletedMarkdownPaths.clear(); + + const survivingDeletes = new Set(); + for (const path of batchDeletes) { + if (!this.consumeDeleteSuppression(path)) { + survivingDeletes.add(path); + } + } + + const dirtyFiles: DirtyFile[] = []; + for (const [path, reason] of batchDirty) { + const current = await this.readDirtyFile(path, reason); + if (!current) continue; + const suppressed = reason === "create" + ? await this.shouldSuppressWriteEvent(path, "create", current.stats) + : await this.shouldSuppressWriteEvent(path, "modify", current.stats); + if (suppressed) continue; + dirtyFiles.push(current); + } + + const renamePairs = this.inferRenamePairs( + dirtyFiles.filter((entry) => entry.reason === "create"), + survivingDeletes, + ); + + for (const [oldPath, newPath] of renamePairs) { + this.vaultSync.queueRename(oldPath, newPath); + survivingDeletes.delete(oldPath); + } + + for (const path of survivingDeletes) { + this.vaultSync.handleDelete(path, this.options.deviceName); + } + + for (const dirtyFile of dirtyFiles) { + if (dirtyFile.reason === "create" && this.vaultSync.isPendingRenameTarget(dirtyFile.path)) { + continue; + } + await this.syncFileFromDisk(dirtyFile.path, dirtyFile.content); + } + } + + private inferRenamePairs( + creates: DirtyFile[], + deletes: Set, + ): Map { + const renames = new Map(); + for (const create of creates) { + const exactBasenameMatches = this.findRenameCandidates(create, deletes, true); + const candidates = exactBasenameMatches.length === 1 + ? exactBasenameMatches + : this.findRenameCandidates(create, deletes, false); + if (candidates.length !== 1) continue; + const oldPath = candidates[0]; + if (!oldPath) continue; + renames.set(oldPath, create.path); + deletes.delete(oldPath); + } + return renames; + } + + private findRenameCandidates( + create: DirtyFile, + deletes: Set, + requireSameBasename: boolean, + ): string[] { + const matches: string[] = []; + const newBasename = nodePath.posix.basename(create.path); + for (const oldPath of deletes) { + if (requireSameBasename && nodePath.posix.basename(oldPath) !== newBasename) { + continue; + } + const oldText = this.vaultSync.getTextForPath(oldPath); + if (!oldText) continue; + if (oldText.toJSON() !== create.content) continue; + matches.push(oldPath); + } + return matches; + } + + private async readDirtyFile(path: string, reason: DirtyReason): Promise { + const absolutePath = this.toAbsolutePath(path); + try { + const [stats, content] = await Promise.all([ + fs.stat(absolutePath), + fs.readFile(absolutePath, "utf8"), + ]); + if (this.maxFileSize > 0 && content.length > this.maxFileSize) { + this.log( + `syncFileFromDisk: skipping "${path}" (${Math.round(content.length / 1024)} KB exceeds limit)`, + ); + return null; + } + return { path, reason, content, stats }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[yaos-cli] failed reading dirty file "${path}":`, error); + } + return null; + } + } + + private async syncFileFromDisk(path: string, content: string): Promise { + if (!this.isMarkdownPathSyncable(path)) return; + if (this.options.externalEditPolicy === "never") { + this.log(`syncFileFromDisk: skipping "${path}" (external edit policy: never)`); + return; + } + + const existingText = this.vaultSync.getTextForPath(path); + if (existingText) { + const crdtContent = existingText.toJSON(); + if (crdtContent === content) return; + if (this.shouldBlockFrontmatterIngest(path, crdtContent, content, "disk-to-crdt")) { + return; + } + applyDiffToYText(existingText, crdtContent, content, "disk-sync"); + return; + } + + if (this.shouldBlockFrontmatterIngest(path, null, content, "disk-to-crdt-seed")) { + return; + } + + this.vaultSync.ensureFile(path, content, this.options.deviceName); + } + + private shouldBlockFrontmatterIngest( + path: string, + previousContent: string | null, + nextContent: string, + reason: string, + ): boolean { + if (!this.options.frontmatterGuardEnabled) return false; + const validation = validateFrontmatterTransition(previousContent, nextContent); + if (!isFrontmatterBlocked(validation)) return false; + this.log( + `frontmatter ingest blocked for "${path}" ` + + `(${validation.reasons.join(", ") || validation.risk}) [${reason}]`, + ); + return true; + } + + private scheduleWrite(path: string): void { + path = normalizeVaultPath(path); + const existing = this.debounceTimers.get(path); + if (existing) clearTimeout(existing); + const delay = this.writeQueue.size >= WRITE_BURST_THRESHOLD + ? WRITE_DEBOUNCE_BURST_MS + : WRITE_DEBOUNCE_MS; + this.debounceTimers.set( + path, + setTimeout(() => { + this.debounceTimers.delete(path); + this.writeQueue.add(path); + void this.kickWriteDrain(); + }, delay), + ); + } + + private queueImmediateWrite(path: string, reason: string, force = false): void { + path = normalizeVaultPath(path); + if (force) { + this.forcedWritePaths.add(path); + } + this.writeQueue.add(path); + this.log(`queueImmediateWrite: "${path}" (${reason}${force ? ", forced" : ""})`); + void this.kickWriteDrain(); + } + + private kickWriteDrain(): Promise { + if (this.writeDrainPromise) return this.writeDrainPromise; + this.writeDrainPromise = this.drainWriteQueue().finally(() => { + this.writeDrainPromise = null; + }); + return this.writeDrainPromise; + } + + private async drainWriteQueue(): Promise { + while (this.writeQueue.size > 0) { + if (this.writeQueue.size > WRITE_BURST_THRESHOLD) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + const batch: string[] = []; + for (const path of this.writeQueue) { + batch.push(path); + if (batch.length >= MAX_CONCURRENT_WRITES) break; + } + for (const path of batch) { + this.writeQueue.delete(path); + } + + await Promise.all( + batch.map((path) => { + const force = this.forcedWritePaths.delete(path); + return this.flushWrite(path, force); + }), + ); + } + } + + private async flushWrite(path: string, force = false): Promise { + path = normalizeVaultPath(path); + return this.runPathWriteLocked(path, () => this.flushWriteUnlocked(path, force)); + } + + private async flushWriteUnlocked(path: string, _force: boolean): Promise { + const ytext = this.vaultSync.getTextForPath(path); + if (!ytext) return; + const content = ytext.toJSON(); + const absolutePath = this.toAbsolutePath(path); + const currentContent = await this.readFileIfExists(absolutePath); + if (currentContent === content) return; + if (this.shouldBlockFrontmatterWrite(path, currentContent, content)) return; + + await fs.mkdir(nodePath.dirname(absolutePath), { recursive: true }); + await this.suppressWrite(path, content); + await fs.writeFile(absolutePath, content, "utf8"); + } + + private shouldBlockFrontmatterWrite( + path: string, + previousContent: string | null, + nextContent: string, + ): boolean { + if (!this.options.frontmatterGuardEnabled) return false; + const validation = validateFrontmatterTransition(previousContent, nextContent); + if (!isFrontmatterBlocked(validation)) return false; + this.log( + `frontmatter write blocked for "${path}" ` + + `(${validation.reasons.join(", ") || validation.risk})`, + ); + return true; + } + + private async handleRemoteDelete(path: string): Promise { + path = normalizeVaultPath(path); + this.deletedMarkdownPaths.delete(path); + this.dirtyMarkdownPaths.delete(path); + this.writeQueue.delete(path); + this.forcedWritePaths.delete(path); + const timer = this.debounceTimers.get(path); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(path); + } + + this.suppressDelete(path); + const absolutePath = this.toAbsolutePath(path); + try { + await fs.rm(absolutePath, { force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[yaos-cli] remote delete failed for "${path}":`, error); + } + } + } + + private async handleRemoteRename(oldPath: string, newPath: string): Promise { + oldPath = normalizeVaultPath(oldPath); + newPath = normalizeVaultPath(newPath); + if (oldPath === newPath) return; + + this.deletedMarkdownPaths.delete(oldPath); + this.dirtyMarkdownPaths.delete(oldPath); + this.dirtyMarkdownPaths.delete(newPath); + this.writeQueue.delete(oldPath); + this.forcedWritePaths.delete(oldPath); + const oldTimer = this.debounceTimers.get(oldPath); + if (oldTimer) { + clearTimeout(oldTimer); + this.debounceTimers.delete(oldPath); + } + + const newContent = this.vaultSync.getTextForPath(newPath)?.toJSON() ?? this.vaultSync.getTextForPath(oldPath)?.toJSON() ?? null; + if (newContent != null) { + await this.suppressWrite(newPath, newContent); + } + this.suppressDelete(oldPath); + + const oldAbsolutePath = this.toAbsolutePath(oldPath); + const newAbsolutePath = this.toAbsolutePath(newPath); + try { + await fs.mkdir(nodePath.dirname(newAbsolutePath), { recursive: true }); + await fs.rename(oldAbsolutePath, newAbsolutePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + this.log(`remote rename fell back to write for "${oldPath}" -> "${newPath}"`); + } + } + this.queueImmediateWrite(newPath, "remote-rename", true); + } + + private consumeDeleteSuppression(path: string): boolean { + path = normalizeVaultPath(path); + const entry = this.getActiveSuppression(path); + if (!entry) return false; + this.suppressedPaths.delete(path); + return entry.kind === "delete"; + } + + private async shouldSuppressWriteEvent( + path: string, + event: "modify" | "create", + stats: Stats | null, + ): Promise { + path = normalizeVaultPath(path); + const entry = this.getActiveSuppression(path); + if (!entry) return false; + if (entry.kind !== "write") { + this.suppressedPaths.delete(path); + return false; + } + + if ( + stats + && typeof entry.expectedBytes === "number" + && stats.size !== entry.expectedBytes + ) { + this.suppressedPaths.delete(path); + this.log( + `suppression: "${path}" ${event} size mismatch ` + + `(expected=${entry.expectedBytes}, observed=${stats.size})`, + ); + return false; + } + + try { + const content = await fs.readFile(this.toAbsolutePath(path), "utf8"); + const fingerprint = this.fingerprintContent(content); + if ( + fingerprint.bytes === entry.expectedBytes + && fingerprint.hash === entry.expectedHash + ) { + this.suppressedPaths.delete(path); + return true; + } + } catch { + // Fall through and let normal sync handle it. + } + + this.suppressedPaths.delete(path); + return false; + } + + private getActiveSuppression(path: string): SuppressionEntry | null { + path = normalizeVaultPath(path); + const entry = this.suppressedPaths.get(path); + if (!entry) return null; + if (Date.now() < entry.expiresAt) { + return entry; + } + this.suppressedPaths.delete(path); + return null; + } + + private async suppressWrite(path: string, content: string): Promise { + const fingerprint = this.fingerprintContent(content); + this.suppressedPaths.set(normalizeVaultPath(path), { + kind: "write", + expiresAt: Date.now() + SUPPRESS_MS, + expectedBytes: fingerprint.bytes, + expectedHash: fingerprint.hash, + }); + } + + private suppressDelete(path: string): void { + this.suppressedPaths.set(normalizeVaultPath(path), { + kind: "delete", + expiresAt: Date.now() + SUPPRESS_MS, + }); + } + + private fingerprintContent(content: string): { bytes: number; hash: string } { + const bytes = Buffer.byteLength(content, "utf8"); + const hash = createHash("sha256").update(content, "utf8").digest("hex"); + return { bytes, hash }; + } + + private runPathWriteLocked(path: string, work: () => Promise): Promise { + const previous = this.pathWriteLocks.get(path) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(work); + let tracked: Promise; + tracked = next.finally(() => { + if (this.pathWriteLocks.get(path) === tracked) { + this.pathWriteLocks.delete(path); + } + }); + this.pathWriteLocks.set(path, tracked); + return tracked; + } + + private async scanMarkdownFiles(): Promise { + const contents = new Map(); + const presentPaths = new Set(); + await this.scanDirectory("", contents, presentPaths); + return { contents, presentPaths }; + } + + private async scanDirectory( + relativeDir: string, + contents: Map, + presentPaths: Set, + ): Promise { + const absoluteDir = relativeDir + ? this.toAbsolutePath(relativeDir) + : this.rootDir; + let entries: Dirent[]; + try { + entries = await fs.readdir(absoluteDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + + for (const entry of entries) { + const relativePath = normalizeVaultPath( + relativeDir ? `${relativeDir}/${entry.name}` : entry.name, + ); + const absolutePath = this.toAbsolutePath(relativePath); + const stats = await fs.lstat(absolutePath); + if (stats.isDirectory()) { + if (isExcluded(`${relativePath}/`, this.options.excludePatterns, this.configDir)) { + continue; + } + await this.scanDirectory(relativePath, contents, presentPaths); + continue; + } + if (!stats.isFile()) continue; + if (!this.isMarkdownPathSyncable(relativePath)) continue; + presentPaths.add(relativePath); + const content = await fs.readFile(absolutePath, "utf8"); + if (this.maxFileSize > 0 && content.length > this.maxFileSize) { + continue; + } + contents.set(relativePath, content); + } + } + + private shouldIgnoreWatchPath(rawPath: string, stats: Stats | null): boolean { + const path = this.normalizeEventPath(rawPath); + if (!path) return false; + return this.shouldIgnoreNormalizedPath(path, stats); + } + + private shouldIgnoreNormalizedPath(path: string, stats: Stats | null): boolean { + if (stats?.isDirectory()) { + return isExcluded(`${path}/`, this.options.excludePatterns, this.configDir); + } + return !this.isMarkdownPathSyncable(path); + } + + private isMarkdownPathSyncable(path: string): boolean { + return isMarkdownSyncable(path, this.options.excludePatterns, this.configDir); + } + + private normalizeEventPath(rawPath: string): string | null { + const normalized = normalizeVaultPath(rawPath); + return normalized.length > 0 ? normalized : null; + } + + private toAbsolutePath(vaultPath: string): string { + const parts = normalizeVaultPath(vaultPath) + .split("/") + .filter((segment) => segment.length > 0); + return nodePath.join(this.rootDir, ...parts); + } + + private async readFileIfExists(absolutePath: string): Promise { + try { + return await fs.readFile(absolutePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } + } + + private log(message: string): void { + if (this.options.debug) { + console.debug(`[yaos-cli:disk] ${message}`); + } + } +} diff --git a/packages/cli/src/nodeVaultSync.ts b/packages/cli/src/nodeVaultSync.ts new file mode 100644 index 0000000..933fe19 --- /dev/null +++ b/packages/cli/src/nodeVaultSync.ts @@ -0,0 +1,187 @@ +import type { VaultSyncSettings } from "../../../src/settings"; +import { + VaultSync, + type ReconcileMode, + type ReconcileResult, + type VaultSyncOptions, + type VaultSyncPersistence, +} from "../../../src/sync/vaultSync"; +import { NodeDiskMirror } from "./nodeDiskMirror"; +import type { ResolvedCliConfig, RuntimeCliConfig } from "./config"; + +const NOOP_INDEXEDDB_ERROR = new Error("IndexedDB is unavailable in the headless Node runtime"); + +export interface HeadlessStartupResult { + localLoaded: boolean; + providerSynced: boolean; + mode: ReconcileMode; + reconcileResult: ReconcileResult; +} + +export function createNodeVaultSync( + config: ResolvedCliConfig, + options?: Omit, +): VaultSync { + return new VaultSync(toVaultSyncSettings(config), { + ...options, + persistenceFactory: () => createNoopPersistence(), + logPersistenceOpenError: false, + }); +} + +export class HeadlessYaosClient { + readonly vaultSync: VaultSync; + readonly diskMirror: NodeDiskMirror; + private reconcileInFlight = false; + private reconcilePending = false; + private awaitingFirstProviderSyncAfterStartup = false; + private lastReconciledGeneration = 0; + private reconnectionHandlerInstalled = false; + private stopped = false; + + constructor(private readonly config: RuntimeCliConfig) { + this.vaultSync = createNodeVaultSync(config); + this.diskMirror = new NodeDiskMirror(this.vaultSync, { + rootDir: config.dir, + deviceName: config.deviceName, + debug: config.debug, + excludePatterns: config.excludePatterns + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + maxFileSizeKB: config.maxFileSizeKB, + externalEditPolicy: config.externalEditPolicy, + frontmatterGuardEnabled: config.frontmatterGuardEnabled, + configDir: config.configDir, + }); + } + + async startup(options: { watch: boolean }): Promise { + const localLoaded = await this.vaultSync.waitForLocalPersistence(); + const providerSynced = await this.vaultSync.waitForProviderSync(); + if (this.vaultSync.fatalAuthError) { + throw new Error(formatFatalAuthError(this.vaultSync)); + } + + const mode = this.vaultSync.getSafeReconcileMode(); + const reconcileResult = await this.diskMirror.reconcileFromDisk(mode); + this.lastReconciledGeneration = this.vaultSync.connectionGeneration; + this.awaitingFirstProviderSyncAfterStartup = !providerSynced; + + if (options.watch) { + this.diskMirror.startMapObservers(); + this.installReconnectionHandler(); + await this.diskMirror.startWatching(); + } + + return { + localLoaded, + providerSynced, + mode, + reconcileResult, + }; + } + + async stop(): Promise { + this.stopped = true; + await this.diskMirror.stop(); + this.vaultSync.destroy(); + } + + getStatus(): Record { + return { + host: this.config.host, + vaultId: this.config.vaultId, + deviceName: this.config.deviceName, + connected: this.vaultSync.connected, + localReady: this.vaultSync.localReady, + connectionGeneration: this.vaultSync.connectionGeneration, + storedSchemaVersion: this.vaultSync.storedSchemaVersion, + safeReconcileMode: this.vaultSync.getSafeReconcileMode(), + fatalAuthError: this.vaultSync.fatalAuthError, + fatalAuthCode: this.vaultSync.fatalAuthCode, + diskMirror: this.diskMirror.getDebugSnapshot(), + }; + } + + private installReconnectionHandler(): void { + if (this.reconnectionHandlerInstalled) return; + this.reconnectionHandlerInstalled = true; + this.vaultSync.onProviderSync((generation) => { + if (this.stopped) return; + if (this.awaitingFirstProviderSyncAfterStartup) { + this.awaitingFirstProviderSyncAfterStartup = false; + if (this.reconcileInFlight) { + this.reconcilePending = true; + return; + } + void this.runReconnectReconciliation(generation); + return; + } + if (generation <= this.lastReconciledGeneration) { + return; + } + if (this.reconcileInFlight) { + this.reconcilePending = true; + return; + } + void this.runReconnectReconciliation(generation); + }); + } + + private async runReconnectReconciliation(generation: number): Promise { + if (this.stopped) return; + this.reconcileInFlight = true; + try { + await this.diskMirror.reconcileFromDisk("authoritative"); + this.lastReconciledGeneration = generation; + this.awaitingFirstProviderSyncAfterStartup = false; + } finally { + this.reconcileInFlight = false; + if (!this.reconcilePending || this.stopped) return; + this.reconcilePending = false; + if (this.vaultSync.connectionGeneration > this.lastReconciledGeneration) { + void this.runReconnectReconciliation(this.vaultSync.connectionGeneration); + } + } + } +} + +function toVaultSyncSettings(config: ResolvedCliConfig): VaultSyncSettings { + return { + host: config.host ?? "", + token: config.token ?? "", + vaultId: config.vaultId ?? "", + deviceName: config.deviceName, + debug: config.debug, + frontmatterGuardEnabled: config.frontmatterGuardEnabled, + excludePatterns: config.excludePatterns, + maxFileSizeKB: config.maxFileSizeKB, + externalEditPolicy: config.externalEditPolicy, + enableAttachmentSync: false, + attachmentSyncExplicitlyConfigured: true, + maxAttachmentSizeKB: 0, + attachmentConcurrency: 1, + showRemoteCursors: false, + updateRepoUrl: "", + updateRepoBranch: "main", + }; +} + +function createNoopPersistence(): VaultSyncPersistence { + const db = Promise.reject(NOOP_INDEXEDDB_ERROR); + db.catch(() => undefined); + return { + once() { + // Headless v1 intentionally skips local CRDT persistence. + }, + destroy() { + return; + }, + _db: db, + }; +} + +function formatFatalAuthError(vaultSync: VaultSync): string { + return `Provider rejected the connection (${vaultSync.fatalAuthCode ?? "unknown"})`; +} diff --git a/packages/cli/src/shims.d.ts b/packages/cli/src/shims.d.ts new file mode 100644 index 0000000..b1b9798 --- /dev/null +++ b/packages/cli/src/shims.d.ts @@ -0,0 +1,2 @@ +declare module "js-yaml"; +declare module "qrcode"; diff --git a/packages/cli/tests/config.test.ts b/packages/cli/tests/config.test.ts new file mode 100644 index 0000000..15c7df6 --- /dev/null +++ b/packages/cli/tests/config.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import * as os from "node:os"; +import * as nodePath from "node:path"; +import test from "node:test"; +import { requireRuntimeConfig, resolveCliConfig } from "../src/config"; + +const ENV_KEYS = [ + "XDG_CONFIG_HOME", + "YAOS_HOST", + "YAOS_TOKEN", + "YAOS_VAULT_ID", + "YAOS_DIR", + "YAOS_DEVICE_NAME", + "YAOS_DEBUG", + "YAOS_EXCLUDE_PATTERNS", + "YAOS_MAX_FILE_SIZE_KB", + "YAOS_EXTERNAL_EDIT_POLICY", + "YAOS_FRONTMATTER_GUARD", + "YAOS_CONFIG_DIR", +] as const; + +test("resolveCliConfig applies CLI > env > file precedence", async () => { + const originalEnv = snapshotEnv(); + const tempRoot = await mkdtemp(nodePath.join(os.tmpdir(), "yaos-cli-config-")); + try { + process.env.XDG_CONFIG_HOME = tempRoot; + const configDir = nodePath.join(tempRoot, "yaos"); + await mkdir(configDir, { recursive: true }); + await writeFile( + nodePath.join(configDir, "cli.json"), + JSON.stringify({ + host: "https://file.example", + token: "file-token", + vaultId: "file-vault", + dir: "/file/vault", + deviceName: "file-device", + debug: false, + excludePatterns: "from-file/", + maxFileSizeKB: 111, + externalEditPolicy: "never", + frontmatterGuardEnabled: false, + configDir: ".obsidian-file", + }), + "utf8", + ); + + process.env.YAOS_HOST = "https://env.example"; + process.env.YAOS_TOKEN = "env-token"; + process.env.YAOS_VAULT_ID = "env-vault"; + process.env.YAOS_DIR = "/env/vault"; + process.env.YAOS_DEVICE_NAME = "env-device"; + process.env.YAOS_DEBUG = "true"; + process.env.YAOS_EXCLUDE_PATTERNS = "from-env/"; + process.env.YAOS_MAX_FILE_SIZE_KB = "222"; + process.env.YAOS_EXTERNAL_EDIT_POLICY = "closed-only"; + process.env.YAOS_FRONTMATTER_GUARD = "true"; + process.env.YAOS_CONFIG_DIR = ".obsidian-env"; + + const resolved = await resolveCliConfig({ + host: "https://flag.example", + token: "flag-token", + vaultId: "flag-vault", + dir: "/flag/vault", + deviceName: "flag-device", + debug: false, + excludePatterns: "from-flag/", + maxFileSizeKB: 333, + externalEditPolicy: "always", + configDir: ".obsidian-flag", + }); + + assert.equal(resolved.host, "https://flag.example"); + assert.equal(resolved.token, "flag-token"); + assert.equal(resolved.vaultId, "flag-vault"); + assert.equal(resolved.dir, "/flag/vault"); + assert.equal(resolved.deviceName, "flag-device"); + assert.equal(resolved.debug, false); + assert.equal(resolved.excludePatterns, "from-flag/"); + assert.equal(resolved.maxFileSizeKB, 333); + assert.equal(resolved.externalEditPolicy, "always"); + assert.equal(resolved.frontmatterGuardEnabled, true); + assert.equal(resolved.configDir, ".obsidian-flag"); + } finally { + restoreEnv(originalEnv); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("requireRuntimeConfig rejects missing required fields", () => { + assert.throws( + () => requireRuntimeConfig({ + deviceName: "device", + debug: false, + excludePatterns: "", + maxFileSizeKB: 2048, + externalEditPolicy: "always", + frontmatterGuardEnabled: true, + configDir: ".obsidian", + configPath: "/tmp/cli.json", + }, { requireDir: true }), + /Missing required configuration: host, token, vaultId, dir/, + ); +}); + +function snapshotEnv(): Record { + return Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +} + +function restoreEnv(snapshot: Record): void { + for (const key of ENV_KEYS) { + const value = snapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..7bb0264 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/src/sync/exclude.ts b/src/sync/exclude.ts index c392b24..06fc8bd 100644 --- a/src/sync/exclude.ts +++ b/src/sync/exclude.ts @@ -1,14 +1,8 @@ -/** Paths that are always excluded, regardless of user settings. */ -function normalizePrefix(path: string): string { - return path - .replace(/\\/g, "/") - .replace(/\/{2,}/g, "/") - .replace(/^\.\//, "") - .replace(/^\/+/, ""); -} +import { normalizeVaultPath } from "../utils/normalizeVaultPath"; +/** Paths that are always excluded, regardless of user settings. */ function alwaysExcludedPrefixes(configDir: string): string[] { - const normalizedConfigDir = normalizePrefix(configDir).replace(/\/$/, ""); + const normalizedConfigDir = normalizeVaultPath(configDir).replace(/\/$/, ""); return [ `${normalizedConfigDir}/`, ".trash/", @@ -26,12 +20,12 @@ function alwaysExcludedPrefixes(configDir: string): string[] { * @returns true if the path matches any exclude pattern */ export function isExcluded(path: string, patterns: string[], configDir: string): boolean { - const normalizedPath = normalizePrefix(path); + const normalizedPath = normalizeVaultPath(path); for (const prefix of alwaysExcludedPrefixes(configDir)) { if (normalizedPath.startsWith(prefix)) return true; } for (const prefix of patterns) { - if (normalizedPath.startsWith(normalizePrefix(prefix))) return true; + if (normalizedPath.startsWith(normalizeVaultPath(prefix))) return true; } return false; } diff --git a/src/sync/snapshotClient.ts b/src/sync/snapshotClient.ts index f3a977a..0dbaf2b 100644 --- a/src/sync/snapshotClient.ts +++ b/src/sync/snapshotClient.ts @@ -16,6 +16,7 @@ import type { FileMeta, BlobRef } from "../types"; import { appendTraceParams, type TraceHttpContext } from "../debug/trace"; import { obsidianRequest } from "../utils/http"; import { yTextToString } from "../utils/format"; +import { normalizeVaultPath } from "../utils/normalizeVaultPath"; // ------------------------------------------------------------------- // Types (mirrors server SnapshotIndex) @@ -64,13 +65,6 @@ export interface SnapshotDiff { blobsChanged: Array<{ path: string; snapshotHash: string; currentHash: string }>; } -function normalizeVaultPath(path: string): string { - return path - .replace(/\\/g, "/") - .replace(/\/{2,}/g, "/") - .replace(/^\.\//, "") - .replace(/^\/+/, ""); -} function getStoredSchemaVersion(doc: Y.Doc): number | null { const stored = doc.getMap("sys").get("schemaVersion"); diff --git a/src/sync/vaultSync.ts b/src/sync/vaultSync.ts index 37d0d0d..f75066f 100644 --- a/src/sync/vaultSync.ts +++ b/src/sync/vaultSync.ts @@ -1,12 +1,12 @@ import * as Y from "yjs"; import YSyncProvider from "y-partyserver/provider"; import { IndexeddbPersistence } from "y-indexeddb"; -import { normalizePath } from "obsidian"; import { type FileMeta, type BlobRef, type BlobMeta, type BlobTombstone, ORIGIN_SEED } from "../types"; import type { VaultSyncSettings } from "../settings"; import type { TraceHttpContext, TraceRecord } from "../debug/trace"; import { randomBase64Url } from "../utils/base64url"; import { formatUnknown } from "../utils/format"; +import { normalizeVaultPath } from "../utils/normalizeVaultPath"; /** Current schema version. Stored in sys.schemaVersion. */ export const SCHEMA_VERSION = 2; @@ -27,6 +27,33 @@ const MAX_BACKOFF_TIME_MS = 30_000; /** Debounce window for batching rename events (folder renames). */ const RENAME_BATCH_MS = 50; +/** Persistence adapter contract shared by browser and headless clients. */ +export interface VaultSyncPersistenceDatabase { + addEventListener( + type: "error", + listener: (event: { target: { error?: unknown } | null }) => void, + ): void; +} + +export interface VaultSyncPersistence { + once(event: "synced", listener: () => void): void; + destroy(): Promise | void; + _db: Promise; +} + +export type VaultSyncPersistenceFactory = (name: string, doc: Y.Doc) => VaultSyncPersistence; + +export interface VaultSyncOptions { + traceContext?: TraceHttpContext; + trace?: TraceRecord; + persistenceFactory?: VaultSyncPersistenceFactory; + logPersistenceOpenError?: boolean; +} + +function createIndexedDbPersistence(name: string, doc: Y.Doc): VaultSyncPersistence { + return new IndexeddbPersistence(name, doc) as unknown as VaultSyncPersistence; +} + /** Reconciliation mode determines what operations are safe. */ export type ReconcileMode = "conservative" | "authoritative"; type FatalAuthCode = "unauthorized" | "server_misconfigured" | "unclaimed" | "update_required"; @@ -37,7 +64,6 @@ interface FatalAuthMessage { roomSchemaVersion: number | null; reason: string | null; } - const FATAL_AUTH_CODES = new Set([ "unauthorized", "server_misconfigured", @@ -102,7 +128,7 @@ interface IndexedDbErrorDetails { export class VaultSync { readonly ydoc: Y.Doc; readonly provider: YSyncProvider; - readonly persistence: IndexeddbPersistence; + readonly persistence: VaultSyncPersistence; readonly pathToId: Y.Map; readonly idToText: Y.Map; @@ -165,10 +191,7 @@ export class VaultSync { constructor( settings: VaultSyncSettings, - options?: { - traceContext?: TraceHttpContext; - trace?: TraceRecord; - }, + options?: VaultSyncOptions, ) { this.debug = settings.debug; this._device = settings.deviceName || undefined; @@ -194,19 +217,22 @@ export class VaultSync { this.log(`IndexedDB database: ${idbName}`); // Start both persistence and provider in parallel. - this.persistence = new IndexeddbPersistence(idbName, this.ydoc); + const persistenceFactory = options?.persistenceFactory ?? createIndexedDbPersistence; + this.persistence = persistenceFactory(idbName, this.ydoc); + const persistenceDb = this.persistence._db; // Catch IndexedDB open/write failures (unavailable, quota, permissions). // y-indexeddb's internal _db promise rejects if IDB can't open. // We also listen for unhandled IDB transaction errors. - (this.persistence as unknown as { _db: Promise })._db - .catch((err: unknown) => { - this.captureIndexedDbError(err, "open"); + persistenceDb.catch((err: unknown) => { + this.captureIndexedDbError(err, "open"); + if (options?.logPersistenceOpenError !== false) { console.error("[yaos] IndexedDB failed to open:", err); - }); + } + }); - (this.persistence as unknown as { _db: Promise })._db - .then((db: IDBDatabase) => { + persistenceDb + .then((db) => { db.addEventListener("error", (event) => { const target = event.target as { error?: unknown } | null; this.captureIndexedDbError( @@ -307,13 +333,12 @@ export class VaultSync { }); // Also resolve (false) if IDB errors out after we started waiting - (this.persistence as unknown as { _db: Promise })._db - .catch(() => { - clearTimeout(timeout); - this.captureIndexedDbError(new Error("IndexedDB failed during waitForLocalPersistence"), "wait"); - this.log("IndexedDB errored during wait — proceeding without cache"); - resolve(false); - }); + this.persistence._db.catch(() => { + clearTimeout(timeout); + this.captureIndexedDbError(new Error("IndexedDB failed during waitForLocalPersistence"), "wait"); + this.log("IndexedDB errored during wait — proceeding without cache"); + resolve(false); + }); }); } @@ -426,7 +451,7 @@ export class VaultSync { /** Normalize a vault-relative path for consistent CRDT keys. */ private normPath(path: string): string { - return normalizePath(path); + return normalizeVaultPath(path); } isFileMetaDeleted(meta: FileMeta | undefined): boolean { diff --git a/src/utils/normalizeVaultPath.ts b/src/utils/normalizeVaultPath.ts new file mode 100644 index 0000000..cbab4b7 --- /dev/null +++ b/src/utils/normalizeVaultPath.ts @@ -0,0 +1,7 @@ +export function normalizeVaultPath(path: string): string { + return path + .replace(/\\/g, "/") + .replace(/\/{2,}/g, "/") + .replace(/^(\.\/)+/, "") + .replace(/^\/+/, ""); +} From b2f1471ea2948444f98f9e575a7633e585f70b84 Mon Sep 17 00:00:00 2001 From: enieuwy Date: Sun, 12 Apr 2026 13:33:56 +0800 Subject: [PATCH 2/3] Add WebSocket polyfill (ws) for Node.js runtime --- package-lock.json | 17 ++++++++++++++--- packages/cli/package.json | 4 +++- packages/cli/src/nodeVaultSync.ts | 3 +++ src/sync/vaultSync.ts | 2 ++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ce41cf..b30dcd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -801,6 +801,16 @@ "@types/estree": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.1", "dev": true, @@ -4709,7 +4719,6 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4955,13 +4964,15 @@ "version": "0.0.0", "dependencies": { "chokidar": "^4.0.3", - "commander": "^14.0.0" + "commander": "^14.0.0", + "ws": "^8.18.2" }, "bin": { - "yaos-cli": "dist/index.js" + "yaos-cli": "dist/index.cjs" }, "devDependencies": { "@types/node": "^16.11.6", + "@types/ws": "^8.18.0", "esbuild": "0.25.5", "typescript": "^5.8.3" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c45626..c4d0a09 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,10 +13,12 @@ }, "dependencies": { "chokidar": "^4.0.3", - "commander": "^14.0.0" + "commander": "^14.0.0", + "ws": "^8.18.2" }, "devDependencies": { "@types/node": "^16.11.6", + "@types/ws": "^8.18.0", "esbuild": "0.25.5", "typescript": "^5.8.3" } diff --git a/packages/cli/src/nodeVaultSync.ts b/packages/cli/src/nodeVaultSync.ts index 933fe19..8e33f27 100644 --- a/packages/cli/src/nodeVaultSync.ts +++ b/packages/cli/src/nodeVaultSync.ts @@ -1,3 +1,5 @@ +import WebSocket from "ws"; + import type { VaultSyncSettings } from "../../../src/settings"; import { VaultSync, @@ -24,6 +26,7 @@ export function createNodeVaultSync( ): VaultSync { return new VaultSync(toVaultSyncSettings(config), { ...options, +webSocketPolyfill: WebSocket as unknown as typeof globalThis.WebSocket, persistenceFactory: () => createNoopPersistence(), logPersistenceOpenError: false, }); diff --git a/src/sync/vaultSync.ts b/src/sync/vaultSync.ts index f75066f..27dec42 100644 --- a/src/sync/vaultSync.ts +++ b/src/sync/vaultSync.ts @@ -48,6 +48,7 @@ export interface VaultSyncOptions { trace?: TraceRecord; persistenceFactory?: VaultSyncPersistenceFactory; logPersistenceOpenError?: boolean; + webSocketPolyfill?: typeof WebSocket; } function createIndexedDbPersistence(name: string, doc: Y.Doc): VaultSyncPersistence { @@ -261,6 +262,7 @@ export class VaultSync { params, connect: true, maxBackoffTime: MAX_BACKOFF_TIME_MS, + WebSocketPolyfill: options?.webSocketPolyfill, }); // Track connection generations for reconnect detection From 0bb4cb43c592b61c4189fc672c154bb5effed01b Mon Sep 17 00:00:00 2001 From: enieuwy Date: Mon, 13 Apr 2026 09:31:47 +0800 Subject: [PATCH 3/3] Fix chokidar watcher: handle null-stats and array-wrap ignored callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented the filesystem watcher from working: 1. shouldIgnoreNormalizedPath treated null-stats paths as files. When chokidar calls _isIgnored for the root directory without stats, the path was checked against isMarkdownSyncable (returns false for directory names not ending in .md), causing the entire tree to be pruned. Fix: when stats are null, only ignore paths that are definitively non-markdown files (have an extension but not .md). 2. Chokidar's internal _isIgnored calls .map() on the ignored option. When ignored is a bare function, .map() throws TypeError (functions don't have .map), which is silently caught — the watcher starts but watches nothing. Wrapping in an array preserves the function through normalizeIgnored's type check. Also upgraded chokidar from 4.0.3 to 5.0.0. --- package-lock.json | 20 ++++++++++---------- packages/cli/package.json | 2 +- packages/cli/src/nodeDiskMirror.ts | 11 ++++++++++- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index b30dcd5..dfadaeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1374,15 +1374,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -3802,12 +3802,12 @@ "license": "MIT" }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -4963,7 +4963,7 @@ "name": "@yaos/cli", "version": "0.0.0", "dependencies": { - "chokidar": "^4.0.3", + "chokidar": "^5.0.0", "commander": "^14.0.0", "ws": "^8.18.2" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index c4d0a09..0fb0c67 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,7 +12,7 @@ "test": "node --import jiti/register --test tests/*.ts" }, "dependencies": { - "chokidar": "^4.0.3", + "chokidar": "^5.0.0", "commander": "^14.0.0", "ws": "^8.18.2" }, diff --git a/packages/cli/src/nodeDiskMirror.ts b/packages/cli/src/nodeDiskMirror.ts index ccbd9b6..4651d86 100644 --- a/packages/cli/src/nodeDiskMirror.ts +++ b/packages/cli/src/nodeDiskMirror.ts @@ -191,7 +191,7 @@ export class NodeDiskMirror { stabilityThreshold: WATCHER_STABILITY_MS, pollInterval: WATCHER_POLL_MS, }, - ignored: (rawPath, stats) => this.shouldIgnoreWatchPath(rawPath, stats ?? null), + ignored: [(rawPath, stats) => this.shouldIgnoreWatchPath(rawPath, stats ?? null)], }); watcher @@ -766,6 +766,15 @@ export class NodeDiskMirror { if (stats?.isDirectory()) { return isExcluded(`${path}/`, this.options.excludePatterns, this.configDir); } + // When stats are unavailable, only ignore if we can definitively determine + // the path is not a syncable markdown file. If there's no extension match + // but stats are missing, it might be a directory — don't prune it. + if (stats === null) { + // Cannot determine if directory; only ignore known non-markdown files + // that have an extension (i.e., are definitely files). + if (path.includes(".") && !path.endsWith(".md")) return true; + return false; + } return !this.isMarkdownPathSyncable(path); }