diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..4a00273 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,19 @@ +# Copy to .env.local and fill values. Do NOT commit .env.local + +TASK_SYNC_PROVIDER_A=google +TASK_SYNC_PROVIDER_B=microsoft + +TASK_SYNC_STATE_DIR=.task-sync +TASK_SYNC_LOG_LEVEL=info + +# Google Tasks +TASK_SYNC_GOOGLE_CLIENT_ID= +TASK_SYNC_GOOGLE_CLIENT_SECRET= +TASK_SYNC_GOOGLE_REFRESH_TOKEN= +TASK_SYNC_GOOGLE_TASKLIST_ID=@default + +# Microsoft To Do +TASK_SYNC_MS_CLIENT_ID= +TASK_SYNC_MS_TENANT_ID=common +TASK_SYNC_MS_REFRESH_TOKEN= +# TASK_SYNC_MS_LIST_ID= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..916206e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: CI +on: [push, pull_request] +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/.gitignore b/.gitignore index 58aeaba..9b615bb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,9 @@ yarn-error.log* # local state .task-sync/ +# local env +.env.local +.env + # OS .DS_Store diff --git a/README.md b/README.md index 286545a..2cd9ac6 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,11 @@ # task-sync -Sync tasks between **Microsoft To Do (Microsoft Graph)** and **Google Tasks**. +Sync tasks between **Google Tasks** and **Microsoft To Do**. -This repo currently contains a solid **MVP scaffolding**: +Providers: -- A working CLI (`task-sync`) with: - - `task-sync doctor` → checks config/env - - `task-sync sync --dry-run` → runs the sync engine using **mock providers** (no API keys required) - - `task-sync sync` → intended for real providers (currently scaffolded; will error with clear instructions) -- A minimal sync engine: - - Canonical `Task` model - - JSON state store under `.task-sync/state.json` - - Mapping between provider IDs - - Conflict policy: **last-write-wins** (by `updatedAt`) - - “Zombie prevention”: completed/deleted tasks produce **tombstones** to avoid resurrecting them later -- Unit tests (Vitest) - -## MVP scope (what works today) - -✅ Works: - -- Project builds (`npm run build`) -- Tests pass (`npm test`) -- Dry-run sync with mock providers (`task-sync sync --dry-run`) -- State store + mapping + tombstones logic - -🚧 Not yet implemented (by design for this MVP): - -- Real Google Tasks API calls -- Real Microsoft Graph API calls -- OAuth flows / token refresh - -Those are intentionally left as **scaffolds** so you can add keys/tokens when ready. +- Google Tasks (OAuth refresh-token) +- Microsoft To Do via Microsoft Graph (OAuth refresh-token) ## Quickstart @@ -45,45 +19,73 @@ Those are intentionally left as **scaffolds** so you can add keys/tokens when re npm install ``` -### Run health check +### Build + run doctor ```bash npm run build node dist/cli.js doctor -# or after global install: task-sync doctor ``` -### Run dry-run sync (no API keys) +### Run sync once ```bash -npm run build -node dist/cli.js sync --dry-run +node dist/cli.js sync +``` + +### Polling mode + +```bash +# every 5 minutes +node dist/cli.js sync --poll 5 + +# or env +export TASK_SYNC_POLL_INTERVAL_MINUTES=5 +node dist/cli.js sync ``` -You should see a JSON report describing the actions the engine would take. +### Dry-run + +Dry-run still uses your configured providers, but **does not write** any changes. -## Configuration (for when real providers are implemented) +```bash +node dist/cli.js sync --dry-run +``` -Set these env vars (placeholders for next steps): +## Configuration (.env) + +Create a `.env.local` (recommended) or `.env`: ### Provider selection -- `TASK_SYNC_PROVIDER_A` = `google` | `microsoft` -- `TASK_SYNC_PROVIDER_B` = `google` | `microsoft` +```bash +TASK_SYNC_PROVIDER_A=google +TASK_SYNC_PROVIDER_B=microsoft +``` + +### State + +```bash +TASK_SYNC_STATE_DIR=.task-sync +TASK_SYNC_LOG_LEVEL=info +``` -### Google Tasks (scaffold) +### Google Tasks -- `TASK_SYNC_GOOGLE_CLIENT_ID` -- `TASK_SYNC_GOOGLE_CLIENT_SECRET` -- `TASK_SYNC_GOOGLE_REFRESH_TOKEN` -- `TASK_SYNC_GOOGLE_TASKLIST_ID` (optional; defaults to `@default`) +```bash +TASK_SYNC_GOOGLE_CLIENT_ID=... +TASK_SYNC_GOOGLE_CLIENT_SECRET=... +TASK_SYNC_GOOGLE_REFRESH_TOKEN=... +TASK_SYNC_GOOGLE_TASKLIST_ID=@default # optional +``` -### Microsoft Graph / To Do (scaffold) +### Microsoft To Do (Graph) -- `TASK_SYNC_MS_CLIENT_ID` -- `TASK_SYNC_MS_TENANT_ID` -- `TASK_SYNC_MS_REFRESH_TOKEN` -- `TASK_SYNC_MS_LIST_ID` (optional) +```bash +TASK_SYNC_MS_CLIENT_ID=... +TASK_SYNC_MS_TENANT_ID=common # or your tenant id +TASK_SYNC_MS_REFRESH_TOKEN=... +TASK_SYNC_MS_LIST_ID=... # optional (defaults to first list) +``` Run: @@ -91,9 +93,48 @@ Run: task-sync doctor ``` -to see what’s missing. +to see what's missing. + +## OAuth helper scripts (refresh tokens) + +These scripts spin up a local HTTP callback server, print an auth URL, and on success print the refresh token. + +### Google refresh token + +1) Create OAuth credentials in Google Cloud Console: +- APIs & Services → Credentials +- Create Credentials → OAuth client ID +- Application type: **Desktop app** (recommended) +- Enable the **Google Tasks API** on the project + +2) Set env vars and run: + +```bash +export TASK_SYNC_GOOGLE_CLIENT_ID=... +export TASK_SYNC_GOOGLE_CLIENT_SECRET=... +npm run oauth:google +``` + +### Microsoft refresh token + +1) Create an app registration in Azure: +- Azure Portal → App registrations → New registration +- Add a **redirect URI** (platform: *Mobile and desktop applications*): + - `http://localhost:53683/callback` +- API permissions (Delegated): + - `offline_access` + - `User.Read` + - `Tasks.ReadWrite` -## How state works (.task-sync/) +2) Run: + +```bash +export TASK_SYNC_MS_CLIENT_ID=... +export TASK_SYNC_MS_TENANT_ID=common +npm run oauth:microsoft +``` + +## How state works `task-sync` writes local state under: @@ -103,9 +144,9 @@ This includes: - `lastSyncAt` watermark (ISO timestamp) - `mappings`: links a canonical ID to provider IDs -- `tombstones`: prevents resurrecting completed/deleted tasks +- `tombstones`: prevents resurrecting deleted tasks -You can delete `.task-sync/` to reset state. +Delete `.task-sync/` to reset sync state. ## Development @@ -117,16 +158,6 @@ npm run lint npm run typecheck ``` -## Next steps (planned) - -- Implement GoogleTasksProvider using Google Tasks API -- Implement MicrosoftTodoProvider using Microsoft Graph -- Add real delta queries (list only changed tasks since watermark) -- Improve conflict handling: - - per-field merge strategies - - better deletion semantics -- Add a persistent DB store option (SQLite) - ## License MIT (see LICENSE) diff --git a/package-lock.json b/package-lock.json index 3ab99bf..4246073 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "task-sync", - "version": "1.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "task-sync", - "version": "1.0.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "commander": "^12.1.0", diff --git a/package.json b/package.json index 6b3ec5e..3cc7232 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "task-sync", "version": "0.1.0", - "description": "Sync tasks between Microsoft To Do and Google Tasks", + "description": "Sync tasks between Google Tasks and Microsoft To Do", "main": "index.js", "scripts": { "test": "vitest run", @@ -9,7 +9,9 @@ "dev": "tsx src/cli.ts", "test:watch": "vitest", "lint": "eslint .", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "oauth:google": "tsx scripts/google_oauth.ts", + "oauth:microsoft": "tsx scripts/microsoft_oauth.ts" }, "repository": { "type": "git", diff --git a/scripts/dev/e2e.ts b/scripts/dev/e2e.ts new file mode 100644 index 0000000..0c1106b --- /dev/null +++ b/scripts/dev/e2e.ts @@ -0,0 +1,124 @@ +import { loadEnvFiles } from '../../src/env.js'; +import { readEnv } from '../../src/config.js'; +import { GoogleTasksProvider } from '../../src/providers/google.js'; +import { MicrosoftTodoProvider } from '../../src/providers/microsoft.js'; +import type { Task } from '../../src/model.js'; + +const PREFIX = '[task-sync e2e]'; + +type ProviderKey = 'google' | 'microsoft'; + +function usage(): never { + console.error('Usage: tsx scripts/dev/e2e.ts [provider]'); + console.error(' provider optional: google|microsoft (default: all configured)'); + process.exit(2); +} + +function makeProviders(env: ReturnType) { + const providers = new Map; upsertTask(i: Omit & { updatedAt?: string }): Promise; deleteTask(id: string): Promise } }>(); + + providers.set('google', { + name: 'google', + p: new GoogleTasksProvider({ + clientId: env.TASK_SYNC_GOOGLE_CLIENT_ID!, + clientSecret: env.TASK_SYNC_GOOGLE_CLIENT_SECRET!, + refreshToken: env.TASK_SYNC_GOOGLE_REFRESH_TOKEN!, + tasklistId: env.TASK_SYNC_GOOGLE_TASKLIST_ID, + }), + }); + + providers.set('microsoft', { + name: 'microsoft', + p: new MicrosoftTodoProvider({ + clientId: env.TASK_SYNC_MS_CLIENT_ID!, + tenantId: env.TASK_SYNC_MS_TENANT_ID!, + refreshToken: env.TASK_SYNC_MS_REFRESH_TOKEN!, + listId: env.TASK_SYNC_MS_LIST_ID, + }), + }); + + return providers; +} + +function configuredProviders(env: ReturnType): ProviderKey[] { + const list = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B].filter(Boolean) as ProviderKey[]; + return [...new Set(list)]; +} + +async function seedOne(p: { upsertTask(i: Omit & { updatedAt?: string }): Promise }, title: string) { + const t = await p.upsertTask({ + id: '', + title, + notes: 'e2e seed', + status: 'active', + dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }); + return t; +} + +async function listTagged(p: { listTasks(): Promise }): Promise { + const tasks = await p.listTasks(); + return tasks.filter((t) => t.title.startsWith(PREFIX)); +} + +async function cleanupTagged(p: { listTasks(): Promise; deleteTask(id: string): Promise }) { + const tagged = await listTagged(p); + for (const t of tagged) await p.deleteTask(t.id); + return tagged.length; +} + +async function main() { + loadEnvFiles(); + const env = readEnv(); + + const cmd = (process.argv[2] as string | undefined) ?? ''; + const providerArg = process.argv[3] as ProviderKey | undefined; + + const configured = providerArg ? [providerArg] : configuredProviders(env); + if (!configured.length) { + console.error('No providers configured. Set TASK_SYNC_PROVIDER_A/B.'); + process.exit(2); + } + + const providers = makeProviders(env); + + if (cmd === 'seed') { + const now = new Date().toISOString(); + for (const k of configured) { + const entry = providers.get(k); + if (!entry) continue; + const title = `${PREFIX} ${k} seed ${now}`; + const t = await seedOne(entry.p, title); + console.log(`${k}: seeded id=${t.id} title=${JSON.stringify(t.title)}`); + } + return; + } + + if (cmd === 'list') { + for (const k of configured) { + const entry = providers.get(k); + if (!entry) continue; + const tagged = await listTagged(entry.p); + console.log(`${k}: tagged=${tagged.length}`); + for (const t of tagged) console.log(`- ${t.id} ${t.status} ${JSON.stringify(t.title)}`); + } + return; + } + + if (cmd === 'cleanup') { + for (const k of configured) { + const entry = providers.get(k); + if (!entry) continue; + const n = await cleanupTagged(entry.p); + console.log(`${k}: deleted=${n}`); + } + return; + } + + usage(); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/dev/mutate.ts b/scripts/dev/mutate.ts new file mode 100644 index 0000000..ca1fb9b --- /dev/null +++ b/scripts/dev/mutate.ts @@ -0,0 +1,95 @@ +import { loadEnvFiles } from '../../src/env.js'; +import { readEnv } from '../../src/config.js'; +import type { Task } from '../../src/model.js'; +import type { TaskProvider } from '../../src/providers/provider.js'; +import { GoogleTasksProvider } from '../../src/providers/google.js'; +import { MicrosoftTodoProvider } from '../../src/providers/microsoft.js'; + +/** + * Dev helper: mutate a single task by exact title match. + * + * Usage: + * tsx scripts/dev/mutate.ts [noteText] + */ + +type ProviderKey = 'google' | 'microsoft'; +type Action = 'delete' | 'complete' | 'activate' | 'note'; + +function usage(): never { + console.error('Usage: tsx scripts/dev/mutate.ts <google|microsoft> <delete|complete|activate|note> <title> [noteText]'); + process.exit(2); +} + +async function main() { + loadEnvFiles(); + const env = readEnv(); + + const providerName = process.argv[2] as ProviderKey | undefined; + const action = process.argv[3] as Action | undefined; + const title = process.argv.slice(4).join(' ').trim(); + + if (!providerName || !action || !title) usage(); + + const google = new GoogleTasksProvider({ + clientId: env.TASK_SYNC_GOOGLE_CLIENT_ID!, + clientSecret: env.TASK_SYNC_GOOGLE_CLIENT_SECRET!, + refreshToken: env.TASK_SYNC_GOOGLE_REFRESH_TOKEN!, + tasklistId: env.TASK_SYNC_GOOGLE_TASKLIST_ID, + }); + const microsoft = new MicrosoftTodoProvider({ + clientId: env.TASK_SYNC_MS_CLIENT_ID!, + tenantId: env.TASK_SYNC_MS_TENANT_ID!, + refreshToken: env.TASK_SYNC_MS_REFRESH_TOKEN!, + listId: env.TASK_SYNC_MS_LIST_ID, + }); + + const map: Record<ProviderKey, TaskProvider> = { google, microsoft }; + const p = map[providerName]; + + const tasks: Task[] = await p.listTasks(); + const matches = tasks.filter((t) => t.title === title); + if (!matches.length) { + console.error(`No match for title=${JSON.stringify(title)} in ${providerName}`); + process.exit(2); + } + + const t = matches[0]; + console.log(`${providerName}: matched ${matches.length} tasks; using id=${t.id}`); + + if (action === 'delete') { + await p.deleteTask(t.id); + console.log('deleted'); + return; + } + + if (action === 'complete') { + await p.upsertTask({ ...t, status: 'completed' }); + console.log('completed'); + return; + } + + if (action === 'activate') { + await p.upsertTask({ ...t, status: 'active' }); + console.log('activated'); + return; + } + + if (action === 'note') { + const noteText = process.argv.slice(5).join(' ').trim(); + if (!noteText) { + console.error('note action requires noteText'); + process.exit(2); + } + await p.upsertTask({ ...t, notes: noteText }); + console.log('noted'); + return; + } + + console.error(`Unknown action: ${String(action)}`); + process.exit(2); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/dev/purge_all_lists.ts b/scripts/dev/purge_all_lists.ts new file mode 100644 index 0000000..36d91f5 --- /dev/null +++ b/scripts/dev/purge_all_lists.ts @@ -0,0 +1,182 @@ +import { loadEnvFiles } from '../../src/env.js'; +import { readEnv } from '../../src/config.js'; + +type FetchLike = typeof fetch; + +type QueryValue = string | number | boolean | null | undefined; +type RequestInitWithQuery = RequestInit & { query?: Record<string, QueryValue> }; + +async function requestJson<T>(url: string, init: RequestInitWithQuery = {}, fetcher: FetchLike = fetch): Promise<T> { + const u = new URL(url); + if (init.query) { + for (const [k, v] of Object.entries(init.query)) { + if (v === undefined || v === null || v === '') continue; + u.searchParams.set(k, String(v)); + } + } + const res = await fetcher(u.toString(), { + ...init, + headers: { + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + body: + init.body && typeof init.body !== 'string' && !(init.body instanceof URLSearchParams) + ? JSON.stringify(init.body) + : (init.body as BodyInit | null | undefined), + }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status} for ${u.toString()} ${txt}`); + } + if (res.status === 204) return undefined as unknown as T; + return (await res.json()) as T; +} + +async function getGoogleAccessToken(env: ReturnType<typeof readEnv>, fetcher: FetchLike = fetch) { + const body = new URLSearchParams({ + client_id: env.TASK_SYNC_GOOGLE_CLIENT_ID!, + client_secret: env.TASK_SYNC_GOOGLE_CLIENT_SECRET!, + refresh_token: env.TASK_SYNC_GOOGLE_REFRESH_TOKEN!, + grant_type: 'refresh_token', + }); + const res = await fetcher('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`Google token refresh failed: HTTP ${res.status} ${txt}`); + } + const json = (await res.json()) as { access_token: string }; + return json.access_token; +} + +async function getMicrosoftAccessToken(env: ReturnType<typeof readEnv>, fetcher: FetchLike = fetch) { + const body = new URLSearchParams({ + client_id: env.TASK_SYNC_MS_CLIENT_ID!, + refresh_token: env.TASK_SYNC_MS_REFRESH_TOKEN!, + grant_type: 'refresh_token', + scope: 'offline_access https://graph.microsoft.com/Tasks.ReadWrite https://graph.microsoft.com/User.Read', + }); + const url = `https://login.microsoftonline.com/${encodeURIComponent(env.TASK_SYNC_MS_TENANT_ID!)}/oauth2/v2.0/token`; + const res = await fetcher(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`Microsoft token refresh failed: HTTP ${res.status} ${txt}`); + } + const json = (await res.json()) as { access_token: string; refresh_token?: string }; + return { accessToken: json.access_token, rotatedRefreshToken: json.refresh_token }; +} + +async function purgeGoogleAllLists(env: ReturnType<typeof readEnv>) { + console.log('GOOGLE: purge all tasklists'); + const token = await getGoogleAccessToken(env); + const base = 'https://tasks.googleapis.com/tasks/v1'; + + type ListResp = { items?: Array<{ id: string; title: string }>; nextPageToken?: string }; + let pageToken: string | undefined; + const lists: Array<{ id: string; title: string }> = []; + do { + const r = await requestJson<ListResp>(`${base}/users/@me/lists`, { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + query: { maxResults: 100, pageToken }, + }); + for (const it of r.items ?? []) lists.push({ id: it.id, title: it.title }); + pageToken = r.nextPageToken; + } while (pageToken); + + console.log(`GOOGLE: found ${lists.length} tasklists`); + + let totalDeleted = 0; + for (const l of lists) { + type TasksResp = { items?: Array<{ id: string; title: string }>; nextPageToken?: string }; + let tPage: string | undefined; + const taskIds: string[] = []; + do { + const r = await requestJson<TasksResp>(`${base}/lists/${encodeURIComponent(l.id)}/tasks`, { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + query: { maxResults: 100, showCompleted: true, showHidden: true, pageToken: tPage }, + }); + for (const t of r.items ?? []) taskIds.push(t.id); + tPage = r.nextPageToken; + } while (tPage); + + console.log(`GOOGLE: list=${JSON.stringify(l.title)} id=${l.id} tasks=${taskIds.length}`); + + for (const id of taskIds) { + await requestJson<void>(`${base}/lists/${encodeURIComponent(l.id)}/tasks/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${token}` }, + }); + totalDeleted++; + } + } + + console.log(`GOOGLE: deleted ${totalDeleted} tasks across all lists`); +} + +async function purgeMicrosoftAllLists(env: ReturnType<typeof readEnv>) { + console.log('MICROSOFT: purge all To Do lists'); + const { accessToken } = await getMicrosoftAccessToken(env); + const base = 'https://graph.microsoft.com/v1.0'; + + type ListsResp = { value: Array<{ id: string; displayName: string }> }; + const lists = await requestJson<ListsResp>(`${base}/me/todo/lists`, { + method: 'GET', + headers: { authorization: `Bearer ${accessToken}` }, + }); + + console.log(`MICROSOFT: found ${lists.value.length} lists`); + + let totalDeleted = 0; + for (const l of lists.value) { + type TasksResp = { value: Array<{ id: string; title: string }>; '@odata.nextLink'?: string }; + let next: string | undefined = `${base}/me/todo/lists/${encodeURIComponent(l.id)}/tasks?$top=100`; + const taskIds: string[] = []; + while (next) { + const r = await requestJson<TasksResp>(next, { + method: 'GET', + headers: { authorization: `Bearer ${accessToken}` }, + }); + for (const t of r.value ?? []) taskIds.push(t.id); + next = r['@odata.nextLink']; + } + + console.log(`MICROSOFT: list=${JSON.stringify(l.displayName)} id=${l.id} tasks=${taskIds.length}`); + + for (const id of taskIds) { + await requestJson<void>(`${base}/me/todo/lists/${encodeURIComponent(l.id)}/tasks/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${accessToken}` }, + }); + totalDeleted++; + } + } + + console.log(`MICROSOFT: deleted ${totalDeleted} tasks across all lists`); +} + +async function main() { + loadEnvFiles(); + const env = readEnv(); + + console.log('PURGE ALL LISTS: deleting tasks across ALL lists in Google Tasks + Microsoft To Do.'); + + await purgeGoogleAllLists(env); + await purgeMicrosoftAllLists(env); + + console.log('PURGE ALL LISTS DONE'); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/google_oauth.ts b/scripts/google_oauth.ts new file mode 100644 index 0000000..1d87b70 --- /dev/null +++ b/scripts/google_oauth.ts @@ -0,0 +1,120 @@ +import http from 'node:http'; +import { once } from 'node:events'; + +const port = Number(process.env.TASK_SYNC_GOOGLE_OAUTH_PORT ?? 53682); +const redirectUri = `http://localhost:${port}/callback`; + +const clientId = process.env.TASK_SYNC_GOOGLE_CLIENT_ID; +const clientSecret = process.env.TASK_SYNC_GOOGLE_CLIENT_SECRET; + +if (!clientId || !clientSecret) { + console.error('Missing env vars: TASK_SYNC_GOOGLE_CLIENT_ID, TASK_SYNC_GOOGLE_CLIENT_SECRET'); + process.exit(2); +} + +const scopes = ['https://www.googleapis.com/auth/tasks']; + +const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); +authUrl.searchParams.set('client_id', clientId); +authUrl.searchParams.set('redirect_uri', redirectUri); +authUrl.searchParams.set('response_type', 'code'); +authUrl.searchParams.set('scope', scopes.join(' ')); +authUrl.searchParams.set('access_type', 'offline'); +authUrl.searchParams.set('prompt', 'consent'); + +type TokenResponse = { + access_token: string; + expires_in: number; + refresh_token?: string; + scope: string; + token_type: string; +}; + +async function exchange(code: string): Promise<TokenResponse> { + const body = new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`Token exchange failed: HTTP ${res.status} ${txt}`); + } + + return (await res.json()) as TokenResponse; +} + +async function main() { + console.log('Google OAuth (Installed app) refresh-token helper'); + console.log('Redirect URI:', redirectUri); + console.log('\n1) Open this URL in your browser and consent:'); + console.log(authUrl.toString()); + + const server = http + .createServer(async (req, res) => { + try { + const u = new URL(req.url ?? '/', `http://localhost:${port}`); + if (u.pathname !== '/callback') { + res.writeHead(404); + res.end('Not found'); + return; + } + + const code = u.searchParams.get('code'); + const err = u.searchParams.get('error'); + if (err) { + res.writeHead(400); + res.end(`OAuth error: ${err}`); + return; + } + if (!code) { + res.writeHead(400); + res.end('Missing code'); + return; + } + + const token = await exchange(code); + + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('Done. You can close this tab and go back to your terminal.'); + + console.log('\n2) Tokens received:'); + console.log('- access_token:', token.access_token); + console.log('- refresh_token:', token.refresh_token ?? '(missing)'); + + if (!token.refresh_token) { + console.log( + '\nNOTE: No refresh_token returned. Common fixes: remove prior consent in Google Account security, then re-run; ensure prompt=consent + access_type=offline.', + ); + } + + console.log('\n3) Set env vars:'); + if (token.refresh_token) console.log(`TASK_SYNC_GOOGLE_REFRESH_TOKEN=${token.refresh_token}`); + + server.close(); + } catch (e) { + res.writeHead(500); + res.end('Internal error'); + console.error(e); + server.close(); + process.exitCode = 1; + } + }) + .listen(port); + + await once(server, 'listening'); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/microsoft_oauth.ts b/scripts/microsoft_oauth.ts new file mode 100644 index 0000000..f3fb887 --- /dev/null +++ b/scripts/microsoft_oauth.ts @@ -0,0 +1,122 @@ +import http from 'node:http'; +import { once } from 'node:events'; + +const port = Number(process.env.TASK_SYNC_MS_OAUTH_PORT ?? 53683); +const redirectUri = `http://localhost:${port}/callback`; + +const clientId = process.env.TASK_SYNC_MS_CLIENT_ID; +const tenantId = process.env.TASK_SYNC_MS_TENANT_ID ?? 'common'; + +if (!clientId) { + console.error('Missing env var: TASK_SYNC_MS_CLIENT_ID'); + process.exit(2); +} + +// Use Microsoft Graph resource scopes (required for consumer accounts and avoids ambiguous scope errors) +const scopes = [ + 'offline_access', + 'https://graph.microsoft.com/User.Read', + 'https://graph.microsoft.com/Tasks.ReadWrite', +]; + +const authUrl = new URL(`https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/authorize`); +authUrl.searchParams.set('client_id', clientId); +authUrl.searchParams.set('redirect_uri', redirectUri); +authUrl.searchParams.set('response_type', 'code'); +authUrl.searchParams.set('response_mode', 'query'); +authUrl.searchParams.set('scope', scopes.join(' ')); + +type TokenResponse = { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token?: string; +}; + +async function exchange(code: string): Promise<TokenResponse> { + const body = new URLSearchParams({ + client_id: clientId, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + scope: scopes.join(' '), + }); + + const res = await fetch( + `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }, + ); + + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`Token exchange failed: HTTP ${res.status} ${txt}`); + } + + return (await res.json()) as TokenResponse; +} + +async function main() { + console.log('Microsoft OAuth (Installed app) refresh-token helper'); + console.log('Redirect URI:', redirectUri); + console.log('\n1) Open this URL in your browser and consent:'); + console.log(authUrl.toString()); + + const server = http + .createServer(async (req, res) => { + try { + const u = new URL(req.url ?? '/', `http://localhost:${port}`); + if (u.pathname !== '/callback') { + res.writeHead(404); + res.end('Not found'); + return; + } + + const code = u.searchParams.get('code'); + const err = u.searchParams.get('error'); + if (err) { + res.writeHead(400); + res.end(`OAuth error: ${err}`); + return; + } + if (!code) { + res.writeHead(400); + res.end('Missing code'); + return; + } + + const token = await exchange(code); + + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('Done. You can close this tab and go back to your terminal.'); + + console.log('\n2) Tokens received:'); + console.log('- access_token:', token.access_token); + console.log('- refresh_token:', token.refresh_token ?? '(missing)'); + + console.log('\n3) Set env vars:'); + if (token.refresh_token) console.log(`TASK_SYNC_MS_REFRESH_TOKEN=${token.refresh_token}`); + + server.close(); + } catch (e) { + res.writeHead(500); + res.end('Internal error'); + console.error(e); + server.close(); + process.exitCode = 1; + } + }) + .listen(port); + + await once(server, 'listening'); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/src/cli.ts b/src/cli.ts index eca4c02..7b65b2d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,7 @@ const program = new Command(); program .name('task-sync') - .description('Sync tasks between providers (MVP: dry-run with mock providers)') + .description('Sync tasks between Google Tasks and Microsoft To Do') .version('0.1.0'); program @@ -40,84 +40,141 @@ program else if (report.missing.length) process.exitCode = 2; }); +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + program .command('sync') - .description('Run sync engine') - .option('--dry-run', 'Use mock providers and do not persist state') + .description('Run sync engine (2-3 providers)') + .option('--dry-run', 'Do not perform writes/deletes (still uses configured providers)') .option('--state-dir <dir>', 'Override state dir (default: .task-sync or TASK_SYNC_STATE_DIR)') .option('--format <format>', 'Output format: pretty|json', 'pretty') - .action(async (opts: { dryRun?: boolean; stateDir?: string; format?: string }) => { + .option('--poll <minutes>', 'Polling mode: run sync every N minutes (or use TASK_SYNC_POLL_INTERVAL_MINUTES)') + .action(async (opts: { dryRun?: boolean; stateDir?: string; format?: string; poll?: string }) => { const env = readEnv(); const logger = createLogger(env.TASK_SYNC_LOG_LEVEL ?? 'info'); - const engine = new SyncEngine(new JsonStore(opts.stateDir ?? env.TASK_SYNC_STATE_DIR)); + const store = new JsonStore(opts.stateDir ?? env.TASK_SYNC_STATE_DIR); + const engine = new SyncEngine(store); const dryRun = !!opts.dryRun; - if (!dryRun) { - const dr = doctorReport(env); - if (!dr.providers.a || !dr.providers.b || dr.missing.length) { - console.error('Configuration incomplete. Run: task-sync doctor'); - process.exitCode = 2; - return; + const providers = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B].filter( + Boolean, + ) as Array<'google' | 'microsoft'>; + + if (providers.length < 2) { + console.error('Need at least 2 providers. Set TASK_SYNC_PROVIDER_A=google + TASK_SYNC_PROVIDER_B=microsoft.'); + process.exitCode = 2; + return; + } + + const dr = doctorReport(env); + if (!dryRun && dr.missing.length) { + console.error('Configuration incomplete. Run: task-sync doctor'); + process.exitCode = 2; + return; + } + + const makeProvider = (p: 'google' | 'microsoft') => { + if (p === 'google') { + return new GoogleTasksProvider({ + clientId: env.TASK_SYNC_GOOGLE_CLIENT_ID!, + clientSecret: env.TASK_SYNC_GOOGLE_CLIENT_SECRET!, + refreshToken: env.TASK_SYNC_GOOGLE_REFRESH_TOKEN!, + tasklistId: env.TASK_SYNC_GOOGLE_TASKLIST_ID, + }); + } + return new MicrosoftTodoProvider({ + clientId: env.TASK_SYNC_MS_CLIENT_ID!, + tenantId: env.TASK_SYNC_MS_TENANT_ID!, + refreshToken: env.TASK_SYNC_MS_REFRESH_TOKEN!, + listId: env.TASK_SYNC_MS_LIST_ID, + }); + }; + + const providerInstances = providers.map(makeProvider); + + const pollMinutes = opts.poll ? Number(opts.poll) : env.TASK_SYNC_POLL_INTERVAL_MINUTES; + const polling = Number.isFinite(pollMinutes) && (pollMinutes ?? 0) > 0; + + let runCount = 0; + while (true) { + runCount++; + logger.info(`sync start (dryRun=${dryRun}, run=${runCount})`, { providers }); + + const report = await engine.syncMany(providerInstances, { + dryRun, + mode: env.TASK_SYNC_MODE ?? 'bidirectional', + tombstoneTtlDays: env.TASK_SYNC_TOMBSTONE_TTL_DAYS ?? 30, + }); + + if ((opts.format ?? 'pretty') === 'json') { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`task-sync report`); + console.log(`providers: ${report.providers.join(' <-> ')}`); + console.log(`lastSyncAt: ${report.lastSyncAt ?? '(none)'}`); + console.log(`newLastSyncAt: ${report.newLastSyncAt}`); + console.log(`dryRun: ${report.dryRun}`); + console.log(`durationMs: ${report.durationMs}`); + + console.log('\ncounts:'); + for (const k of Object.keys(report.counts) as Array<keyof typeof report.counts>) { + console.log(`- ${k}: ${report.counts[k]}`); + } + + if (report.errors.length) { + console.log('\nerrors:'); + for (const e of report.errors) console.log(`- ${e.provider} (${e.stage}): ${e.error}`); + } + + if (report.conflicts.length) { + console.log(`\nconflicts: ${report.conflicts.length} (see conflicts.log in state dir)`); + } + + console.log('\nactions:'); + for (const a of report.actions) { + const exec = a.executed ? 'exec' : 'plan'; + const tgt = a.target.id ? `${a.target.provider}:${a.target.id}` : a.target.provider; + console.log( + `- [${exec}] ${a.kind} ${tgt} <= ${a.source.provider}:${a.source.id} ${a.title ? `"${a.title}"` : ''} :: ${a.detail}`, + ); + } } + + if (!polling) break; + + const waitMs = Math.max(1, pollMinutes!) * 60_000; + logger.info(`poll sleep ${pollMinutes}m`); + await sleep(waitMs); } + }); - const providerA = dryRun - ? new MockProvider({ - name: 'mockA', - tasks: [ - { - id: 'a1', - title: 'Mock A task', - status: 'active', - updatedAt: new Date(Date.now() - 60_000).toISOString(), - }, - ], - }) - : env.TASK_SYNC_PROVIDER_A === 'google' - ? new GoogleTasksProvider({ - clientId: env.TASK_SYNC_GOOGLE_CLIENT_ID!, - clientSecret: env.TASK_SYNC_GOOGLE_CLIENT_SECRET!, - refreshToken: env.TASK_SYNC_GOOGLE_REFRESH_TOKEN!, - tasklistId: env.TASK_SYNC_GOOGLE_TASKLIST_ID, - }) - : new MicrosoftTodoProvider({ - clientId: env.TASK_SYNC_MS_CLIENT_ID!, - tenantId: env.TASK_SYNC_MS_TENANT_ID!, - refreshToken: env.TASK_SYNC_MS_REFRESH_TOKEN!, - listId: env.TASK_SYNC_MS_LIST_ID, - }); - - const providerB = dryRun - ? new MockProvider({ - name: 'mockB', - tasks: [ - { - id: 'b1', - title: 'Mock B task', - status: 'active', - updatedAt: new Date(Date.now() - 120_000).toISOString(), - }, - ], - }) - : env.TASK_SYNC_PROVIDER_B === 'google' - ? new GoogleTasksProvider({ - clientId: env.TASK_SYNC_GOOGLE_CLIENT_ID!, - clientSecret: env.TASK_SYNC_GOOGLE_CLIENT_SECRET!, - refreshToken: env.TASK_SYNC_GOOGLE_REFRESH_TOKEN!, - tasklistId: env.TASK_SYNC_GOOGLE_TASKLIST_ID, - }) - : new MicrosoftTodoProvider({ - clientId: env.TASK_SYNC_MS_CLIENT_ID!, - tenantId: env.TASK_SYNC_MS_TENANT_ID!, - refreshToken: env.TASK_SYNC_MS_REFRESH_TOKEN!, - listId: env.TASK_SYNC_MS_LIST_ID, - }); - - logger.info(`sync start (dryRun=${dryRun})`, { a: providerA.name, b: providerB.name }); - - const report = await engine.sync(providerA, providerB, { dryRun }); +program + .command('mock') + .description('Run a 2-provider dry-run using in-memory mock providers (for demos/tests)') + .option('--format <format>', 'Output format: pretty|json', 'pretty') + .action(async (opts: { format?: string }) => { + const logger = createLogger('info'); + const engine = new SyncEngine(new JsonStore()); + + const a = new MockProvider({ + name: 'mockA', + tasks: [ + { + id: 'a1', + title: 'Mock A task', + status: 'active', + updatedAt: new Date(Date.now() - 60_000).toISOString(), + }, + ], + }); + const b = new MockProvider({ name: 'mockB', tasks: [] }); + + logger.info('mock sync start', { providers: [a.name, b.name] }); + const report = await engine.syncMany([a, b], { dryRun: true }); if ((opts.format ?? 'pretty') === 'json') { console.log(JSON.stringify(report, null, 2)); @@ -125,22 +182,9 @@ program } console.log(`task-sync report`); - console.log(`providers: ${report.providerA} <-> ${report.providerB}`); - console.log(`lastSyncAt: ${report.lastSyncAt ?? '(none)'}`); + console.log(`providers: ${report.providers.join(' <-> ')}`); console.log(`newLastSyncAt: ${report.newLastSyncAt}`); console.log(`dryRun: ${report.dryRun}`); - - console.log('\ncounts:'); - for (const k of Object.keys(report.counts) as Array<keyof typeof report.counts>) { - console.log(`- ${k}: ${report.counts[k]}`); - } - - console.log('\nactions:'); - for (const a of report.actions) { - const exec = a.executed ? 'exec' : 'plan'; - const tgt = a.target.id ? `${a.target.provider}:${a.target.id}` : a.target.provider; - console.log(`- [${exec}] ${a.kind} ${tgt} <= ${a.source.provider}:${a.source.id} ${a.title ? `"${a.title}"` : ''}`); - } }); program.parseAsync(process.argv).catch((err) => { diff --git a/src/config.ts b/src/config.ts index c8f9797..729bd82 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,25 +2,33 @@ import { z } from 'zod'; const str = z.string().min(1); +export const ProviderSchema = z.enum(['google', 'microsoft']); + export const EnvSchema = z.object({ - TASK_SYNC_PROVIDER_A: z.enum(['google', 'microsoft']).optional(), - TASK_SYNC_PROVIDER_B: z.enum(['google', 'microsoft']).optional(), + // providers + TASK_SYNC_PROVIDER_A: ProviderSchema.optional(), + TASK_SYNC_PROVIDER_B: ProviderSchema.optional(), // behavior TASK_SYNC_LOG_LEVEL: z.enum(['silent', 'error', 'warn', 'info', 'debug']).optional(), TASK_SYNC_STATE_DIR: str.optional(), + TASK_SYNC_POLL_INTERVAL_MINUTES: z.coerce.number().int().positive().optional(), + TASK_SYNC_MODE: z.enum(['bidirectional', 'a-to-b-only', 'mirror']).optional(), + TASK_SYNC_TOMBSTONE_TTL_DAYS: z.coerce.number().int().positive().optional(), + TASK_SYNC_HTTP_RPS: z.coerce.number().positive().optional(), - // Google Tasks (scaffold) + // Google Tasks TASK_SYNC_GOOGLE_CLIENT_ID: str.optional(), TASK_SYNC_GOOGLE_CLIENT_SECRET: str.optional(), TASK_SYNC_GOOGLE_REFRESH_TOKEN: str.optional(), TASK_SYNC_GOOGLE_TASKLIST_ID: str.optional(), - // Microsoft Graph (scaffold) + // Microsoft Graph TASK_SYNC_MS_CLIENT_ID: str.optional(), TASK_SYNC_MS_TENANT_ID: str.optional(), TASK_SYNC_MS_REFRESH_TOKEN: str.optional(), TASK_SYNC_MS_LIST_ID: str.optional(), + }); export type EnvConfig = z.infer<typeof EnvSchema>; @@ -30,17 +38,18 @@ export function readEnv(env = process.env): EnvConfig { } export function doctorReport(env = readEnv()) { - const providerA = env.TASK_SYNC_PROVIDER_A; - const providerB = env.TASK_SYNC_PROVIDER_B; + const providers = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B].filter( + Boolean, + ) as Array<z.infer<typeof ProviderSchema>>; const missing: string[] = []; const notes: string[] = []; - if (!providerA || !providerB) { - notes.push('Set TASK_SYNC_PROVIDER_A and TASK_SYNC_PROVIDER_B to choose providers (google|microsoft).'); + if (providers.length < 2) { + notes.push('Set TASK_SYNC_PROVIDER_A + TASK_SYNC_PROVIDER_B to choose providers (google|microsoft).'); } - for (const p of [providerA, providerB].filter(Boolean) as Array<'google' | 'microsoft'>) { + for (const p of providers) { if (p === 'google') { if (!env.TASK_SYNC_GOOGLE_CLIENT_ID) missing.push('TASK_SYNC_GOOGLE_CLIENT_ID'); if (!env.TASK_SYNC_GOOGLE_CLIENT_SECRET) missing.push('TASK_SYNC_GOOGLE_CLIENT_SECRET'); @@ -51,13 +60,16 @@ export function doctorReport(env = readEnv()) { if (!env.TASK_SYNC_MS_CLIENT_ID) missing.push('TASK_SYNC_MS_CLIENT_ID'); if (!env.TASK_SYNC_MS_TENANT_ID) missing.push('TASK_SYNC_MS_TENANT_ID'); if (!env.TASK_SYNC_MS_REFRESH_TOKEN) missing.push('TASK_SYNC_MS_REFRESH_TOKEN'); - notes.push('Microsoft: TASK_SYNC_MS_LIST_ID optional (defaults TBD).'); + notes.push('Microsoft: TASK_SYNC_MS_LIST_ID optional (defaults to first list).'); } } return { - providers: { a: providerA, b: providerB }, - missing, - notes, + providers: { + a: env.TASK_SYNC_PROVIDER_A, + b: env.TASK_SYNC_PROVIDER_B, + }, + missing: [...new Set(missing)], + notes: [...new Set(notes)], }; } diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..cf8e097 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,131 @@ +export type FetchLike = typeof fetch; + +export interface JsonRequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record<string, string>; + query?: Record<string, string | number | boolean | undefined>; + body?: unknown; + /** Retries for transient errors (default: 3). */ + retries?: number; + /** Base delay for exponential backoff in ms (default: 200). */ + backoffMs?: number; + /** Optional request-per-second cap for this call (best-effort). */ + rps?: number; +} + +export class HttpError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly url: string, + public readonly responseText?: string, + public readonly retryAfterMs?: number, + ) { + super(message); + } +} + +function withQuery(url: string, query?: JsonRequestOptions['query']) { + if (!query) return url; + const u = new URL(url); + for (const [k, v] of Object.entries(query)) { + if (v === undefined) continue; + u.searchParams.set(k, String(v)); + } + return u.toString(); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Simple global limiter keyed by origin. +const lastRequestAt = new Map<string, number>(); + +function originOf(url: string) { + try { + return new URL(url).origin; + } catch { + return 'unknown'; + } +} + +async function throttle(url: string, rps?: number) { + if (!rps || rps <= 0) return; + const minGap = 1000 / rps; + const key = originOf(url); + const last = lastRequestAt.get(key) ?? 0; + const now = Date.now(); + const wait = last + minGap - now; + if (wait > 0) await sleep(wait); + lastRequestAt.set(key, Date.now()); +} + +function parseRetryAfterMs(v: string | null): number | undefined { + if (!v) return undefined; + const sec = Number(v); + if (Number.isFinite(sec) && sec >= 0) return sec * 1000; + const at = Date.parse(v); + if (Number.isFinite(at)) return Math.max(0, at - Date.now()); + return undefined; +} + +function isTransientStatus(status: number) { + return status === 429 || status >= 500; +} + +export async function requestJson<T>( + url: string, + opts: JsonRequestOptions = {}, + fetcher: FetchLike = fetch, +): Promise<T> { + const finalUrl = withQuery(url, opts.query); + const retries = opts.retries ?? 3; + const backoffMs = opts.backoffMs ?? 200; + + let attempt = 0; + while (true) { + attempt++; + try { + const envRps = process.env.TASK_SYNC_HTTP_RPS ? Number(process.env.TASK_SYNC_HTTP_RPS) : undefined; + await throttle(finalUrl, opts.rps ?? envRps); + + const res = await fetcher(finalUrl, { + method: opts.method ?? 'GET', + headers: { + accept: 'application/json', + ...(opts.body ? { 'content-type': 'application/json' } : {}), + ...(opts.headers ?? {}), + }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + + if (!res.ok) { + const txt = await res.text().catch(() => undefined); + const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); + const err = new HttpError(`HTTP ${res.status} for ${finalUrl}`, res.status, finalUrl, txt, retryAfterMs); + if (attempt <= retries && isTransientStatus(res.status)) { + const wait = retryAfterMs ?? backoffMs * 2 ** (attempt - 1); + await sleep(wait); + continue; + } + throw err; + } + + // empty body + if (res.status === 204) return undefined as T; + + const text = await res.text(); + if (!text) return undefined as T; + return JSON.parse(text) as T; + } catch (e) { + // Don't retry non-transient HTTP errors (400, 401, 403, 404, etc.) + if (e instanceof HttpError && !isTransientStatus(e.status)) throw e; + // Retry network/parse/transient errors + if (attempt <= retries) { + const wait = backoffMs * 2 ** (attempt - 1); + await sleep(wait); + continue; + } + throw e; + } + } +} diff --git a/src/model.ts b/src/model.ts index 4f06772..3cd7264 100644 --- a/src/model.ts +++ b/src/model.ts @@ -9,6 +9,11 @@ export interface Task { notes?: string; status: TaskStatus; dueAt?: string; // ISO + /** + * Provider-specific extra data that should round-trip without loss. + * Engine treats this as opaque. + */ + metadata?: Record<string, unknown>; updatedAt: string; // ISO } diff --git a/src/providers/google.ts b/src/providers/google.ts index fcd10f0..44b6d44 100644 --- a/src/providers/google.ts +++ b/src/providers/google.ts @@ -1,5 +1,6 @@ import type { Task } from '../model.js'; import type { TaskProvider } from './provider.js'; +import { requestJson, type FetchLike } from '../http.js'; export interface GoogleTasksProviderOptions { /** OAuth client id */ @@ -10,40 +11,144 @@ export interface GoogleTasksProviderOptions { refreshToken: string; /** Task list id (defaults to '@default' for Google Tasks) */ tasklistId?: string; + /** Inject fetch for tests */ + fetcher?: FetchLike; +} + +interface GoogleTokenResponse { + access_token: string; + expires_in: number; + token_type: string; + scope?: string; +} + +interface GoogleTask { + id: string; + title: string; + notes?: string; + status: 'needsAction' | 'completed'; + due?: string; + updated: string; +} + +interface GoogleListTasksResponse { + items?: GoogleTask[]; + nextPageToken?: string; +} + +function toCanonical(t: GoogleTask): Task { + return { + id: t.id, + title: t.title, + notes: t.notes, + status: t.status === 'completed' ? 'completed' : 'active', + dueAt: t.due, + updatedAt: t.updated, + }; } -/** - * Scaffold for a real Google Tasks provider. - * - * MVP NOTE: Not implemented yet. - * - * TODO(next): - * - Implement OAuth2 refresh flow - * - Call Google Tasks API (tasks.list/tasks.insert/tasks.update/tasks.delete) - * - Map fields into canonical Task - */ export class GoogleTasksProvider implements TaskProvider { readonly name = 'google' as const; - constructor(private _opts: GoogleTasksProviderOptions) { - // Intentionally empty for MVP + private fetcher: FetchLike; + private accessToken?: { token: string; expMs: number }; + + constructor(private opts: GoogleTasksProviderOptions) { + this.fetcher = opts.fetcher ?? fetch; + } + + private async getAccessToken(): Promise<string> { + const now = Date.now(); + if (this.accessToken && this.accessToken.expMs - 30_000 > now) return this.accessToken.token; + + const body = new URLSearchParams({ + client_id: this.opts.clientId, + client_secret: this.opts.clientSecret, + refresh_token: this.opts.refreshToken, + grant_type: 'refresh_token', + }); + + const res = await this.fetcher('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`Google token refresh failed: HTTP ${res.status} ${txt}`); + } + + const json = (await res.json()) as GoogleTokenResponse; + this.accessToken = { token: json.access_token, expMs: now + json.expires_in * 1000 }; + return json.access_token; } - async listTasks(_since?: string): Promise<Task[]> { - throw new Error( - 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + private async api<T>(path: string, init?: Parameters<typeof requestJson<T>>[1]): Promise<T> { + const token = await this.getAccessToken(); + const base = `https://tasks.googleapis.com/tasks/v1`; + return requestJson<T>(`${base}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, ...(init?.headers ?? {}) } }, this.fetcher); + } + + async listTasks(since?: string): Promise<Task[]> { + const tasklistId = this.opts.tasklistId ?? '@default'; + + const out: Task[] = []; + let pageToken: string | undefined; + + do { + const res = await this.api<GoogleListTasksResponse>(`/lists/${encodeURIComponent(tasklistId)}/tasks`, { + query: { + maxResults: 100, + showCompleted: true, + showHidden: true, + pageToken, + // Server-side filter: Google Tasks API supports updatedMin (RFC3339, + // returns tasks updated at-or-after the timestamp). This avoids + // fetching the full task list when only recent changes are needed. + ...(since ? { updatedMin: since } : {}), + }, + }); + + for (const t of res.items ?? []) out.push(toCanonical(t)); + pageToken = res.nextPageToken; + } while (pageToken); + + return out; } - async upsertTask(_input: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> { - throw new Error( - 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async upsertTask(input: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> { + const tasklistId = this.opts.tasklistId ?? '@default'; + const isCreate = !input.id; + + const payload: Partial<GoogleTask> = { + title: input.title, + notes: input.notes, + status: input.status === 'completed' ? 'completed' : 'needsAction', + due: input.dueAt, + }; + + const res = isCreate + ? await this.api<GoogleTask>(`/lists/${encodeURIComponent(tasklistId)}/tasks`, { + method: 'POST', + body: payload, + }) + : await this.api<GoogleTask>( + `/lists/${encodeURIComponent(tasklistId)}/tasks/${encodeURIComponent(input.id)}`, + { + method: 'PATCH', + body: payload, + }, + ); + + // Google sets updated server-side. + return toCanonical(res); } - async deleteTask(_id: string): Promise<void> { - throw new Error( - 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async deleteTask(id: string): Promise<void> { + const tasklistId = this.opts.tasklistId ?? '@default'; + await this.api<void>(`/lists/${encodeURIComponent(tasklistId)}/tasks/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); } } diff --git a/src/providers/microsoft.ts b/src/providers/microsoft.ts index c37d0eb..af4e085 100644 --- a/src/providers/microsoft.ts +++ b/src/providers/microsoft.ts @@ -1,49 +1,237 @@ import type { Task } from '../model.js'; import type { TaskProvider } from './provider.js'; +import { requestJson, type FetchLike } from '../http.js'; export interface MicrosoftTodoProviderOptions { /** Azure AD app client id */ clientId: string; /** Tenant id (or 'common') */ tenantId: string; - /** OAuth refresh token (or other credential, TBD) */ + /** OAuth refresh token */ refreshToken: string; - /** Task list id (defaults TBD) */ + /** Task list id (defaults to first list) */ listId?: string; + /** Inject fetch for tests */ + fetcher?: FetchLike; +} + +interface MsTokenResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + /** Microsoft may rotate refresh tokens. If present, you must use the new one going forward. */ + refresh_token?: string; +} + +interface GraphTodoList { + id: string; + displayName: string; +} + +interface GraphListListsResponse { + value: GraphTodoList[]; +} + +interface GraphBody { + content: string; + contentType: 'text' | 'html'; +} + +interface GraphTask { + id: string; + title: string; + body?: GraphBody; + dueDateTime?: { dateTime: string; timeZone: string }; + completedDateTime?: { dateTime: string; timeZone: string }; + /** Graph To Do supports a status field. Completed date is derived server-side. */ + status?: 'notStarted' | 'inProgress' | 'completed' | 'waitingOnOthers' | 'deferred' | string; + lastModifiedDateTime: string; + createdDateTime: string; +} + +interface GraphListTasksResponse { + value: GraphTask[]; + '@odata.nextLink'?: string; } /** - * Scaffold for a real Microsoft To Do provider via Microsoft Graph. - * - * MVP NOTE: Not implemented yet. - * - * TODO(next): - * - Implement OAuth2 refresh flow (MSAL or raw token endpoint) - * - Call Graph endpoints for To Do tasks - * - Map fields into canonical Task + * Normalize fractional seconds in an ISO timestamp to 3 digits (milliseconds). + * e.g. "2026-02-08T00:00:00.0000000Z" → "2026-02-08T00:00:00.000Z" */ +function normalizeIsoPrecision(iso: string): string { + return iso.replace(/\.(\d+)Z$/, (_match, frac: string) => { + const ms = (frac + '000').slice(0, 3); + return `.${ms}Z`; + }); +} + +function normalizeGraphDate(dt?: { dateTime: string; timeZone: string }): string | undefined { + if (!dt?.dateTime) return undefined; + + // Microsoft Graph To Do uses a { dateTime, timeZone } pair. + // Often dateTime is "YYYY-MM-DDTHH:mm:ss(.sss)" with NO timezone suffix. + // Our canonical format expects RFC3339 / ISO with timezone (prefer Z for UTC). + let raw = dt.dateTime; + + // If already has timezone info, normalize precision and return. + if (/[zZ]$/.test(raw) || /[+-]\d\d:\d\d$/.test(raw)) return normalizeIsoPrecision(raw); + + // Normalize UTC-like values to Z. + if (dt.timeZone?.toUpperCase() === 'UTC') { + raw = `${raw}Z`; + return normalizeIsoPrecision(raw); + } + + // Fallback: keep raw (better than guessing an offset). + return raw; +} + +function toCanonical(t: GraphTask): Task { + return { + id: t.id, + title: t.title, + notes: t.body?.content, + status: t.status === 'completed' || t.completedDateTime ? 'completed' : 'active', + dueAt: normalizeGraphDate(t.dueDateTime), + updatedAt: t.lastModifiedDateTime, + }; +} + export class MicrosoftTodoProvider implements TaskProvider { readonly name = 'microsoft' as const; - constructor(private _opts: MicrosoftTodoProviderOptions) { - // Intentionally empty for MVP + private fetcher: FetchLike; + private accessToken?: { token: string; expMs: number }; + private resolvedListId?: string; + + constructor(private opts: MicrosoftTodoProviderOptions) { + this.fetcher = opts.fetcher ?? fetch; + } + + private async getAccessToken(): Promise<string> { + const now = Date.now(); + if (this.accessToken && this.accessToken.expMs - 30_000 > now) return this.accessToken.token; + + const body = new URLSearchParams({ + client_id: this.opts.clientId, + refresh_token: this.opts.refreshToken, + grant_type: 'refresh_token', + // Keep scopes aligned with initial consent. Use Graph resource scopes. + scope: 'offline_access https://graph.microsoft.com/Tasks.ReadWrite https://graph.microsoft.com/User.Read', + }); + + const url = `https://login.microsoftonline.com/${encodeURIComponent(this.opts.tenantId)}/oauth2/v2.0/token`; + const res = await this.fetcher(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`Microsoft token refresh failed: HTTP ${res.status} ${txt}`); + } + + const json = (await res.json()) as MsTokenResponse; + + // Microsoft can rotate refresh tokens. Keep using the latest one in-memory so polling works. + if (json.refresh_token) this.opts.refreshToken = json.refresh_token; + + this.accessToken = { token: json.access_token, expMs: now + json.expires_in * 1000 }; + return json.access_token; + } + + private async api<T>(pathOrUrl: string, init?: Parameters<typeof requestJson<T>>[1]): Promise<T> { + const token = await this.getAccessToken(); + const base = `https://graph.microsoft.com/v1.0`; + const url = pathOrUrl.startsWith('https://') ? pathOrUrl : `${base}${pathOrUrl}`; + return requestJson<T>(url, { ...init, headers: { authorization: `Bearer ${token}`, ...(init?.headers ?? {}) } }, this.fetcher); } - async listTasks(_since?: string): Promise<Task[]> { - throw new Error( - 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + private async getListId(): Promise<string> { + if (this.resolvedListId) return this.resolvedListId; + if (this.opts.listId) { + this.resolvedListId = this.opts.listId; + return this.opts.listId; + } + + const lists = await this.api<GraphListListsResponse>(`/me/todo/lists`); + const first = lists.value?.[0]; + if (!first) throw new Error('Microsoft To Do: no lists found for this account'); + this.resolvedListId = first.id; + return first.id; } - async upsertTask(_input: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> { - throw new Error( - 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async listTasks(since?: string): Promise<Task[]> { + const listId = await this.getListId(); + + const out: Task[] = []; + + // Build the initial URL. When `since` is provided, use a server-side + // OData $filter on lastModifiedDateTime so Graph only returns changed + // tasks instead of the full list. + let next: string | undefined; + if (since) { + const filter = `lastModifiedDateTime ge ${since}`; + next = `/me/todo/lists/${encodeURIComponent(listId)}/tasks?$top=100&$filter=${encodeURIComponent(filter)}`; + } else { + next = `/me/todo/lists/${encodeURIComponent(listId)}/tasks?$top=100`; + } + + while (next) { + const url = next; + const res: GraphListTasksResponse = await this.api<GraphListTasksResponse>(url); + for (const t of res.value ?? []) out.push(toCanonical(t)); + next = res['@odata.nextLink']; + } + + return out; + } + + async upsertTask(input: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> { + const listId = await this.getListId(); + const isCreate = !input.id; + + const payload: Partial<GraphTask> & { body?: GraphBody } = { + title: input.title, + body: input.notes + ? { + contentType: 'text', + content: input.notes, + } + : undefined, + dueDateTime: input.dueAt + ? { + dateTime: input.dueAt, + timeZone: 'UTC', + } + : undefined, + // Graph expects status mutations, not completedDateTime writes. + status: input.status === 'completed' ? 'completed' : 'notStarted', + }; + + const res = isCreate + ? await this.api<GraphTask>(`/me/todo/lists/${encodeURIComponent(listId)}/tasks`, { + method: 'POST', + body: payload, + }) + : await this.api<GraphTask>( + `/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(input.id)}`, + { + method: 'PATCH', + body: payload, + }, + ); + + return toCanonical(res); } - async deleteTask(_id: string): Promise<void> { - throw new Error( - 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async deleteTask(id: string): Promise<void> { + const listId = await this.getListId(); + await this.api<void>(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); } } diff --git a/src/providers/mock.ts b/src/providers/mock.ts index 2a9f70d..dae0c44 100644 --- a/src/providers/mock.ts +++ b/src/providers/mock.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import type { Task } from '../model.js'; +import type { ProviderName, Task } from '../model.js'; import type { TaskProvider } from './provider.js'; /** @@ -10,10 +10,10 @@ import type { TaskProvider } from './provider.js'; * - Supports delete (tombstone via status='deleted'). */ export class MockProvider implements TaskProvider { - readonly name: 'mockA' | 'mockB'; + readonly name: ProviderName; private tasks = new Map<string, Task>(); - constructor(opts?: { name?: 'mockA' | 'mockB'; tasks?: Task[] }) { + constructor(opts?: { name?: ProviderName; tasks?: Task[] }) { this.name = opts?.name ?? 'mockA'; for (const t of opts?.tasks ?? []) this.tasks.set(t.id, t); } diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts index 5101cd3..8ef7496 100644 --- a/src/store/jsonStore.ts +++ b/src/store/jsonStore.ts @@ -1,11 +1,13 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, rename, copyFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; -import type { ProviderName } from '../model.js'; +import type { ProviderName, Task } from '../model.js'; export interface MappingRecord { canonicalId: string; byProvider: Partial<Record<ProviderName, string>>; + /** Last canonical snapshot we synced to (used for field-level diffing). */ + canonical?: Omit<Task, 'id'>; updatedAt: string; } @@ -16,12 +18,17 @@ export interface TombstoneRecord { } export interface SyncState { + /** State schema version. */ + version: 1; lastSyncAt?: string; mappings: MappingRecord[]; tombstones: TombstoneRecord[]; } +type LegacySyncState = Partial<Omit<SyncState, 'version'>> & { version?: number }; + const DEFAULT_STATE: SyncState = { + version: 1, mappings: [], tombstones: [], }; @@ -29,22 +36,72 @@ const DEFAULT_STATE: SyncState = { export class JsonStore { constructor(private dir = path.join(process.cwd(), '.task-sync')) {} - private statePath() { + getDir() { + return this.dir; + } + + statePath() { return path.join(this.dir, 'state.json'); } + conflictsLogPath() { + return path.join(this.dir, 'conflicts.log'); + } + + /** Best-effort migration to the latest state schema. */ + private migrate(input: LegacySyncState): SyncState { + const version = input.version ?? 0; + if (version === 1) { + // Ensure defaults + return { + ...DEFAULT_STATE, + ...input, + version: 1, + mappings: (input.mappings ?? []) as MappingRecord[], + tombstones: (input.tombstones ?? []) as TombstoneRecord[], + }; + } + + // v0 -> v1 + return { + version: 1, + lastSyncAt: input.lastSyncAt, + mappings: ((input.mappings ?? []) as MappingRecord[]).map((m) => ({ + canonicalId: m.canonicalId, + byProvider: m.byProvider ?? {}, + canonical: (m as MappingRecord).canonical, + updatedAt: m.updatedAt ?? new Date().toISOString(), + })), + tombstones: (input.tombstones ?? []) as TombstoneRecord[], + }; + } + async load(): Promise<SyncState> { try { const raw = await readFile(this.statePath(), 'utf8'); - return { ...DEFAULT_STATE, ...JSON.parse(raw) } as SyncState; + const parsed = JSON.parse(raw) as LegacySyncState; + return this.migrate(parsed); } catch { return structuredClone(DEFAULT_STATE); } } + private async backupStateFile(): Promise<void> { + try { + await stat(this.statePath()); + } catch { + return; + } + await mkdir(this.dir, { recursive: true }); + await copyFile(this.statePath(), this.statePath() + '.bak'); + } + async save(state: SyncState): Promise<void> { await mkdir(this.dir, { recursive: true }); - await writeFile(this.statePath(), JSON.stringify(state, null, 2) + '\n', 'utf8'); + await this.backupStateFile(); + const tmp = this.statePath() + '.tmp'; + await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8'); + await rename(tmp, this.statePath()); } findMapping(state: SyncState, provider: ProviderName, id: string): MappingRecord | undefined { @@ -78,4 +135,23 @@ export class JsonStore { if (this.isTombstoned(state, provider, id)) return; state.tombstones.push({ provider, id, deletedAt }); } + + pruneExpiredTombstones(state: SyncState, ttlDays: number, now = Date.now()): number { + const ttlMs = Math.max(0, ttlDays) * 24 * 60 * 60 * 1000; + if (!ttlMs) return 0; + const before = state.tombstones.length; + state.tombstones = state.tombstones.filter((t) => now - Date.parse(t.deletedAt) <= ttlMs); + return before - state.tombstones.length; + } + + removeMapping(state: SyncState, canonicalId: string): void { + state.mappings = state.mappings.filter((m) => m.canonicalId !== canonicalId); + } + + upsertCanonicalSnapshot(state: SyncState, canonicalId: string, data: Omit<Task, 'id'>): void { + const rec = state.mappings.find((m) => m.canonicalId === canonicalId); + if (!rec) return; + rec.canonical = data; + rec.updatedAt = new Date().toISOString(); + } } diff --git a/src/store/lock.ts b/src/store/lock.ts new file mode 100644 index 0000000..8f36bca --- /dev/null +++ b/src/store/lock.ts @@ -0,0 +1,58 @@ +import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; + +export interface LockHandle { + path: string; + release(): Promise<void>; +} + +export async function acquireLock(dir: string, filename = 'lock'): Promise<LockHandle> { + await mkdir(dir, { recursive: true }); + const lockPath = path.join(dir, filename); + + const pid = process.pid; + const payload = JSON.stringify({ pid, at: new Date().toISOString() }) + '\n'; + + // Try create-or-fail semantics by writing only if not exists. + // Node doesn't expose O_EXCL easily in fs/promises without handle flags in older versions, + // so we do a small dance: + try { + await writeFile(lockPath, payload, { flag: 'wx' }); + } catch { + // If it exists, check whether it's stale. + let isHeld = false; + try { + const raw = await readFile(lockPath, 'utf8'); + const parsed = JSON.parse(raw) as { pid?: number }; + const otherPid = parsed.pid; + if (otherPid && isProcessAlive(otherPid)) { + isHeld = true; + } + } catch { + // If unreadable/invalid, treat as stale. + } + + if (isHeld) { + throw new Error(`Another task-sync process is running. Remove ${lockPath} if this is wrong.`); + } + + // Stale lock: overwrite. + await writeFile(lockPath, payload, { flag: 'w' }); + } + + return { + path: lockPath, + release: async () => { + await unlink(lockPath).catch(() => undefined); + }, + }; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/src/sync/engine.ts b/src/sync/engine.ts index 37c9ba0..c2de0d8 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -1,12 +1,20 @@ -import type { Task } from '../model.js'; +import { appendFile } from 'node:fs/promises'; +import type { ProviderName, Task } from '../model.js'; import type { TaskProvider } from '../providers/provider.js'; -import { JsonStore, type SyncState } from '../store/jsonStore.js'; +import { JsonStore, type MappingRecord } from '../store/jsonStore.js'; +import { acquireLock } from '../store/lock.js'; export type ConflictPolicy = 'last-write-wins'; +export type SyncMode = 'bidirectional' | 'a-to-b-only' | 'mirror'; + export interface SyncOptions { dryRun?: boolean; conflictPolicy?: ConflictPolicy; + /** Sync mode. Default: bidirectional. */ + mode?: SyncMode; + /** Tombstone TTL in days. Default: 30. */ + tombstoneTtlDays?: number; } export type SyncActionKind = 'create' | 'update' | 'delete' | 'recreate' | 'noop'; @@ -20,14 +28,24 @@ export interface SyncAction { detail: string; } +export interface SyncConflict { + canonicalId: string; + field: 'title' | 'notes' | 'dueAt' | 'status'; + providers: Array<{ provider: string; id: string; updatedAt: string; value: unknown }>; + winner: { provider: string; id: string; updatedAt: string }; + overwritten: Array<{ provider: string; id: string }>; +} + export interface SyncReport { dryRun: boolean; - providerA: string; - providerB: string; + providers: string[]; lastSyncAt?: string; newLastSyncAt: string; counts: Record<SyncActionKind, number>; actions: SyncAction[]; + conflicts: SyncConflict[]; + errors: Array<{ provider: string; stage: 'listChanges' | 'listAll' | 'write'; error: string }>; + durationMs: number; } function newer(a: string, b: string) { @@ -38,205 +56,576 @@ function indexById(tasks: Task[]) { return new Map(tasks.map((t) => [t.id, t] as const)); } +function norm(s?: string) { + return (s ?? '').trim().replace(/\s+/g, ' ').toLowerCase(); +} + +/** + * Normalize a string field for comparison. + * Treats undefined, null, and empty/whitespace-only strings as equivalent. + */ +function normField(s?: string): string { + return (s ?? '').trim(); +} + +/** + * Normalize an ISO timestamp for comparison. + * Truncates fractional seconds to milliseconds so that providers returning + * different precisions (e.g. ".000Z" vs ".0000000Z") compare as equal. + */ +function normIso(s?: string): string { + if (!s) return ''; + return s.replace(/\.(\d+)Z$/, (_match, frac: string) => { + const ms = (frac + '000').slice(0, 3); + return `.${ms}Z`; + }); +} + +/** + * Semantic equality for a task field. Handles undefined/empty normalization + * and ISO timestamp precision differences. + */ +function fieldEqual(field: 'title' | 'notes' | 'dueAt' | 'status', a?: string, b?: string): boolean { + if (field === 'dueAt') return normIso(a) === normIso(b); + if (field === 'notes') return normField(a) === normField(b); + return (a ?? '') === (b ?? ''); +} + +function matchKey(t: Task) { + return `${norm(t.title)}\n${norm(t.notes)}`; +} + +function pickProvidersByMode(mode: SyncMode, providers: TaskProvider[]) { + if (mode === 'bidirectional') return { sources: providers, targets: providers }; + if (providers.length < 2) return { sources: providers, targets: providers }; + + const a = providers[0]; + const rest = providers.slice(1); + if (mode === 'a-to-b-only') return { sources: [a], targets: rest }; + // mirror: A is source of truth, so only apply A -> others, and never write back to A + return { sources: [a], targets: rest }; +} + +type Snapshot = { changes: Task[]; all: Task[]; index: Map<string, Task>; changeIndex: Map<string, Task> }; + export class SyncEngine { constructor(private store = new JsonStore()) {} + /** Back-compat: two-way sync. */ async sync(a: TaskProvider, b: TaskProvider, opts: SyncOptions = {}): Promise<SyncReport> { + return this.syncMany([a, b], opts); + } + + /** N-way sync. */ + async syncMany(providers: TaskProvider[], opts: SyncOptions = {}): Promise<SyncReport> { + const started = Date.now(); + if (providers.length < 2) throw new Error('syncMany requires at least 2 providers'); + const dryRun = !!opts.dryRun; - const state = await this.store.load(); - - const lastSyncAt = state.lastSyncAt; - const actions: SyncAction[] = []; - - const counts: SyncReport['counts'] = { - create: 0, - update: 0, - delete: 0, - recreate: 0, - noop: 0, - }; - - const push = (a: SyncAction) => { - actions.push(a); - counts[a.kind]++; - }; - - // For MVP simplicity: pull incremental changes for deciding what to reconcile, - // plus a full snapshot to cheaply lookup any target task by id. - const [aChanges, bChanges, aAll, bAll] = await Promise.all([ - a.listTasks(lastSyncAt), - b.listTasks(lastSyncAt), - a.listTasks(undefined), - b.listTasks(undefined), - ]); - - const aIndex = indexById(aAll); - const bIndex = indexById(bAll); - - // 1) Process tasks from A -> B - for (const t of aChanges) { - if (this.store.isTombstoned(state, a.name, t.id)) continue; - await this.reconcileOne({ - source: a, - target: b, - targetIndex: bIndex, - state, - task: t, - dryRun, - push, - }); - } + const mode: SyncMode = opts.mode ?? 'bidirectional'; + const tombstoneTtlDays = opts.tombstoneTtlDays ?? 30; - // 2) Process tasks from B -> A - for (const t of bChanges) { - if (this.store.isTombstoned(state, b.name, t.id)) continue; - await this.reconcileOne({ - source: b, - target: a, - targetIndex: aIndex, - state, - task: t, - dryRun, - push, - }); - } + const lock = await acquireLock(this.store.getDir()); + try { + const state = await this.store.load(); + const lastSyncAt = state.lastSyncAt; - const newLastSyncAt = new Date().toISOString(); - state.lastSyncAt = newLastSyncAt; - if (!dryRun) await this.store.save(state); - - return { - dryRun, - providerA: a.name, - providerB: b.name, - lastSyncAt, - newLastSyncAt, - counts, - actions, - }; - } + const actions: SyncAction[] = []; + const conflicts: SyncConflict[] = []; + const errors: SyncReport['errors'] = []; + + const counts: SyncReport['counts'] = { + create: 0, + update: 0, + delete: 0, + recreate: 0, + noop: 0, + }; + + const push = (a: SyncAction) => { + // Noops are counted but not stored in actions to keep reports concise. + if (a.kind === 'noop') { + counts.noop++; + return; + } + actions.push(a); + counts[a.kind]++; + }; + + // 1) Prune expired tombstones + this.store.pruneExpiredTombstones(state, tombstoneTtlDays); + + // 2) Preload snapshots for all providers (best-effort) + const snapshots = new Map<string, Snapshot>(); + const listAllFailed = new Set<string>(); + + await Promise.all( + providers.map(async (p) => { + let changes: Task[] = []; + let all: Task[] = []; + try { + changes = await p.listTasks(lastSyncAt); + } catch (e) { + errors.push({ + provider: p.name, + stage: 'listChanges', + error: e instanceof Error ? e.message : String(e), + }); + } + try { + all = await p.listTasks(undefined); + } catch (e) { + listAllFailed.add(p.name); + errors.push({ + provider: p.name, + stage: 'listAll', + error: e instanceof Error ? e.message : String(e), + }); + } + snapshots.set(p.name, { + changes, + all, + index: indexById(all), + changeIndex: indexById(changes), + }); + }), + ); - private async reconcileOne(params: { - source: TaskProvider; - target: TaskProvider; - targetIndex: Map<string, Task>; - state: SyncState; - task: Task; - dryRun: boolean; - push: (a: SyncAction) => void; - }) { - const { source, target, targetIndex, state, task, dryRun, push } = params; - - const map = this.store.ensureMapping(state, source.name, task.id); - const targetId = map.byProvider[target.name]; - - // zombie prevention: completed/deleted tasks become tombstones - if (task.status === 'completed' || task.status === 'deleted') { - this.store.addTombstone(state, source.name, task.id); - if (targetId) this.store.addTombstone(state, target.name, targetId); - - if (targetId) { - push({ - kind: 'delete', - executed: !dryRun, - source: { provider: source.name, id: task.id }, - target: { provider: target.name, id: targetId }, - title: task.title, - detail: `${target.name}:${targetId} due to ${source.name}:${task.id} status=${task.status}`, - }); - if (!dryRun) await target.deleteTask(targetId); - } else { - push({ - kind: 'noop', - executed: false, - source: { provider: source.name, id: task.id }, - target: { provider: target.name }, - title: task.title, - detail: `tombstoned ${source.name}:${task.id} status=${task.status} (no mapped target)`, - }); + // Only reconcile among providers we can at least read a full snapshot for. + const healthyProviders = providers.filter((p) => !listAllFailed.has(p.name)); + + // 3) Cold start: if no state, match tasks by title+notes across providers to avoid dupes. + if (!state.lastSyncAt && state.mappings.length === 0) { + const buckets = new Map<string, Array<{ provider: ProviderName; task: Task }>>(); + for (const p of healthyProviders) { + const snap = snapshots.get(p.name)!; + for (const t of snap.all) { + if (t.status === 'deleted') continue; + const k = matchKey(t); + if (!buckets.has(k)) buckets.set(k, []); + buckets.get(k)!.push({ provider: p.name, task: t }); + } + } + + for (const group of buckets.values()) { + if (group.length < 2) continue; + // create a single mapping across all matching tasks + const first = group[0]!; + const rec = this.store.ensureMapping(state, first.provider, first.task.id); + for (const g of group.slice(1)) { + rec.byProvider[g.provider] = g.task.id; + } + rec.canonical = { + title: first.task.title, + notes: first.task.notes, + dueAt: first.task.dueAt, + status: first.task.status, + metadata: first.task.metadata, + updatedAt: first.task.updatedAt, + }; + } } - return; - } - if (!targetId) { - push({ - kind: 'create', - executed: !dryRun, - source: { provider: source.name, id: task.id }, - target: { provider: target.name }, - title: task.title, - detail: `${target.name} from ${source.name}:${task.id} "${task.title}"`, - }); - - if (!dryRun) { - const created = await target.upsertTask({ - id: '', - title: task.title, - notes: task.notes, - status: task.status, - dueAt: task.dueAt, - updatedAt: task.updatedAt, - }); - this.store.upsertProviderId(state, map.canonicalId, target.name, created.id); + // Helper: get mapping record for a provider task id. + const mappingFor = (provider: ProviderName, id: string): MappingRecord => this.store.ensureMapping(state, provider, id); + + // 4) Zombie prevention (delete-wins): process hard-deletions first. + // Completed tasks are NOT terminal — their status propagates via the + // normal field-level update path (step 6) so they remain visible on + // all providers as "completed" rather than being hard-deleted. + const isTerminal = (t: Task) => t.status === 'deleted'; + + const tombstoneCanonicalIds = new Set<string>(); + + for (const p of healthyProviders) { + const snap = snapshots.get(p.name)!; + for (const t of snap.changes) { + if (!isTerminal(t)) continue; + const map = mappingFor(p.name, t.id); + tombstoneCanonicalIds.add(map.canonicalId); + + // Tombstone all known provider ids for this canonical task. + for (const [prov, pid] of Object.entries(map.byProvider) as Array<[ProviderName, string]>) { + if (!pid) continue; + this.store.addTombstone(state, prov, pid); + } + } } - return; - } - const targetTask = targetIndex.get(targetId); - if (!targetTask) { - // mapping points to missing task -> re-create, unless tombstoned - if (this.store.isTombstoned(state, target.name, targetId)) return; - push({ - kind: 'recreate', - executed: !dryRun, - source: { provider: source.name, id: task.id }, - target: { provider: target.name, id: targetId }, - title: task.title, - detail: `${target.name}:${targetId} missing; recreate from ${source.name}:${task.id}`, - }); - if (!dryRun) { - const created = await target.upsertTask({ - id: '', - title: task.title, - notes: task.notes, - status: task.status, - dueAt: task.dueAt, - updatedAt: task.updatedAt, - }); - this.store.upsertProviderId(state, map.canonicalId, target.name, created.id); + // Propagate deletes for tombstoned canonical tasks to all providers. + for (const canonicalId of tombstoneCanonicalIds) { + const map = state.mappings.find((m) => m.canonicalId === canonicalId); + if (!map) continue; + + for (const p of healthyProviders) { + const pid = map.byProvider[p.name]; + if (!pid) continue; + if (this.store.isTombstoned(state, p.name, pid)) { + push({ + kind: 'delete', + executed: !dryRun, + source: { provider: 'tombstone', id: canonicalId }, + target: { provider: p.name, id: pid }, + title: map.canonical?.title, + detail: `delete-wins: canonical=${canonicalId}`, + }); + if (!dryRun) { + try { + await p.deleteTask(pid); + } catch (e) { + errors.push({ + provider: p.name, + stage: 'write', + error: e instanceof Error ? e.message : String(e), + }); + } + } + } + } } - return; - } - // If both sides exist, we do simple LWW based on updatedAt. - if (newer(task.updatedAt, targetTask.updatedAt)) { - push({ - kind: 'update', - executed: !dryRun, - source: { provider: source.name, id: task.id }, - target: { provider: target.name, id: targetId }, - title: task.title, - detail: `${target.name}:${targetId} <= ${source.name}:${task.id} (LWW)`, - }); - if (!dryRun) { - await target.upsertTask({ - id: targetId, - title: task.title, - notes: task.notes, - status: task.status, - dueAt: task.dueAt, - updatedAt: task.updatedAt, - }); + // 5) Orphan & external-deletion detection. + // - If a task is missing from ALL providers → orphan, remove mapping. + // - If a previously-synced task is missing from ONE provider (external deletion), + // treat as delete: tombstone ALL sides and propagate the delete. + for (const m of [...state.mappings]) { + const present: Array<[ProviderName, string]> = []; + const missing: Array<[ProviderName, string]> = []; + + for (const [prov, pid] of Object.entries(m.byProvider) as Array<[ProviderName, string]>) { + if (!pid) continue; + const snap = snapshots.get(prov); + if (!snap) continue; // provider not healthy, skip + if (snap.index.has(pid)) { + present.push([prov, pid]); + } else { + missing.push([prov, pid]); + } + } + + if (present.length === 0 && missing.length > 0) { + // Missing in ALL providers → orphan. Tombstone and remove mapping. + for (const [prov, pid] of missing) { + this.store.addTombstone(state, prov, pid); + } + this.store.removeMapping(state, m.canonicalId); + continue; + } + + // External deletion: task was previously synced (has canonical baseline) + // and is now missing from at least one provider that had it mapped. + if (missing.length > 0 && m.canonical && lastSyncAt) { + // Treat as intentional deletion — tombstone ALL sides and propagate. + for (const [prov, pid] of [...present, ...missing]) { + this.store.addTombstone(state, prov, pid); + } + tombstoneCanonicalIds.add(m.canonicalId); + + // Delete from providers that still have the task. + for (const [prov, pid] of present) { + const provider = healthyProviders.find((p) => p.name === prov); + if (!provider) continue; + push({ + kind: 'delete', + executed: !dryRun, + source: { provider: missing[0]![0], id: missing[0]![1] }, + target: { provider: prov, id: pid }, + title: m.canonical.title, + detail: `external-delete: ${missing.map(([p, i]) => `${p}:${i}`).join(',')} missing`, + }); + if (!dryRun) { + try { + await provider.deleteTask(pid); + } catch (e) { + errors.push({ + provider: prov, + stage: 'write', + error: e instanceof Error ? e.message : String(e), + }); + } + } + } + } + } + + // 6) Main reconciliation: compute canonical per mapping and fan out (true N-way). + const { sources, targets } = pickProvidersByMode(mode, healthyProviders); + const targetSet = new Set(targets.map((t) => t.name)); + const sourceSet = new Set(sources.map((t) => t.name)); + + // Ensure every task we can see is mapped (so brand-new tasks propagate). + for (const p of healthyProviders) { + const snap = snapshots.get(p.name)!; + for (const t of snap.all) { + mappingFor(p.name, t.id); + } } - } else { - push({ - kind: 'noop', - executed: false, - source: { provider: source.name, id: task.id }, - target: { provider: target.name, id: targetId }, - title: task.title, - detail: `no-op: ${source.name}:${task.id} not newer than ${target.name}:${targetId}`, - }); + + for (const m of state.mappings) { + // If any provider id is tombstoned, skip updates and let delete-wins handle it. + const tombstoned = (Object.entries(m.byProvider) as Array<[ProviderName, string]>).some( + ([prov, pid]) => !!pid && this.store.isTombstoned(state, prov, pid), + ); + if (tombstoned) continue; + + const baseline = m.canonical; + + // Build per-provider task snapshots for this mapping. + const byProvTask = new Map<ProviderName, Task>(); + for (const [prov, pid] of Object.entries(m.byProvider) as Array<[ProviderName, string]>) { + if (!pid) continue; + const t = snapshots.get(prov)?.index.get(pid); + if (t) byProvTask.set(prov, t); + } + + if (byProvTask.size === 0) continue; + + type CanonicalData = Omit<Task, 'id'>; + const fields = ['title', 'notes', 'dueAt', 'status'] as const; + type Field = (typeof fields)[number]; + + const firstTask = [...byProvTask.values()][0]!; + + const canonical: CanonicalData = { + title: baseline?.title ?? firstTask.title, + notes: baseline?.notes, + dueAt: baseline?.dueAt, + status: baseline?.status ?? firstTask.status, + metadata: baseline?.metadata, + updatedAt: baseline?.updatedAt ?? firstTask.updatedAt, + }; + + const changedBy = new Map<ProviderName, Set<Field>>(); + + for (const [prov, t] of byProvTask.entries()) { + const set = new Set<Field>(); + for (const f of fields) { + const baseVal = baseline ? baseline[f] : undefined; + const val = t[f]; + if (!fieldEqual(f, baseVal as string | undefined, val as string | undefined)) set.add(f); + } + if (set.size) changedBy.set(prov, set); + } + + // Field-level resolve + for (const f of fields) { + const contenders: Array<{ prov: ProviderName; t: Task }> = []; + for (const [prov, set] of changedBy.entries()) { + if (set.has(f)) contenders.push({ prov, t: byProvTask.get(prov)! }); + } + + const assign = (field: Field, val: Task[Field]) => { + switch (field) { + case 'title': + canonical.title = val as Task['title']; + break; + case 'notes': + canonical.notes = val as Task['notes']; + break; + case 'dueAt': + canonical.dueAt = val as Task['dueAt']; + break; + case 'status': + canonical.status = val as Task['status']; + break; + } + }; + + if (contenders.length === 0) continue; + if (contenders.length === 1) { + assign(f, contenders[0]!.t[f]); + canonical.updatedAt = contenders[0]!.t.updatedAt; + continue; + } + + // Conflict: multiple providers changed the same field since baseline. Pick per-field LWW. + contenders.sort((a, b) => (newer(a.t.updatedAt, b.t.updatedAt) ? -1 : 1)); + const winner = contenders[0]!; + assign(f, winner.t[f]); + + conflicts.push({ + canonicalId: m.canonicalId, + field: f, + providers: contenders.map((c) => ({ + provider: c.prov, + id: c.t.id, + updatedAt: c.t.updatedAt, + value: c.t[f], + })), + winner: { provider: winner.prov, id: winner.t.id, updatedAt: winner.t.updatedAt }, + overwritten: contenders.slice(1).map((c) => ({ provider: c.prov, id: c.t.id })), + }); + } + + // Update canonical snapshot in state. + this.store.upsertCanonicalSnapshot(state, m.canonicalId, canonical); + + // Skip tasks with empty titles — some providers (Microsoft Graph) reject them, + // and they would cause persistent errors on every sync cycle. + if (!canonical.title?.trim()) continue; + + // Fan out canonical to all targets. + for (const target of healthyProviders) { + const targetId = m.byProvider[target.name]; + const canWrite = targetSet.has(target.name) && (mode !== 'mirror' || target.name !== providers[0]!.name); + const isSourceAllowed = sourceSet.has(target.name); + void isSourceAllowed; // kept for future refinement + + if (!canWrite) continue; + + const existing = targetId ? snapshots.get(target.name)!.index.get(targetId) : undefined; + + if (!targetId) { + push({ + kind: 'create', + executed: !dryRun, + source: { provider: 'canonical', id: m.canonicalId }, + target: { provider: target.name }, + title: canonical.title, + detail: `create from canonical ${m.canonicalId}`, + }); + if (!dryRun) { + try { + const created = await target.upsertTask({ + id: '', + title: canonical.title, + notes: canonical.notes, + dueAt: canonical.dueAt, + status: canonical.status, + metadata: canonical.metadata, + updatedAt: canonical.updatedAt, + }); + this.store.upsertProviderId(state, m.canonicalId, target.name, created.id); + } catch (e) { + errors.push({ + provider: target.name, + stage: 'write', + error: e instanceof Error ? e.message : String(e), + }); + } + } + continue; + } + + if (!existing) { + // Missing task: recreate unless tombstoned. + if (this.store.isTombstoned(state, target.name, targetId)) continue; + push({ + kind: 'recreate', + executed: !dryRun, + source: { provider: 'canonical', id: m.canonicalId }, + target: { provider: target.name, id: targetId }, + title: canonical.title, + detail: `${target.name}:${targetId} missing; recreate`, + }); + if (!dryRun) { + try { + const created = await target.upsertTask({ + id: '', + title: canonical.title, + notes: canonical.notes, + dueAt: canonical.dueAt, + status: canonical.status, + metadata: canonical.metadata, + updatedAt: canonical.updatedAt, + }); + this.store.upsertProviderId(state, m.canonicalId, target.name, created.id); + } catch (e) { + errors.push({ + provider: target.name, + stage: 'write', + error: e instanceof Error ? e.message : String(e), + }); + } + } + continue; + } + + // Update only if any field differs (using semantic comparison). + const differs = + !fieldEqual('title', existing.title, canonical.title) || + !fieldEqual('notes', existing.notes, canonical.notes) || + !fieldEqual('dueAt', existing.dueAt, canonical.dueAt) || + !fieldEqual('status', existing.status, canonical.status); + + if (!differs) { + push({ + kind: 'noop', + executed: false, + source: { provider: 'canonical', id: m.canonicalId }, + target: { provider: target.name, id: targetId }, + title: canonical.title, + detail: 'already in sync', + }); + continue; + } + + push({ + kind: 'update', + executed: !dryRun, + source: { provider: 'canonical', id: m.canonicalId }, + target: { provider: target.name, id: targetId }, + title: canonical.title, + detail: `field-level update (title/notes/status/dueAt)`, + }); + + if (!dryRun) { + try { + await target.upsertTask({ + id: targetId, + title: canonical.title, + notes: canonical.notes, + dueAt: canonical.dueAt, + status: canonical.status, + metadata: canonical.metadata ?? existing.metadata, + updatedAt: canonical.updatedAt, + }); + } catch (e) { + errors.push({ + provider: target.name, + stage: 'write', + error: e instanceof Error ? e.message : String(e), + }); + } + } + } + } + + const newLastSyncAt = new Date().toISOString(); + state.lastSyncAt = newLastSyncAt; + + // Persist conflicts log (even for dry-run, we keep it in-memory only) + if (conflicts.length && !dryRun) { + const lines = conflicts + .map((c) => + JSON.stringify( + { + at: new Date().toISOString(), + ...c, + }, + null, + 0, + ), + ) + .join('\n'); + await appendFile(this.store.conflictsLogPath(), lines + '\n', 'utf8').catch(() => undefined); + } + + if (!dryRun) await this.store.save(state); + + return { + dryRun, + providers: healthyProviders.map((p) => p.name), + lastSyncAt, + newLastSyncAt, + counts, + actions, + conflicts, + errors, + durationMs: Date.now() - started, + }; + } finally { + await lock.release(); } } } diff --git a/test/engine.hardening.test.ts b/test/engine.hardening.test.ts new file mode 100644 index 0000000..913934d --- /dev/null +++ b/test/engine.hardening.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import { mkdtemp } from 'node:fs/promises'; +import { SyncEngine } from '../src/sync/engine.js'; +import { MockProvider } from '../src/providers/mock.js'; +import { JsonStore } from '../src/store/jsonStore.js'; +import type { Task } from '../src/model.js'; +import type { TaskProvider } from '../src/providers/provider.js'; + +describe('SyncEngine hardening', () => { + it('completed status propagates via field-level update (not delete)', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'task-sync-')); + const store = new JsonStore(dir); + const engine = new SyncEngine(store); + + const t0 = new Date(Date.now() - 60_000).toISOString(); + const t1 = new Date().toISOString(); + + const a = new MockProvider({ + name: 'mockA', + tasks: [{ id: 'a1', title: 'A', status: 'completed', updatedAt: t1 }], + }); + + const b = new MockProvider({ + name: 'mockB', + tasks: [{ id: 'b1', title: 'A', status: 'active', updatedAt: t0 }], + }); + + // Establish mapping + const s = await store.load(); + const map = store.ensureMapping(s, 'mockA', 'a1'); + store.upsertProviderId(s, map.canonicalId, 'mockB', 'b1'); + // baseline canonical + store.upsertCanonicalSnapshot(s, map.canonicalId, { + title: 'A', + notes: undefined, + dueAt: undefined, + status: 'active', + metadata: undefined, + updatedAt: t0, + }); + await store.save(s); + + // Simulate B attempting a title update (but A's completed status should also propagate) + await b.upsertTask({ id: 'b1', title: 'A updated', status: 'active', updatedAt: t1 }); + + const report = await engine.syncMany([a, b], { dryRun: false }); + // Completed status should propagate as an update, not trigger a delete + expect(report.actions.some((x) => x.kind === 'update')).toBe(true); + expect(report.actions.some((x) => x.kind === 'delete')).toBe(false); + + const bAll = await b.listTasks(); + const bTask = bAll.find((t) => t.id === 'b1')!; + // B should now be completed (status synced from A), with title from B's update + expect(bTask.status).toBe('completed'); + expect(bTask.title).toBe('A updated'); + }); + + it('tombstone expiry prunes old tombstones', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'task-sync-')); + const store = new JsonStore(dir); + const engine = new SyncEngine(store); + + const s = await store.load(); + s.tombstones.push({ provider: 'mockA', id: 'x', deletedAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString() }); + await store.save(s); + + const a = new MockProvider({ name: 'mockA', tasks: [] }); + const b = new MockProvider({ name: 'mockB', tasks: [] }); + + await engine.syncMany([a, b], { dryRun: false, tombstoneTtlDays: 30 }); + + const s2 = await store.load(); + expect(s2.tombstones.some((t) => t.id === 'x')).toBe(false); + }); + + it('orphan cleanup removes mappings that do not exist in any provider', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'task-sync-')); + const store = new JsonStore(dir); + const engine = new SyncEngine(store); + + const s = await store.load(); + const map = store.ensureMapping(s, 'mockA', 'ghostA'); + store.upsertProviderId(s, map.canonicalId, 'mockB', 'ghostB'); + await store.save(s); + + const a = new MockProvider({ name: 'mockA', tasks: [] }); + const b = new MockProvider({ name: 'mockB', tasks: [] }); + + await engine.syncMany([a, b], { dryRun: false }); + const s2 = await store.load(); + expect(s2.mappings.find((m) => m.canonicalId === map.canonicalId)).toBeUndefined(); + }); + + it('field-level conflict resolution preserves independent edits', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'task-sync-')); + const store = new JsonStore(dir); + const engine = new SyncEngine(store); + + const baseAt = new Date(Date.now() - 120_000).toISOString(); + const notesAt = new Date(Date.now() - 60_000).toISOString(); + const titleAt = new Date().toISOString(); + + const a = new MockProvider({ + name: 'mockA', + tasks: [{ id: 'a1', title: 'Title v2', notes: 'n0', status: 'active', updatedAt: titleAt }], + }); + + const b = new MockProvider({ + name: 'mockB', + tasks: [{ id: 'b1', title: 'Title', notes: 'n1', status: 'active', updatedAt: notesAt }], + }); + + // Setup mapping + baseline canonical snapshot + const s = await store.load(); + s.lastSyncAt = new Date(Date.now() - 30_000).toISOString(); + const map = store.ensureMapping(s, 'mockA', 'a1'); + store.upsertProviderId(s, map.canonicalId, 'mockB', 'b1'); + store.upsertCanonicalSnapshot(s, map.canonicalId, { + title: 'Title', + notes: 'n0', + dueAt: undefined, + status: 'active', + metadata: undefined, + updatedAt: baseAt, + }); + await store.save(s); + + await engine.syncMany([a, b], { dryRun: false }); + + const aTask = (await a.listTasks()).find((t) => t.id === 'a1')!; + const bTask = (await b.listTasks()).find((t) => t.id === 'b1')!; + + expect(aTask.title).toBe('Title v2'); + expect(bTask.title).toBe('Title v2'); + expect(aTask.notes).toBe('n1'); + expect(bTask.notes).toBe('n1'); + }); + + it('graceful degradation: if a provider is down, still sync healthy providers', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'task-sync-')); + const store = new JsonStore(dir); + const engine = new SyncEngine(store); + + const now = new Date().toISOString(); + const a = new MockProvider({ name: 'mockA', tasks: [{ id: 'a1', title: 'A', status: 'active', updatedAt: now }] }); + + const down: TaskProvider = { + name: 'mockB', + listTasks: async () => { + throw new Error('Provider down'); + }, + upsertTask: async (_input: unknown): Promise<Task> => { + throw new Error('Provider down'); + }, + deleteTask: async () => { + throw new Error('Provider down'); + }, + }; + + const report = await engine.syncMany([a, down], { dryRun: true }); + expect(report.providers).toEqual(['mockA']); + expect(report.errors.some((e) => e.provider === 'mockB')).toBe(true); + }); +}); diff --git a/test/engine.test.ts b/test/engine.test.ts index 4898a11..fb75ee1 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -19,28 +19,58 @@ describe('SyncEngine', () => { const report = await engine.sync(a, b, { dryRun: true }); expect(report.actions.some((x) => x.kind === 'create' && x.executed === false)).toBe(true); + expect(report.providers).toEqual(['mockA', 'mockB']); }); - it('tombstones completed tasks and deletes on the other side (dry-run)', async () => { + it('syncs completed status to the other side via update (dry-run)', async () => { const store = new JsonStore(await mkdtemp(path.join(os.tmpdir(), 'task-sync-'))); const engine = new SyncEngine(store); + const tOld = new Date(Date.now() - 60_000).toISOString(); + const tNew = new Date().toISOString(); + const a = new MockProvider({ name: 'mockA', - tasks: [{ id: 'a1', title: 'A', status: 'completed', updatedAt: new Date().toISOString() }], + tasks: [{ id: 'a1', title: 'A', status: 'completed', updatedAt: tNew }], }); const b = new MockProvider({ name: 'mockB', - tasks: [{ id: 'b1', title: 'B', status: 'active', updatedAt: new Date().toISOString() }], + tasks: [{ id: 'b1', title: 'A', status: 'active', updatedAt: tOld }], }); - // pre-create mapping by running a dry sync once for active task to establish linkage + // pre-create mapping with baseline showing the task was previously active const s = await store.load(); const map = store.ensureMapping(s, 'mockA', 'a1'); store.upsertProviderId(s, map.canonicalId, 'mockB', 'b1'); + store.upsertCanonicalSnapshot(s, map.canonicalId, { + title: 'A', + notes: undefined, + dueAt: undefined, + status: 'active', + metadata: undefined, + updatedAt: tOld, + }); await store.save(s); const report = await engine.sync(a, b, { dryRun: true }); - expect(report.actions.some((x) => x.kind === 'delete' && x.executed === false)).toBe(true); + // Completed status should propagate as an update, not a delete + expect(report.actions.some((x) => x.kind === 'update' && x.executed === false)).toBe(true); + expect(report.actions.some((x) => x.kind === 'delete')).toBe(false); + }); + + it('2-way: plans create into target (dry-run)', async () => { + const store = new JsonStore(await mkdtemp(path.join(os.tmpdir(), 'task-sync-'))); + const engine = new SyncEngine(store); + + const now = new Date().toISOString(); + const a = new MockProvider({ name: 'mockA', tasks: [{ id: 'a1', title: 'A', status: 'active', updatedAt: now }] }); + const b = new MockProvider({ name: 'mockB', tasks: [] }); + + const report = await engine.syncMany([a, b], { dryRun: true }); + + const creates = report.actions.filter((x) => x.kind === 'create'); + // a1 should be created into b + expect(creates.length).toBeGreaterThanOrEqual(1); + expect(creates.some((x) => x.target.provider === 'mockB')).toBe(true); }); }); diff --git a/test/googleProvider.test.ts b/test/googleProvider.test.ts new file mode 100644 index 0000000..d821e49 --- /dev/null +++ b/test/googleProvider.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { GoogleTasksProvider } from '../src/providers/google.js'; + +function jsonResponse(obj: unknown, status = 200) { + return new Response(JSON.stringify(obj), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('GoogleTasksProvider', () => { + it('lists tasks and maps fields', async () => { + const calls: string[] = []; + + const fetcher: typeof fetch = async (url, init) => { + calls.push(`${init?.method ?? 'GET'} ${url.toString()}`); + + if (String(url).startsWith('https://oauth2.googleapis.com/token')) { + return jsonResponse({ access_token: 'atok', expires_in: 3600, token_type: 'Bearer' }); + } + + if (String(url).includes('https://tasks.googleapis.com/tasks/v1/lists/%40default/tasks')) { + return jsonResponse({ + items: [ + { + id: 'g1', + title: 'Hello', + notes: 'N', + status: 'needsAction', + updated: '2026-02-06T00:00:00.000Z', + due: '2026-02-10T00:00:00.000Z', + }, + ], + }); + } + + return new Response('not found', { status: 404 }); + }; + + const p = new GoogleTasksProvider({ + clientId: 'cid', + clientSecret: 'sec', + refreshToken: 'rtok', + fetcher, + }); + + const tasks = await p.listTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ + id: 'g1', + title: 'Hello', + notes: 'N', + status: 'active', + dueAt: '2026-02-10T00:00:00.000Z', + updatedAt: '2026-02-06T00:00:00.000Z', + }); + + expect(calls.some((c) => c.includes('oauth2.googleapis.com/token'))).toBe(true); + }); +}); diff --git a/test/microsoftProvider.test.ts b/test/microsoftProvider.test.ts new file mode 100644 index 0000000..c7dc263 --- /dev/null +++ b/test/microsoftProvider.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { MicrosoftTodoProvider } from '../src/providers/microsoft.js'; + +function jsonResponse(obj: unknown, status = 200) { + return new Response(JSON.stringify(obj), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('MicrosoftTodoProvider', () => { + it('lists tasks from first list and maps fields', async () => { + const fetcher: typeof fetch = async (url, _init) => { + const u = String(url); + + if (u.includes('/oauth2/v2.0/token')) { + return jsonResponse({ + token_type: 'Bearer', + scope: 'Tasks.ReadWrite User.Read', + expires_in: 3600, + ext_expires_in: 3600, + access_token: 'atok', + }); + } + + if (u === 'https://graph.microsoft.com/v1.0/me/todo/lists') { + return jsonResponse({ value: [{ id: 'L1', displayName: 'Tasks' }] }); + } + + if (u.startsWith('https://graph.microsoft.com/v1.0/me/todo/lists/L1/tasks')) { + return jsonResponse({ + value: [ + { + id: 'm1', + title: 'Hi', + body: { content: 'B', contentType: 'text' }, + dueDateTime: { dateTime: '2026-02-10T00:00:00.000Z', timeZone: 'UTC' }, + lastModifiedDateTime: '2026-02-06T00:00:00.000Z', + createdDateTime: '2026-02-01T00:00:00.000Z', + }, + ], + }); + } + + return new Response('not found', { status: 404 }); + }; + + const p = new MicrosoftTodoProvider({ + clientId: 'cid', + tenantId: 'common', + refreshToken: 'rtok', + fetcher, + }); + + const tasks = await p.listTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ + id: 'm1', + title: 'Hi', + notes: 'B', + status: 'active', + dueAt: '2026-02-10T00:00:00.000Z', + updatedAt: '2026-02-06T00:00:00.000Z', + }); + }); +});