From 4184cbb025293317823f62ae972a0a5358546260 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 15:50:47 +0900 Subject: [PATCH 1/3] feat: guide setup through output selection --- .changeset/ready-cars-turn.md | 5 + apps/cli/src/program.test.ts | 28 +++- apps/cli/src/program.ts | 6 + packages/core/src/config.test.ts | 34 ++++- packages/core/src/config.ts | 31 ++++- packages/core/src/index.ts | 1 + packages/tui/src/app.test.ts | 192 +++++++++++++++++++++++++++- packages/tui/src/app.ts | 47 +++++-- packages/tui/src/state.test.ts | 52 ++++++++ packages/tui/src/state.ts | 8 +- packages/tui/src/view-state.test.ts | 46 ++++++- packages/tui/src/view-state.ts | 22 +++- 12 files changed, 451 insertions(+), 21 deletions(-) create mode 100644 .changeset/ready-cars-turn.md diff --git a/.changeset/ready-cars-turn.md b/.changeset/ready-cars-turn.md new file mode 100644 index 0000000..e48f966 --- /dev/null +++ b/.changeset/ready-cars-turn.md @@ -0,0 +1,5 @@ +--- +"@syncdown/cli": patch +--- + +guide first-run setup through Output and require an empty managed output directory diff --git a/apps/cli/src/program.test.ts b/apps/cli/src/program.test.ts index 4111932..673303d 100644 --- a/apps/cli/src/program.test.ts +++ b/apps/cli/src/program.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { type AppIo, @@ -514,6 +514,32 @@ test("config set stores gmail.syncFilter in the config file", async () => { }); }); +test("config set rejects a non-empty outputDir", async () => { + const { io, errors } = createIoCapture(); + const { app } = createAppStub(); + + await withTempCliPaths(async (paths) => { + const nonEmptyDir = path.join(paths.dataDir, "non-empty-output"); + mkdirSync(nonEmptyDir, { recursive: true }); + writeFileSync(path.join(nonEmptyDir, ".keep"), "occupied"); + + const exitCode = await runTestCli( + ["syncdown", "syncdown", "config", "set", "outputDir", nonEmptyDir], + { + app, + io, + secrets: createSecretsStub(), + }, + ); + + expect(exitCode).toBe(EXIT_CODES.CONFIG_ERROR); + expect(errors).toContain( + "Output folder must be completely empty before syncdown can use it.", + ); + expect((await readConfig(paths)).outputDir).toBeUndefined(); + }); +}); + test("config set rejects invalid gmail.syncFilter values", async () => { const { io, errors } = createIoCapture(); const { app } = createAppStub(); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index e4c2102..b894732 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -30,6 +30,7 @@ import { isGmailIntegration, readConfig, resolveAppPaths, + validateManagedOutputDirectory, writeConfig, } from "@syncdown/core"; import { createMarkdownRenderer } from "@syncdown/renderer-md"; @@ -416,6 +417,11 @@ async function handleConfigSet( io.error("outputDir cannot be empty."); return EXIT_CODES.CONFIG_ERROR; } + const validationError = await validateManagedOutputDirectory(nextValue); + if (validationError) { + io.error(validationError); + return EXIT_CODES.CONFIG_ERROR; + } config.outputDir = nextValue; await writeConfig(paths, config); io.write(`Set outputDir=${config.outputDir}`); diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 1262d68..1eb971c 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -1,10 +1,11 @@ import { expect, test } from "bun:test"; -import { rm } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { ensureAppDirectories, readConfig, resolveAppPaths, + validateManagedOutputDirectory, writeConfig, } from "./config.js"; import { @@ -97,6 +98,37 @@ test("writeConfig persists config after ensuring app directories", async () => { } }); +test("validateManagedOutputDirectory allows missing and empty directories", async () => { + const { cleanup, paths } = await createTestPaths(); + + try { + const missingDir = path.join(paths.dataDir, "missing-output"); + expect(await validateManagedOutputDirectory(missingDir)).toBeNull(); + + const emptyDir = path.join(paths.dataDir, "empty-output"); + await mkdir(emptyDir, { recursive: true }); + expect(await validateManagedOutputDirectory(emptyDir)).toBeNull(); + } finally { + await cleanup(); + } +}); + +test("validateManagedOutputDirectory rejects non-empty directories", async () => { + const { cleanup, paths } = await createTestPaths(); + + try { + const nonEmptyDir = path.join(paths.dataDir, "non-empty-output"); + await mkdir(nonEmptyDir, { recursive: true }); + await writeFile(path.join(nonEmptyDir, ".keep"), "occupied"); + + expect(await validateManagedOutputDirectory(nonEmptyDir)).toBe( + "Output folder must be completely empty before syncdown can use it.", + ); + } finally { + await cleanup(); + } +}); + test("readConfig normalizes legacy gmail config without a sync filter", async () => { const { cleanup, paths, config } = await createTestPaths(); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e45d8e8..7642a5e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,5 +1,5 @@ import { constants as fsConstants } from "node:fs"; -import { access, mkdir } from "node:fs/promises"; +import { access, mkdir, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { createDefaultConfig, normalizeConfig } from "./config-model.js"; @@ -113,6 +113,35 @@ export async function writeConfig( await Bun.write(paths.configPath, `${serialized}\n`); } +export async function validateManagedOutputDirectory( + outputDir: string, +): Promise { + try { + const target = await stat(outputDir); + if (!target.isDirectory()) { + return "Output folder must be an empty directory."; + } + + const entries = await readdir(outputDir); + if (entries.length > 0) { + return "Output folder must be completely empty before syncdown can use it."; + } + + return null; + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + return null; + } + + return "Failed to inspect the output folder."; + } +} + export async function describeOutputDirectory( outputDir: string | undefined, ): Promise { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0cc85bc..6fc22e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export { ensureAppDirectories, readConfig, resolveAppPaths, + validateManagedOutputDirectory, writeConfig, } from "./config.js"; export { diff --git a/packages/tui/src/app.test.ts b/packages/tui/src/app.test.ts index 3a9410d..3444bcc 100644 --- a/packages/tui/src/app.test.ts +++ b/packages/tui/src/app.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import * as OpenTuiTesting from "@opentui/core/testing"; import { @@ -25,7 +25,11 @@ import { import { ConfigTuiApp } from "./app.js"; import type { GoogleAuthSession, TuiAuthService } from "./auth.js"; -import { createDraftState, normalizeOutputPath } from "./state.js"; +import { + buildOutputPresetPaths, + createDraftState, + normalizeOutputPath, +} from "./state.js"; import { createConfirmDisconnectRoute, createConnectorAuthRoute, @@ -495,6 +499,24 @@ function readConfigFile(paths: AppPaths): SyncdownConfig { return JSON.parse(readFileSync(paths.configPath, "utf8")) as SyncdownConfig; } +async function withHomeDirectory( + homeDir: string, + run: () => Promise, +): Promise { + const previousHome = Bun.env.HOME; + Bun.env.HOME = homeDir; + + try { + return await run(); + } finally { + if (previousHome === undefined) { + delete Bun.env.HOME; + } else { + Bun.env.HOME = previousHome; + } + } +} + function createUpdaterStub( options: { currentVersion?: string; @@ -956,6 +978,172 @@ test("custom output path autosaves as soon as the page is confirmed", async () = tui.destroy(); }); +test("custom output path saves when the folder already exists and is empty", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const paths = createPaths(); + const draft = createDraftState(createConfig(), { + notionTokenStored: false, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }); + const tui = await ConfigTuiApp.create( + { + app: createApp(), + io: createIo(), + secrets: createSecretsStore().store, + session: createSessionStub().session, + }, + paths, + draft, + renderer, + createDefaultAuthService(), + ); + const emptyDir = path.join(paths.dataDir, "empty-output"); + mkdirSync(emptyDir, { recursive: true }); + + (tui as any).ui.routes = [ + { id: "output", selectedIndex: 0 }, + createOutputCustomRoute(draft), + ]; + (tui as any).ui.routes.at(-1).value = emptyDir; + + await (tui as any).submitInput(); + + expect((tui as any).ui.routes.at(-1)?.id).toBe("output"); + expect(draft.config.outputDir).toBe(normalizeOutputPath(emptyDir)); + expect(readConfigFile(paths).outputDir).toBe(normalizeOutputPath(emptyDir)); + + tui.destroy(); +}); + +test("custom output path rejects an existing non-empty folder", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const paths = createPaths(); + const draft = createDraftState(createConfig(), { + notionTokenStored: false, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }); + const tui = await ConfigTuiApp.create( + { + app: createApp(), + io: createIo(), + secrets: createSecretsStore().store, + session: createSessionStub().session, + }, + paths, + draft, + renderer, + createDefaultAuthService(), + ); + const nonEmptyDir = path.join(paths.dataDir, "non-empty-output"); + mkdirSync(nonEmptyDir, { recursive: true }); + writeFileSync(path.join(nonEmptyDir, ".keep"), "occupied"); + + (tui as any).ui.routes = [ + { id: "output", selectedIndex: 0 }, + createOutputCustomRoute(draft), + ]; + (tui as any).ui.routes.at(-1).value = nonEmptyDir; + + await (tui as any).submitInput(); + + expect((tui as any).ui.routes.at(-1)?.id).toBe("outputCustom"); + expect((tui as any).ui.routes.at(-1)?.error).toBe( + "Output folder must be completely empty before syncdown can use it.", + ); + expect((tui as any).ui.notice).toEqual({ + kind: "error", + text: "Output folder must be completely empty before syncdown can use it.", + }); + expect(draft.config.outputDir).toBe("/tmp/output"); + + tui.destroy(); +}); + +test("preset output saves a syncdown subdirectory", async () => { + const homeDir = mkdtempSync( + path.join(resolveTempDirectory(), "syncdown-home-"), + ); + + await withHomeDirectory(homeDir, async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const paths = createPaths(); + const draft = createDraftState(createConfig(), { + notionTokenStored: false, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }); + const tui = await ConfigTuiApp.create( + { + app: createApp(), + io: createIo(), + secrets: createSecretsStore().store, + session: createSessionStub().session, + }, + paths, + draft, + renderer, + createDefaultAuthService(), + ); + const expected = buildOutputPresetPaths().desktop; + + (tui as any).ui.routes = [{ id: "output", selectedIndex: 0 }]; + await (tui as any).activateCurrentSelection(); + + expect(draft.config.outputDir).toBe(expected); + expect(readConfigFile(paths).outputDir).toBe(expected); + + tui.destroy(); + }); +}); + +test("preset output rejects a non-empty syncdown subdirectory", async () => { + const homeDir = mkdtempSync( + path.join(resolveTempDirectory(), "syncdown-home-"), + ); + + await withHomeDirectory(homeDir, async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const paths = createPaths(); + const draft = createDraftState(createConfig(), { + notionTokenStored: false, + googleClientIdStored: false, + googleClientSecretStored: false, + googleRefreshTokenStored: false, + }); + const tui = await ConfigTuiApp.create( + { + app: createApp(), + io: createIo(), + secrets: createSecretsStore().store, + session: createSessionStub().session, + }, + paths, + draft, + renderer, + createDefaultAuthService(), + ); + const presetPath = buildOutputPresetPaths().desktop; + mkdirSync(presetPath, { recursive: true }); + writeFileSync(path.join(presetPath, "existing.md"), "occupied"); + + (tui as any).ui.routes = [{ id: "output", selectedIndex: 0 }]; + await (tui as any).activateCurrentSelection(); + + expect((tui as any).ui.notice).toEqual({ + kind: "error", + text: "Output folder must be completely empty before syncdown can use it.", + }); + expect(draft.config.outputDir).toBe("/tmp/output"); + + tui.destroy(); + }); +}); + test("keyboard typing updates the focused input route", async () => { const { renderer, mockInput } = await createTestRenderer({ width: 100, diff --git a/packages/tui/src/app.ts b/packages/tui/src/app.ts index afca983..63e9602 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -13,6 +13,7 @@ import { EXIT_CODES, getGoogleConnectionSecretNames, getGoogleOAuthAppSecretNames, + validateManagedOutputDirectory, } from "@syncdown/core"; import type { BrowserOpenResult, @@ -885,11 +886,7 @@ export class ConfigTuiApp { } const presetPaths = buildOutputPresetPaths(); - const saved = await this.persistDraftMutation( - (draft) => - setOutputDirectory(draft, normalizeOutputPath(presetPaths[preset])), - "Failed to save output directory.", - ); + const saved = await this.persistOutputDirectory(presetPaths[preset]); if (saved) { setNotice(this.ui, { kind: "success", @@ -1335,10 +1332,7 @@ export class ConfigTuiApp { return; } - const saved = await this.persistDraftMutation( - (draft) => setOutputDirectory(draft, normalizeOutputPath(value)), - "Failed to save output directory.", - ); + const saved = await this.persistOutputDirectory(value, route); if (saved) { popRoute(this.ui); setNotice(this.ui, { @@ -1968,6 +1962,41 @@ export class ConfigTuiApp { } } + private async persistOutputDirectory( + outputDir: string, + route?: { error: string | null }, + ): Promise { + const normalizedOutputDir = normalizeOutputPath(outputDir); + const validationError = + await this.validateOutputDirectory(normalizedOutputDir); + if (validationError) { + if (route) { + route.error = validationError; + } + setNotice(this.ui, { + kind: "error", + text: validationError, + }); + this.refreshView(); + return false; + } + + if (route) { + route.error = null; + } + + return this.persistDraftMutation( + (draft) => setOutputDirectory(draft, normalizedOutputDir), + "Failed to save output directory.", + ); + } + + private async validateOutputDirectory( + outputDir: string, + ): Promise { + return validateManagedOutputDirectory(outputDir); + } + private async persistDraftMutation( mutate: (draft: DraftState) => void, failureFallback: string, diff --git a/packages/tui/src/state.test.ts b/packages/tui/src/state.test.ts index b50d6d5..1c5f247 100644 --- a/packages/tui/src/state.test.ts +++ b/packages/tui/src/state.test.ts @@ -12,8 +12,10 @@ import { import { applySecretAction, + buildOutputPresetPaths, cloneDraftState, createDraftState, + detectOutputPreset, getConnectorStatus, getDraftNotionAuthMethod, hasCompleteGoogleCredentials, @@ -95,6 +97,21 @@ function createSecretsStore(values = new Map()): SecretsStore { }; } +function withHomeDirectory(homeDir: string, run: () => T): T { + const previousHome = Bun.env.HOME; + Bun.env.HOME = homeDir; + + try { + return run(); + } finally { + if (previousHome === undefined) { + delete Bun.env.HOME; + } else { + Bun.env.HOME = previousHome; + } + } +} + test("editing connector state updates the config", () => { const draft = createDraftState(createConfig(), createSecrets()); @@ -217,6 +234,41 @@ test("output preset and interval updates modify the draft immediately", () => { expect(getDefaultIntegration(draft.config, "gmail").interval).toBe("24h"); }); +test("output preset paths use syncdown subdirectories", () => { + const homeDir = mkdtempSync( + path.join(resolveTempDirectory(), "syncdown-home-"), + ); + + withHomeDirectory(homeDir, () => { + const presetPaths = buildOutputPresetPaths(); + + expect(presetPaths.desktop).toBe(path.join(homeDir, "Desktop", "syncdown")); + expect(presetPaths.documents).toBe( + path.join(homeDir, "Documents", "syncdown"), + ); + expect(presetPaths.downloads).toBe( + path.join(homeDir, "Downloads", "syncdown"), + ); + expect(presetPaths.home).toBe(path.join(homeDir, "syncdown")); + }); +}); + +test("output preset detection matches syncdown subdirectories", () => { + const homeDir = mkdtempSync( + path.join(resolveTempDirectory(), "syncdown-home-"), + ); + + withHomeDirectory(homeDir, () => { + const presetPaths = buildOutputPresetPaths(); + + expect(detectOutputPreset(presetPaths.desktop)).toBe("desktop"); + expect(detectOutputPreset(presetPaths.documents)).toBe("documents"); + expect(detectOutputPreset(presetPaths.downloads)).toBe("downloads"); + expect(detectOutputPreset(presetPaths.home)).toBe("home"); + expect(detectOutputPreset(path.join(homeDir, "notes"))).toBe("custom"); + }); +}); + test("clone and sync draft state support atomic persistence", () => { const draft = createDraftState(createConfig(), createSecrets()); const clone = cloneDraftState(draft); diff --git a/packages/tui/src/state.ts b/packages/tui/src/state.ts index f6b0792..e798293 100644 --- a/packages/tui/src/state.ts +++ b/packages/tui/src/state.ts @@ -526,10 +526,10 @@ export function buildOutputPresetPaths(): Record< > { const homeDir = resolveHomeDirectory(); return { - desktop: path.join(homeDir, "Desktop"), - documents: path.join(homeDir, "Documents"), - downloads: path.join(homeDir, "Downloads"), - home: homeDir, + desktop: path.join(homeDir, "Desktop", "syncdown"), + documents: path.join(homeDir, "Documents", "syncdown"), + downloads: path.join(homeDir, "Downloads", "syncdown"), + home: path.join(homeDir, "syncdown"), }; } diff --git a/packages/tui/src/view-state.test.ts b/packages/tui/src/view-state.test.ts index 780e8c6..4bd3aa7 100644 --- a/packages/tui/src/view-state.test.ts +++ b/packages/tui/src/view-state.test.ts @@ -7,7 +7,7 @@ import { type SyncdownConfig, type SyncRuntimeSnapshot, } from "@syncdown/core"; -import { createDraftState } from "./state.js"; +import { buildOutputPresetPaths, createDraftState } from "./state.js"; import { createConfigUiState, createConnectorAuthRoute, @@ -121,6 +121,25 @@ test("home options expose the top-level single-focus sections", () => { ).toEqual(["Sync", "Connectors", "Output", "Schedule", "Advanced", "Update"]); }); +test("home options collapse to output when output directory is missing", () => { + const config = createConfig(); + config.outputDir = undefined; + const draft = createDraftState(config, { + notionTokenStored: true, + googleClientIdStored: true, + googleClientSecretStored: true, + googleRefreshTokenStored: true, + }); + const ui = createConfigUiState(createPaths(), draft, "0.1.0", true, null); + + expect( + getRouteOptions(getCurrentRoute(ui), draft).map((option) => option.name), + ).toEqual(["Output"]); + expect(getRouteBody(getCurrentRoute(ui), createPaths(), draft)).toContain( + "Choose an output folder before configuring connectors or running syncs.", + ); +}); + test("routes push and pop like a page stack", () => { const draft = createDraftState(createConfig(), { notionTokenStored: true, @@ -194,7 +213,30 @@ test("connector detail and output pages render focused body text", () => { ).toContain("Current output directory"); expect( getRouteBody({ id: "output", selectedIndex: 0 }, paths, draft), - ).toContain("Syncdown treats this directory as managed output."); + ).toContain("Choose a completely empty folder for syncdown output."); + expect( + getRouteBody({ id: "output", selectedIndex: 0 }, paths, draft), + ).toContain( + "Do not rename, move, modify, or reorganize synced files or internal connector folders by hand.", + ); +}); + +test("output preset options point to syncdown subdirectories", () => { + const draft = createDraftState(createConfig(), { + notionTokenStored: true, + googleClientIdStored: true, + googleClientSecretStored: true, + googleRefreshTokenStored: true, + }); + const presetPaths = buildOutputPresetPaths(); + const options = getRouteOptions({ id: "output", selectedIndex: 0 }, draft); + + expect(options.slice(0, 4).map((option) => option.description)).toEqual([ + presetPaths.desktop, + presetPaths.documents, + presetPaths.downloads, + presetPaths.home, + ]); }); test("home body surfaces an available update above the connector summary", () => { diff --git a/packages/tui/src/view-state.ts b/packages/tui/src/view-state.ts index 0f61555..8c5d60d 100644 --- a/packages/tui/src/view-state.ts +++ b/packages/tui/src/view-state.ts @@ -730,6 +730,15 @@ export function getRouteBody( ): string { switch (route.id) { case "home": + if (!draft.config.outputDir) { + return [ + "Choose an output folder before configuring connectors or running syncs.", + "", + "Use a completely empty folder for syncdown output.", + "Do not rename or modify syncdown-managed folders or files by hand after setup.", + ].join("\n"); + } + return [ ...getHomeUpdateBannerLines(route), `Output: ${draft.config.outputDir ?? ""}`, @@ -798,8 +807,9 @@ export function getRouteBody( "", "Choose a preset or enter a custom path. Changes are written as soon as you confirm them.", "", + "Choose a completely empty folder for syncdown output.", "Syncdown treats this directory as managed output.", - "It is best not to rename, move, or reorganize synced Markdown files or connector folders by hand.", + "Do not rename, move, modify, or reorganize synced files or internal connector folders by hand.", "Later syncs or full resyncs may recreate, overwrite, or remove them.", ].join("\n"); case "outputCustom": @@ -954,6 +964,16 @@ export function getRouteOptions( ): UiSelectOption[] { switch (route.id) { case "home": + if (!draft.config.outputDir) { + return [ + { + name: "Output", + description: "Choose the empty folder syncdown will manage", + value: "output", + }, + ]; + } + return [ { name: "Sync", From fbf4769db1952f991db74cabf6853d273d4c37c3 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 15:54:36 +0900 Subject: [PATCH 2/3] test: share home-directory helper --- packages/tui/src/app.test.ts | 19 +------------------ packages/tui/src/state.test.ts | 16 +--------------- packages/tui/src/test-support.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 33 deletions(-) create mode 100644 packages/tui/src/test-support.ts diff --git a/packages/tui/src/app.test.ts b/packages/tui/src/app.test.ts index 3444bcc..1490e23 100644 --- a/packages/tui/src/app.test.ts +++ b/packages/tui/src/app.test.ts @@ -30,6 +30,7 @@ import { createDraftState, normalizeOutputPath, } from "./state.js"; +import { withHomeDirectory } from "./test-support.js"; import { createConfirmDisconnectRoute, createConnectorAuthRoute, @@ -499,24 +500,6 @@ function readConfigFile(paths: AppPaths): SyncdownConfig { return JSON.parse(readFileSync(paths.configPath, "utf8")) as SyncdownConfig; } -async function withHomeDirectory( - homeDir: string, - run: () => Promise, -): Promise { - const previousHome = Bun.env.HOME; - Bun.env.HOME = homeDir; - - try { - return await run(); - } finally { - if (previousHome === undefined) { - delete Bun.env.HOME; - } else { - Bun.env.HOME = previousHome; - } - } -} - function createUpdaterStub( options: { currentVersion?: string; diff --git a/packages/tui/src/state.test.ts b/packages/tui/src/state.test.ts index 1c5f247..e2526a5 100644 --- a/packages/tui/src/state.test.ts +++ b/packages/tui/src/state.test.ts @@ -31,6 +31,7 @@ import { stageProviderDisconnect, syncDraftState, } from "./state.js"; +import { withHomeDirectory } from "./test-support.js"; function createConfig(): SyncdownConfig { const config = createDefaultConfig(); @@ -97,21 +98,6 @@ function createSecretsStore(values = new Map()): SecretsStore { }; } -function withHomeDirectory(homeDir: string, run: () => T): T { - const previousHome = Bun.env.HOME; - Bun.env.HOME = homeDir; - - try { - return run(); - } finally { - if (previousHome === undefined) { - delete Bun.env.HOME; - } else { - Bun.env.HOME = previousHome; - } - } -} - test("editing connector state updates the config", () => { const draft = createDraftState(createConfig(), createSecrets()); diff --git a/packages/tui/src/test-support.ts b/packages/tui/src/test-support.ts new file mode 100644 index 0000000..c86ac22 --- /dev/null +++ b/packages/tui/src/test-support.ts @@ -0,0 +1,30 @@ +export function withHomeDirectory(homeDir: string, run: () => T): T { + const previousHome = Bun.env.HOME; + const restore = () => { + if (previousHome === undefined) { + delete Bun.env.HOME; + } else { + Bun.env.HOME = previousHome; + } + }; + + Bun.env.HOME = homeDir; + + try { + const result = run(); + if ( + result && + typeof result === "object" && + "then" in result && + typeof result.then === "function" + ) { + return (result as Promise>).finally(restore) as T; + } + + restore(); + return result; + } catch (error) { + restore(); + throw error; + } +} From a03014f3e8602651c6b8e0bc5bf209f029e3afbc Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 15:57:40 +0900 Subject: [PATCH 3/3] test: fix shared helper typing --- packages/tui/src/test-support.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/test-support.ts b/packages/tui/src/test-support.ts index c86ac22..e386160 100644 --- a/packages/tui/src/test-support.ts +++ b/packages/tui/src/test-support.ts @@ -1,3 +1,8 @@ +export function withHomeDirectory( + homeDir: string, + run: () => Promise, +): Promise; +export function withHomeDirectory(homeDir: string, run: () => T): T; export function withHomeDirectory(homeDir: string, run: () => T): T { const previousHome = Bun.env.HOME; const restore = () => { @@ -12,13 +17,8 @@ export function withHomeDirectory(homeDir: string, run: () => T): T { try { const result = run(); - if ( - result && - typeof result === "object" && - "then" in result && - typeof result.then === "function" - ) { - return (result as Promise>).finally(restore) as T; + if (result instanceof Promise) { + return result.finally(restore) as T; } restore();