diff --git a/.changeset/orange-spoons-slide.md b/.changeset/orange-spoons-slide.md new file mode 100644 index 0000000..fef2e01 --- /dev/null +++ b/.changeset/orange-spoons-slide.md @@ -0,0 +1,5 @@ +--- +"@syncdown/cli": patch +--- + +refactor connectors to use plugin manifests and shared connector metadata diff --git a/apps/cli/package.json b/apps/cli/package.json index 6fa5334..1940081 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,6 +19,7 @@ "src" ], "devDependencies": { + "@syncdown/connectors": "workspace:*", "@syncdown/connector-apple-notes": "workspace:*", "@syncdown/connector-google-calendar": "workspace:*", "@syncdown/connector-gmail": "workspace:*", diff --git a/apps/cli/src/program.test.ts b/apps/cli/src/program.test.ts index 034af9f..af5a162 100644 --- a/apps/cli/src/program.test.ts +++ b/apps/cli/src/program.test.ts @@ -7,9 +7,9 @@ import { type AppSnapshot, createDefaultConfig, EXIT_CODES, + ensureConfig, getDefaultIntegration, type RunOptions, - readConfig, resolveAppPaths, type SecretsStore, type SelfUpdater, @@ -535,7 +535,7 @@ test("config set stores gmail.syncFilter in the config file", async () => { expect(exitCode).toBe(EXIT_CODES.OK); expect(errors).toEqual([]); expect(writes).toContain("Set gmail.syncFilter=primary-important"); - const config = await readConfig(paths); + const config = await ensureConfig(paths); const gmail = getDefaultIntegration(config, "gmail"); if (gmail.connectorId !== "gmail") { throw new Error("expected gmail integration"); @@ -566,7 +566,7 @@ test("config set rejects a non-empty outputDir", async () => { expect(errors).toContain( "Output folder must be completely empty before syncdown can use it.", ); - expect((await readConfig(paths)).outputDir).toBeUndefined(); + expect((await ensureConfig(paths)).outputDir).toBeUndefined(); }); }); @@ -624,7 +624,7 @@ test("config set stores googleCalendar.selectedCalendarIds in the config file", expect(writes).toContain( "Set googleCalendar.selectedCalendarIds=primary,work@example.com", ); - const config = await readConfig(paths); + const config = await ensureConfig(paths); const googleCalendar = getDefaultIntegration(config, "google-calendar"); if (googleCalendar.connectorId !== "google-calendar") { throw new Error("expected google calendar integration"); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 67c2ef9..b061217 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -1,12 +1,11 @@ -import { createAppleNotesConnector } from "@syncdown/connector-apple-notes"; -import { createGmailConnector } from "@syncdown/connector-gmail"; -import { createGoogleCalendarConnector } from "@syncdown/connector-google-calendar"; -import { createNotionConnector } from "@syncdown/connector-notion"; +import { + createBuiltinConnectorPlugins, + createConnectorAliasMap, +} from "@syncdown/connectors"; import type { AppIo, ApplyUpdateResult, ConnectorId, - GmailSyncFilter, RunOptions, SelfUpdater, SyncdownApp, @@ -23,16 +22,13 @@ import { DEFAULT_NOTION_TOKEN_CONNECTION_ID, EXIT_CODES, ensureAppDirectories, - GOOGLE_SECRET_NAMES, + ensureConfig, getDefaultIntegration, - getNotionOAuthAppSecretNames, - getNotionOAuthConnectionSecretNames, hasGoogleCredentials, hasNotionOAuthConnectionCredentials, isAppleNotesIntegration, isCalendarIntegration, isGmailIntegration, - readConfig, resolveAppPaths, validateManagedOutputDirectory, writeConfig, @@ -54,9 +50,7 @@ function supportsAppleNotes( function getSupportedRunConnectorIds( platform: NodeJS.Platform = process.platform, ): ConnectorId[] { - return supportsAppleNotes(platform) - ? ["notion", "gmail", "google-calendar", "apple-notes"] - : ["notion", "gmail", "google-calendar"]; + return createBuiltinConnectorPlugins(platform).map((plugin) => plugin.id); } function isSupportedRunConnectorId( @@ -69,32 +63,10 @@ function isSupportedRunConnectorId( function getConfigSetKeys( platform: NodeJS.Platform = process.platform, ): string[] { - const keys = [ + return [ "outputDir", - "notion.enabled", - "notion.interval", - "notion.authMethod", - "notion.token", - "notion.oauth.clientId", - "notion.oauth.clientSecret", - "notion.oauth.refreshToken", - "gmail.enabled", - "gmail.interval", - "gmail.fetchConcurrency", - "gmail.syncFilter", - "googleCalendar.enabled", - "googleCalendar.interval", - "googleCalendar.selectedCalendarIds", - "google.clientId", - "google.clientSecret", - "google.refreshToken", + ...createConnectorAliasMap(createBuiltinConnectorPlugins(platform)).keys(), ]; - - if (supportsAppleNotes(platform)) { - keys.splice(15, 0, "appleNotes.enabled", "appleNotes.interval"); - } - - return keys; } function getHelpLines(): string[] { @@ -176,22 +148,6 @@ function isSyncIntervalPreset(value: string): value is SyncIntervalPreset { return INTERVAL_PRESETS.includes(value as SyncIntervalPreset); } -function parseBoolean(value: string): boolean | null { - if (value === "true") { - return true; - } - - if (value === "false") { - return false; - } - - return null; -} - -function isGmailSyncFilter(value: string): value is GmailSyncFilter { - return value === "primary" || value === "primary-important"; -} - async function readValueFromStdin(): Promise { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { @@ -206,7 +162,7 @@ async function loadConfig(): Promise<{ }> { const paths = resolveAppPaths(); await ensureAppDirectories(paths); - const config = await readConfig(paths); + const config = await ensureConfig(paths, createBuiltinConnectorPlugins()); return { config, paths }; } @@ -240,15 +196,6 @@ function isInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -function parsePositiveInteger(value: string): number | null { - if (!/^\d+$/.test(value)) { - return null; - } - - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; -} - function parseRunOptions(args: string[], io: AppIo): RunOptions | null { let watch = false; let watchInterval: SyncIntervalPreset | undefined; @@ -437,17 +384,6 @@ function getAppleNotesIntegrationConfig(config: SyncdownConfig) { return integration; } -function parseCommaSeparatedIds(value: string): string[] { - return [ - ...new Set( - value - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0), - ), - ]; -} - async function handleConfigSet( io: AppIo, argv: string[], @@ -463,239 +399,51 @@ async function handleConfigSet( const value = rawValue === "--stdin" ? await readValueFromStdin() : rawValue; const { config, paths } = await loadConfig(); + const alias = createConnectorAliasMap(createBuiltinConnectorPlugins()).get( + key, + ); - switch (key) { - case "outputDir": { - const nextValue = value.trim(); - if (!nextValue) { - 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}`); - return EXIT_CODES.OK; - } - case "notion.enabled": { - const parsed = parseBoolean(value.trim()); - if (parsed === null) { - io.error("notion.enabled must be `true` or `false`."); - return EXIT_CODES.CONFIG_ERROR; - } - getNotionIntegrationConfig(config).enabled = parsed; - await writeConfig(paths, config); - io.write( - `Set notion.enabled=${getNotionIntegrationConfig(config).enabled}`, - ); - return EXIT_CODES.OK; - } - case "notion.authMethod": { - const nextValue = value.trim(); - if (nextValue !== "token" && nextValue !== "oauth") { - io.error("notion.authMethod must be `token` or `oauth`."); - return EXIT_CODES.CONFIG_ERROR; - } - getNotionIntegrationConfig(config).connectionId = - nextValue === "oauth" - ? DEFAULT_NOTION_OAUTH_CONNECTION_ID - : DEFAULT_NOTION_TOKEN_CONNECTION_ID; - await writeConfig(paths, config); - io.write(`Set notion.authMethod=${getNotionAuthMethod(config)}`); - return EXIT_CODES.OK; + if (key === "outputDir") { + const nextValue = value.trim(); + if (!nextValue) { + io.error("outputDir cannot be empty."); + return EXIT_CODES.CONFIG_ERROR; } - case "notion.interval": { - const nextValue = value.trim(); - if (!isSyncIntervalPreset(nextValue)) { - io.error( - `notion.interval must be one of: ${INTERVAL_PRESETS.join(", ")}`, - ); - return EXIT_CODES.CONFIG_ERROR; - } - getNotionIntegrationConfig(config).interval = nextValue; - await writeConfig(paths, config); - io.write( - `Set notion.interval=${getNotionIntegrationConfig(config).interval}`, - ); - return EXIT_CODES.OK; + const validationError = await validateManagedOutputDirectory(nextValue); + if (validationError) { + io.error(validationError); + return EXIT_CODES.CONFIG_ERROR; } - case "notion.token": { - const nextValue = value.trim(); - if (!nextValue) { - io.error("notion.token cannot be empty."); - return EXIT_CODES.CONFIG_ERROR; - } - await secrets.setSecret( - `connections.${DEFAULT_NOTION_TOKEN_CONNECTION_ID}.token`, - nextValue, + config.outputDir = nextValue; + await writeConfig(paths, config); + io.write(`Set outputDir=${config.outputDir}`); + return EXIT_CODES.OK; + } + + if (!alias) { + io.error(`Unknown config key: ${key}`); + printConfigSetUsage(io); + return EXIT_CODES.CONFIG_ERROR; + } + + try { + const message = await alias.setValue( + { + config, + io, paths, - ); - io.write("Stored notion.token in encrypted secrets store."); - return EXIT_CODES.OK; - } - case "notion.oauth.clientId": - case "notion.oauth.clientSecret": - case "notion.oauth.refreshToken": { - const nextValue = value.trim(); - if (!nextValue) { - io.error(`${key} cannot be empty.`); - return EXIT_CODES.CONFIG_ERROR; - } - const mappedKey = - key === "notion.oauth.clientId" - ? getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID).clientId - : key === "notion.oauth.clientSecret" - ? getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID) - .clientSecret - : getNotionOAuthConnectionSecretNames( - DEFAULT_NOTION_OAUTH_CONNECTION_ID, - ).refreshToken; - await secrets.setSecret(mappedKey, nextValue, paths); - io.write(`Stored ${key} in encrypted secrets store.`); - return EXIT_CODES.OK; - } - case "gmail.enabled": { - const parsed = parseBoolean(value.trim()); - if (parsed === null) { - io.error("gmail.enabled must be `true` or `false`."); - return EXIT_CODES.CONFIG_ERROR; - } - getGmailIntegrationConfig(config).enabled = parsed; - await writeConfig(paths, config); - io.write( - `Set gmail.enabled=${getGmailIntegrationConfig(config).enabled}`, - ); - return EXIT_CODES.OK; - } - case "gmail.interval": { - const nextValue = value.trim(); - if (!isSyncIntervalPreset(nextValue)) { - io.error( - `gmail.interval must be one of: ${INTERVAL_PRESETS.join(", ")}`, - ); - return EXIT_CODES.CONFIG_ERROR; - } - getGmailIntegrationConfig(config).interval = nextValue; - await writeConfig(paths, config); - io.write( - `Set gmail.interval=${getGmailIntegrationConfig(config).interval}`, - ); - return EXIT_CODES.OK; - } - case "gmail.fetchConcurrency": { - const parsed = parsePositiveInteger(value.trim()); - if (parsed === null) { - io.error("gmail.fetchConcurrency must be a positive integer."); - return EXIT_CODES.CONFIG_ERROR; - } - getGmailIntegrationConfig(config).config.fetchConcurrency = parsed; - await writeConfig(paths, config); - io.write( - `Set gmail.fetchConcurrency=${getGmailIntegrationConfig(config).config.fetchConcurrency}`, - ); - return EXIT_CODES.OK; - } - case "gmail.syncFilter": { - const nextValue = value.trim(); - if (!isGmailSyncFilter(nextValue)) { - io.error( - "gmail.syncFilter must be one of: primary, primary-important.", - ); - return EXIT_CODES.CONFIG_ERROR; - } - getGmailIntegrationConfig(config).config.syncFilter = nextValue; - await writeConfig(paths, config); - io.write( - `Set gmail.syncFilter=${getGmailIntegrationConfig(config).config.syncFilter}`, - ); - return EXIT_CODES.OK; - } - case "googleCalendar.enabled": { - const parsed = parseBoolean(value.trim()); - if (parsed === null) { - io.error("googleCalendar.enabled must be `true` or `false`."); - return EXIT_CODES.CONFIG_ERROR; - } - getGoogleCalendarIntegrationConfig(config).enabled = parsed; - await writeConfig(paths, config); - io.write( - `Set googleCalendar.enabled=${getGoogleCalendarIntegrationConfig(config).enabled}`, - ); - return EXIT_CODES.OK; - } - case "googleCalendar.interval": { - const nextValue = value.trim(); - if (!isSyncIntervalPreset(nextValue)) { - io.error( - `googleCalendar.interval must be one of: ${INTERVAL_PRESETS.join(", ")}`, - ); - return EXIT_CODES.CONFIG_ERROR; - } - getGoogleCalendarIntegrationConfig(config).interval = nextValue; - await writeConfig(paths, config); - io.write( - `Set googleCalendar.interval=${getGoogleCalendarIntegrationConfig(config).interval}`, - ); - return EXIT_CODES.OK; - } - case "googleCalendar.selectedCalendarIds": { - const selectedCalendarIds = parseCommaSeparatedIds(value.trim()); - getGoogleCalendarIntegrationConfig(config).config.selectedCalendarIds = - selectedCalendarIds; - await writeConfig(paths, config); - io.write( - `Set googleCalendar.selectedCalendarIds=${selectedCalendarIds.join(",")}`, - ); - return EXIT_CODES.OK; - } - case "appleNotes.enabled": { - const parsed = parseBoolean(value.trim()); - if (parsed === null) { - io.error("appleNotes.enabled must be `true` or `false`."); - return EXIT_CODES.CONFIG_ERROR; - } - getAppleNotesIntegrationConfig(config).enabled = parsed; - await writeConfig(paths, config); - io.write( - `Set appleNotes.enabled=${getAppleNotesIntegrationConfig(config).enabled}`, - ); - return EXIT_CODES.OK; - } - case "appleNotes.interval": { - const nextValue = value.trim(); - if (!isSyncIntervalPreset(nextValue)) { - io.error( - `appleNotes.interval must be one of: ${INTERVAL_PRESETS.join(", ")}`, - ); - return EXIT_CODES.CONFIG_ERROR; - } - getAppleNotesIntegrationConfig(config).interval = nextValue; + secrets, + }, + value, + ); + if (!alias.secret) { await writeConfig(paths, config); - io.write( - `Set appleNotes.interval=${getAppleNotesIntegrationConfig(config).interval}`, - ); - return EXIT_CODES.OK; } - case GOOGLE_SECRET_NAMES.clientId: - case GOOGLE_SECRET_NAMES.clientSecret: - case GOOGLE_SECRET_NAMES.refreshToken: { - const nextValue = value.trim(); - if (!nextValue) { - io.error(`${key} cannot be empty.`); - return EXIT_CODES.CONFIG_ERROR; - } - await secrets.setSecret(key, nextValue, paths); - io.write(`Stored ${key} in encrypted secrets store.`); - return EXIT_CODES.OK; - } - default: - io.error(`Unknown config key: ${key}`); - printConfigSetUsage(io); - return EXIT_CODES.CONFIG_ERROR; + io.write(message); + return EXIT_CODES.OK; + } catch (error) { + io.error(error instanceof Error ? error.message : `Failed to set ${key}.`); + return EXIT_CODES.CONFIG_ERROR; } } @@ -712,56 +460,40 @@ async function handleConfigUnset( } const { config, paths } = await loadConfig(); + const alias = createConnectorAliasMap(createBuiltinConnectorPlugins()).get( + key, + ); + + if (key === "outputDir") { + delete config.outputDir; + await writeConfig(paths, config); + io.write("Removed outputDir."); + return EXIT_CODES.OK; + } - switch (key) { - case "outputDir": - delete config.outputDir; + if (!alias?.unsetValue) { + io.error(`Unknown config key: ${key}`); + printConfigUnsetUsage(io); + return EXIT_CODES.CONFIG_ERROR; + } + + try { + const message = await alias.unsetValue({ + config, + io, + paths, + secrets, + }); + if (!alias.secret) { await writeConfig(paths, config); - io.write("Removed outputDir."); - return EXIT_CODES.OK; - case "notion.token": - await secrets.deleteSecret( - `connections.${DEFAULT_NOTION_TOKEN_CONNECTION_ID}.token`, - paths, - ); - io.write("Removed notion.token from encrypted secrets store."); - return EXIT_CODES.OK; - case "notion.oauth.clientId": - await secrets.deleteSecret( - getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID).clientId, - paths, - ); - io.write("Removed notion.oauth.clientId from encrypted secrets store."); - return EXIT_CODES.OK; - case "notion.oauth.clientSecret": - await secrets.deleteSecret( - getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID).clientSecret, - paths, - ); - io.write( - "Removed notion.oauth.clientSecret from encrypted secrets store.", - ); - return EXIT_CODES.OK; - case "notion.oauth.refreshToken": - await secrets.deleteSecret( - getNotionOAuthConnectionSecretNames(DEFAULT_NOTION_OAUTH_CONNECTION_ID) - .refreshToken, - paths, - ); - io.write( - "Removed notion.oauth.refreshToken from encrypted secrets store.", - ); - return EXIT_CODES.OK; - case GOOGLE_SECRET_NAMES.clientId: - case GOOGLE_SECRET_NAMES.clientSecret: - case GOOGLE_SECRET_NAMES.refreshToken: - await secrets.deleteSecret(key, paths); - io.write(`Removed ${key} from encrypted secrets store.`); - return EXIT_CODES.OK; - default: - io.error(`Unknown config key: ${key}`); - printConfigUnsetUsage(io); - return EXIT_CODES.CONFIG_ERROR; + } + io.write(message); + return EXIT_CODES.OK; + } catch (error) { + io.error( + error instanceof Error ? error.message : `Failed to unset ${key}.`, + ); + return EXIT_CODES.CONFIG_ERROR; } } @@ -1032,12 +764,7 @@ export async function runCli( const app = dependencies.app ?? createSyncdownApp({ - connectors: [ - createNotionConnector(), - createGmailConnector(), - createGoogleCalendarConnector(), - ...(supportsAppleNotes() ? [createAppleNotesConnector()] : []), - ], + plugins: createBuiltinConnectorPlugins(), renderer: createMarkdownRenderer(), sink: createFileSystemSink(), state: createStateStore(), diff --git a/bun.lock b/bun.lock index b592efe..ee733b9 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "apps/cli": { "name": "@syncdown/cli", - "version": "0.1.2", + "version": "0.2.0", "bin": { "syncdown": "./src/bin.ts", }, @@ -25,6 +25,7 @@ "@syncdown/connector-gmail": "workspace:*", "@syncdown/connector-google-calendar": "workspace:*", "@syncdown/connector-notion": "workspace:*", + "@syncdown/connectors": "workspace:*", "@syncdown/core": "workspace:*", "@syncdown/renderer-md": "workspace:*", "@syncdown/secrets": "workspace:*", @@ -91,6 +92,17 @@ "@syncdown/core": "workspace:*", }, }, + "packages/connectors": { + "name": "@syncdown/connectors", + "version": "0.1.0", + "dependencies": { + "@syncdown/connector-apple-notes": "workspace:*", + "@syncdown/connector-gmail": "workspace:*", + "@syncdown/connector-google-calendar": "workspace:*", + "@syncdown/connector-notion": "workspace:*", + "@syncdown/core": "workspace:*", + }, + }, "packages/core": { "name": "@syncdown/core", "version": "0.1.0", @@ -99,6 +111,7 @@ "name": "@syncdown/renderer-md", "version": "0.1.0", "dependencies": { + "@syncdown/connectors": "workspace:*", "@syncdown/core": "workspace:*", }, }, @@ -633,6 +646,8 @@ "@syncdown/connector-notion": ["@syncdown/connector-notion@workspace:packages/connector-notion"], + "@syncdown/connectors": ["@syncdown/connectors@workspace:packages/connectors"], + "@syncdown/core": ["@syncdown/core@workspace:packages/core"], "@syncdown/renderer-md": ["@syncdown/renderer-md@workspace:packages/renderer-md"], diff --git a/packages/connector-apple-notes/src/index.ts b/packages/connector-apple-notes/src/index.ts index bfa8604..802a6a2 100644 --- a/packages/connector-apple-notes/src/index.ts +++ b/packages/connector-apple-notes/src/index.ts @@ -1,12 +1,19 @@ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import type { Connector, + ConnectorPlugin, ConnectorSyncRequest, ConnectorSyncResult, HealthCheck, + IntegrationConfig, SourceSnapshot, } from "@syncdown/core"; +import { + DEFAULT_APPLE_NOTES_CONNECTION_ID, + defineConnectorPlugin, +} from "@syncdown/core"; export interface AppleNotesNote { id: string; @@ -363,7 +370,14 @@ class MacAppleNotesAdapter implements AppleNotesAdapter { class AppleNotesConnector implements Connector { readonly id = "apple-notes"; readonly label = "Apple Notes"; - readonly setupMethods = [] as const; + readonly setupMethods = [ + { + kind: "local", + connectionId: DEFAULT_APPLE_NOTES_CONNECTION_ID, + connectionKind: "apple-notes-local", + label: "Local Access", + }, + ] as const; constructor( private readonly adapter: AppleNotesAdapter, @@ -461,13 +475,160 @@ export function createAppleNotesAdapter(): AppleNotesAdapter { return new MacAppleNotesAdapter(); } -export function createAppleNotesConnector( +function normalizeAppleNotesConnection( + entry: Partial<{ id: string; kind: string; label: string }>, +) { + if ( + entry.kind !== "apple-notes-local" || + typeof entry.id !== "string" || + typeof entry.label !== "string" + ) { + return []; + } + + return [ + { + id: entry.id, + kind: "apple-notes-local" as const, + label: entry.label, + }, + ]; +} + +function normalizeAppleNotesIntegration(entry: Partial) { + if ( + entry.connectorId !== "apple-notes" || + typeof entry.id !== "string" || + typeof entry.connectionId !== "string" || + typeof entry.label !== "string" || + typeof entry.enabled !== "boolean" || + (entry.interval !== "5m" && + entry.interval !== "15m" && + entry.interval !== "1h" && + entry.interval !== "6h" && + entry.interval !== "24h") + ) { + return []; + } + + return [ + { + id: entry.id, + connectorId: "apple-notes" as const, + connectionId: entry.connectionId, + label: entry.label, + enabled: entry.enabled, + interval: entry.interval, + config: {}, + }, + ]; +} + +export function createAppleNotesConnectorPlugin( options: CreateAppleNotesConnectorOptions = {}, -): Connector { - return new AppleNotesConnector( +): ConnectorPlugin { + const runtime = new AppleNotesConnector( options.adapter ?? createAppleNotesAdapter(), options.platform ?? process.platform, ); + + const setupMethods = [ + { + kind: "local" as const, + connectionId: DEFAULT_APPLE_NOTES_CONNECTION_ID, + connectionKind: "apple-notes-local", + label: "Local Access", + }, + ]; + + return defineConnectorPlugin({ + id: runtime.id, + label: runtime.label, + setupMethods, + validate: runtime.validate.bind(runtime), + sync: runtime.sync.bind(runtime), + manifest: { + id: runtime.id, + label: runtime.label, + setupMethods, + supportedPlatforms: ["darwin"], + cliAliases: [ + { + key: "appleNotes.enabled", + async setValue(context, rawValue) { + if (rawValue !== "true" && rawValue !== "false") { + throw new Error("appleNotes.enabled must be `true` or `false`."); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "apple-notes", + ); + if (!integration) { + throw new Error("Missing default Apple Notes integration."); + } + integration.enabled = rawValue === "true"; + return `Set appleNotes.enabled=${integration.enabled}`; + }, + }, + { + key: "appleNotes.interval", + async setValue(context, rawValue) { + if ( + rawValue !== "5m" && + rawValue !== "15m" && + rawValue !== "1h" && + rawValue !== "6h" && + rawValue !== "24h" + ) { + throw new Error( + "appleNotes.interval must be one of: 5m, 15m, 1h, 6h, 24h", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "apple-notes", + ); + if (!integration) { + throw new Error("Missing default Apple Notes integration."); + } + integration.interval = rawValue; + return `Set appleNotes.interval=${integration.interval}`; + }, + }, + ], + }, + render: { + version: "1", + }, + seedConnections() { + return [ + { + id: DEFAULT_APPLE_NOTES_CONNECTION_ID, + kind: "apple-notes-local", + label: "Default Apple Notes Connection", + }, + ]; + }, + seedIntegrations() { + return [ + { + id: randomUUID(), + connectorId: "apple-notes", + connectionId: DEFAULT_APPLE_NOTES_CONNECTION_ID, + label: "Apple Notes", + enabled: false, + interval: "1h", + config: {}, + }, + ]; + }, + normalizeConnection: normalizeAppleNotesConnection, + normalizeIntegration: normalizeAppleNotesIntegration, + }); +} + +export function createAppleNotesConnector( + options: CreateAppleNotesConnectorOptions = {}, +): Connector { + return createAppleNotesConnectorPlugin(options); } export { APPLE_NOTES_ACCESS_ERROR, APPLE_NOTES_SNAPSHOT_SCHEMA_VERSION }; diff --git a/packages/connector-gmail/src/index.ts b/packages/connector-gmail/src/index.ts index d33942d..6363340 100644 --- a/packages/connector-gmail/src/index.ts +++ b/packages/connector-gmail/src/index.ts @@ -1,16 +1,26 @@ +import { randomUUID } from "node:crypto"; + import type { Connector, + ConnectorPlugin, ConnectorSyncRequest, ConnectorSyncResult, GmailSyncFilter, GoogleAccessTokenProvider, GoogleOAuthCredentials, HealthCheck, + IntegrationConfig, SourceSnapshot, } from "@syncdown/core"; import { assertGoogleGrantedScopes, createGoogleAccessTokenProvider, + DEFAULT_GOOGLE_CONNECTION_ID, + DEFAULT_GOOGLE_OAUTH_APP_ID, + defineConnectorPlugin, + GOOGLE_SECRET_NAMES, + getGoogleConnectionSecretNames, + getGoogleOAuthAppSecretNames, } from "@syncdown/core"; const HISTORY_ID_INVALID_REASON = "invalid_history_id"; @@ -750,6 +760,9 @@ class GmailConnector implements Connector { kind: "provider-oauth", providerId: "google", requiredScopes: GMAIL_REQUIRED_SCOPES, + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + connectionKind: "google-account", + label: "Google OAuth", }, ] as const; private static readonly DEFAULT_FETCH_CONCURRENCY = 10; @@ -1008,10 +1021,313 @@ class GmailConnector implements Connector { } } +function normalizeGmailConnection( + entry: Partial<{ + id: string; + kind: string; + label: string; + oauthAppId?: string; + accountEmail?: string; + }>, +) { + if ( + entry.kind !== "google-account" || + typeof entry.id !== "string" || + typeof entry.label !== "string" || + typeof entry.oauthAppId !== "string" + ) { + return []; + } + + return [ + { + id: entry.id, + kind: "google-account" as const, + label: entry.label, + oauthAppId: entry.oauthAppId, + accountEmail: + typeof entry.accountEmail === "string" ? entry.accountEmail : undefined, + }, + ]; +} + +function normalizeGmailIntegration(entry: Partial) { + if ( + entry.connectorId !== "gmail" || + typeof entry.id !== "string" || + typeof entry.connectionId !== "string" || + typeof entry.label !== "string" || + typeof entry.enabled !== "boolean" || + (entry.interval !== "5m" && + entry.interval !== "15m" && + entry.interval !== "1h" && + entry.interval !== "6h" && + entry.interval !== "24h") + ) { + return []; + } + + const config = entry.config as + | { fetchConcurrency?: unknown; syncFilter?: unknown } + | undefined; + const syncFilter: GmailSyncFilter = + config?.syncFilter === "primary-important" + ? "primary-important" + : "primary"; + return [ + { + id: entry.id, + connectorId: "gmail" as const, + connectionId: entry.connectionId, + label: entry.label, + enabled: entry.enabled, + interval: entry.interval, + config: { + fetchConcurrency: + typeof config?.fetchConcurrency === "number" + ? config.fetchConcurrency + : 10, + syncFilter, + }, + }, + ]; +} + +export function createGmailConnectorPlugin( + options: CreateGmailConnectorOptions = {}, +): ConnectorPlugin { + const runtime = new GmailConnector( + options.adapter ?? new OfficialGmailAdapter(), + ); + const setupMethods = [ + { + kind: "provider-oauth" as const, + providerId: "google" as const, + requiredScopes: [...GMAIL_REQUIRED_SCOPES], + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + connectionKind: "google-account", + label: "Google OAuth", + }, + ]; + + return defineConnectorPlugin({ + id: runtime.id, + label: runtime.label, + setupMethods, + validate: runtime.validate.bind(runtime), + sync: runtime.sync.bind(runtime), + manifest: { + id: runtime.id, + label: runtime.label, + setupMethods, + cliAliases: [ + { + key: "gmail.enabled", + async setValue(context, rawValue) { + if (rawValue !== "true" && rawValue !== "false") { + throw new Error("gmail.enabled must be `true` or `false`."); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "gmail", + ); + if (!integration) { + throw new Error("Missing default Gmail integration."); + } + integration.enabled = rawValue === "true"; + return `Set gmail.enabled=${integration.enabled}`; + }, + }, + { + key: "gmail.interval", + async setValue(context, rawValue) { + if ( + rawValue !== "5m" && + rawValue !== "15m" && + rawValue !== "1h" && + rawValue !== "6h" && + rawValue !== "24h" + ) { + throw new Error( + "gmail.interval must be one of: 5m, 15m, 1h, 6h, 24h", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "gmail", + ); + if (!integration) { + throw new Error("Missing default Gmail integration."); + } + integration.interval = rawValue; + return `Set gmail.interval=${integration.interval}`; + }, + }, + { + key: "gmail.fetchConcurrency", + async setValue(context, rawValue) { + const parsed = Number.parseInt(rawValue.trim(), 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + "gmail.fetchConcurrency must be a positive integer.", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "gmail", + ); + if (!integration || integration.connectorId !== "gmail") { + throw new Error("Missing default Gmail integration."); + } + integration.config.fetchConcurrency = parsed; + return `Set gmail.fetchConcurrency=${integration.config.fetchConcurrency}`; + }, + }, + { + key: "gmail.syncFilter", + async setValue(context, rawValue) { + if (rawValue !== "primary" && rawValue !== "primary-important") { + throw new Error( + "gmail.syncFilter must be one of: primary, primary-important.", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "gmail", + ); + if (!integration || integration.connectorId !== "gmail") { + throw new Error("Missing default Gmail integration."); + } + integration.config.syncFilter = rawValue; + return `Set gmail.syncFilter=${integration.config.syncFilter}`; + }, + }, + { + key: GOOGLE_SECRET_NAMES.clientId, + secret: true, + async setValue(context, rawValue) { + const value = rawValue.trim(); + if (!value) { + throw new Error( + `${GOOGLE_SECRET_NAMES.clientId} cannot be empty.`, + ); + } + await context.secrets.setSecret( + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) + .clientId, + value, + context.paths, + ); + return `Stored ${GOOGLE_SECRET_NAMES.clientId} in encrypted secrets store.`; + }, + async unsetValue(context) { + await context.secrets.deleteSecret( + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) + .clientId, + context.paths, + ); + return `Removed ${GOOGLE_SECRET_NAMES.clientId} from encrypted secrets store.`; + }, + }, + { + key: GOOGLE_SECRET_NAMES.clientSecret, + secret: true, + async setValue(context, rawValue) { + const value = rawValue.trim(); + if (!value) { + throw new Error( + `${GOOGLE_SECRET_NAMES.clientSecret} cannot be empty.`, + ); + } + await context.secrets.setSecret( + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) + .clientSecret, + value, + context.paths, + ); + return `Stored ${GOOGLE_SECRET_NAMES.clientSecret} in encrypted secrets store.`; + }, + async unsetValue(context) { + await context.secrets.deleteSecret( + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID) + .clientSecret, + context.paths, + ); + return `Removed ${GOOGLE_SECRET_NAMES.clientSecret} from encrypted secrets store.`; + }, + }, + { + key: GOOGLE_SECRET_NAMES.refreshToken, + secret: true, + async setValue(context, rawValue) { + const value = rawValue.trim(); + if (!value) { + throw new Error( + `${GOOGLE_SECRET_NAMES.refreshToken} cannot be empty.`, + ); + } + await context.secrets.setSecret( + getGoogleConnectionSecretNames(DEFAULT_GOOGLE_CONNECTION_ID) + .refreshToken, + value, + context.paths, + ); + return `Stored ${GOOGLE_SECRET_NAMES.refreshToken} in encrypted secrets store.`; + }, + async unsetValue(context) { + await context.secrets.deleteSecret( + getGoogleConnectionSecretNames(DEFAULT_GOOGLE_CONNECTION_ID) + .refreshToken, + context.paths, + ); + return `Removed ${GOOGLE_SECRET_NAMES.refreshToken} from encrypted secrets store.`; + }, + }, + ], + }, + render: { + version: "1", + }, + seedOAuthApps() { + return [ + { + id: DEFAULT_GOOGLE_OAUTH_APP_ID, + providerId: "google", + label: "Default Google OAuth App", + }, + ]; + }, + seedConnections() { + return [ + { + id: DEFAULT_GOOGLE_CONNECTION_ID, + kind: "google-account", + label: "Default Google Account", + oauthAppId: DEFAULT_GOOGLE_OAUTH_APP_ID, + }, + ]; + }, + seedIntegrations() { + return [ + { + id: randomUUID(), + connectorId: "gmail", + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + label: "Gmail", + enabled: false, + interval: "1h", + config: { + fetchConcurrency: 10, + syncFilter: "primary", + }, + }, + ]; + }, + normalizeConnection: normalizeGmailConnection, + normalizeIntegration: normalizeGmailIntegration, + }); +} + export function createGmailConnector( options: CreateGmailConnectorOptions = {}, ): Connector { - return new GmailConnector(options.adapter ?? new OfficialGmailAdapter()); + return createGmailConnectorPlugin(options); } export { diff --git a/packages/connector-google-calendar/src/index.ts b/packages/connector-google-calendar/src/index.ts index b7c75a9..345ee1a 100644 --- a/packages/connector-google-calendar/src/index.ts +++ b/packages/connector-google-calendar/src/index.ts @@ -1,15 +1,22 @@ +import { randomUUID } from "node:crypto"; + import type { Connector, + ConnectorPlugin, ConnectorSyncRequest, ConnectorSyncResult, GoogleAccessTokenProvider, GoogleOAuthCredentials, HealthCheck, + IntegrationConfig, SourceSnapshot, } from "@syncdown/core"; import { assertGoogleGrantedScopes, createGoogleAccessTokenProvider, + DEFAULT_GOOGLE_CONNECTION_ID, + DEFAULT_GOOGLE_OAUTH_APP_ID, + defineConnectorPlugin, } from "@syncdown/core"; const GOOGLE_CALENDAR_API_BASE_URL = "https://www.googleapis.com/calendar/v3/"; @@ -598,6 +605,9 @@ class GoogleCalendarConnector implements Connector { kind: "provider-oauth", providerId: "google", requiredScopes: GOOGLE_CALENDAR_REQUIRED_SCOPES, + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + connectionKind: "google-account", + label: "Google OAuth", }, ] as const; @@ -777,10 +787,213 @@ class GoogleCalendarConnector implements Connector { } } -export function createGoogleCalendarConnector( +function normalizeGoogleCalendarConnection( + entry: Partial<{ + id: string; + kind: string; + label: string; + oauthAppId?: string; + accountEmail?: string; + }>, +) { + if ( + entry.kind !== "google-account" || + typeof entry.id !== "string" || + typeof entry.label !== "string" || + typeof entry.oauthAppId !== "string" + ) { + return []; + } + + return [ + { + id: entry.id, + kind: "google-account" as const, + label: entry.label, + oauthAppId: entry.oauthAppId, + accountEmail: + typeof entry.accountEmail === "string" ? entry.accountEmail : undefined, + }, + ]; +} + +function normalizeGoogleCalendarIntegration(entry: Partial) { + if ( + entry.connectorId !== "google-calendar" || + typeof entry.id !== "string" || + typeof entry.connectionId !== "string" || + typeof entry.label !== "string" || + typeof entry.enabled !== "boolean" || + (entry.interval !== "5m" && + entry.interval !== "15m" && + entry.interval !== "1h" && + entry.interval !== "6h" && + entry.interval !== "24h") + ) { + return []; + } + + const config = entry.config as { selectedCalendarIds?: unknown } | undefined; + return [ + { + id: entry.id, + connectorId: "google-calendar" as const, + connectionId: entry.connectionId, + label: entry.label, + enabled: entry.enabled, + interval: entry.interval, + config: { + selectedCalendarIds: Array.isArray(config?.selectedCalendarIds) + ? [ + ...new Set( + config.selectedCalendarIds.filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ), + ), + ] + : [], + }, + }, + ]; +} + +export function createGoogleCalendarConnectorPlugin( options: CreateGoogleCalendarConnectorOptions = {}, -): Connector { - return new GoogleCalendarConnector( +): ConnectorPlugin { + const runtime = new GoogleCalendarConnector( options.adapter ?? createGoogleCalendarAdapter(), ); + const setupMethods = [ + { + kind: "provider-oauth" as const, + providerId: "google" as const, + requiredScopes: [...GOOGLE_CALENDAR_REQUIRED_SCOPES], + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + connectionKind: "google-account", + label: "Google OAuth", + }, + ]; + + return defineConnectorPlugin({ + id: runtime.id, + label: runtime.label, + setupMethods, + validate: runtime.validate.bind(runtime), + sync: runtime.sync.bind(runtime), + manifest: { + id: runtime.id, + label: runtime.label, + setupMethods, + cliAliases: [ + { + key: "googleCalendar.enabled", + async setValue(context, rawValue) { + if (rawValue !== "true" && rawValue !== "false") { + throw new Error( + "googleCalendar.enabled must be `true` or `false`.", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "google-calendar", + ); + if (!integration) { + throw new Error("Missing default Google Calendar integration."); + } + integration.enabled = rawValue === "true"; + return `Set googleCalendar.enabled=${integration.enabled}`; + }, + }, + { + key: "googleCalendar.interval", + async setValue(context, rawValue) { + if ( + rawValue !== "5m" && + rawValue !== "15m" && + rawValue !== "1h" && + rawValue !== "6h" && + rawValue !== "24h" + ) { + throw new Error( + "googleCalendar.interval must be one of: 5m, 15m, 1h, 6h, 24h", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "google-calendar", + ); + if (!integration) { + throw new Error("Missing default Google Calendar integration."); + } + integration.interval = rawValue; + return `Set googleCalendar.interval=${integration.interval}`; + }, + }, + { + key: "googleCalendar.selectedCalendarIds", + async setValue(context, rawValue) { + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "google-calendar", + ); + if (!integration || integration.connectorId !== "google-calendar") { + throw new Error("Missing default Google Calendar integration."); + } + integration.config.selectedCalendarIds = [ + ...new Set( + rawValue + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ), + ]; + return `Set googleCalendar.selectedCalendarIds=${integration.config.selectedCalendarIds.join(",")}`; + }, + }, + ], + }, + render: { + version: "1", + }, + seedOAuthApps() { + return [ + { + id: DEFAULT_GOOGLE_OAUTH_APP_ID, + providerId: "google", + label: "Default Google OAuth App", + }, + ]; + }, + seedConnections() { + return [ + { + id: DEFAULT_GOOGLE_CONNECTION_ID, + kind: "google-account", + label: "Default Google Account", + oauthAppId: DEFAULT_GOOGLE_OAUTH_APP_ID, + }, + ]; + }, + seedIntegrations() { + return [ + { + id: randomUUID(), + connectorId: "google-calendar", + connectionId: DEFAULT_GOOGLE_CONNECTION_ID, + label: "Google Calendar", + enabled: false, + interval: "1h", + config: { + selectedCalendarIds: [], + }, + }, + ]; + }, + normalizeConnection: normalizeGoogleCalendarConnection, + normalizeIntegration: normalizeGoogleCalendarIntegration, + }); +} + +export function createGoogleCalendarConnector( + options: CreateGoogleCalendarConnectorOptions = {}, +): Connector { + return createGoogleCalendarConnectorPlugin(options); } diff --git a/packages/connector-notion/src/index.ts b/packages/connector-notion/src/index.ts index dd93142..3a1eaa6 100644 --- a/packages/connector-notion/src/index.ts +++ b/packages/connector-notion/src/index.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import { Client, collectPaginatedAPI, @@ -7,9 +9,19 @@ import { import type { Connector, + ConnectorPlugin, ConnectorSyncRequest, ConnectorSyncResult, HealthCheck, + IntegrationConfig, +} from "@syncdown/core"; +import { + DEFAULT_NOTION_OAUTH_APP_ID, + DEFAULT_NOTION_OAUTH_CONNECTION_ID, + DEFAULT_NOTION_TOKEN_CONNECTION_ID, + defineConnectorPlugin, + getNotionOAuthAppSecretNames, + getNotionOAuthConnectionSecretNames, } from "@syncdown/core"; import { toNotionCandidatePage, @@ -214,11 +226,20 @@ class NotionConnector implements Connector { readonly setupMethods = [ { kind: "token", + connectionId: DEFAULT_NOTION_TOKEN_CONNECTION_ID, + connectionKind: "notion-token", + label: "Token", + secretName(connectionId: string) { + return `connections.${connectionId}.token`; + }, }, { kind: "provider-oauth", providerId: "notion", requiredScopes: [], + connectionId: DEFAULT_NOTION_OAUTH_CONNECTION_ID, + connectionKind: "notion-oauth-account", + label: "OAuth", }, ] as const; @@ -625,10 +646,357 @@ class NotionConnector implements Connector { } } -export function createNotionConnector( +function normalizeNotionConnection( + entry: Partial<{ + id: string; + kind: string; + label: string; + oauthAppId?: string; + workspaceId?: string; + workspaceName?: string; + botId?: string; + ownerUserId?: string; + ownerUserName?: string; + }>, +) { + if (typeof entry.id !== "string" || typeof entry.label !== "string") { + return []; + } + + if (entry.kind === "notion-token") { + return [ + { + id: entry.id, + kind: "notion-token" as const, + label: entry.label, + workspaceName: + typeof entry.workspaceName === "string" + ? entry.workspaceName + : undefined, + }, + ]; + } + + if ( + entry.kind === "notion-oauth-account" && + typeof entry.oauthAppId === "string" + ) { + return [ + { + id: entry.id, + kind: "notion-oauth-account" as const, + label: entry.label, + oauthAppId: entry.oauthAppId, + workspaceId: + typeof entry.workspaceId === "string" ? entry.workspaceId : undefined, + workspaceName: + typeof entry.workspaceName === "string" + ? entry.workspaceName + : undefined, + botId: typeof entry.botId === "string" ? entry.botId : undefined, + ownerUserId: + typeof entry.ownerUserId === "string" ? entry.ownerUserId : undefined, + ownerUserName: + typeof entry.ownerUserName === "string" + ? entry.ownerUserName + : undefined, + }, + ]; + } + + return []; +} + +function normalizeNotionIntegration(entry: Partial) { + if ( + entry.connectorId !== "notion" || + typeof entry.id !== "string" || + typeof entry.connectionId !== "string" || + typeof entry.label !== "string" || + typeof entry.enabled !== "boolean" || + (entry.interval !== "5m" && + entry.interval !== "15m" && + entry.interval !== "1h" && + entry.interval !== "6h" && + entry.interval !== "24h") + ) { + return []; + } + + return [ + { + id: entry.id, + connectorId: "notion" as const, + connectionId: entry.connectionId, + label: entry.label, + enabled: entry.enabled, + interval: entry.interval, + config: {}, + }, + ]; +} + +export function createNotionConnectorPlugin( options: CreateNotionConnectorOptions = {}, -): Connector { - return new NotionConnector( +): ConnectorPlugin { + const runtime = new NotionConnector( options.adapter ?? new OfficialNotionAdapter(options.clientFactory), ); + return defineConnectorPlugin({ + id: runtime.id, + label: runtime.label, + setupMethods: [ + { + kind: "token", + connectionId: DEFAULT_NOTION_TOKEN_CONNECTION_ID, + connectionKind: "notion-token", + label: "Token", + secretName(connectionId) { + return `connections.${connectionId}.token`; + }, + }, + { + kind: "provider-oauth", + providerId: "notion", + requiredScopes: [], + connectionId: DEFAULT_NOTION_OAUTH_CONNECTION_ID, + connectionKind: "notion-oauth-account", + label: "OAuth", + }, + ], + validate: runtime.validate.bind(runtime), + sync: runtime.sync.bind(runtime), + manifest: { + id: runtime.id, + label: runtime.label, + setupMethods: [ + { + kind: "token", + connectionId: DEFAULT_NOTION_TOKEN_CONNECTION_ID, + connectionKind: "notion-token", + label: "Token", + secretName(connectionId) { + return `connections.${connectionId}.token`; + }, + }, + { + kind: "provider-oauth", + providerId: "notion", + requiredScopes: [], + connectionId: DEFAULT_NOTION_OAUTH_CONNECTION_ID, + connectionKind: "notion-oauth-account", + label: "OAuth", + }, + ], + cliAliases: [ + { + key: "notion.enabled", + async setValue(context, rawValue) { + if (rawValue !== "true" && rawValue !== "false") { + throw new Error("notion.enabled must be `true` or `false`."); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "notion", + ); + if (!integration) { + throw new Error("Missing default Notion integration."); + } + integration.enabled = rawValue === "true"; + return `Set notion.enabled=${integration.enabled}`; + }, + }, + { + key: "notion.interval", + async setValue(context, rawValue) { + if ( + rawValue !== "5m" && + rawValue !== "15m" && + rawValue !== "1h" && + rawValue !== "6h" && + rawValue !== "24h" + ) { + throw new Error( + "notion.interval must be one of: 5m, 15m, 1h, 6h, 24h", + ); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "notion", + ); + if (!integration) { + throw new Error("Missing default Notion integration."); + } + integration.interval = rawValue; + return `Set notion.interval=${integration.interval}`; + }, + }, + { + key: "notion.authMethod", + async setValue(context, rawValue) { + if (rawValue !== "token" && rawValue !== "oauth") { + throw new Error("notion.authMethod must be `token` or `oauth`."); + } + const integration = context.config.integrations.find( + (candidate) => candidate.connectorId === "notion", + ); + if (!integration) { + throw new Error("Missing default Notion integration."); + } + integration.connectionId = + rawValue === "oauth" + ? DEFAULT_NOTION_OAUTH_CONNECTION_ID + : DEFAULT_NOTION_TOKEN_CONNECTION_ID; + return `Set notion.authMethod=${rawValue}`; + }, + }, + { + key: "notion.token", + secret: true, + async setValue(context, rawValue) { + const value = rawValue.trim(); + if (!value) { + throw new Error("notion.token cannot be empty."); + } + await context.secrets.setSecret( + `connections.${DEFAULT_NOTION_TOKEN_CONNECTION_ID}.token`, + value, + context.paths, + ); + return "Stored notion.token in encrypted secrets store."; + }, + async unsetValue(context) { + await context.secrets.deleteSecret( + `connections.${DEFAULT_NOTION_TOKEN_CONNECTION_ID}.token`, + context.paths, + ); + return "Removed notion.token from encrypted secrets store."; + }, + }, + { + key: "notion.oauth.clientId", + secret: true, + async setValue(context, rawValue) { + const value = rawValue.trim(); + if (!value) { + throw new Error("notion.oauth.clientId cannot be empty."); + } + await context.secrets.setSecret( + getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID) + .clientId, + value, + context.paths, + ); + return "Stored notion.oauth.clientId in encrypted secrets store."; + }, + async unsetValue(context) { + await context.secrets.deleteSecret( + getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID) + .clientId, + context.paths, + ); + return "Removed notion.oauth.clientId from encrypted secrets store."; + }, + }, + { + key: "notion.oauth.clientSecret", + secret: true, + async setValue(context, rawValue) { + const value = rawValue.trim(); + if (!value) { + throw new Error("notion.oauth.clientSecret cannot be empty."); + } + await context.secrets.setSecret( + getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID) + .clientSecret, + value, + context.paths, + ); + return "Stored notion.oauth.clientSecret in encrypted secrets store."; + }, + async unsetValue(context) { + await context.secrets.deleteSecret( + getNotionOAuthAppSecretNames(DEFAULT_NOTION_OAUTH_APP_ID) + .clientSecret, + context.paths, + ); + return "Removed notion.oauth.clientSecret from encrypted secrets store."; + }, + }, + { + key: "notion.oauth.refreshToken", + secret: true, + async setValue(context, rawValue) { + const value = rawValue.trim(); + if (!value) { + throw new Error("notion.oauth.refreshToken cannot be empty."); + } + await context.secrets.setSecret( + getNotionOAuthConnectionSecretNames( + DEFAULT_NOTION_OAUTH_CONNECTION_ID, + ).refreshToken, + value, + context.paths, + ); + return "Stored notion.oauth.refreshToken in encrypted secrets store."; + }, + async unsetValue(context) { + await context.secrets.deleteSecret( + getNotionOAuthConnectionSecretNames( + DEFAULT_NOTION_OAUTH_CONNECTION_ID, + ).refreshToken, + context.paths, + ); + return "Removed notion.oauth.refreshToken from encrypted secrets store."; + }, + }, + ], + }, + render: { + version: "1", + }, + seedOAuthApps() { + return [ + { + id: DEFAULT_NOTION_OAUTH_APP_ID, + providerId: "notion", + label: "Default Notion OAuth App", + }, + ]; + }, + seedConnections() { + return [ + { + id: DEFAULT_NOTION_TOKEN_CONNECTION_ID, + kind: "notion-token", + label: "Default Notion Token Connection", + }, + { + id: DEFAULT_NOTION_OAUTH_CONNECTION_ID, + kind: "notion-oauth-account", + label: "Default Notion OAuth Connection", + oauthAppId: DEFAULT_NOTION_OAUTH_APP_ID, + }, + ]; + }, + seedIntegrations() { + return [ + { + id: randomUUID(), + connectorId: "notion", + connectionId: DEFAULT_NOTION_TOKEN_CONNECTION_ID, + label: "Notion", + enabled: false, + interval: "1h", + config: {}, + }, + ]; + }, + normalizeConnection: normalizeNotionConnection, + normalizeIntegration: normalizeNotionIntegration, + }); +} + +export function createNotionConnector( + options: CreateNotionConnectorOptions = {}, +): Connector { + return createNotionConnectorPlugin(options); } diff --git a/packages/connectors/package.json b/packages/connectors/package.json new file mode 100644 index 0000000..81c20bf --- /dev/null +++ b/packages/connectors/package.json @@ -0,0 +1,22 @@ +{ + "name": "@syncdown/connectors", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsgo -p ./tsconfig.json --noEmit --pretty false", + "test": "bun test ./src" + }, + "dependencies": { + "@syncdown/connector-apple-notes": "workspace:*", + "@syncdown/connector-gmail": "workspace:*", + "@syncdown/connector-google-calendar": "workspace:*", + "@syncdown/connector-notion": "workspace:*", + "@syncdown/core": "workspace:*" + } +} diff --git a/packages/connectors/src/index.test.ts b/packages/connectors/src/index.test.ts new file mode 100644 index 0000000..5dfcd46 --- /dev/null +++ b/packages/connectors/src/index.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; + +import { + createBuiltinConnectorPlugins, + createConnectorAliasMap, +} from "./index.js"; + +test("createBuiltinConnectorPlugins respects platform support", () => { + expect( + createBuiltinConnectorPlugins("darwin").map((plugin) => plugin.id), + ).toEqual(["notion", "gmail", "google-calendar", "apple-notes"]); + expect( + createBuiltinConnectorPlugins("linux").map((plugin) => plugin.id), + ).toEqual(["notion", "gmail", "google-calendar"]); +}); + +test("createConnectorAliasMap exposes built-in config aliases", () => { + const aliases = createConnectorAliasMap( + createBuiltinConnectorPlugins("darwin"), + ); + + expect(aliases.get("notion.enabled")?.key).toBe("notion.enabled"); + expect(aliases.get("gmail.syncFilter")?.key).toBe("gmail.syncFilter"); + expect(aliases.get("googleCalendar.selectedCalendarIds")?.key).toBe( + "googleCalendar.selectedCalendarIds", + ); + expect(aliases.get("appleNotes.interval")?.key).toBe("appleNotes.interval"); +}); diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts new file mode 100644 index 0000000..e4a29ba --- /dev/null +++ b/packages/connectors/src/index.ts @@ -0,0 +1,30 @@ +import { createAppleNotesConnectorPlugin } from "@syncdown/connector-apple-notes"; +import { createGmailConnectorPlugin } from "@syncdown/connector-gmail"; +import { createGoogleCalendarConnectorPlugin } from "@syncdown/connector-google-calendar"; +import { createNotionConnectorPlugin } from "@syncdown/connector-notion"; +import type { ConnectorCliAlias, ConnectorPlugin } from "@syncdown/core"; + +export function createBuiltinConnectorPlugins( + platform: NodeJS.Platform = process.platform, +): ConnectorPlugin[] { + return [ + createNotionConnectorPlugin(), + createGmailConnectorPlugin(), + createGoogleCalendarConnectorPlugin(), + ...(platform === "darwin" ? [createAppleNotesConnectorPlugin()] : []), + ]; +} + +export function createConnectorAliasMap( + plugins: readonly ConnectorPlugin[], +): Map { + const aliases = new Map(); + for (const plugin of plugins) { + for (const alias of plugin.manifest.cliAliases ?? []) { + if (!aliases.has(alias.key)) { + aliases.set(alias.key, alias); + } + } + } + return aliases; +} diff --git a/packages/connectors/tsconfig.json b/packages/connectors/tsconfig.json new file mode 100644 index 0000000..effd452 --- /dev/null +++ b/packages/connectors/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index 88cd0ec..904edf6 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -4,7 +4,7 @@ import { createStdIo, describeOutputDirectory, ensureAppDirectories, - readConfig, + ensureConfig, resolveAppPaths, } from "./config.js"; import { @@ -18,6 +18,7 @@ import { getIntegrationRenderVersion, hasStoredCredentials, } from "./execution.js"; +import { getServicePlugins } from "./plugin.js"; import { acquireRunLock } from "./run-lock.js"; import { type AppRuntime, @@ -43,6 +44,7 @@ export function createSyncdownApp( services: SyncdownServices, runtimeOverrides: Partial = {}, ): SyncdownApp { + const plugins = getServicePlugins(services); const runtime = createRuntime(runtimeOverrides); const resetPaths = (paths: AppSnapshot["paths"]) => [ paths.configPath, @@ -56,19 +58,19 @@ export function createSyncdownApp( const inspect = async (): Promise => { const paths = resolveAppPaths(); await ensureAppDirectories(paths); - const config = await readConfig(paths); + const config = await ensureConfig(paths, plugins); const integrations = await Promise.all( config.integrations.flatMap(async (integration) => { - const connector = services.connectors.find( + const plugin = plugins.find( (candidate) => candidate.id === integration.connectorId, ); - if (!connector) { + if (!plugin) { return []; } return [ buildIntegrationSummary( - connector, + plugin, integration, await services.state.getLastSyncAt(integration.id), ), @@ -79,7 +81,7 @@ export function createSyncdownApp( return { paths, config, - connectors: toConnectorDefinitions(services.connectors), + connectors: toConnectorDefinitions(plugins), connections: toConnectionSummaries(config), integrations: integrations.flat(), }; @@ -221,10 +223,10 @@ export function createSyncdownApp( } for (const integrationSummary of snapshot.integrations) { - const connector = services.connectors.find( + const plugin = plugins.find( (candidate) => candidate.id === integrationSummary.connectorId, ); - if (!connector) { + if (!plugin) { continue; } const integration = snapshot.config.integrations.find( @@ -234,20 +236,20 @@ export function createSyncdownApp( continue; } const request = await buildSyncRequest( - connector, + plugin, integration, services, snapshot.config, snapshot.paths, io, - getIntegrationRenderVersion(services, integration), + getIntegrationRenderVersion(services, plugin, integration), async () => {}, async () => {}, async () => {}, async () => {}, () => {}, ); - const check = await connector.validate(request); + const check = await plugin.validate(request); io.write(formatHealth(integration.label, check)); } diff --git a/packages/core/src/config-model.ts b/packages/core/src/config-model.ts index 1229d8d..4f191d9 100644 --- a/packages/core/src/config-model.ts +++ b/packages/core/src/config-model.ts @@ -5,9 +5,9 @@ import type { CalendarIntegrationConfig, ConnectionConfig, ConnectionSummary, - Connector, ConnectorDefinitionSummary, ConnectorId, + ConnectorPlugin, GmailIntegrationConfig, GoogleAccountConnectionConfig, IntegrationConfig, @@ -25,7 +25,7 @@ export const DEFAULT_NOTION_TOKEN_CONNECTION_ID = "notion-token-default"; export const DEFAULT_NOTION_OAUTH_CONNECTION_ID = "notion-oauth-default"; export const DEFAULT_APPLE_NOTES_CONNECTION_ID = "apple-notes-local-default"; -export function createDefaultOAuthApps(): OAuthAppConfig[] { +function getFallbackOAuthApps(): OAuthAppConfig[] { return [ { id: DEFAULT_GOOGLE_OAUTH_APP_ID, @@ -40,7 +40,7 @@ export function createDefaultOAuthApps(): OAuthAppConfig[] { ]; } -export function createDefaultConnections(): ConnectionConfig[] { +function getFallbackConnections(): ConnectionConfig[] { return [ { id: DEFAULT_GOOGLE_CONNECTION_ID, @@ -67,7 +67,7 @@ export function createDefaultConnections(): ConnectionConfig[] { ]; } -export function createDefaultIntegrations(): IntegrationConfig[] { +function getFallbackIntegrations(): IntegrationConfig[] { return [ { id: randomUUID(), @@ -113,11 +113,69 @@ export function createDefaultIntegrations(): IntegrationConfig[] { ]; } -export function createDefaultConfig(): SyncdownConfig { +function getConfigPlugins( + plugins: readonly ConnectorPlugin[] = [], +): readonly ConnectorPlugin[] { + return plugins; +} + +function mergeById(values: readonly T[]): T[] { + return [...values].reduceRight((acc, value) => { + if (!acc.some((candidate) => candidate.id === value.id)) { + acc.unshift(value); + } + return acc; + }, []); +} + +function mergeIntegrationsByConnector( + values: readonly IntegrationConfig[], +): IntegrationConfig[] { + return [...values].reduceRight((acc, value) => { + if (!acc.some((candidate) => candidate.connectorId === value.connectorId)) { + acc.unshift(value); + } + return acc; + }, []); +} + +export function createDefaultOAuthApps( + plugins: readonly ConnectorPlugin[] = [], +): OAuthAppConfig[] { + const seeded = getConfigPlugins(plugins).flatMap( + (plugin) => plugin.seedOAuthApps?.() ?? [], + ); + return mergeById([...seeded, ...getFallbackOAuthApps()]); +} + +export function createDefaultConnections( + plugins: readonly ConnectorPlugin[] = [], +): ConnectionConfig[] { + const seeded = getConfigPlugins(plugins).flatMap( + (plugin) => plugin.seedConnections?.() ?? [], + ); + return mergeById([...seeded, ...getFallbackConnections()]); +} + +export function createDefaultIntegrations( + plugins: readonly ConnectorPlugin[] = [], +): IntegrationConfig[] { + const seeded = getConfigPlugins(plugins).flatMap( + (plugin) => plugin.seedIntegrations?.() ?? [], + ); + return mergeIntegrationsByConnector([ + ...seeded, + ...getFallbackIntegrations(), + ]); +} + +export function createDefaultConfig( + plugins: readonly ConnectorPlugin[] = [], +): SyncdownConfig { return { - oauthApps: createDefaultOAuthApps(), - connections: createDefaultConnections(), - integrations: createDefaultIntegrations(), + oauthApps: createDefaultOAuthApps(plugins), + connections: createDefaultConnections(plugins), + integrations: createDefaultIntegrations(plugins), }; } @@ -227,12 +285,12 @@ export function isAppleNotesIntegration( } export function toConnectorDefinitions( - connectors: readonly Connector[], + plugins: readonly ConnectorPlugin[], ): ConnectorDefinitionSummary[] { - return connectors.map((connector) => ({ - id: connector.id, - label: connector.label, - setupMethods: [...connector.setupMethods], + return plugins.map((plugin) => ({ + id: plugin.id, + label: plugin.label, + setupMethods: [...plugin.setupMethods], })); } @@ -248,7 +306,7 @@ export function toConnectionSummaries( export function toIntegrationSummary( integration: IntegrationConfig, - connector: Connector, + plugin: ConnectorPlugin, lastSyncAt: string | null, ): IntegrationSummary { return { @@ -256,7 +314,7 @@ export function toIntegrationSummary( connectorId: integration.connectorId, connectionId: integration.connectionId, label: integration.label, - setupMethods: [...connector.setupMethods], + setupMethods: [...plugin.setupMethods], enabled: integration.enabled, interval: integration.interval, lastSyncAt, @@ -265,8 +323,9 @@ export function toIntegrationSummary( export function normalizeConfig( parsed: Partial, + plugins: readonly ConnectorPlugin[] = [], ): SyncdownConfig { - const defaults = createDefaultConfig(); + const defaults = createDefaultConfig(plugins); const outputDir = typeof parsed.outputDir === "string" ? parsed.outputDir : undefined; @@ -312,88 +371,14 @@ export function normalizeConfig( return []; } - const googleAccountCandidate = - candidate as Partial; - if ( - candidate.kind === "google-account" && - typeof googleAccountCandidate.oauthAppId === "string" - ) { - return [ - { - id: candidate.id, - kind: "google-account", - label: candidate.label, - oauthAppId: googleAccountCandidate.oauthAppId, - accountEmail: - typeof googleAccountCandidate.accountEmail === "string" - ? googleAccountCandidate.accountEmail - : undefined, - }, - ]; - } - - if (candidate.kind === "notion-token") { - return [ - { - id: candidate.id, - kind: "notion-token", - label: candidate.label, - workspaceName: - typeof (candidate as { workspaceName?: unknown }) - .workspaceName === "string" - ? (candidate as { workspaceName?: string }).workspaceName - : undefined, - }, - ]; + for (const plugin of plugins) { + const normalized = plugin.normalizeConnection?.(candidate); + if (normalized && normalized.length > 0) { + return normalized; + } } - if (candidate.kind === "apple-notes-local") { - return [ - { - id: candidate.id, - kind: "apple-notes-local", - label: candidate.label, - }, - ]; - } - - const notionOauthCandidate = - candidate as Partial; - if ( - candidate.kind === "notion-oauth-account" && - typeof notionOauthCandidate.oauthAppId === "string" - ) { - return [ - { - id: candidate.id, - kind: "notion-oauth-account", - label: candidate.label, - oauthAppId: notionOauthCandidate.oauthAppId, - workspaceId: - typeof notionOauthCandidate.workspaceId === "string" - ? notionOauthCandidate.workspaceId - : undefined, - workspaceName: - typeof notionOauthCandidate.workspaceName === "string" - ? notionOauthCandidate.workspaceName - : undefined, - botId: - typeof notionOauthCandidate.botId === "string" - ? notionOauthCandidate.botId - : undefined, - ownerUserId: - typeof notionOauthCandidate.ownerUserId === "string" - ? notionOauthCandidate.ownerUserId - : undefined, - ownerUserName: - typeof notionOauthCandidate.ownerUserName === "string" - ? notionOauthCandidate.ownerUserName - : undefined, - }, - ]; - } - - return []; + return normalizeLegacyConnection(candidate); }) : defaults.connections; @@ -418,104 +403,204 @@ export function normalizeConfig( return []; } - if (candidate.connectorId === "notion") { - return [ - { - id: candidate.id, - connectorId: "notion", - connectionId: candidate.connectionId, - label: candidate.label, - enabled: candidate.enabled, - interval: candidate.interval, - config: {}, - }, - ]; - } - - if (candidate.connectorId === "gmail") { - const settings = (candidate as Partial) - .config; - return [ - { - id: candidate.id, - connectorId: "gmail", - connectionId: candidate.connectionId, - label: candidate.label, - enabled: candidate.enabled, - interval: candidate.interval, - config: { - fetchConcurrency: - typeof settings?.fetchConcurrency === "number" - ? settings.fetchConcurrency - : 10, - syncFilter: - settings?.syncFilter === "primary-important" - ? "primary-important" - : "primary", - }, - }, - ]; + for (const plugin of plugins) { + const normalized = plugin.normalizeIntegration?.(candidate); + if (normalized && normalized.length > 0) { + return normalized; + } } - if (candidate.connectorId === "google-calendar") { - const settings = (candidate as Partial) - .config; - return [ - { - id: candidate.id, - connectorId: "google-calendar", - connectionId: candidate.connectionId, - label: candidate.label, - enabled: candidate.enabled, - interval: candidate.interval, - config: { - selectedCalendarIds: Array.isArray( - settings?.selectedCalendarIds, - ) - ? [ - ...new Set( - settings.selectedCalendarIds.filter( - (value): value is string => - typeof value === "string" && - value.trim().length > 0, - ), - ), - ] - : [], - }, - }, - ]; - } - - if (candidate.connectorId === "apple-notes") { - return [ - { - id: candidate.id, - connectorId: "apple-notes", - connectionId: candidate.connectionId, - label: candidate.label, - enabled: candidate.enabled, - interval: candidate.interval, - config: {}, - }, - ]; - } - - return []; + return normalizeLegacyIntegration(candidate); }) : defaults.integrations; return { outputDir, - oauthApps: ensureSeededOauthApps(oauthApps), - connections: ensureSeededConnections(connections), - integrations: ensureSeededIntegrations(integrations), + oauthApps: ensureSeededOauthApps(oauthApps, plugins), + connections: ensureSeededConnections(connections, plugins), + integrations: ensureSeededIntegrations(integrations, plugins), }; } -function ensureSeededOauthApps(oauthApps: OAuthAppConfig[]): OAuthAppConfig[] { +function normalizeLegacyConnection( + candidate: Partial, +): ConnectionConfig[] { + const googleAccountCandidate = + candidate as Partial; + if ( + candidate.kind === "google-account" && + typeof googleAccountCandidate.oauthAppId === "string" + ) { + return [ + { + id: candidate.id ?? DEFAULT_GOOGLE_CONNECTION_ID, + kind: "google-account", + label: candidate.label ?? "Google Account", + oauthAppId: googleAccountCandidate.oauthAppId, + accountEmail: + typeof googleAccountCandidate.accountEmail === "string" + ? googleAccountCandidate.accountEmail + : undefined, + }, + ]; + } + + if (candidate.kind === "notion-token") { + return [ + { + id: candidate.id ?? DEFAULT_NOTION_TOKEN_CONNECTION_ID, + kind: "notion-token", + label: candidate.label ?? "Notion Token Connection", + workspaceName: + typeof (candidate as { workspaceName?: unknown }).workspaceName === + "string" + ? (candidate as { workspaceName?: string }).workspaceName + : undefined, + }, + ]; + } + + if (candidate.kind === "apple-notes-local") { + return [ + { + id: candidate.id ?? DEFAULT_APPLE_NOTES_CONNECTION_ID, + kind: "apple-notes-local", + label: candidate.label ?? "Apple Notes Connection", + }, + ]; + } + + const notionOauthCandidate = + candidate as Partial; + if ( + candidate.kind === "notion-oauth-account" && + typeof notionOauthCandidate.oauthAppId === "string" + ) { + return [ + { + id: candidate.id ?? DEFAULT_NOTION_OAUTH_CONNECTION_ID, + kind: "notion-oauth-account", + label: candidate.label ?? "Notion OAuth Connection", + oauthAppId: notionOauthCandidate.oauthAppId, + workspaceId: + typeof notionOauthCandidate.workspaceId === "string" + ? notionOauthCandidate.workspaceId + : undefined, + workspaceName: + typeof notionOauthCandidate.workspaceName === "string" + ? notionOauthCandidate.workspaceName + : undefined, + botId: + typeof notionOauthCandidate.botId === "string" + ? notionOauthCandidate.botId + : undefined, + ownerUserId: + typeof notionOauthCandidate.ownerUserId === "string" + ? notionOauthCandidate.ownerUserId + : undefined, + ownerUserName: + typeof notionOauthCandidate.ownerUserName === "string" + ? notionOauthCandidate.ownerUserName + : undefined, + }, + ]; + } + + return []; +} + +function normalizeLegacyIntegration( + candidate: Partial, +): IntegrationConfig[] { + if (candidate.connectorId === "notion") { + return [ + { + id: candidate.id ?? randomUUID(), + connectorId: "notion", + connectionId: + candidate.connectionId ?? DEFAULT_NOTION_TOKEN_CONNECTION_ID, + label: candidate.label ?? "Notion", + enabled: candidate.enabled ?? false, + interval: candidate.interval ?? "1h", + config: {}, + }, + ]; + } + + if (candidate.connectorId === "gmail") { + const settings = (candidate as Partial).config; + return [ + { + id: candidate.id ?? randomUUID(), + connectorId: "gmail", + connectionId: candidate.connectionId ?? DEFAULT_GOOGLE_CONNECTION_ID, + label: candidate.label ?? "Gmail", + enabled: candidate.enabled ?? false, + interval: candidate.interval ?? "1h", + config: { + fetchConcurrency: + typeof settings?.fetchConcurrency === "number" + ? settings.fetchConcurrency + : 10, + syncFilter: + settings?.syncFilter === "primary-important" + ? "primary-important" + : "primary", + }, + }, + ]; + } + + if (candidate.connectorId === "google-calendar") { + const settings = (candidate as Partial).config; + return [ + { + id: candidate.id ?? randomUUID(), + connectorId: "google-calendar", + connectionId: candidate.connectionId ?? DEFAULT_GOOGLE_CONNECTION_ID, + label: candidate.label ?? "Google Calendar", + enabled: candidate.enabled ?? false, + interval: candidate.interval ?? "1h", + config: { + selectedCalendarIds: Array.isArray(settings?.selectedCalendarIds) + ? [ + ...new Set( + settings.selectedCalendarIds.filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ), + ), + ] + : [], + }, + }, + ]; + } + + if (candidate.connectorId === "apple-notes") { + return [ + { + id: candidate.id ?? randomUUID(), + connectorId: "apple-notes", + connectionId: + candidate.connectionId ?? DEFAULT_APPLE_NOTES_CONNECTION_ID, + label: candidate.label ?? "Apple Notes", + enabled: candidate.enabled ?? false, + interval: candidate.interval ?? "1h", + config: {}, + }, + ]; + } + + return []; +} + +function ensureSeededOauthApps( + oauthApps: OAuthAppConfig[], + plugins: readonly ConnectorPlugin[] = [], +): OAuthAppConfig[] { const values = [...oauthApps]; - const defaults = createDefaultOAuthApps(); + const defaults = createDefaultOAuthApps(plugins); for (const seed of defaults.reverse()) { if (!values.some((oauthApp) => oauthApp.id === seed.id)) { values.unshift(seed); @@ -526,9 +611,10 @@ function ensureSeededOauthApps(oauthApps: OAuthAppConfig[]): OAuthAppConfig[] { function ensureSeededConnections( connections: ConnectionConfig[], + plugins: readonly ConnectorPlugin[] = [], ): ConnectionConfig[] { const values = [...connections]; - const defaults = createDefaultConnections(); + const defaults = createDefaultConnections(plugins); for (const seed of defaults.reverse()) { if (!values.some((connection) => connection.id === seed.id)) { values.unshift(seed); @@ -539,9 +625,10 @@ function ensureSeededConnections( function ensureSeededIntegrations( integrations: IntegrationConfig[], + plugins: readonly ConnectorPlugin[] = [], ): IntegrationConfig[] { const values = [...integrations]; - const defaults = createDefaultIntegrations(); + const defaults = createDefaultIntegrations(plugins); for (const seed of defaults.reverse()) { if ( !values.some( diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 1eb971c..81df2ac 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -3,6 +3,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { ensureAppDirectories, + ensureConfig, readConfig, resolveAppPaths, validateManagedOutputDirectory, @@ -43,6 +44,24 @@ test("readConfig returns defaults when config file is missing", async () => { } }); +test("ensureConfig writes a missing config file and keeps default integration ids stable", async () => { + const { cleanup, paths } = await createTestPaths(); + + try { + await rm(paths.configPath, { force: true }); + + const initial = await ensureConfig(paths); + const reloaded = await ensureConfig(paths); + + expect(await Bun.file(paths.configPath).exists()).toBe(true); + expect(reloaded.integrations.map((integration) => integration.id)).toEqual( + initial.integrations.map((integration) => integration.id), + ); + } finally { + await cleanup(); + } +}); + test("normalizeConfig re-seeds a missing integration per connector without duplicating existing connectors", async () => { const { cleanup, paths, config } = await createTestPaths(); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7642a5e..40ffc3b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,9 +3,12 @@ import { access, mkdir, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { createDefaultConfig, normalizeConfig } from "./config-model.js"; -import type { AppIo, AppPaths, SyncdownConfig } from "./types.js"; - -const DEFAULT_CONFIG: SyncdownConfig = createDefaultConfig(); +import type { + AppIo, + AppPaths, + ConnectorPlugin, + SyncdownConfig, +} from "./types.js"; interface PathResolutionRuntime { env: NodeJS.ProcessEnv; @@ -88,15 +91,32 @@ function resolveHomeDirectory( return home; } -export async function readConfig(paths: AppPaths): Promise { +export async function readConfig( + paths: AppPaths, + plugins: readonly ConnectorPlugin[] = [], +): Promise { const configFile = Bun.file(paths.configPath); if (!(await configFile.exists())) { - return structuredClone(DEFAULT_CONFIG); + return structuredClone(createDefaultConfig(plugins)); } const raw = await configFile.text(); const parsed = JSON.parse(raw) as Partial; - return normalizeConfig(parsed); + return normalizeConfig(parsed, plugins); +} + +export async function ensureConfig( + paths: AppPaths, + plugins: readonly ConnectorPlugin[] = [], +): Promise { + const configFile = Bun.file(paths.configPath); + if (await configFile.exists()) { + return readConfig(paths, plugins); + } + + const config = createDefaultConfig(plugins); + await writeConfig(paths, config); + return config; } export async function ensureAppDirectories(paths: AppPaths): Promise { diff --git a/packages/core/src/execution.ts b/packages/core/src/execution.ts index 104a2cb..47bd91e 100644 --- a/packages/core/src/execution.ts +++ b/packages/core/src/execution.ts @@ -3,6 +3,7 @@ import { findIntegration, findOAuthApp, getDefaultIntegration, + isGoogleAccountConnection, isNotionOAuthConnection, toIntegrationSummary, } from "./config-model.js"; @@ -17,6 +18,7 @@ import { readNotionOAuthConnectionCredentials, refreshNotionAccessToken, } from "./notion-auth.js"; +import { getServicePlugins } from "./plugin.js"; import type { AppRuntime } from "./runtime.js"; import { isSyncCancelledError } from "./session-internals.js"; import type { @@ -24,8 +26,8 @@ import type { AppPaths, AppSnapshot, ConnectionConfig, - Connector, ConnectorId, + ConnectorPlugin, ConnectorSyncRequest, ExitCode, HealthCheck, @@ -47,18 +49,18 @@ export function getNotionConnectionSecretName(connectionId: string): string { } export function buildIntegrationSummary( - connector: Connector, + plugin: ConnectorPlugin, integration: IntegrationConfig, lastSyncAt: string | null, ): IntegrationSummary { - return toIntegrationSummary(integration, connector, lastSyncAt); + return toIntegrationSummary(integration, plugin, lastSyncAt); } -export function getConnectorForIntegration( +export function getPluginForIntegration( services: SyncdownServices, integration: IntegrationConfig, -): Connector | undefined { - return services.connectors.find( +): ConnectorPlugin | undefined { + return getServicePlugins(services).find( (candidate) => candidate.id === integration.connectorId, ); } @@ -76,14 +78,34 @@ export function getRunTargetLabel(target: SyncRunTarget): string { export function getIntegrationRenderVersion( services: SyncdownServices, - integration: IntegrationConfig, + plugin: ConnectorPlugin, + _integration: IntegrationConfig, ): string { - return services.renderer.getVersion(integration.connectorId); + return services.renderer.getVersion(plugin); +} + +function getSetupMethodForConnection( + plugin: ConnectorPlugin, + connection: ConnectionConfig, +) { + return plugin.setupMethods.find( + (setupMethod) => + setupMethod.connectionKind === connection.kind || + setupMethod.connectionId === connection.id || + (setupMethod.kind === "token" && connection.kind === "notion-token") || + (setupMethod.kind === "local" && + connection.kind === "apple-notes-local") || + (isGoogleProviderAuth(setupMethod) && + connection.kind === "google-account") || + (setupMethod.kind === "provider-oauth" && + setupMethod.providerId === "notion" && + connection.kind === "notion-oauth-account"), + ); } async function resolveConnectionAuth( integration: IntegrationConfig, - connector: Connector, + plugin: ConnectorPlugin, config: SyncdownConfig, paths: AppPaths, services: SyncdownServices, @@ -96,22 +118,17 @@ async function resolveConnectionAuth( throw new Error(`Missing connection: ${integration.connectionId}`); } - if ( - integration.connectorId === "gmail" || - integration.connectorId === "google-calendar" - ) { - if (connection.kind !== "google-account") { - throw new Error( - `Integration ${integration.id} requires a google-account connection`, - ); - } - - const googleSetupMethod = connector.setupMethods.find((setupMethod) => - isGoogleProviderAuth(setupMethod), + const setupMethod = getSetupMethodForConnection(plugin, connection); + if (!setupMethod) { + throw new Error( + `Connector ${plugin.id} does not define a setup method for connection kind ${connection.kind}`, ); - if (!googleSetupMethod) { + } + + if (isGoogleProviderAuth(setupMethod)) { + if (!isGoogleAccountConnection(connection)) { throw new Error( - `Connector ${connector.id} is not configured for Google provider auth`, + `Integration ${integration.id} requires a google-account connection`, ); } @@ -137,45 +154,47 @@ async function resolveConnectionAuth( oauthAppId: oauthApp.id, connectionId: connection.id, })), - requiredScopes: googleSetupMethod.requiredScopes, + requiredScopes: setupMethod.requiredScopes, } : null, }; } - if (integration.connectorId === "apple-notes") { - if (connection.kind !== "apple-notes-local") { - throw new Error( - `Integration ${integration.id} requires an apple-notes-local connection`, - ); - } - + if (setupMethod.kind === "local") { return { connection, resolvedAuth: null, }; } - if (connection.kind === "notion-token") { + if (setupMethod.kind === "token") { const token = await services.secrets.getSecret( - getNotionConnectionSecretName(connection.id), + setupMethod.secretName?.(connection.id) ?? + getNotionConnectionSecretName(connection.id), paths, ); - - return { - connection, - resolvedAuth: token + const resolvedAuth = !token + ? null + : connection.kind === "notion-token" ? { - kind: "notion-token", + kind: "notion-token" as const, token, } - : null, + : { + kind: "token" as const, + token, + connectionKind: connection.kind, + }; + + return { + connection, + resolvedAuth, }; } if (!isNotionOAuthConnection(connection)) { throw new Error( - `Integration ${integration.id} requires a notion connection`, + `Integration ${integration.id} requires a supported provider-oauth connection`, ); } @@ -218,7 +237,7 @@ async function resolveConnectionAuth( } export async function buildSyncRequest( - connector: Connector, + plugin: ConnectorPlugin, integration: IntegrationConfig, services: SyncdownServices, config: SyncdownConfig, @@ -233,7 +252,7 @@ export async function buildSyncRequest( ): Promise { const { connection, resolvedAuth } = await resolveConnectionAuth( integration, - connector, + plugin, config, paths, services, @@ -272,11 +291,18 @@ export async function hasIntegrationStoredCredentials( return false; } - if ( - integration.connectorId === "gmail" || - integration.connectorId === "google-calendar" - ) { - if (connection.kind !== "google-account") { + const plugin = getPluginForIntegration(services, integration); + if (!plugin) { + return false; + } + + const setupMethod = getSetupMethodForConnection(plugin, connection); + if (!setupMethod) { + return false; + } + + if (isGoogleProviderAuth(setupMethod)) { + if (!isGoogleAccountConnection(connection)) { return false; } @@ -286,13 +312,14 @@ export async function hasIntegrationStoredCredentials( }); } - if (integration.connectorId === "apple-notes") { + if (setupMethod.kind === "local") { return process.platform === "darwin"; } - if (connection.kind === "notion-token") { + if (setupMethod.kind === "token") { return services.secrets.hasSecret( - getNotionConnectionSecretName(connection.id), + setupMethod.secretName?.(connection.id) ?? + getNotionConnectionSecretName(connection.id), paths, ); } @@ -324,14 +351,14 @@ export async function hasStoredCredentials( export function getEnabledIntegrations( services: SyncdownServices, config: SyncdownConfig, -): Array<{ connector: Connector; integration: IntegrationConfig }> { +): Array<{ plugin: ConnectorPlugin; integration: IntegrationConfig }> { return config.integrations.flatMap((integration) => { if (!integration.enabled) { return []; } - const connector = getConnectorForIntegration(services, integration); - return connector ? [{ connector, integration }] : []; + const plugin = getPluginForIntegration(services, integration); + return plugin ? [{ plugin, integration }] : []; }); } @@ -384,7 +411,7 @@ async function withRetries( } export async function runIntegrationSync({ - connector, + plugin, integration, snapshot, services, @@ -394,7 +421,7 @@ export async function runIntegrationSync({ throwIfCancelled, emitSnapshot, }: { - connector: Connector; + plugin: ConnectorPlugin; integration: IntegrationConfig; snapshot: IntegrationRuntimeSnapshot; services: SyncdownServices; @@ -405,7 +432,11 @@ export async function runIntegrationSync({ emitSnapshot(): void; }): Promise { const startedAt = runtime.now().toISOString(); - const renderVersion = getIntegrationRenderVersion(services, integration); + const renderVersion = getIntegrationRenderVersion( + services, + plugin, + integration, + ); snapshot.running = true; snapshot.status = "running"; snapshot.lastStartedAt = startedAt; @@ -429,7 +460,7 @@ export async function runIntegrationSync({ integrationId: integration.id, connectorId: integration.connectorId, }; - const rendered = services.renderer.render(withIds); + const rendered = services.renderer.render(withIds, plugin); const previousRecord = await services.state.getSourceRecord( integration.id, withIds.sourceId, @@ -510,7 +541,7 @@ export async function runIntegrationSync({ }; const request = await buildSyncRequest( - connector, + plugin, integration, services, appSnapshot.config, @@ -524,7 +555,7 @@ export async function runIntegrationSync({ setProgress, ); request.throwIfCancelled(); - const check = await connector.validate(request); + const check = await plugin.validate(request); request.throwIfCancelled(); io.write(formatHealth(integration.label, check)); @@ -541,7 +572,7 @@ export async function runIntegrationSync({ const result = await withRetries( `${integration.label} sync`, io, - () => connector.sync(request), + () => plugin.sync(request), runtime, ); request.throwIfCancelled(); @@ -616,7 +647,7 @@ export function getTargetIntegrations( services: SyncdownServices, appSnapshot: AppSnapshot, target: SyncRunTarget, -): Array<{ connector: Connector; integration: IntegrationConfig }> { +): Array<{ plugin: ConnectorPlugin; integration: IntegrationConfig }> { if (target.kind === "all") { return getEnabledIntegrations(services, appSnapshot.config); } @@ -632,6 +663,6 @@ export function getTargetIntegrations( return []; } - const connector = getConnectorForIntegration(services, integration); - return connector ? [{ connector, integration }] : []; + const plugin = getPluginForIntegration(services, integration); + return plugin ? [{ plugin, integration }] : []; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 440e2f3..64bf747 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export { createSyncdownApp } from "./app.js"; export { createStdIo, ensureAppDirectories, + ensureConfig, readConfig, resolveAppPaths, validateManagedOutputDirectory, @@ -72,6 +73,7 @@ export { readNotionOAuthConnectionCredentials, refreshNotionAccessToken, } from "./notion-auth.js"; +export { defineConnectorPlugin } from "./plugin.js"; export type { AppIo, AppleNotesIntegrationConfig, @@ -86,11 +88,24 @@ export type { ConnectionKind, ConnectionSummary, Connector, + ConnectorCliAlias, + ConnectorCliAliasContext, + ConnectorConfigFieldDescriptor, + ConnectorConfigFieldType, + ConnectorConfigOption, ConnectorDefinitionSummary, ConnectorId, + ConnectorManifest, + ConnectorOptionLoader, + ConnectorOptionLoaderContext, + ConnectorPlugin, + ConnectorRenderHooks, ConnectorSyncRequest, ConnectorSyncResult, DocumentSink, + GenericConnectionConfig, + GenericIntegrationConfig, + GenericTokenResolvedAuth, GmailIntegrationConfig, GmailIntegrationSettings, GmailSyncFilter, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts new file mode 100644 index 0000000..41a99ed --- /dev/null +++ b/packages/core/src/plugin.ts @@ -0,0 +1,45 @@ +import type { Connector, ConnectorPlugin, SyncdownServices } from "./types.js"; + +export function defineConnectorPlugin( + plugin: TPlugin, +): TPlugin { + return plugin; +} + +function isConnectorPlugin( + connector: Connector | ConnectorPlugin, +): connector is ConnectorPlugin { + return "manifest" in connector && "render" in connector; +} + +export function toConnectorPlugin( + connector: Connector | ConnectorPlugin, +): ConnectorPlugin { + if (isConnectorPlugin(connector)) { + return connector; + } + + return defineConnectorPlugin({ + ...connector, + manifest: { + id: connector.id, + label: connector.label, + setupMethods: [...connector.setupMethods], + }, + render: { + version: "1", + }, + }); +} + +export function getServicePlugins( + services: Pick, +): ConnectorPlugin[] { + if (services.plugins) { + return [...services.plugins]; + } + + return (services.connectors ?? []).map((connector) => + toConnectorPlugin(connector), + ); +} diff --git a/packages/core/src/session.test.ts b/packages/core/src/session.test.ts index d4472d9..846d33b 100644 --- a/packages/core/src/session.test.ts +++ b/packages/core/src/session.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { createSyncdownApp } from "./app.js"; -import { readConfig, writeConfig } from "./config.js"; +import { ensureConfig, writeConfig } from "./config.js"; import { getDefaultIntegration } from "./config-model.js"; import { createConnector, @@ -189,7 +189,7 @@ test("session tracks integrations added or replaced after it opens", async () => ); const session = await app.openSession(createIo()); - const initialConfig = await readConfig(paths); + const initialConfig = await ensureConfig(paths); const originalGoogleCalendarId = getDefaultIntegration( initialConfig, "google-calendar", diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2744e19..a26e168 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -8,6 +8,7 @@ import { resetIntegrationState, runIntegrationSync, } from "./execution.js"; +import { getServicePlugins } from "./plugin.js"; import { acquireRunLock, type RunLockHandle, @@ -59,6 +60,7 @@ async function createSyncSession( io: AppIo, ): Promise { const initialAppSnapshot = await inspect(); + const plugins = getServicePlugins(services); const integrationStates = new Map( initialAppSnapshot.integrations.flatMap((summary) => { const integration = findIntegration( @@ -340,7 +342,7 @@ async function createSyncSession( const runPromise = (async () => { let exitCode = await runIntegrationSync({ - connector: entry.connector, + plugin: entry.plugin, integration: entry.integration, snapshot: integrationState.snapshot, services, @@ -387,16 +389,12 @@ async function createSyncSession( latestSnapshot.config, entry.integration.id, ); - const latestConnector = latestIntegration - ? services.connectors.find( + const latestPlugin = latestIntegration + ? plugins.find( (candidate) => candidate.id === latestIntegration.connectorId, ) : undefined; - if ( - !latestIntegration || - !latestConnector || - !latestIntegration.enabled - ) { + if (!latestIntegration || !latestPlugin || !latestIntegration.enabled) { appendLog( "info", `Integration skipped: ${entry.integration.label} is disabled.`, @@ -411,7 +409,7 @@ async function createSyncSession( integrationState.integration = latestIntegration; exitCode = await runIntegrationSync({ - connector: latestConnector, + plugin: latestPlugin, integration: latestIntegration, snapshot: integrationState.snapshot, services, diff --git a/packages/core/src/test-support.ts b/packages/core/src/test-support.ts index e15c235..c6343a4 100644 --- a/packages/core/src/test-support.ts +++ b/packages/core/src/test-support.ts @@ -2,10 +2,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { writeConfig } from "./config.js"; import { createDefaultConfig, getDefaultIntegration } from "./config-model.js"; +import { defineConnectorPlugin } from "./plugin.js"; import type { AppIo, AppPaths, - Connector, + ConnectorPlugin, ConnectorSyncRequest, ConnectorSyncResult, DocumentSink, @@ -133,8 +134,8 @@ export class StaticSecretsStore implements SecretsStore { } export class TestRenderer implements MarkdownRenderer { - getVersion(connectorId: SourceSnapshot["connectorId"]): string { - switch (connectorId) { + getVersion(plugin: ConnectorPlugin): string { + switch (plugin.id) { case "notion": return "test-renderer-notion-v1"; case "gmail": @@ -144,11 +145,11 @@ export class TestRenderer implements MarkdownRenderer { case "apple-notes": return "test-renderer-apple-notes-v1"; default: - throw new Error(`Unsupported connector: ${connectorId}`); + throw new Error(`Unsupported connector: ${plugin.id}`); } } - render(document: SourceSnapshot): RenderedDocument { + render(document: SourceSnapshot, _plugin: ConnectorPlugin): RenderedDocument { const relativePath = document.pathHint.kind === "message" ? `${document.connectorId}/primary/${document.sourceId}.md` @@ -224,50 +225,66 @@ export function createSource( export function createConnector( id: "notion" | "gmail" | "google-calendar" | "apple-notes", syncImpl?: (request: ConnectorSyncRequest) => Promise, -): Connector { - return { - id, - label: - id === "notion" - ? "Notion" - : id === "gmail" - ? "Gmail" - : id === "google-calendar" - ? "Google Calendar" - : "Apple Notes", - setupMethods: - id === "gmail" +): ConnectorPlugin { + const setupMethods = + id === "gmail" + ? [ + { + kind: "provider-oauth" as const, + providerId: "google" as const, + requiredScopes: ["https://www.googleapis.com/auth/gmail.readonly"], + connectionId: "google-account-default", + connectionKind: "google-account", + }, + ] + : id === "google-calendar" ? [ { - kind: "provider-oauth", - providerId: "google", + kind: "provider-oauth" as const, + providerId: "google" as const, requiredScopes: [ - "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/calendar.readonly", ], + connectionId: "google-account-default", + connectionKind: "google-account", }, ] - : id === "google-calendar" + : id === "apple-notes" ? [ { - kind: "provider-oauth", - providerId: "google", - requiredScopes: [ - "https://www.googleapis.com/auth/calendar.readonly", - ], + kind: "local" as const, + connectionId: "apple-notes-local-default", + connectionKind: "apple-notes-local", }, ] - : id === "apple-notes" - ? [] - : [ - { - kind: "token", - }, - { - kind: "provider-oauth", - providerId: "notion", - requiredScopes: [], + : [ + { + kind: "token" as const, + connectionId: "notion-token-default", + connectionKind: "notion-token", + secretName(connectionId: string) { + return `connections.${connectionId}.token`; }, - ], + }, + { + kind: "provider-oauth" as const, + providerId: "notion" as const, + requiredScopes: [], + connectionId: "notion-oauth-default", + connectionKind: "notion-oauth-account", + }, + ]; + return defineConnectorPlugin({ + id, + label: + id === "notion" + ? "Notion" + : id === "gmail" + ? "Gmail" + : id === "google-calendar" + ? "Google Calendar" + : "Apple Notes", + setupMethods, async validate(): Promise<{ status: "ok"; message: string }> { return { status: "ok", message: "credentials valid" }; }, @@ -285,7 +302,22 @@ export function createConnector( nextCursor: `${id}-cursor`, }; }, - }; + manifest: { + id, + label: + id === "notion" + ? "Notion" + : id === "gmail" + ? "Gmail" + : id === "google-calendar" + ? "Google Calendar" + : "Apple Notes", + setupMethods, + }, + render: { + version: `test-renderer-${id}-v1`, + }, + }); } export async function createTestPaths(): Promise<{ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b3c0c75..e4d7734 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -11,30 +11,73 @@ export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; export type SyncIntervalPreset = "5m" | "15m" | "1h" | "6h" | "24h"; export type ProviderId = "google" | "notion"; -export type ConnectorId = - | "notion" - | "gmail" - | "google-calendar" - | "apple-notes"; -export type ConnectionKind = - | "google-account" - | "notion-token" - | "notion-oauth-account" - | "apple-notes-local"; - -export interface ProviderOAuthSetupMethodDescriptor { +export type ConnectorId = string; +export type ConnectionKind = string; + +export interface BaseSetupMethodDescriptor { + connectionId?: string; + connectionKind?: ConnectionKind; + label?: string; +} + +export interface ProviderOAuthSetupMethodDescriptor + extends BaseSetupMethodDescriptor { kind: "provider-oauth"; providerId: ProviderId; requiredScopes: readonly string[]; } -export interface TokenSetupMethodDescriptor { +export interface TokenSetupMethodDescriptor extends BaseSetupMethodDescriptor { kind: "token"; + secretName?(connectionId: string): string; +} + +export interface LocalSetupMethodDescriptor extends BaseSetupMethodDescriptor { + kind: "local"; +} + +export type ConnectorConfigFieldType = + | "boolean" + | "string" + | "number" + | "enum" + | "string-array" + | "async-multi-select" + | "interval"; + +export interface ConnectorConfigOption { + value: string; + label: string; + description?: string; +} + +export interface ConnectorOptionLoaderContext { + config: SyncdownConfig; + integration: IntegrationConfig; + connection: ConnectionConfig; + io: AppIo; + paths: AppPaths; + secrets: SecretsStore; + resolvedAuth: ResolvedConnectionAuth | null; +} + +export type ConnectorOptionLoader = ( + context: ConnectorOptionLoaderContext, +) => Promise; + +export interface ConnectorConfigFieldDescriptor { + id: string; + label: string; + type: ConnectorConfigFieldType; + description?: string; + options?: readonly ConnectorConfigOption[]; + loadOptions?: ConnectorOptionLoader; } export type SetupMethodDescriptor = | ProviderOAuthSetupMethodDescriptor - | TokenSetupMethodDescriptor; + | TokenSetupMethodDescriptor + | LocalSetupMethodDescriptor; export interface OAuthAppConfig { id: string; @@ -73,6 +116,10 @@ export interface AppleNotesLocalConnectionConfig extends BaseConnectionConfig { kind: "apple-notes-local"; } +export interface GenericConnectionConfig extends BaseConnectionConfig { + metadata?: Record; +} + export type ConnectionConfig = | GoogleAccountConnectionConfig | NotionTokenConnectionConfig @@ -119,6 +166,9 @@ export type AppleNotesIntegrationConfig = BaseIntegrationConfig< "apple-notes", AppleNotesIntegrationSettings >; +export interface GenericIntegrationConfig + extends BaseIntegrationConfig> {} + export type IntegrationConfig = | NotionIntegrationConfig | GmailIntegrationConfig @@ -250,6 +300,12 @@ export interface NotionResolvedAuth { token: string; } +export interface GenericTokenResolvedAuth { + kind: "token"; + token: string; + connectionKind: ConnectionKind; +} + export interface NotionOAuthResolvedAuth { kind: "notion-oauth"; accessToken: string; @@ -262,6 +318,7 @@ export interface NotionOAuthResolvedAuth { export type ResolvedConnectionAuth = | GoogleResolvedAuth + | GenericTokenResolvedAuth | NotionResolvedAuth | NotionOAuthResolvedAuth; @@ -295,9 +352,51 @@ export interface Connector { sync(request: ConnectorSyncRequest): Promise; } +export interface ConnectorRenderHooks { + version: string; + buildRelativePath?(source: SourceSnapshot): string; + extendFrontmatter?(source: SourceSnapshot): Map; +} + +export interface ConnectorCliAliasContext { + config: SyncdownConfig; + io: AppIo; + paths: AppPaths; + secrets: SecretsStore; +} + +export interface ConnectorCliAlias { + key: string; + secret?: boolean; + setValue( + context: ConnectorCliAliasContext, + rawValue: string, + ): Promise; + unsetValue?(context: ConnectorCliAliasContext): Promise; +} + +export interface ConnectorManifest { + id: ConnectorId; + label: string; + setupMethods: readonly SetupMethodDescriptor[]; + supportedPlatforms?: readonly NodeJS.Platform[]; + configFields?: readonly ConnectorConfigFieldDescriptor[]; + cliAliases?: readonly ConnectorCliAlias[]; +} + +export interface ConnectorPlugin extends Connector { + manifest: ConnectorManifest; + render: ConnectorRenderHooks; + seedOAuthApps?(): OAuthAppConfig[]; + seedConnections?(): ConnectionConfig[]; + seedIntegrations?(): IntegrationConfig[]; + normalizeConnection?(entry: Partial): ConnectionConfig[]; + normalizeIntegration?(entry: Partial): IntegrationConfig[]; +} + export interface MarkdownRenderer { - getVersion(connectorId: ConnectorId): string; - render(source: SourceSnapshot): RenderedDocument; + getVersion(plugin: ConnectorPlugin): string; + render(source: SourceSnapshot, plugin: ConnectorPlugin): RenderedDocument; } export interface SinkWriteRequest { @@ -494,7 +593,8 @@ export interface SyncSession { } export interface SyncdownServices { - connectors: Connector[]; + plugins?: ConnectorPlugin[]; + connectors?: Connector[]; renderer: MarkdownRenderer; sink: DocumentSink; state: StateStore; diff --git a/packages/renderer-md/package.json b/packages/renderer-md/package.json index f04a369..32ece8f 100644 --- a/packages/renderer-md/package.json +++ b/packages/renderer-md/package.json @@ -13,6 +13,7 @@ "test": "bun test ./src" }, "dependencies": { + "@syncdown/connectors": "workspace:*", "@syncdown/core": "workspace:*" } } diff --git a/packages/renderer-md/src/frontmatter.ts b/packages/renderer-md/src/frontmatter.ts index 759bd4b..2b499f0 100644 --- a/packages/renderer-md/src/frontmatter.ts +++ b/packages/renderer-md/src/frontmatter.ts @@ -112,13 +112,20 @@ export function buildFrontmatterFields( return fields; } -export function stringifyFrontmatter(document: SourceSnapshot): string { +export function stringifyFrontmatter( + document: SourceSnapshot, + extraFields?: Map, +): string { const lines = ["---"]; for (const [key, value] of buildFrontmatterFields(document)) { appendYamlValue(lines, key, value); } + for (const [key, value] of extraFields ?? []) { + appendYamlValue(lines, key, value); + } + lines.push("---", "", `# ${document.title}`, ""); return lines.join("\n"); } diff --git a/packages/renderer-md/src/index.test.ts b/packages/renderer-md/src/index.test.ts index 71ef8f7..a14ee27 100644 --- a/packages/renderer-md/src/index.test.ts +++ b/packages/renderer-md/src/index.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; +import { createBuiltinConnectorPlugins } from "@syncdown/connectors"; import type { SourceSnapshot } from "@syncdown/core"; import { createMarkdownRenderer } from "./index.js"; @@ -9,6 +10,16 @@ const GMAIL_INTEGRATION_ID = "22222222-2222-4222-8222-222222222222"; const CALENDAR_INTEGRATION_ID = "33333333-3333-4333-8333-333333333333"; const APPLE_NOTES_INTEGRATION_ID = "44444444-4444-4444-8444-444444444444"; +function getPlugin(connectorId: SourceSnapshot["connectorId"]) { + const plugin = createBuiltinConnectorPlugins("darwin").find( + (candidate) => candidate.id === connectorId, + ); + if (!plugin) { + throw new Error(`Missing test plugin for ${connectorId}`); + } + return plugin; +} + function createGmailSnapshot(): SourceSnapshot { return { integrationId: GMAIL_INTEGRATION_ID, @@ -125,7 +136,8 @@ function createAppleNotesSnapshot(): SourceSnapshot { test("gmail message paths render under account, year, and month folders", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createGmailSnapshot()); + const snapshot = createGmailSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.relativePath).toBe( "gmail/owner-example-com/2026/03/launch-status-update-msg-123.md", @@ -135,15 +147,16 @@ test("gmail message paths render under account, year, and month folders", () => test("renderer exposes connector-specific versions", () => { const renderer = createMarkdownRenderer(); - expect(renderer.getVersion("notion")).toBe("1"); - expect(renderer.getVersion("gmail")).toBe("1"); - expect(renderer.getVersion("google-calendar")).toBe("1"); - expect(renderer.getVersion("apple-notes")).toBe("1"); + expect(renderer.getVersion(getPlugin("notion"))).toBe("1"); + expect(renderer.getVersion(getPlugin("gmail"))).toBe("1"); + expect(renderer.getVersion(getPlugin("google-calendar"))).toBe("1"); + expect(renderer.getVersion(getPlugin("apple-notes"))).toBe("1"); }); test("calendar paths use event ids for filenames when available", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createCalendarSnapshot()); + const snapshot = createCalendarSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.relativePath).toBe( "google-calendar/primary-calendar/2026/03/weekly-review-event-123.md", @@ -152,7 +165,8 @@ test("calendar paths use event ids for filenames when available", () => { test("notion database item paths omit integration ids", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createNotionSnapshot()); + const snapshot = createNotionSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.relativePath).toBe( "notion/databases/projects/roadmap-page-123.md", @@ -161,7 +175,8 @@ test("notion database item paths omit integration ids", () => { test("apple notes paths include account and folder", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createAppleNotesSnapshot()); + const snapshot = createAppleNotesSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.relativePath).toBe( "apple-notes/personal-icloud/work/scratchpad/daily-notes-note-id-123.md", @@ -170,7 +185,8 @@ test("apple notes paths include account and folder", () => { test("gmail frontmatter includes gmail metadata fields", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createGmailSnapshot()); + const snapshot = createGmailSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.contents).not.toMatch(/^archived:/m); expect(document.contents).not.toMatch(/^integration_id:/m); @@ -193,7 +209,7 @@ test("gmail frontmatter includes gmail metadata fields", () => { test("gmail account path segments are slugified from the raw email", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render({ + const snapshot: SourceSnapshot = { ...createGmailSnapshot(), pathHint: { kind: "message", @@ -203,7 +219,8 @@ test("gmail account path segments are slugified from the raw email", () => { ...createGmailSnapshot().metadata, gmailAccountEmail: " User.Name+Alias@Example.COM ", }, - }); + }; + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.relativePath).toBe( "gmail/user-name-alias-example-com/2026/03/launch-status-update-msg-123.md", @@ -212,7 +229,8 @@ test("gmail account path segments are slugified from the raw email", () => { test("notion frontmatter excludes archived while keeping notion metadata fields", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createNotionSnapshot()); + const snapshot = createNotionSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.contents).not.toMatch(/^archived:/m); expect(document.contents).not.toMatch(/^integration_id:/m); @@ -230,7 +248,8 @@ test("notion frontmatter excludes archived while keeping notion metadata fields" test("apple notes frontmatter stays user-facing", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createAppleNotesSnapshot()); + const snapshot = createAppleNotesSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.contents).toMatch(/^folder: "Work\/Scratchpad"$/m); expect(document.contents).not.toMatch(/^folder_path:/m); @@ -239,7 +258,7 @@ test("apple notes frontmatter stays user-facing", () => { test("notion frontmatter flattens normalized property keys", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render({ + const snapshot = { ...createNotionSnapshot(), metadata: { ...createNotionSnapshot().metadata, @@ -254,7 +273,8 @@ test("notion frontmatter flattens normalized property keys", () => { }, }, }, - }); + }; + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.contents).toMatch(/^due_date: "2026-03-17"$/m); expect(document.contents).toMatch(/^담당자_이름: "홍길동"$/m); @@ -267,7 +287,7 @@ test("notion frontmatter flattens normalized property keys", () => { test("notion property collisions overwrite frontmatter fields only", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render({ + const snapshot = { ...createNotionSnapshot(), metadata: { ...createNotionSnapshot().metadata, @@ -277,7 +297,8 @@ test("notion property collisions overwrite frontmatter fields only", () => { Created: "2026-03-01T00:00:00.000Z", }, }, - }); + }; + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.relativePath).toBe( "notion/databases/projects/roadmap-page-123.md", @@ -293,7 +314,8 @@ test("notion property collisions overwrite frontmatter fields only", () => { test("calendar frontmatter stays user-facing without syncdown namespacing", () => { const renderer = createMarkdownRenderer(); - const document = renderer.render(createCalendarSnapshot()); + const snapshot = createCalendarSnapshot(); + const document = renderer.render(snapshot, getPlugin(snapshot.connectorId)); expect(document.contents).toMatch( /^source: "https:\/\/calendar\.google\.com\/event\?eid=123"$/m, diff --git a/packages/renderer-md/src/renderer.ts b/packages/renderer-md/src/renderer.ts index 319c048..d868ec3 100644 --- a/packages/renderer-md/src/renderer.ts +++ b/packages/renderer-md/src/renderer.ts @@ -1,5 +1,5 @@ import type { - ConnectorId, + ConnectorPlugin, MarkdownRenderer, RenderedDocument, SourceSnapshot, @@ -7,19 +7,21 @@ import type { import { stringifyFrontmatter } from "./frontmatter.js"; import { buildRelativePath } from "./path-builder.js"; -import { MARKDOWN_RENDERER_VERSIONS } from "./versions.js"; class DefaultMarkdownRenderer implements MarkdownRenderer { - getVersion(connectorId: ConnectorId): string { - return MARKDOWN_RENDERER_VERSIONS[connectorId]; + getVersion(plugin: ConnectorPlugin): string { + return plugin.render.version; } - render(document: SourceSnapshot): RenderedDocument { + render(document: SourceSnapshot, plugin: ConnectorPlugin): RenderedDocument { + const extraFrontmatter = plugin.render.extendFrontmatter?.(document); return { sourceId: document.sourceId, title: document.title, - relativePath: buildRelativePath(document), - contents: `${stringifyFrontmatter(document)}${document.bodyMd}\n`, + relativePath: + plugin.render.buildRelativePath?.(document) ?? + buildRelativePath(document), + contents: `${stringifyFrontmatter(document, extraFrontmatter)}${document.bodyMd}\n`, sourceHash: document.sourceHash, }; } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 2ba9386..7e85ce6 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -13,11 +13,11 @@ import { DEFAULT_NOTION_TOKEN_CONNECTION_ID, EXIT_CODES, ensureAppDirectories, + ensureConfig, getGoogleConnectionSecretNames, getGoogleOAuthAppSecretNames, getNotionOAuthAppSecretNames, getNotionOAuthConnectionSecretNames, - readConfig, resolveAppPaths, } from "@syncdown/core"; @@ -60,7 +60,7 @@ export async function launchConfigTui( const paths = resolveAppPaths(); await ensureAppDirectories(paths); - const currentConfig = await readConfig(paths); + const currentConfig = await ensureConfig(paths); const [ notionTokenStored, notionOauthClientIdStored, diff --git a/templates/connector-package/README.md b/templates/connector-package/README.md new file mode 100644 index 0000000..ab85aa4 --- /dev/null +++ b/templates/connector-package/README.md @@ -0,0 +1,11 @@ +# Connector Package Template + +이 템플릿은 새 first-party connector를 추가할 때 시작점으로 쓰기 위한 스캐폴드입니다. + +절차: + +1. `templates/connector-package`를 `packages/connector-`로 복사합니다. +2. ``, `