From f1d8035c053be6fe935829e1788435f1d095d707 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 16:21:54 +0900 Subject: [PATCH 1/2] feat: add app data reset flow --- .changeset/twenty-bugs-peel.md | 5 + README.md | 7 + apps/cli/src/program.test.ts | 56 +++++++ apps/cli/src/program.ts | 20 +++ apps/docs/content/docs/cli.ja.mdx | 19 +++ apps/docs/content/docs/cli.ko.mdx | 19 +++ apps/docs/content/docs/cli.mdx | 19 +++ apps/docs/content/docs/cli.zh-CN.mdx | 19 +++ docs/readme/README.ja.md | 7 + docs/readme/README.ko.md | 7 + docs/readme/README.zh-CN.md | 7 + packages/core/src/app.test.ts | 122 +++++++++++++- packages/core/src/app.ts | 52 ++++++ packages/core/src/test-support.ts | 2 + packages/core/src/types.ts | 2 + packages/state-sqlite/src/index.test.ts | 22 +++ packages/state-sqlite/src/index.ts | 19 +++ packages/tui/src/app.test.ts | 207 +++++++++++++++++++++++- packages/tui/src/app.ts | 73 +++++++++ packages/tui/src/index.test.ts | 3 + packages/tui/src/view-state.test.ts | 2 +- packages/tui/src/view-state.ts | 48 ++++++ 22 files changed, 734 insertions(+), 3 deletions(-) create mode 100644 .changeset/twenty-bugs-peel.md diff --git a/.changeset/twenty-bugs-peel.md b/.changeset/twenty-bugs-peel.md new file mode 100644 index 0000000..7ed8dd9 --- /dev/null +++ b/.changeset/twenty-bugs-peel.md @@ -0,0 +1,5 @@ +--- +"@syncdown/cli": patch +--- + +Add a confirmed `syncdown reset --yes` command and expose app data reset from the TUI advanced menu while keeping synced Markdown output intact. diff --git a/README.md b/README.md index b3e7a8c..7a668e2 100644 --- a/README.md +++ b/README.md @@ -146,11 +146,18 @@ syncdown connectors syncdown doctor syncdown run syncdown run --watch +syncdown reset --yes syncdown update --check ``` Use `syncdown config set ` and `syncdown config unset ` for non-interactive configuration. +Reset local app data without touching synced Markdown output: + +```sh +syncdown reset --yes +``` + ## Connectors `syncdown` currently supports: diff --git a/apps/cli/src/program.test.ts b/apps/cli/src/program.test.ts index 673303d..8a1cee6 100644 --- a/apps/cli/src/program.test.ts +++ b/apps/cli/src/program.test.ts @@ -182,14 +182,17 @@ function createAppStub( options: { inspect?: () => Promise; run?: (options?: RunOptions) => Promise; + reset?: () => Promise; listConnectors?: () => Promise; doctor?: () => Promise; } = {}, ): { app: SyncdownApp; runCalls: RunOptions[]; + resetCalls: number; } { const runCalls: RunOptions[] = []; + let resetCalls = 0; return { app: { @@ -206,6 +209,10 @@ function createAppStub( runCalls.push(runOptions ?? {}); return options.run ? options.run(runOptions) : EXIT_CODES.OK; }, + async reset() { + resetCalls += 1; + return options.reset ? options.reset() : EXIT_CODES.OK; + }, async listConnectors() { if (options.listConnectors) { return options.listConnectors(); @@ -220,6 +227,9 @@ function createAppStub( }, }, runCalls, + get resetCalls() { + return resetCalls; + }, }; } @@ -806,6 +816,52 @@ test("run rejects an invalid watch interval", async () => { expect(errors[0] ?? "").toMatch(/--interval must be one of/); }); +test("reset dispatches to app.reset when --yes is provided", async () => { + const { io, errors } = createIoCapture(); + const stub = createAppStub(); + + const exitCode = await runTestCli( + ["syncdown", "syncdown", "reset", "--yes"], + { + app: stub.app, + io, + secrets: createSecretsStub(), + }, + ); + + expect(exitCode).toBe(EXIT_CODES.OK); + expect(stub.resetCalls).toBe(1); + expect(errors).toEqual([]); +}); + +test("reset requires --yes and rejects extra arguments", async () => { + const { io, errors } = createIoCapture(); + const stub = createAppStub(); + + const missingConfirmationExitCode = await runTestCli( + ["syncdown", "syncdown", "reset"], + { + app: stub.app, + io, + secrets: createSecretsStub(), + }, + ); + + const extraArgumentExitCode = await runTestCli( + ["syncdown", "syncdown", "reset", "--yes", "--force"], + { + app: stub.app, + io, + secrets: createSecretsStub(), + }, + ); + + expect(missingConfirmationExitCode).toBe(EXIT_CODES.CONFIG_ERROR); + expect(extraArgumentExitCode).toBe(EXIT_CODES.CONFIG_ERROR); + expect(stub.resetCalls).toBe(0); + expect(errors).toContain("Usage: syncdown reset --yes"); +}); + test("run rejects --reset with --watch", async () => { const { io, errors } = createIoCapture(); const { app, runCalls } = createAppStub(); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index b894732..212bbbc 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -56,6 +56,7 @@ function getHelpLines(): string[] { " syncdown run --integration ", " syncdown run --reset [--connector |--integration ]", " syncdown run --watch [--interval <5m|15m|1h|6h|24h>]", + " syncdown reset --yes", " syncdown connectors", " syncdown doctor", " syncdown update", @@ -191,6 +192,10 @@ function printUpdateUsage(io: AppIo): void { io.error("Usage: syncdown update [--check]"); } +function printResetUsage(io: AppIo): void { + io.error("Usage: syncdown reset --yes"); +} + function isInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } @@ -874,6 +879,19 @@ async function handleUpdateCommand( } } +async function handleResetCommand( + io: AppIo, + args: string[], + app: SyncdownApp, +): Promise { + if (args.length !== 1 || args[0] !== "--yes") { + printResetUsage(io); + return EXIT_CODES.CONFIG_ERROR; + } + + return app.reset(io); +} + function shouldRunAutoUpdateCheck(command: string | undefined): boolean { return ( command !== undefined && @@ -945,6 +963,8 @@ export async function runCli( } return app.run(io, runOptions); } + case "reset": + return handleResetCommand(io, argv.slice(3), app); case "connectors": return app.listConnectors(io); case "doctor": diff --git a/apps/docs/content/docs/cli.ja.mdx b/apps/docs/content/docs/cli.ja.mdx index 0da2153..72bdb11 100644 --- a/apps/docs/content/docs/cli.ja.mdx +++ b/apps/docs/content/docs/cli.ja.mdx @@ -22,6 +22,7 @@ syncdown run --connector syncdown run --integration syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] +syncdown reset --yes syncdown connectors syncdown doctor syncdown update @@ -111,6 +112,24 @@ syncdown run --watch syncdown run --watch --interval 15m ``` +## `syncdown reset --yes` + +ローカルの syncdown アプリデータを一括で削除します。 + +```sh +syncdown reset --yes +``` + +削除対象: + +- `config.json` +- `state.db` +- `secrets.enc` +- `master.key` +- `sync.lock` + +設定済み `outputDir` 配下の同期済み Markdown 出力は削除しません。 + ## `syncdown connectors` 各 integration について次を一覧表示します。 diff --git a/apps/docs/content/docs/cli.ko.mdx b/apps/docs/content/docs/cli.ko.mdx index 33a144e..9dfaad3 100644 --- a/apps/docs/content/docs/cli.ko.mdx +++ b/apps/docs/content/docs/cli.ko.mdx @@ -22,6 +22,7 @@ syncdown run --connector syncdown run --integration syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] +syncdown reset --yes syncdown connectors syncdown doctor syncdown update @@ -111,6 +112,24 @@ syncdown run --watch syncdown run --watch --interval 15m ``` +## `syncdown reset --yes` + +로컬 syncdown 앱 데이터를 한 번에 삭제합니다. + +```sh +syncdown reset --yes +``` + +다음 파일이 제거됩니다. + +- `config.json` +- `state.db` +- `secrets.enc` +- `master.key` +- `sync.lock` + +설정된 `outputDir` 아래의 동기화된 Markdown 출력은 삭제하지 않습니다. + ## `syncdown connectors` 각 integration에 대해 다음을 나열합니다. diff --git a/apps/docs/content/docs/cli.mdx b/apps/docs/content/docs/cli.mdx index 4eef948..bfede02 100644 --- a/apps/docs/content/docs/cli.mdx +++ b/apps/docs/content/docs/cli.mdx @@ -22,6 +22,7 @@ syncdown run --connector syncdown run --integration syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] +syncdown reset --yes syncdown connectors syncdown doctor syncdown update @@ -112,6 +113,24 @@ syncdown run --watch syncdown run --watch --interval 15m ``` +## `syncdown reset --yes` + +Delete local syncdown app data in one step: + +```sh +syncdown reset --yes +``` + +This removes: + +- `config.json` +- `state.db` +- `secrets.enc` +- `master.key` +- `sync.lock` + +It does not remove synced Markdown output under your configured `outputDir`. + ## `syncdown connectors` Lists each integration with: diff --git a/apps/docs/content/docs/cli.zh-CN.mdx b/apps/docs/content/docs/cli.zh-CN.mdx index 002400f..220c9a2 100644 --- a/apps/docs/content/docs/cli.zh-CN.mdx +++ b/apps/docs/content/docs/cli.zh-CN.mdx @@ -22,6 +22,7 @@ syncdown run --connector syncdown run --integration syncdown run --reset [--connector |--integration ] syncdown run --watch [--interval <5m|15m|1h|6h|24h>] +syncdown reset --yes syncdown connectors syncdown doctor syncdown update @@ -111,6 +112,24 @@ syncdown run --watch syncdown run --watch --interval 15m ``` +## `syncdown reset --yes` + +一次性删除本地 syncdown 应用数据。 + +```sh +syncdown reset --yes +``` + +会删除以下文件: + +- `config.json` +- `state.db` +- `secrets.enc` +- `master.key` +- `sync.lock` + +不会删除已配置 `outputDir` 下的已同步 Markdown 输出。 + ## `syncdown connectors` 列出每个 integration 的: diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index de8c935..c35b0c8 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -148,11 +148,18 @@ syncdown connectors syncdown doctor syncdown run syncdown run --watch +syncdown reset --yes syncdown update --check ``` 非対話型の設定には `syncdown config set ` と `syncdown config unset ` を使います。 +同期済み Markdown 出力はそのままにして、ローカルアプリデータだけを初期化するには: + +```sh +syncdown reset --yes +``` + ## Connectors 現在 `syncdown` が対応しているソースは次のとおりです。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index a876d3c..7fc2bc6 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -147,11 +147,18 @@ syncdown connectors syncdown doctor syncdown run syncdown run --watch +syncdown reset --yes syncdown update --check ``` 비대화형 설정에는 `syncdown config set ` 와 `syncdown config unset ` 를 사용합니다. +동기화된 Markdown 출력은 그대로 둔 채 로컬 앱 데이터만 초기화하려면: + +```sh +syncdown reset --yes +``` + ## Connectors 현재 `syncdown`이 지원하는 소스는 다음과 같습니다. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index d24a21b..7eb5dc7 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -148,11 +148,18 @@ syncdown connectors syncdown doctor syncdown run syncdown run --watch +syncdown reset --yes syncdown update --check ``` 如需非交互式配置,可使用 `syncdown config set ` 和 `syncdown config unset `。 +如果你想在保留已同步 Markdown 输出的同时,仅重置本地应用数据: + +```sh +syncdown reset --yes +``` + ## Connectors 当前 `syncdown` 支持: diff --git a/packages/core/src/app.test.ts b/packages/core/src/app.test.ts index dbf2e85..c03acf2 100644 --- a/packages/core/src/app.test.ts +++ b/packages/core/src/app.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { access, readFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { createSyncdownApp } from "./app.js"; @@ -264,6 +264,126 @@ test("keeps partially persisted documents recoverable across failed and successf } }); +test("reset removes app data files but keeps synced output", async () => { + const { cleanup, config, paths } = await createTestPaths(); + + try { + await mkdir(paths.dataDir, { recursive: true }); + await writeFile(paths.statePath, "state", "utf8"); + await writeFile(`${paths.statePath}-shm`, "state-shm", "utf8"); + await writeFile(`${paths.statePath}-wal`, "state-wal", "utf8"); + await writeFile(paths.secretsPath, "secrets", "utf8"); + await writeFile(paths.masterKeyPath, "master-key", "utf8"); + await writeFile(paths.lockPath, "lock", "utf8"); + + const outputFilePath = path.join( + config.outputDir!, + "notion", + "pages", + "page-1.md", + ); + await mkdir(path.dirname(outputFilePath), { recursive: true }); + await writeFile(outputFilePath, "page-1\nbody-page-1\n", "utf8"); + + const { io, writes, errors } = createIoCapture(); + const app = createSyncdownApp({ + connectors: [], + renderer: new TestRenderer(), + sink: new TestSink(), + state: new MemoryStateStore(), + secrets: new StaticSecretsStore(), + }); + + const exitCode = await app.reset(io); + + expect(exitCode).toBe(EXIT_CODES.OK); + expect(errors).toEqual([]); + await expect(access(paths.configPath)).rejects.toBeDefined(); + await expect(access(paths.statePath)).rejects.toBeDefined(); + await expect(access(`${paths.statePath}-shm`)).rejects.toBeDefined(); + await expect(access(`${paths.statePath}-wal`)).rejects.toBeDefined(); + await expect(access(paths.secretsPath)).rejects.toBeDefined(); + await expect(access(paths.masterKeyPath)).rejects.toBeDefined(); + await expect(access(paths.lockPath)).rejects.toBeDefined(); + expect(await readFile(outputFilePath, "utf8")).toBe( + "page-1\nbody-page-1\n", + ); + expect(writes[0]).toBe("Removed app data:"); + expect(writes).toContain(`- ${paths.configPath}`); + expect(writes).toContain(`- ${paths.lockPath}`); + expect(writes).toContain("Synced output files were not removed."); + } finally { + await cleanup(); + } +}); + +test("reset disposes the state store before deleting app data", async () => { + const { cleanup, paths } = await createTestPaths(); + let disposed = 0; + const state = new MemoryStateStore(); + state.dispose = async () => { + disposed += 1; + }; + + try { + await mkdir(paths.dataDir, { recursive: true }); + await writeFile(paths.statePath, "state", "utf8"); + + const app = createSyncdownApp({ + connectors: [], + renderer: new TestRenderer(), + sink: new TestSink(), + state, + secrets: new StaticSecretsStore(), + }); + + const exitCode = await app.reset(createIo()); + + expect(exitCode).toBe(EXIT_CODES.OK); + expect(disposed).toBe(1); + } finally { + await cleanup(); + } +}); + +test("reset returns locked when another sync lock is active", async () => { + const { cleanup, paths } = await createTestPaths(); + + try { + await mkdir(paths.dataDir, { recursive: true }); + const now = new Date().toISOString(); + await writeFile( + paths.lockPath, + `${JSON.stringify( + { + pid: process.pid, + createdAt: now, + updatedAt: now, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const { io, errors } = createIoCapture(); + const app = createSyncdownApp({ + connectors: [], + renderer: new TestRenderer(), + sink: new TestSink(), + state: new MemoryStateStore(), + secrets: new StaticSecretsStore(), + }); + + const exitCode = await app.reset(io); + + expect(exitCode).toBe(EXIT_CODES.LOCKED); + expect(errors[0] ?? "").toMatch(/Another sync is already running/); + } finally { + await cleanup(); + } +}); + test("stores snapshots and render version when a source is persisted", async () => { const { cleanup, config, notionIntegrationId } = await createTestPaths(); diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index fa047b5..44cbc26 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -1,3 +1,4 @@ +import { rm } from "node:fs/promises"; import { createNullIo, createStdIo, @@ -17,6 +18,7 @@ import { getIntegrationRenderVersion, hasStoredCredentials, } from "./execution.js"; +import { acquireRunLock } from "./run-lock.js"; import { type AppRuntime, createRuntime, @@ -42,6 +44,14 @@ export function createSyncdownApp( runtimeOverrides: Partial = {}, ): SyncdownApp { const runtime = createRuntime(runtimeOverrides); + const resetPaths = (paths: AppSnapshot["paths"]) => [ + paths.configPath, + paths.statePath, + `${paths.statePath}-shm`, + `${paths.statePath}-wal`, + paths.secretsPath, + paths.masterKeyPath, + ]; const inspect = async (): Promise => { const paths = resolveAppPaths(); @@ -139,6 +149,48 @@ export function createSyncdownApp( } }, + async reset(io = createStdIo()): Promise { + const paths = resolveAppPaths(); + + try { + const lock = await acquireRunLock(paths, runtime); + let released = false; + try { + const deletedPaths = resetPaths(paths); + await services.state.dispose?.(); + + for (const filePath of deletedPaths) { + await rm(filePath, { force: true }); + } + + await lock.release(); + released = true; + + io.write("Removed app data:"); + for (const filePath of [...deletedPaths, paths.lockPath]) { + io.write(`- ${filePath}`); + } + io.write("Synced output files were not removed."); + return EXIT_CODES.OK; + } finally { + if (!released) { + await lock.release(); + } + } + } catch (error) { + const message = + error instanceof Error ? error.message : "App reset failed."; + io.error(message); + if (error instanceof Error && error.name === "RunLockError") { + return EXIT_CODES.LOCKED; + } + if ((error as Partial).exitCode) { + return (error as SessionRunError).exitCode; + } + return EXIT_CODES.GENERAL_ERROR; + } + }, + async listConnectors(io = createStdIo()): Promise { const snapshot = await inspect(); for (const integration of snapshot.integrations) { diff --git a/packages/core/src/test-support.ts b/packages/core/src/test-support.ts index eb8aa73..3d2ab27 100644 --- a/packages/core/src/test-support.ts +++ b/packages/core/src/test-support.ts @@ -110,6 +110,8 @@ export class MemoryStateStore implements StateStore { async describe(): Promise { return []; } + + async dispose(): Promise {} } export class StaticSecretsStore implements SecretsStore { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b469224..a2c91cc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -313,6 +313,7 @@ export interface StateStore { upsertSourceSnapshot(snapshot: StoredSourceSnapshot): Promise; deleteSourceSnapshot(integrationId: string, sourceId: string): Promise; describe(): Promise; + dispose?(): Promise; } export interface SecretsStore { @@ -482,6 +483,7 @@ export interface SyncdownApp { inspect(): Promise; openSession(io?: AppIo): Promise; run(io?: AppIo, options?: RunOptions): Promise; + reset(io?: AppIo): Promise; listConnectors(io?: AppIo): Promise; doctor(io?: AppIo): Promise; } diff --git a/packages/state-sqlite/src/index.test.ts b/packages/state-sqlite/src/index.test.ts index 4b6f8a1..0c9acaf 100644 --- a/packages/state-sqlite/src/index.test.ts +++ b/packages/state-sqlite/src/index.test.ts @@ -167,6 +167,28 @@ test("renames legacy json state files before creating a new sqlite database", as } }); +test("dispose closes the database and allows the store to reopen", async () => { + const { cleanup } = await createStateEnvironment(); + + try { + const store = createStateStore(); + + await store.setCursor(NOTION_INTEGRATION_ID, "cursor-1"); + await store.dispose?.(); + await store.setLastSyncAt( + NOTION_INTEGRATION_ID, + "2026-03-16T10:00:00.000Z", + ); + + expect(await store.getCursor(NOTION_INTEGRATION_ID)).toBe("cursor-1"); + expect(await store.getLastSyncAt(NOTION_INTEGRATION_ID)).toBe( + "2026-03-16T10:00:00.000Z", + ); + } finally { + await cleanup(); + } +}); + test("deletes persisted source records and snapshots", async () => { const { cleanup } = await createStateEnvironment(); diff --git a/packages/state-sqlite/src/index.ts b/packages/state-sqlite/src/index.ts index 740a0fe..2282712 100644 --- a/packages/state-sqlite/src/index.ts +++ b/packages/state-sqlite/src/index.ts @@ -209,6 +209,25 @@ class BunSqliteStateStore implements StateStore { database.run(sql, args); } + async dispose(): Promise { + const ready = this.ready; + + if (ready) { + try { + await ready; + } catch { + this.ready = null; + this.database = null; + return; + } + } + + const database = this.database; + this.ready = null; + this.database = null; + database?.close(); + } + async getCursor(integrationId: string): Promise { const row = await this.queryOne( "SELECT cursor_value FROM integration_state WHERE integration_id = ?", diff --git a/packages/tui/src/app.test.ts b/packages/tui/src/app.test.ts index 1490e23..eedf152 100644 --- a/packages/tui/src/app.test.ts +++ b/packages/tui/src/app.test.ts @@ -9,6 +9,7 @@ import { createDefaultConfig, DEFAULT_GOOGLE_CONNECTION_ID, DEFAULT_GOOGLE_OAUTH_APP_ID, + EXIT_CODES, getDefaultIntegration, getGoogleConnectionSecretNames, getGoogleOAuthAppSecretNames, @@ -136,7 +137,9 @@ function createSecretsStore(initial = new Map()) { }; } -function createApp(): SyncdownApp { +function createApp( + options: { reset?: (io?: AppIo) => Promise } = {}, +): SyncdownApp { return { async inspect() { const config = createConfig(); @@ -271,6 +274,9 @@ function createApp(): SyncdownApp { async run() { throw new Error("unused"); }, + async reset(io) { + return options.reset ? options.reset(io) : 0; + }, async listConnectors() { throw new Error("unused"); }, @@ -348,6 +354,7 @@ function createSessionStub(initial = createSyncSnapshot()) { const runCalls: Array<{ target: SyncRunTarget; options?: RunNowOptions }> = []; let cancelCalls = 0; + let disposeCalls = 0; const session: SyncSession = { getSnapshot() { return structuredClone(snapshot); @@ -446,6 +453,7 @@ function createSessionStub(initial = createSyncSnapshot()) { } }, async dispose() { + disposeCalls += 1; if (watchActive) { await session.stopWatch(); } @@ -458,6 +466,9 @@ function createSessionStub(initial = createSyncSnapshot()) { get cancelCalls() { return cancelCalls; }, + get disposeCalls() { + return disposeCalls; + }, pushSnapshot(next: SyncRuntimeSnapshot) { snapshot = next; for (const listener of listeners) { @@ -2405,6 +2416,57 @@ test("diagnostics is reached through Advanced and loads doctor output", async () tui.destroy(); }); +test("advanced menu exposes reset app data", async () => { + const draft = createDraftState(createConfig(), { + notionTokenStored: true, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }); + const options = getRouteOptions({ id: "advanced", selectedIndex: 0 }, draft); + + expect(options.map((option) => option.value)).toContain("resetAppData"); +}); + +test("reset app data can be cancelled from the TUI", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + let resetCalls = 0; + const session = createSessionStub(); + const tui = await ConfigTuiApp.create( + { + app: createApp({ + async reset() { + resetCalls += 1; + return 0; + }, + }), + io: createIo(), + secrets: createSecretsStore().store, + session: session.session, + }, + createPaths(), + createDraftState(createConfig(), { + notionTokenStored: true, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }), + renderer, + createDefaultAuthService(), + ); + + (tui as any).ui.routes = [{ id: "advanced", selectedIndex: 1 }]; + await (tui as any).activateCurrentSelection(); + expect((tui as any).ui.routes.at(-1)?.id).toBe("confirmReset"); + + await (tui as any).activateCurrentSelection(); + expect((tui as any).ui.routes.at(-1)?.id).toBe("advanced"); + expect(resetCalls).toBe(0); + expect(session.disposeCalls).toBe(0); + + tui.destroy(); +}); + test("sync dashboard can start watch and run actions", async () => { const { renderer } = await createTestRenderer({ width: 100, height: 30 }); const session = createSessionStub(); @@ -2471,6 +2533,100 @@ test("sync dashboard can start watch and run actions", async () => { tui.destroy(); }); +test("reset app data confirms, disposes the session, and exits the TUI", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const ioWrites: string[] = []; + let resetCalls = 0; + const session = createSessionStub(); + const tui = await ConfigTuiApp.create( + { + app: createApp({ + async reset(io) { + expect(session.disposeCalls).toBe(1); + resetCalls += 1; + io?.write("Removed app data:"); + io?.write("Synced output files were not removed."); + return 0; + }, + }), + io: { + write(line) { + ioWrites.push(line); + }, + error() {}, + }, + secrets: createSecretsStore().store, + session: session.session, + }, + createPaths(), + createDraftState(createConfig(), { + notionTokenStored: true, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }), + renderer, + createDefaultAuthService(), + ); + + const runPromise = tui.run(); + (tui as any).ui.routes = [{ id: "advanced", selectedIndex: 1 }]; + await (tui as any).activateCurrentSelection(); + expect((tui as any).ui.routes.at(-1)?.id).toBe("confirmReset"); + + (tui as any).ui.routes.at(-1).selectedIndex = 1; + await (tui as any).activateCurrentSelection(); + + expect(await runPromise).toBe(0); + expect(resetCalls).toBe(1); + expect(session.disposeCalls).toBe(1); + expect(ioWrites).toContain("Removed app data:"); + expect(ioWrites).toContain("Synced output files were not removed."); +}); + +test("reset app data exits and prints the error when reset fails", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const ioErrors: string[] = []; + const session = createSessionStub(); + const tui = await ConfigTuiApp.create( + { + app: createApp({ + async reset() { + expect(session.disposeCalls).toBe(1); + return EXIT_CODES.GENERAL_ERROR; + }, + }), + io: { + write() {}, + error(line) { + ioErrors.push(line); + }, + }, + secrets: createSecretsStore().store, + session: session.session, + }, + createPaths(), + createDraftState(createConfig(), { + notionTokenStored: true, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }), + renderer, + createDefaultAuthService(), + ); + + const runPromise = tui.run(); + (tui as any).ui.routes = [{ id: "advanced", selectedIndex: 1 }]; + await (tui as any).activateCurrentSelection(); + (tui as any).ui.routes.at(-1).selectedIndex = 1; + await (tui as any).activateCurrentSelection(); + + expect(await runPromise).toBe(EXIT_CODES.GENERAL_ERROR); + expect(session.disposeCalls).toBe(1); + expect(ioErrors).toEqual(["Failed to reset app data."]); +}); + test("sync dashboard can cancel an active run while the start action is still busy", async () => { const { renderer } = await createTestRenderer({ width: 100, height: 30 }); const session = createSessionStub(); @@ -2566,6 +2722,55 @@ test("sync dashboard can cancel an active run while the start action is still bu tui.destroy(); }); +test("advanced reset is blocked while sync activity is still running", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + let resetCalls = 0; + const session = createSessionStub( + createSyncSnapshot({ + watch: { + active: true, + strategy: { kind: "per-integration" }, + startedAt: "2026-03-17T00:00:00.000Z", + }, + status: "watching", + }), + ); + const tui = await ConfigTuiApp.create( + { + app: createApp({ + async reset() { + resetCalls += 1; + return 0; + }, + }), + io: createIo(), + secrets: createSecretsStore().store, + session: session.session, + }, + createPaths(), + createDraftState(createConfig(), { + notionTokenStored: true, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }), + renderer, + createDefaultAuthService(), + ); + + (tui as any).ui.routes = [{ id: "advanced", selectedIndex: 1 }]; + await (tui as any).activateCurrentSelection(); + + expect((tui as any).ui.routes.at(-1)?.id).toBe("advanced"); + expect((tui as any).ui.notice).toEqual({ + kind: "error", + text: "Stop the current sync before resetting app data.", + }); + expect(resetCalls).toBe(0); + + tui.destroy(); +}); + test("sync dashboard renders compact cards and progress at 100x30", async () => { const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ width: 100, diff --git a/packages/tui/src/app.ts b/packages/tui/src/app.ts index 63e9602..1eab054 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -4,6 +4,7 @@ import type { GmailSyncFilter, SyncIntervalPreset, SyncRuntimeEvent, + SyncRuntimeSnapshot, UpdateStatus, } from "@syncdown/core"; import { @@ -60,6 +61,7 @@ import { clampRouteSelection, createConfigUiState, createConfirmDisconnectRoute, + createConfirmResetRoute, createConnectorAuthRoute, createConnectorDetailsRoute, createDiagnosticsRoute, @@ -217,6 +219,15 @@ function createNoopUpdater(): NonNullable { }; } +function isSyncSnapshotBusy(snapshot: SyncRuntimeSnapshot): boolean { + return ( + snapshot.watch.active || + snapshot.integrations.some( + (integration) => integration.running || integration.queuedImmediateRun, + ) + ); +} + export class ConfigTuiApp { private readonly renderer: CliRenderer; private readonly request: ConfigTuiRequest; @@ -998,6 +1009,68 @@ export class ConfigTuiApp { pushRoute(this.ui, createDiagnosticsRoute(this.paths, this.draft)); this.refreshView(); await this.refreshDiagnostics(); + } else if (selection === "resetAppData") { + if (isSyncSnapshotBusy(this.request.session.getSnapshot())) { + setNotice(this.ui, { + kind: "error", + text: "Stop the current sync before resetting app data.", + }); + this.refreshView(); + return; + } + + pushRoute(this.ui, createConfirmResetRoute()); + this.refreshView(); + } + return; + } + + if (route.id === "confirmReset") { + if (selection === "cancel") { + popRoute(this.ui); + this.refreshView(); + return; + } + + if (selection !== "reset") { + return; + } + + if (isSyncSnapshotBusy(this.request.session.getSnapshot())) { + setNotice(this.ui, { + kind: "error", + text: "Stop the current sync before resetting app data.", + }); + popRoute(this.ui); + this.refreshView(); + return; + } + + const writes: string[] = []; + const errors: string[] = []; + await this.request.session.dispose(); + const exitCode = await this.request.app.reset({ + write(line) { + writes.push(line); + }, + error(line) { + errors.push(line); + }, + }); + + if (exitCode !== EXIT_CODES.OK) { + this.finish(exitCode); + for (const line of errors.length > 0 + ? errors + : ["Failed to reset app data."]) { + this.request.io.error(line); + } + return; + } + + this.finish(EXIT_CODES.OK); + for (const line of writes) { + this.request.io.write(line); } return; } diff --git a/packages/tui/src/index.test.ts b/packages/tui/src/index.test.ts index 8b7de39..d3d4437 100644 --- a/packages/tui/src/index.test.ts +++ b/packages/tui/src/index.test.ts @@ -52,6 +52,9 @@ function createApp(): SyncdownApp { async run() { throw new Error("should not be called"); }, + async reset() { + throw new Error("should not be called"); + }, async listConnectors() { throw new Error("should not be called"); }, diff --git a/packages/tui/src/view-state.test.ts b/packages/tui/src/view-state.test.ts index 4bd3aa7..5c2b471 100644 --- a/packages/tui/src/view-state.test.ts +++ b/packages/tui/src/view-state.test.ts @@ -617,5 +617,5 @@ test("diagnostics entry only appears under Advanced", () => { getRouteOptions({ id: "advanced", selectedIndex: 0 }, draft).map( (option) => option.name, ), - ).toEqual(["Diagnostics"]); + ).toEqual(["Diagnostics", "Reset app data"]); }); diff --git a/packages/tui/src/view-state.ts b/packages/tui/src/view-state.ts index 8c5d60d..6d07dfb 100644 --- a/packages/tui/src/view-state.ts +++ b/packages/tui/src/view-state.ts @@ -90,6 +90,10 @@ export interface ConfirmDisconnectRoute extends BaseRoute { provider?: ProviderTarget; } +export interface ConfirmResetRoute extends BaseRoute { + id: "confirmReset"; +} + export interface OutputRoute extends BaseRoute { id: "output"; } @@ -155,6 +159,7 @@ export type ConfigRoute = | ConnectorDetailsRoute | ConnectorAuthRoute | ConfirmDisconnectRoute + | ConfirmResetRoute | OutputRoute | OutputCustomRoute | ScheduleRoute @@ -378,6 +383,13 @@ export function createOutputCustomRoute(draft: DraftState): OutputCustomRoute { }; } +export function createConfirmResetRoute(): ConfirmResetRoute { + return { + id: "confirmReset", + selectedIndex: 0, + }; +} + export function createIntervalRoute(connector: ConnectorTarget): IntervalRoute { return { id: "interval", @@ -623,6 +635,8 @@ export function getRouteTitle(route: ConfigRoute): string { return route.connector === "notion" ? "Disconnect Notion" : "Disable Gmail"; + case "confirmReset": + return "Reset App Data"; case "output": return "Output"; case "outputCustom": @@ -801,6 +815,18 @@ export function getRouteBody( return route.connector === "notion" ? "This will disable the connector and remove its stored credentials immediately." : "This will disable Gmail but keep the stored Google account."; + case "confirmReset": + return [ + "This will remove all local syncdown app data immediately.", + "", + `Config file: ${paths.configPath}`, + `State DB: ${paths.statePath}`, + `Secrets store: ${paths.secretsPath}`, + `Master key: ${paths.masterKeyPath}`, + `Run lock: ${paths.lockPath}`, + "", + "Synced Markdown output under the configured output directory is kept.", + ].join("\n"); case "output": return [ `Current output directory: ${draft.config.outputDir ?? ""}`, @@ -846,6 +872,8 @@ export function getRouteBody( `Config file: ${paths.configPath}`, `Secrets store: ${paths.secretsPath}`, `State DB: ${paths.statePath}`, + "", + "Use Reset app data to clear syncdown's local config, state, and stored credentials without touching synced Markdown output.", ].join("\n"); case "update": return [ @@ -1288,6 +1316,20 @@ export function getRouteOptions( value: "disconnect", }, ]; + case "confirmReset": + return [ + { + name: "Keep app data", + description: "Return without removing any local syncdown files", + value: "cancel", + }, + { + name: "Reset now", + description: + "Delete local config, state, secrets, and the lock file immediately", + value: "reset", + }, + ]; case "output": { const presetPaths = buildOutputPresetPaths(); const detected = detectOutputPreset(draft.config.outputDir); @@ -1403,6 +1445,12 @@ export function getRouteOptions( description: "Run doctor and inspect the detailed output", value: "diagnostics", }, + { + name: "Reset app data", + description: + "Delete local config, state, and stored credentials while keeping synced Markdown output", + value: "resetAppData", + }, ]; case "update": return [ From efe111d91f8f4810fd2d7056f45e6f3098b2bb97 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 16:30:07 +0900 Subject: [PATCH 2/2] perf: delete reset files in parallel --- packages/core/src/app.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index 44cbc26..88cd0ec 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -159,9 +159,9 @@ export function createSyncdownApp( const deletedPaths = resetPaths(paths); await services.state.dispose?.(); - for (const filePath of deletedPaths) { - await rm(filePath, { force: true }); - } + await Promise.all( + deletedPaths.map((filePath) => rm(filePath, { force: true })), + ); await lock.release(); released = true;