Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ready-cars-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@syncdown/cli": patch
---

guide first-run setup through Output and require an empty managed output directory
28 changes: 27 additions & 1 deletion apps/cli/src/program.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
isGmailIntegration,
readConfig,
resolveAppPaths,
validateManagedOutputDirectory,
writeConfig,
} from "@syncdown/core";
import { createMarkdownRenderer } from "@syncdown/renderer-md";
Expand Down Expand Up @@ -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}`);
Expand Down
34 changes: 33 additions & 1 deletion packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();

Expand Down
31 changes: 30 additions & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -113,6 +113,35 @@ export async function writeConfig(
await Bun.write(paths.configPath, `${serialized}\n`);
}

export async function validateManagedOutputDirectory(
outputDir: string,
): Promise<string | null> {
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<string> {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
ensureAppDirectories,
readConfig,
resolveAppPaths,
validateManagedOutputDirectory,
writeConfig,
} from "./config.js";
export {
Expand Down
175 changes: 173 additions & 2 deletions packages/tui/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -25,7 +25,12 @@ 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 { withHomeDirectory } from "./test-support.js";
import {
createConfirmDisconnectRoute,
createConnectorAuthRoute,
Expand Down Expand Up @@ -956,6 +961,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,
Expand Down
Loading
Loading