Test Security Seed refresh in Real Obsidian

This commit is contained in:
vorotamoroz
2026-07-25 03:49:18 +00:00
parent 0d68b6530f
commit a7bc337fd1
9 changed files with 1005 additions and 23 deletions
+1
View File
@@ -64,6 +64,7 @@
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
"test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts",
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
"test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts",
+11 -2
View File
@@ -55,6 +55,7 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc
```bash
npm run test:e2e:obsidian:focused -- settings-ui
npm run test:e2e:obsidian:focused -- two-vault-sync
npm run test:e2e:obsidian:focused -- security-seed-reconnect
```
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
@@ -101,7 +102,7 @@ The same workflow checks the two remote-activity status boundaries. It first hol
`test:e2e:obsidian:couchdb-manual-setup-workflow` follows the visible onboarding path for the first device when no Setup URI is available. It enters end-to-end encryption and CouchDB details, runs the read-only `Check server requirements` step, requires the prepared fixture to pass without applying a server fix, and lets the onboarding connection test create the named database. After Rebuild completes on the first device, it creates an ordinary note, asks that working device to generate a Setup URI for a second device, completes Fetch there, and verifies a bidirectional note round-trip. The workflow captures each decision point and the expanded server-check result; password controls remain visually masked.
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, and the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option, and the Security Seed reconnect workflow captures each significant application state. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a decision for the first device. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode.
@@ -134,6 +135,12 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
`test:e2e:obsidian:security-seed-reconnect` is a focused CouchDB release-acceptance workflow. Device A first recognises an initial remote Security Seed, stops automatic replication while remaining open, and creates an unsent note. The runner replaces only the Security Seed in the managed remote synchronisation-parameter fixture. Device A must retain its deliberately stale cached value until the next one-shot synchronisation, refresh it before sending, and upload an HKDF-encrypted payload which uses the replacement value. A fresh device B must decrypt that note and send an encrypted note back; the original device A then receives the return journey with its Vault and isolated profile preserved. Desktop Obsidian may enforce a single application instance, so the two device sessions run sequentially after the same-process stale-cache assertion has completed.
The workflow creates a random dedicated database, records only SHA-256 Seed fingerprints, and never writes a Seed, passphrase, or CouchDB credentials to its result. It also requires the remote Seed and all other synchronisation parameters to remain unchanged after the replacement revision, rejects HKDF and Seed errors from either session, writes `security-seed-reconnect-result.json`, and verifies that every Obsidian process, temporary Vault, isolated profile, and database has been removed. The result file and stage screenshots are retained in `E2E_OBSIDIAN_DIAGNOSTICS_DIR`; the screenshots show ordinary Vault content, not settings or secrets. The strict cleanup workflow rejects `E2E_OBSIDIAN_KEEP_VAULT` and `E2E_OBSIDIAN_KEEP_COUCHDB`.
This proves in real Obsidian the plug-in behaviour shared by supported platforms, including the encrypted bidirectional round-trip and protection against a stale client restoring the old remote Seed. It does not verify iPadOS-specific background or reconnect lifecycle behaviour, and it does not count as Android device evidence. The workflow remains outside `test:e2e:obsidian:local-suite` because it is a focused release-acceptance check.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
@@ -204,12 +211,14 @@ Useful environment variables:
- `LIVESYNC_CLI_COMMAND`: optional LiveSync CLI executable and prefix arguments used by the CLI-to-Obsidian compatibility check. The default is the locally built CLI.
- `E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT`: optional cache directory containing the exact pinned 0.25.83 plug-in artefacts. Cached files are always checksum-verified.
- `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT`: directory containing the built 1.0 target `main.js`, `manifest.json`, and `styles.css`; default is the repository root.
- `E2E_OBSIDIAN_ARTIFACT_ROOT`: directory containing the plug-in artefact installed by a direct scenario invocation; default is the repository root.
- `E2E_OBSIDIAN_ARTIFACT_REVISION`: exact source commit recorded by the Security Seed reconnect result when `E2E_OBSIDIAN_ARTIFACT_ROOT` is a downloaded artefact rather than a Git worktree.
- `E2E_OBSIDIAN_FILE_TIMEOUT_MS`: timeout for waiting until a note created through Obsidian's vault API is reflected to disk.
- `E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready.
- `E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database.
- `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents.
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots captured after a remote-activity failure; default is `/tmp/obsidian-livesync-e2e`.
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`.
- `E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS`: timeout for waiting until Object Storage contains uploaded E2E objects.
- `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection.
- `E2E_OBSIDIAN_KEEP_OBJECT_STORAGE=true`: keep the temporary Object Storage prefix for inspection.
+46 -2
View File
@@ -42,6 +42,12 @@ export type CouchDbDatabaseInfo = {
update_seq: number | string;
};
export type CouchDbPutResponse = {
ok: boolean;
id: string;
rev: string;
};
function parseEnvFile(content: string): Record<string, string> {
const entries = content
.split(/\r?\n/u)
@@ -79,6 +85,14 @@ function databaseUrl(config: Pick<CouchDbConfig, "uri">, dbName: string, suffix
return `${config.uri.replace(/\/+$/u, "")}/${encodeURIComponent(dbName)}${suffix}`;
}
function documentSuffix(documentId: string): string {
const localPrefix = "_local/";
if (documentId.startsWith(localPrefix)) {
return `/_local/${encodeURIComponent(documentId.slice(localPrefix.length))}`;
}
return `/${encodeURIComponent(documentId)}`;
}
async function couchDbRequest(
config: Pick<CouchDbConfig, "uri" | "username" | "password">,
path: string,
@@ -146,8 +160,8 @@ export async function putCouchDbDocument(
config: CouchDbConfig,
dbName: string,
document: CouchDbDocument
): Promise<void> {
const response = await fetch(databaseUrl(config, dbName, `/${encodeURIComponent(document._id)}`), {
): Promise<CouchDbPutResponse> {
const response = await fetch(databaseUrl(config, dbName, documentSuffix(document._id)), {
method: "PUT",
headers: {
authorization: authHeader(config),
@@ -160,6 +174,23 @@ export async function putCouchDbDocument(
`Failed to write CouchDB document ${document._id}. HTTP ${response.status}: ${await response.text()}`
);
}
return (await response.json()) as CouchDbPutResponse;
}
export async function fetchCouchDbDocument(
config: CouchDbConfig,
dbName: string,
documentId: string
): Promise<CouchDbDocument> {
const response = await fetch(databaseUrl(config, dbName, documentSuffix(documentId)), {
headers: { authorization: authHeader(config) },
});
if (!response.ok) {
throw new Error(
`Failed to read CouchDB document ${documentId}. HTTP ${response.status}: ${await response.text()}`
);
}
return (await response.json()) as CouchDbDocument;
}
export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise<void> {
@@ -174,6 +205,19 @@ export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: strin
}
}
export async function couchDbDatabaseExists(config: CouchDbConfig, dbName: string): Promise<boolean> {
const response = await fetch(databaseUrl(config, dbName), {
headers: { authorization: authHeader(config) },
});
if (response.status === 404) {
return false;
}
if (!response.ok) {
throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`);
}
return true;
}
export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string): Promise<CouchDbAllDocsResponse> {
const response = await fetch(databaseUrl(config, dbName, "/_all_docs?include_docs=true"), {
headers: { authorization: authHeader(config) },
@@ -10,6 +10,7 @@ vi.mock("./cli.ts", () => ({ evalObsidianJson }));
import {
assertE2eCompatibilityMarker,
createE2eCouchDbPluginData,
waitForLiveSyncCoreReady,
type CompatibilityMarkerState,
} from "./liveSyncWorkflow.ts";
@@ -54,3 +55,25 @@ describe("configured CouchDB fixture", () => {
expect(pluginData.activeConfigurationId).toBe(Object.keys(remoteConfigurations ?? {})[0]);
});
});
describe("Real Obsidian core readiness", () => {
it("retries while the plug-in core is temporarily unavailable during reload", async () => {
evalObsidianJson.mockReset();
evalObsidianJson
.mockRejectedValueOnce(new Error("Cannot read properties of undefined (reading 'core')"))
.mockResolvedValueOnce({
databaseReady: true,
appReady: true,
configured: true,
remoteType: "",
settingVersion: 10,
suspended: false,
});
await expect(waitForLiveSyncCoreReady("obsidian-cli", {}, 1000)).resolves.toMatchObject({
databaseReady: true,
appReady: true,
});
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
});
});
+38 -19
View File
@@ -353,31 +353,50 @@ export async function waitForLiveSyncCoreReady(
): Promise<CoreReadiness> {
const deadline = Date.now() + timeoutMs;
let lastReadiness: CoreReadiness | undefined;
let lastError: unknown;
while (Date.now() < deadline) {
lastReadiness = await evalObsidianJson<CoreReadiness>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"});",
"})()",
].join(""),
env
);
try {
lastReadiness = await evalObsidianJson<CoreReadiness>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
"if(!core) return JSON.stringify({databaseReady:false,appReady:false});",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"});",
"})()",
].join(""),
env
);
lastError = undefined;
} catch (error) {
// Obsidian reloads the renderer while enabling the plug-in. During
// that short window the CLI can reach the Vault before the plug-in
// catalogue has exposed its core. This is a readiness state, not a
// failed scenario, so retain the error for the eventual timeout.
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 500));
continue;
}
if (lastReadiness.databaseReady && lastReadiness.appReady) {
return lastReadiness;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}`);
const errorSuffix =
lastError === undefined
? ""
: ` Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`;
throw new Error(
`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}${errorSuffix}`
);
}
/**
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
SECURITY_SEED_DOCUMENT_ID,
changedSynchronisationParameterFields,
fingerprintSecuritySeed,
replaceSecuritySeed,
requireSecuritySeedDocument,
snapshotSecuritySeedDocument,
} from "./securitySeed.ts";
const seedA = Buffer.alloc(32, 1).toString("base64");
const seedB = Buffer.alloc(32, 2).toString("base64");
describe("Security Seed E2E evidence", () => {
it("reports stable, non-secret fingerprints", () => {
expect(fingerprintSecuritySeed(seedA)).toMatch(/^sha256:[0-9a-f]{16}$/u);
expect(fingerprintSecuritySeed(seedA)).toBe(fingerprintSecuritySeed(seedA));
expect(fingerprintSecuritySeed(seedA)).not.toBe(fingerprintSecuritySeed(seedB));
});
it("redacts the Seed from the machine-readable document snapshot", () => {
const document = requireSecuritySeedDocument({
_id: SECURITY_SEED_DOCUMENT_ID,
_rev: "0-1",
type: "syncinfo",
protocolVersion: 2,
pbkdf2salt: seedA,
});
const snapshot = snapshotSecuritySeedDocument(document);
expect(snapshot).toEqual({
id: SECURITY_SEED_DOCUMENT_ID,
revision: "0-1",
fingerprint: fingerprintSecuritySeed(seedA),
fields: {
type: "syncinfo",
protocolVersion: 2,
},
});
expect(JSON.stringify(snapshot)).not.toContain(seedA);
});
it("replaces only the Seed and identifies later synchronisation-parameter changes", () => {
const before = requireSecuritySeedDocument({
_id: SECURITY_SEED_DOCUMENT_ID,
_rev: "0-1",
type: "syncinfo",
protocolVersion: 2,
pbkdf2salt: seedA,
});
const replaced = replaceSecuritySeed(before, seedB);
const laterRevision = {
...replaced,
_rev: "0-3",
};
expect(before.pbkdf2salt).toBe(seedA);
expect(replaced.pbkdf2salt).toBe(seedB);
expect(changedSynchronisationParameterFields(before, replaced)).toEqual(["pbkdf2salt"]);
expect(changedSynchronisationParameterFields(replaced, laterRevision)).toEqual([]);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { createHash, randomBytes } from "node:crypto";
import type { CouchDbDocument } from "./couchdb.ts";
export const SECURITY_SEED_DOCUMENT_ID = "_local/obsidian_livesync_sync_parameters";
export type SecuritySeedDocument = CouchDbDocument & {
_id: typeof SECURITY_SEED_DOCUMENT_ID;
_rev: string;
pbkdf2salt: string;
};
export type SecuritySeedDocumentSnapshot = {
id: string;
revision: string;
fingerprint: string;
fields: Record<string, unknown>;
};
function decodeSecuritySeed(seed: string): Buffer {
const bytes = Buffer.from(seed, "base64");
if (seed.length === 0 || bytes.length === 0) {
throw new Error("The Security Seed is empty or is not valid base64.");
}
return bytes;
}
export function createSecuritySeed(): string {
return randomBytes(32).toString("base64");
}
export function fingerprintSecuritySeed(seed: string): string {
const bytes = Uint8Array.from(decodeSecuritySeed(seed));
return `sha256:${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`;
}
export function requireSecuritySeedDocument(document: CouchDbDocument): SecuritySeedDocument {
if (document._id !== SECURITY_SEED_DOCUMENT_ID) {
throw new Error(`Unexpected synchronisation-parameter document: ${document._id}`);
}
if (typeof document._rev !== "string" || document._rev.length === 0) {
throw new Error("The synchronisation-parameter document does not have a revision.");
}
if (typeof document.pbkdf2salt !== "string") {
throw new Error("The synchronisation-parameter document does not have a Security Seed.");
}
decodeSecuritySeed(document.pbkdf2salt);
return document as SecuritySeedDocument;
}
export function replaceSecuritySeed(document: SecuritySeedDocument, replacementSeed: string): SecuritySeedDocument {
decodeSecuritySeed(replacementSeed);
return {
...document,
pbkdf2salt: replacementSeed,
};
}
export function snapshotSecuritySeedDocument(document: SecuritySeedDocument): SecuritySeedDocumentSnapshot {
const { _id, _rev, pbkdf2salt, ...fields } = document;
return {
id: _id,
revision: _rev,
fingerprint: fingerprintSecuritySeed(pbkdf2salt),
fields,
};
}
export function changedSynchronisationParameterFields(
before: SecuritySeedDocument,
after: SecuritySeedDocument
): string[] {
const ignoredFields = new Set(["_rev"]);
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
.filter((key) => !ignoredFields.has(key))
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
.sort();
}
+1
View File
@@ -22,6 +22,7 @@ const focusedScenarios = new Set([
"startup-scan",
"setup-uri-workflow",
"two-vault-sync",
"security-seed-reconnect",
"hidden-file-snippet-sync",
"customisation-sync",
"setting-markdown-export",
@@ -0,0 +1,745 @@
import { execFileSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
couchDbDatabaseExists,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
type CouchDbConfig,
type CouchDbDocument,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
SECURITY_SEED_DOCUMENT_ID,
changedSynchronisationParameterFields,
createSecuritySeed,
fingerprintSecuritySeed,
replaceSecuritySeed,
requireSecuritySeedDocument,
snapshotSecuritySeedDocument,
type SecuritySeedDocument,
type SecuritySeedDocumentSnapshot,
} from "../runner/securitySeed.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "15000";
const outboundPath = "E2E/security-seed/device-a.md";
const returnPath = "E2E/security-seed/device-b.md";
const hkdfErrorMessages = [
"Encryption with HKDF failed",
"Decryption with HKDF failed",
"Failed to initialise the encryption key",
"Failed to obtain PBKDF2 salt",
] as const;
type RunnerContext = {
binary: string;
cliBinary: string;
artifactRoot: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
allSessions: ObsidianLiveSyncSession[];
screenshots: string[];
};
type DeviceLabel = "device-a" | "device-a-return" | "device-b";
type SourceEvidence = {
exactCommit: string;
revisionSource: "git-worktree" | "provided-artifact";
workingTreeClean: boolean | null;
pluginVersion: string;
pluginArtifactSha256: string;
};
type ReplicationSettingsState = {
liveSync: boolean;
syncOnStart: boolean;
syncOnSave: boolean;
periodicReplication: boolean;
syncOnFileOpen: boolean;
syncOnEditorSave: boolean;
};
type SessionHealth = {
matchingErrorMessages: string[];
};
type ScenarioEvidence = {
source: SourceEvidence;
securitySeed: {
initial: SecuritySeedDocumentSnapshot;
replacement: SecuritySeedDocumentSnapshot;
final: SecuritySeedDocumentSnapshot;
cachedBeforeReplacement: string;
cachedAfterRemoteReplacement: string;
cachedAfterReplication: string;
replacementChangedFields: string[];
finalChangedFields: string[];
};
synchronisation: {
deviceAToDeviceB: boolean;
deviceBToDeviceA: boolean;
deviceAEncryptedPayload: boolean;
deviceBEncryptedPayload: boolean;
};
health: {
deviceA: SessionHealth;
deviceB: SessionHealth;
};
screenshots: string[];
};
type TeardownEvidence = {
sessionsStopped: boolean;
vaultRemoved: boolean;
profileRemoved: boolean;
databaseRemoved: boolean;
remainingTrackedSessions: number;
};
class MultipleErrors extends Error {
readonly errors: unknown[];
constructor(message: string, errors: unknown[]) {
super(message);
this.name = "MultipleErrors";
this.errors = errors;
}
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function inspectGitRevision(
artifactRoot: string
): Pick<SourceEvidence, "exactCommit" | "revisionSource" | "workingTreeClean"> {
try {
const exactCommit = execFileSync("git", ["-C", artifactRoot, "rev-parse", "HEAD"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const status = execFileSync("git", ["-C", artifactRoot, "status", "--porcelain"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
exactCommit,
revisionSource: "git-worktree",
workingTreeClean: status.length === 0,
};
} catch {
const exactCommit = process.env.E2E_OBSIDIAN_ARTIFACT_REVISION?.trim();
if (!exactCommit) {
throw new Error(
"E2E_OBSIDIAN_ARTIFACT_REVISION is required when the plug-in artefact is not in a Git worktree."
);
}
return {
exactCommit,
revisionSource: "provided-artifact",
workingTreeClean: null,
};
}
}
async function inspectSourceEvidence(artifactRoot: string): Promise<SourceEvidence> {
const manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf-8")) as {
version?: unknown;
};
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
throw new Error("The plug-in manifest does not have a version.");
}
const mainJs = await readFile(join(artifactRoot, "main.js"));
return {
...inspectGitRevision(artifactRoot),
pluginVersion: manifest.version,
pluginArtifactSha256: createHash("sha256").update(Uint8Array.from(mainJs)).digest("hex"),
};
}
function e2eeSettings(passphrase: string): Record<string, unknown> {
return {
encrypt: true,
passphrase,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
};
}
async function captureStage(context: RunnerContext, session: ObsidianLiveSyncSession, filename: string): Promise<void> {
const screenshot = await captureObsidianPage(session.remoteDebuggingPort, filename, async () => undefined);
context.screenshots.push(screenshot);
console.log(`Security Seed E2E screenshot: ${screenshot}`);
}
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
passphrase: string,
deviceLabel: DeviceLabel
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const overrides = e2eeSettings(passphrase);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
artifactRoot: context.artifactRoot,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
context.activeSessions.add(session);
context.allSessions.push(session);
try {
await captureStage(context, session, `security-seed-${deviceLabel}-startup.png`);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
await captureStage(context, session, `security-seed-${deviceLabel}-configured.png`);
return session;
} catch (error) {
await captureStage(context, session, `security-seed-${deviceLabel}-setup-failure.png`).catch(() => undefined);
await stopTrackedSession(context, session);
throw error;
}
}
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) {
return;
}
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopTrackedSessions(context: RunnerContext): Promise<void> {
const errors: unknown[] = [];
for (const session of [...context.activeSessions]) {
try {
await stopTrackedSession(context, session);
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new MultipleErrors("Could not stop every Real Obsidian session.", errors);
}
}
async function pauseAutomaticReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ReplicationSettingsState> {
const state = await evalObsidianJson<ReplicationSettingsState>(
cliBinary,
[
"(()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"core.services.replicator.getActiveReplicator()?.closeReplication();",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"liveSync:Boolean(settings.liveSync),",
"syncOnStart:Boolean(settings.syncOnStart),",
"syncOnSave:Boolean(settings.syncOnSave),",
"periodicReplication:Boolean(settings.periodicReplication),",
"syncOnFileOpen:Boolean(settings.syncOnFileOpen),",
"syncOnEditorSave:Boolean(settings.syncOnEditorSave),",
"});",
"})()",
].join(""),
env
);
for (const [name, enabled] of Object.entries(state)) {
if (enabled) {
throw new Error(`Automatic replication remained enabled through ${name}.`);
}
}
return state;
}
async function cachedSecuritySeedFingerprint(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
const result = await evalObsidianJson<{ fingerprint: string }>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const replicator=core.services.replicator.getActiveReplicator();",
"const seed=await replicator.getReplicationPBKDF2Salt(settings,false);",
"const digest=await crypto.subtle.digest('SHA-256',seed);",
"const fingerprint='sha256:'+Array.from(new Uint8Array(digest))",
".map((value)=>value.toString(16).padStart(2,'0')).join('').slice(0,16);",
"return JSON.stringify({fingerprint});",
"})()",
].join(""),
env
);
return result.fingerprint;
}
async function writeNoteViaObsidian(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
content: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
"const folder=path.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.modify(existing,content);",
"else await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function openNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Could not find note to open: ${path}`);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
async function waitForPathContent(
vaultPath: string,
path: string,
expected: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000)
): Promise<void> {
const fullPath = join(vaultPath, path);
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
if (await pathExists(fullPath)) {
lastContent = await readFile(fullPath, "utf-8");
if (lastContent === expected) {
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
function remoteContainsEntry(documents: CouchDbDocument[], entry: LocalDatabaseEntry): boolean {
const ids = new Set(documents.map((document) => document._id));
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
}
async function assertEntryNotRemote(context: RunnerContext, entry: LocalDatabaseEntry): Promise<void> {
const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName);
const documents = response.rows.flatMap((row) => (row.doc ? [row.doc] : []));
if (remoteContainsEntry(documents, entry)) {
throw new Error("The pending device-A document reached CouchDB before the Security Seed replacement.");
}
}
async function waitForEncryptedRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise<boolean> {
const documents = await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) =>
remoteContainsEntry(docs, entry)
);
const byId = new Map(documents.map((document) => [document._id, document]));
const encrypted = entry.children.every((childId) => {
const data = byId.get(childId)?.data;
return typeof data === "string" && data.startsWith("%=");
});
if (!encrypted) {
throw new Error("A replicated chunk did not use the expected HKDF-encrypted payload format.");
}
return true;
}
async function inspectSessionHealth(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SessionHealth> {
return await evalObsidianJson<SessionHealth>(
cliBinary,
[
"(async()=>{",
`const patterns=${JSON.stringify(hkdfErrorMessages)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.API.showWindow('log-log');",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let text='';",
"for(let i=0;i<20;i++){",
"text=Array.from(document.querySelectorAll('.logpane .log pre'))",
".map((element)=>element.textContent??'').join('\\n');",
"if(text.length>0) break;",
"await sleep(50);",
"}",
"const unresolved=JSON.stringify((await core.services.appLifecycle.getUnresolvedMessages()).flat());",
"const matchingErrorMessages=patterns.filter((pattern)=>text.includes(pattern)||unresolved.includes(pattern));",
"for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();",
"return JSON.stringify({matchingErrorMessages});",
"})()",
].join(""),
env
);
}
async function fetchSecuritySeedDocument(context: RunnerContext): Promise<SecuritySeedDocument> {
return requireSecuritySeedDocument(
await fetchCouchDbDocument(context.couchDb, context.dbName, SECURITY_SEED_DOCUMENT_ID)
);
}
async function replaceRemoteSecuritySeed(
context: RunnerContext,
before: SecuritySeedDocument,
replacementSeed: string
): Promise<SecuritySeedDocument> {
const replacement = replaceSecuritySeed(before, replacementSeed);
const putResult = await putCouchDbDocument(context.couchDb, context.dbName, replacement);
const after = await fetchSecuritySeedDocument(context);
assertEqual(after._rev, putResult.rev, "The replacement Security Seed revision was not stored.");
assertEqual(
fingerprintSecuritySeed(after.pbkdf2salt),
fingerprintSecuritySeed(replacementSeed),
"The replacement Security Seed was not stored."
);
const changedFields = changedSynchronisationParameterFields(before, after);
assertEqual(
JSON.stringify(changedFields),
JSON.stringify(["pbkdf2salt"]),
"Replacing the remote Security Seed changed another synchronisation parameter."
);
return after;
}
async function runScenario(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<ScenarioEvidence> {
const passphrase = `security-seed-e2e-${randomUUID()}`;
const source = await inspectSourceEvidence(context.artifactRoot);
let sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a");
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await captureStage(context, sessionA, "security-seed-device-a-initial-sync.png");
const initialDocument = await fetchSecuritySeedDocument(context);
const initial = snapshotSecuritySeedDocument(initialDocument);
const cachedBeforeReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedBeforeReplacement,
initial.fingerprint,
"Device A did not cache the initial remote Security Seed."
);
await pauseAutomaticReplication(context.cliBinary, sessionA.cliEnv);
const outboundContent = `Encrypted from device A: ${randomUUID()}\n`;
await writeNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath, outboundContent);
const outboundEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, outboundPath);
await assertEntryNotRemote(context, outboundEntry);
const replacementSeed = createSecuritySeed();
const replacementDocument = await replaceRemoteSecuritySeed(context, initialDocument, replacementSeed);
const replacement = snapshotSecuritySeedDocument(replacementDocument);
await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath);
await captureStage(context, sessionA, "security-seed-device-a-replacement-pending.png");
const cachedAfterRemoteReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedAfterRemoteReplacement,
initial.fingerprint,
"Device A did not retain the deliberately stale Security Seed before replication."
);
assertEqual(
replacement.fingerprint,
fingerprintSecuritySeed(replacementSeed),
"The runner did not install the intended replacement Security Seed."
);
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
const cachedAfterReplication = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedAfterReplication,
replacement.fingerprint,
"Device A did not refresh the Security Seed before replication."
);
const deviceAEncryptedPayload = await waitForEncryptedRemoteEntry(context, outboundEntry);
await captureStage(context, sessionA, "security-seed-device-a-refreshed-sync.png");
const deviceAHealthBeforeRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv);
await stopTrackedSession(context, sessionA);
const sessionB = await startConfiguredSession(context, vaultB, passphrase, "device-b");
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB.path, outboundPath, outboundContent);
await openNoteViaObsidian(context.cliBinary, sessionB.cliEnv, outboundPath);
await captureStage(context, sessionB, "security-seed-device-b-received.png");
await pauseAutomaticReplication(context.cliBinary, sessionB.cliEnv);
const returnContent = `Encrypted from device B: ${randomUUID()}\n`;
await writeNoteViaObsidian(context.cliBinary, sessionB.cliEnv, returnPath, returnContent);
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, returnPath);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
const deviceBEncryptedPayload = await waitForEncryptedRemoteEntry(context, returnEntry);
const deviceBHealth = await inspectSessionHealth(context.cliBinary, sessionB.cliEnv);
await stopTrackedSession(context, sessionB);
sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a-return");
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await waitForPathContent(vaultA.path, returnPath, returnContent);
await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, returnPath);
await captureStage(context, sessionA, "security-seed-device-a-return-received.png");
const finalDocument = await fetchSecuritySeedDocument(context);
const final = snapshotSecuritySeedDocument(finalDocument);
assertEqual(
final.fingerprint,
replacement.fingerprint,
"A client rolled the remote Security Seed back after reconnecting."
);
const finalChangedFields = changedSynchronisationParameterFields(replacementDocument, finalDocument);
if (finalChangedFields.length > 0) {
throw new Error(
`A client rewrote unexpected synchronisation-parameter fields: ${finalChangedFields.join(", ")}`
);
}
const deviceAHealthAfterRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv);
const deviceAHealth = {
matchingErrorMessages: [
...new Set([
...deviceAHealthBeforeRestart.matchingErrorMessages,
...deviceAHealthAfterRestart.matchingErrorMessages,
]),
],
};
if (deviceAHealth.matchingErrorMessages.length > 0 || deviceBHealth.matchingErrorMessages.length > 0) {
throw new Error(
`HKDF or Security Seed errors were logged: ${JSON.stringify({
deviceA: deviceAHealth.matchingErrorMessages,
deviceB: deviceBHealth.matchingErrorMessages,
})}`
);
}
return {
source,
securitySeed: {
initial,
replacement,
final,
cachedBeforeReplacement,
cachedAfterRemoteReplacement,
cachedAfterReplication,
replacementChangedFields: changedSynchronisationParameterFields(initialDocument, replacementDocument),
finalChangedFields,
},
synchronisation: {
deviceAToDeviceB: true,
deviceBToDeviceA: true,
deviceAEncryptedPayload,
deviceBEncryptedPayload,
},
health: {
deviceA: deviceAHealth,
deviceB: deviceBHealth,
},
screenshots: [...context.screenshots],
};
}
async function cleanupResources(
context: RunnerContext,
vaults: TemporaryVault[],
databaseCreated: boolean
): Promise<TeardownEvidence> {
const errors: unknown[] = [];
try {
await stopTrackedSessions(context);
} catch (error) {
errors.push(error);
}
for (const vault of vaults) {
try {
await vault.dispose();
} catch (error) {
errors.push(error);
}
}
if (databaseCreated) {
try {
await deleteCouchDbDatabase(context.couchDb, context.dbName);
} catch (error) {
errors.push(error);
}
}
const sessionsStopped = context.allSessions.every(
(session) => session.app.process.exitCode !== null || session.app.process.signalCode !== null
);
const vaultRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.path))))).every(
Boolean
);
const profileRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.statePath))))).every(
Boolean
);
let databaseRemoved = !databaseCreated;
if (databaseCreated) {
try {
databaseRemoved = !(await couchDbDatabaseExists(context.couchDb, context.dbName));
} catch (error) {
errors.push(error);
}
}
const evidence = {
sessionsStopped,
vaultRemoved,
profileRemoved,
databaseRemoved,
remainingTrackedSessions: context.activeSessions.size,
};
if (!sessionsStopped || !vaultRemoved || !profileRemoved || !databaseRemoved || context.activeSessions.size > 0) {
errors.push(new Error(`Security Seed E2E teardown was incomplete: ${JSON.stringify(evidence)}`));
}
if (errors.length > 0) {
throw Object.assign(new MultipleErrors("Security Seed E2E teardown failed.", errors), {
evidence,
});
}
return evidence;
}
async function writeResult(result: unknown): Promise<string> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const resultPath = join(outputDirectory, "security-seed-reconnect-result.json");
await mkdir(outputDirectory, { recursive: true });
await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
return resultPath;
}
async function main(): Promise<void> {
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true" || process.env.E2E_OBSIDIAN_KEEP_COUCHDB === "true") {
throw new Error("The Security Seed reconnect scenario requires strict Vault, profile, and database cleanup.");
}
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const artifactRoot = resolve(process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd());
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "security-seed-reconnect");
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
artifactRoot,
couchDb,
dbName,
activeSessions: new Set(),
allSessions: [],
screenshots: [],
};
const vaults: TemporaryVault[] = [];
let databaseCreated = false;
let evidence: ScenarioEvidence | undefined;
let scenarioError: unknown;
let teardown: TeardownEvidence | undefined;
let teardownError: unknown;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
databaseCreated = true;
vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-a-"));
vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-b-"));
evidence = await runScenario(context, vaults[0], vaults[1]);
} catch (error) {
scenarioError = error;
} finally {
try {
teardown = await cleanupResources(context, vaults, databaseCreated);
} catch (error) {
teardownError = error;
}
}
if (scenarioError !== undefined || teardownError !== undefined) {
const errors = [scenarioError, teardownError].filter((error) => error !== undefined);
if (errors.length === 1) {
throw errors[0];
}
throw new MultipleErrors("Security Seed reconnect scenario and teardown both failed.", errors);
}
if (!evidence || !teardown) {
throw new Error("Security Seed reconnect evidence was not produced.");
}
const result = {
scenario: "security-seed-reconnect",
...evidence,
teardown,
limitations: {
platformCommonRealObsidian: true,
iPadOsBackgroundReconnect: false,
androidDeviceLifecycle: false,
},
};
const resultPath = await writeResult(result);
console.log(`Security Seed E2E result: ${resultPath}`);
console.log(JSON.stringify(result, null, 2));
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});