From c78b537cc281379ec4c5d5edc913f44d9edad956 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sun, 1 Feb 2026 02:39:23 +0300 Subject: [PATCH 01/12] feat: MVP CLI + sync engine scaffold (dry-run, store, tests) --- .gitignore | 20 +- README.md | 132 +- eslint.config.js | 15 + package-lock.json | 3841 ++++++++++++++++++++++++++++++++++++ package.json | 46 + src/cli.ts | 117 ++ src/config.ts | 59 + src/model.ts | 24 + src/providers/google.ts | 49 + src/providers/microsoft.ts | 49 + src/providers/mock.ts | 45 + src/providers/provider.ts | 17 + src/store/jsonStore.ts | 81 + src/sync/engine.ts | 154 ++ test/engine.test.ts | 46 + test/jsonStore.test.ts | 28 + tsconfig.json | 22 + tsup.config.ts | 14 + 18 files changed, 4754 insertions(+), 5 deletions(-) create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/model.ts create mode 100644 src/providers/google.ts create mode 100644 src/providers/microsoft.ts create mode 100644 src/providers/mock.ts create mode 100644 src/providers/provider.ts create mode 100644 src/store/jsonStore.ts create mode 100644 src/sync/engine.ts create mode 100644 test/engine.test.ts create mode 100644 test/jsonStore.test.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts diff --git a/.gitignore b/.gitignore index 15b60b1..58aeaba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,16 @@ -bmad -bmad* -*bmad -*bmad* \ No newline at end of file +# dependencies +node_modules/ + +# build +/dist/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local state +.task-sync/ + +# OS +.DS_Store diff --git a/README.md b/README.md index 62e11c0..286545a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,132 @@ # task-sync -sync google tasks, microsoft to do, habitica etc. makes sure zombie tasks are prevented and fully synchronizes tasks in each app including dates, notes, reminders, attachments etc + +Sync tasks between **Microsoft To Do (Microsoft Graph)** and **Google Tasks**. + +This repo currently contains a solid **MVP scaffolding**: + +- 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. + +## Quickstart + +### Requirements + +- Node.js **>= 22** + +### Install + +```bash +npm install +``` + +### Run health check + +```bash +npm run build +node dist/cli.js doctor +# or after global install: task-sync doctor +``` + +### Run dry-run sync (no API keys) + +```bash +npm run build +node dist/cli.js sync --dry-run +``` + +You should see a JSON report describing the actions the engine would take. + +## Configuration (for when real providers are implemented) + +Set these env vars (placeholders for next steps): + +### Provider selection + +- `TASK_SYNC_PROVIDER_A` = `google` | `microsoft` +- `TASK_SYNC_PROVIDER_B` = `google` | `microsoft` + +### Google Tasks (scaffold) + +- `TASK_SYNC_GOOGLE_CLIENT_ID` +- `TASK_SYNC_GOOGLE_CLIENT_SECRET` +- `TASK_SYNC_GOOGLE_REFRESH_TOKEN` +- `TASK_SYNC_GOOGLE_TASKLIST_ID` (optional; defaults to `@default`) + +### Microsoft Graph / To Do (scaffold) + +- `TASK_SYNC_MS_CLIENT_ID` +- `TASK_SYNC_MS_TENANT_ID` +- `TASK_SYNC_MS_REFRESH_TOKEN` +- `TASK_SYNC_MS_LIST_ID` (optional) + +Run: + +```bash +task-sync doctor +``` + +to see what’s missing. + +## How state works (.task-sync/) + +`task-sync` writes local state under: + +- `.task-sync/state.json` + +This includes: + +- `lastSyncAt` watermark (ISO timestamp) +- `mappings`: links a canonical ID to provider IDs +- `tombstones`: prevents resurrecting completed/deleted tasks + +You can delete `.task-sync/` to reset state. + +## Development + +```bash +npm run dev -- doctor +npm run dev -- sync --dry-run +npm test +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/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c71d41e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,15 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ['dist/**', 'node_modules/**'], + }, + { + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + } +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3ab99bf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3841 @@ +{ + "name": "task-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "task-sync", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "zod": "^3.25.76" + }, + "bin": { + "task-sync": "dist/cli.js" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@types/node": "^22.19.7", + "eslint": "^9.39.2", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b3ec5e --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "task-sync", + "version": "0.1.0", + "description": "Sync tasks between Microsoft To Do and Google Tasks", + "main": "index.js", + "scripts": { + "test": "vitest run", + "build": "tsup", + "dev": "tsx src/cli.ts", + "test:watch": "vitest", + "lint": "eslint .", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/salaamdev/task-sync.git" + }, + "keywords": [], + "author": "", + "license": "MIT", + "type": "module", + "bugs": { + "url": "https://github.com/salaamdev/task-sync/issues" + }, + "homepage": "https://github.com/salaamdev/task-sync#readme", + "engines": { + "node": ">=22" + }, + "bin": { + "task-sync": "dist/cli.js" + }, + "dependencies": { + "commander": "^12.1.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@types/node": "^22.19.7", + "eslint": "^9.39.2", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vitest": "^2.1.9" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..33e0bfb --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,117 @@ +import { Command } from 'commander'; +import { doctorReport, readEnv } from './config.js'; +import { MockProvider } from './providers/mock.js'; +import { GoogleTasksProvider } from './providers/google.js'; +import { MicrosoftTodoProvider } from './providers/microsoft.js'; +import { SyncEngine } from './sync/engine.js'; + +const program = new Command(); + +program + .name('task-sync') + .description('Sync tasks between providers (MVP: dry-run with mock providers)') + .version('0.1.0'); + +program + .command('doctor') + .description('Check environment/config and print what is missing') + .action(() => { + const report = doctorReport(); + console.log('task-sync doctor'); + console.log('providers:', report.providers); + if (report.missing.length) { + console.log('\nMissing env vars:'); + for (const k of report.missing) console.log(`- ${k}`); + } else { + console.log('\nNo missing env vars detected for selected providers.'); + } + + if (report.notes.length) { + console.log('\nNotes:'); + for (const n of report.notes) console.log(`- ${n}`); + } + + if (!report.providers.a || !report.providers.b) process.exitCode = 2; + else if (report.missing.length) process.exitCode = 2; + }); + +program + .command('sync') + .description('Run sync engine') + .option('--dry-run', 'Run with mock providers and do not persist state') + .action(async (opts: { dryRun?: boolean }) => { + const engine = new SyncEngine(); + + const dryRun = !!opts.dryRun; + const env = readEnv(); + + 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 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, + }); + + const report = await engine.sync(providerA, providerB, { dryRun }); + + console.log(JSON.stringify(report, null, 2)); + }); + +program.parseAsync(process.argv).catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..8ab601e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +const str = z.string().min(1); + +export const EnvSchema = z.object({ + TASK_SYNC_PROVIDER_A: z.enum(['google', 'microsoft']).optional(), + TASK_SYNC_PROVIDER_B: z.enum(['google', 'microsoft']).optional(), + + // Google Tasks (scaffold) + 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) + 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; + +export function readEnv(env = process.env): EnvConfig { + return EnvSchema.parse(env); +} + +export function doctorReport(env = readEnv()) { + const providerA = env.TASK_SYNC_PROVIDER_A; + const providerB = env.TASK_SYNC_PROVIDER_B; + + 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).'); + } + + for (const p of [providerA, providerB].filter(Boolean) as Array<'google' | 'microsoft'>) { + 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'); + if (!env.TASK_SYNC_GOOGLE_REFRESH_TOKEN) missing.push('TASK_SYNC_GOOGLE_REFRESH_TOKEN'); + notes.push('Google: TASK_SYNC_GOOGLE_TASKLIST_ID optional (defaults to @default).'); + } + if (p === 'microsoft') { + 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).'); + } + } + + return { + providers: { a: providerA, b: providerB }, + missing, + notes, + }; +} diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..4f06772 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,24 @@ +export type ProviderName = 'mockA' | 'mockB' | 'google' | 'microsoft'; + +export type TaskStatus = 'active' | 'completed' | 'deleted'; + +export interface Task { + /** Provider-local id (opaque). */ + id: string; + title: string; + notes?: string; + status: TaskStatus; + dueAt?: string; // ISO + updatedAt: string; // ISO +} + +export interface TaskRef { + provider: ProviderName; + id: string; +} + +export interface CanonicalTask { + /** Stable internal id we assign (uuid). */ + canonicalId: string; + data: Omit; +} diff --git a/src/providers/google.ts b/src/providers/google.ts new file mode 100644 index 0000000..fcd10f0 --- /dev/null +++ b/src/providers/google.ts @@ -0,0 +1,49 @@ +import type { Task } from '../model.js'; +import type { TaskProvider } from './provider.js'; + +export interface GoogleTasksProviderOptions { + /** OAuth client id */ + clientId: string; + /** OAuth client secret */ + clientSecret: string; + /** OAuth refresh token */ + refreshToken: string; + /** Task list id (defaults to '@default' for Google Tasks) */ + tasklistId?: string; +} + +/** + * 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 + } + + async listTasks(_since?: string): Promise { + throw new Error( + 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' + ); + } + + async upsertTask(_input: Omit & { updatedAt?: string }): Promise { + throw new Error( + 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' + ); + } + + async deleteTask(_id: string): Promise { + throw new Error( + 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' + ); + } +} diff --git a/src/providers/microsoft.ts b/src/providers/microsoft.ts new file mode 100644 index 0000000..c37d0eb --- /dev/null +++ b/src/providers/microsoft.ts @@ -0,0 +1,49 @@ +import type { Task } from '../model.js'; +import type { TaskProvider } from './provider.js'; + +export interface MicrosoftTodoProviderOptions { + /** Azure AD app client id */ + clientId: string; + /** Tenant id (or 'common') */ + tenantId: string; + /** OAuth refresh token (or other credential, TBD) */ + refreshToken: string; + /** Task list id (defaults TBD) */ + listId?: 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 + */ +export class MicrosoftTodoProvider implements TaskProvider { + readonly name = 'microsoft' as const; + + constructor(private _opts: MicrosoftTodoProviderOptions) { + // Intentionally empty for MVP + } + + async listTasks(_since?: string): Promise { + throw new Error( + 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' + ); + } + + async upsertTask(_input: Omit & { updatedAt?: string }): Promise { + throw new Error( + 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' + ); + } + + async deleteTask(_id: string): Promise { + throw new Error( + 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' + ); + } +} diff --git a/src/providers/mock.ts b/src/providers/mock.ts new file mode 100644 index 0000000..2a9f70d --- /dev/null +++ b/src/providers/mock.ts @@ -0,0 +1,45 @@ +import { randomUUID } from 'node:crypto'; +import type { Task } from '../model.js'; +import type { TaskProvider } from './provider.js'; + +/** + * Deterministic-ish provider for local dev/tests. + * + * - Stores tasks in memory. + * - Uses ISO timestamps. + * - Supports delete (tombstone via status='deleted'). + */ +export class MockProvider implements TaskProvider { + readonly name: 'mockA' | 'mockB'; + private tasks = new Map(); + + constructor(opts?: { name?: 'mockA' | 'mockB'; tasks?: Task[] }) { + this.name = opts?.name ?? 'mockA'; + for (const t of opts?.tasks ?? []) this.tasks.set(t.id, t); + } + + async listTasks(since?: string): Promise { + const all = [...this.tasks.values()]; + if (!since) return all; + const sinceMs = Date.parse(since); + return all.filter((t) => Date.parse(t.updatedAt) >= sinceMs); + } + + async upsertTask(input: Omit & { updatedAt?: string }): Promise { + const now = input.updatedAt ?? new Date().toISOString(); + const id = input.id || randomUUID(); + const task: Task = { ...input, id, updatedAt: now }; + this.tasks.set(id, task); + return task; + } + + async deleteTask(id: string): Promise { + const existing = this.tasks.get(id); + const updatedAt = new Date().toISOString(); + if (!existing) { + this.tasks.set(id, { id, title: '(deleted)', status: 'deleted', updatedAt }); + return; + } + this.tasks.set(id, { ...existing, status: 'deleted', updatedAt }); + } +} diff --git a/src/providers/provider.ts b/src/providers/provider.ts new file mode 100644 index 0000000..deb85e4 --- /dev/null +++ b/src/providers/provider.ts @@ -0,0 +1,17 @@ +import type { ProviderName, Task } from '../model.js'; + +export interface TaskProvider { + readonly name: ProviderName; + + /** + * List tasks that changed since `since` (inclusive). If `since` is undefined, + * provider should return all tasks. + */ + listTasks(since?: string): Promise; + + /** Create or update a task by provider id (if known). Returns the stored task. */ + upsertTask(input: Omit & { updatedAt?: string }): Promise; + + /** Mark a task deleted (or hard delete if provider supports). */ + deleteTask(id: string): Promise; +} diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts new file mode 100644 index 0000000..5101cd3 --- /dev/null +++ b/src/store/jsonStore.ts @@ -0,0 +1,81 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { ProviderName } from '../model.js'; + +export interface MappingRecord { + canonicalId: string; + byProvider: Partial>; + updatedAt: string; +} + +export interface TombstoneRecord { + provider: ProviderName; + id: string; + deletedAt: string; +} + +export interface SyncState { + lastSyncAt?: string; + mappings: MappingRecord[]; + tombstones: TombstoneRecord[]; +} + +const DEFAULT_STATE: SyncState = { + mappings: [], + tombstones: [], +}; + +export class JsonStore { + constructor(private dir = path.join(process.cwd(), '.task-sync')) {} + + private statePath() { + return path.join(this.dir, 'state.json'); + } + + async load(): Promise { + try { + const raw = await readFile(this.statePath(), 'utf8'); + return { ...DEFAULT_STATE, ...JSON.parse(raw) } as SyncState; + } catch { + return structuredClone(DEFAULT_STATE); + } + } + + async save(state: SyncState): Promise { + await mkdir(this.dir, { recursive: true }); + await writeFile(this.statePath(), JSON.stringify(state, null, 2) + '\n', 'utf8'); + } + + findMapping(state: SyncState, provider: ProviderName, id: string): MappingRecord | undefined { + return state.mappings.find((m) => m.byProvider[provider] === id); + } + + ensureMapping(state: SyncState, provider: ProviderName, id: string): MappingRecord { + const existing = this.findMapping(state, provider, id); + if (existing) return existing; + const rec: MappingRecord = { + canonicalId: randomUUID(), + byProvider: { [provider]: id }, + updatedAt: new Date().toISOString(), + }; + state.mappings.push(rec); + return rec; + } + + upsertProviderId(state: SyncState, canonicalId: string, provider: ProviderName, id: string): void { + const rec = state.mappings.find((m) => m.canonicalId === canonicalId); + if (!rec) throw new Error(`Unknown canonicalId: ${canonicalId}`); + rec.byProvider[provider] = id; + rec.updatedAt = new Date().toISOString(); + } + + isTombstoned(state: SyncState, provider: ProviderName, id: string): boolean { + return state.tombstones.some((t) => t.provider === provider && t.id === id); + } + + addTombstone(state: SyncState, provider: ProviderName, id: string, deletedAt = new Date().toISOString()): void { + if (this.isTombstoned(state, provider, id)) return; + state.tombstones.push({ provider, id, deletedAt }); + } +} diff --git a/src/sync/engine.ts b/src/sync/engine.ts new file mode 100644 index 0000000..532824f --- /dev/null +++ b/src/sync/engine.ts @@ -0,0 +1,154 @@ +import type { Task } from '../model.js'; +import type { TaskProvider } from '../providers/provider.js'; +import { JsonStore, type SyncState } from '../store/jsonStore.js'; + +export type ConflictPolicy = 'last-write-wins'; + +export interface SyncOptions { + dryRun?: boolean; + conflictPolicy?: ConflictPolicy; +} + +export interface SyncReport { + dryRun: boolean; + providerA: string; + providerB: string; + lastSyncAt?: string; + newLastSyncAt: string; + actions: Array<{ action: string; detail: string }>; +} + +function newer(a: string, b: string) { + return Date.parse(a) > Date.parse(b); +} + +export class SyncEngine { + constructor(private store = new JsonStore()) {} + + async sync(a: TaskProvider, b: TaskProvider, opts: SyncOptions = {}): Promise { + const dryRun = !!opts.dryRun; + const state = await this.store.load(); + + const lastSyncAt = state.lastSyncAt; + const actions: SyncReport['actions'] = []; + + const [aTasks, bTasks] = await Promise.all([a.listTasks(lastSyncAt), b.listTasks(lastSyncAt)]); + + // 1) Process tasks from A -> B + for (const t of aTasks) { + if (this.store.isTombstoned(state, a.name, t.id)) continue; + await this.reconcileOne({ source: a, target: b, state, task: t, dryRun, actions }); + } + + // 2) Process tasks from B -> A + for (const t of bTasks) { + if (this.store.isTombstoned(state, b.name, t.id)) continue; + await this.reconcileOne({ source: b, target: a, state, task: t, dryRun, actions }); + } + + 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, + actions, + }; + } + + private async reconcileOne(params: { + source: TaskProvider; + target: TaskProvider; + state: SyncState; + task: Task; + dryRun: boolean; + actions: Array<{ action: string; detail: string }>; + }) { + const { source, target, state, task, dryRun, actions } = 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) { + actions.push({ + action: dryRun ? 'would-delete' : 'delete', + detail: `${target.name}:${targetId} due to ${source.name}:${task.id} status=${task.status}`, + }); + if (!dryRun) await target.deleteTask(targetId); + } + return; + } + + if (!targetId) { + actions.push({ + action: dryRun ? 'would-create' : 'create', + 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); + } + return; + } + + // If both sides exist, we do simple LWW based on updatedAt + // NOTE: This is intentionally minimal for MVP. + // A richer approach would compare field-by-field and/or keep per-field clocks. + const targetTasks = await target.listTasks(undefined); + const targetTask = targetTasks.find((t) => t.id === targetId); + if (!targetTask) { + // mapping points to missing task -> re-create, unless tombstoned + if (this.store.isTombstoned(state, target.name, targetId)) return; + actions.push({ + action: dryRun ? 'would-recreate' : 'recreate', + 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); + } + return; + } + + if (newer(task.updatedAt, targetTask.updatedAt)) { + actions.push({ + action: dryRun ? 'would-update' : 'update', + 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, + }); + } + } + } +} diff --git a/test/engine.test.ts b/test/engine.test.ts new file mode 100644 index 0000000..264a63a --- /dev/null +++ b/test/engine.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { SyncEngine } from '../src/sync/engine.js'; +import { MockProvider } from '../src/providers/mock.js'; +import path from 'node:path'; +import { mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import { JsonStore } from '../src/store/jsonStore.js'; + +describe('SyncEngine', () => { + it('creates missing tasks across providers (dry-run)', async () => { + const store = new JsonStore(await mkdtemp(path.join(os.tmpdir(), 'task-sync-'))); + const engine = new SyncEngine(store); + + const a = new MockProvider({ + name: 'mockA', + tasks: [{ id: 'a1', title: 'A', status: 'active', updatedAt: new Date().toISOString() }], + }); + const b = new MockProvider({ name: 'mockB', tasks: [] }); + + const report = await engine.sync(a, b, { dryRun: true }); + expect(report.actions.some((x) => x.action === 'would-create')).toBe(true); + }); + + it('tombstones completed tasks and deletes on the other side (dry-run)', async () => { + const store = new JsonStore(await mkdtemp(path.join(os.tmpdir(), 'task-sync-'))); + const engine = new SyncEngine(store); + + const a = new MockProvider({ + name: 'mockA', + tasks: [{ id: 'a1', title: 'A', status: 'completed', updatedAt: new Date().toISOString() }], + }); + const b = new MockProvider({ + name: 'mockB', + tasks: [{ id: 'b1', title: 'B', status: 'active', updatedAt: new Date().toISOString() }], + }); + + // pre-create mapping by running a dry sync once for active task to establish linkage + const s = await store.load(); + const map = store.ensureMapping(s, 'mockA', 'a1'); + store.upsertProviderId(s, map.canonicalId, 'mockB', 'b1'); + await store.save(s); + + const report = await engine.sync(a, b, { dryRun: true }); + expect(report.actions.some((x) => x.action === 'would-delete')).toBe(true); + }); +}); diff --git a/test/jsonStore.test.ts b/test/jsonStore.test.ts new file mode 100644 index 0000000..bef7641 --- /dev/null +++ b/test/jsonStore.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { JsonStore } from '../src/store/jsonStore.js'; +import path from 'node:path'; +import { mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; + +describe('JsonStore', () => { + it('creates and re-loads state', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'task-sync-')); + const store = new JsonStore(dir); + + const s1 = await store.load(); + expect(s1.lastSyncAt).toBeUndefined(); + expect(s1.mappings).toHaveLength(0); + + const map = store.ensureMapping(s1, 'mockA', 'a1'); + store.upsertProviderId(s1, map.canonicalId, 'mockA', 'a1'); + store.addTombstone(s1, 'mockA', 'x1'); + s1.lastSyncAt = new Date().toISOString(); + + await store.save(s1); + + const s2 = await store.load(); + expect(s2.lastSyncAt).toBeTruthy(); + expect(s2.mappings.length).toBe(1); + expect(s2.tombstones.length).toBe(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..353de37 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + + "rootDir": ".", + "outDir": "dist", + + "declaration": true, + "sourceMap": true + }, + "include": ["src", "test"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..6ea1687 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/cli.ts'], + format: ['esm'], + platform: 'node', + target: 'node22', + sourcemap: true, + clean: true, + dts: true, + banner: { + js: '#!/usr/bin/env node', + }, +}); From f289420a0d344fcd1d9b3661174043579d9100b9 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sun, 1 Feb 2026 19:36:05 +0300 Subject: [PATCH 02/12] MVP plumbing: env loader, logging, state dir, structured sync report --- src/cli.ts | 42 ++++++++++++-- src/config.ts | 4 ++ src/env.ts | 47 ++++++++++++++++ src/log.ts | 56 +++++++++++++++++++ src/sync/engine.ts | 132 ++++++++++++++++++++++++++++++++++++-------- test/engine.test.ts | 4 +- 6 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 src/env.ts create mode 100644 src/log.ts diff --git a/src/cli.ts b/src/cli.ts index 33e0bfb..eca4c02 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,14 @@ import { Command } from 'commander'; import { doctorReport, readEnv } from './config.js'; +import { loadEnvFiles } from './env.js'; +import { createLogger } from './log.js'; import { MockProvider } from './providers/mock.js'; import { GoogleTasksProvider } from './providers/google.js'; import { MicrosoftTodoProvider } from './providers/microsoft.js'; import { SyncEngine } from './sync/engine.js'; +import { JsonStore } from './store/jsonStore.js'; + +loadEnvFiles(); const program = new Command(); @@ -38,12 +43,16 @@ program program .command('sync') .description('Run sync engine') - .option('--dry-run', 'Run with mock providers and do not persist state') - .action(async (opts: { dryRun?: boolean }) => { - const engine = new SyncEngine(); + .option('--dry-run', 'Use mock providers and do not persist state') + .option('--state-dir ', 'Override state dir (default: .task-sync or TASK_SYNC_STATE_DIR)') + .option('--format ', 'Output format: pretty|json', 'pretty') + .action(async (opts: { dryRun?: boolean; stateDir?: string; format?: 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 dryRun = !!opts.dryRun; - const env = readEnv(); if (!dryRun) { const dr = doctorReport(env); @@ -106,9 +115,32 @@ program 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 }); - console.log(JSON.stringify(report, null, 2)); + if ((opts.format ?? 'pretty') === 'json') { + console.log(JSON.stringify(report, null, 2)); + return; + } + + console.log(`task-sync report`); + console.log(`providers: ${report.providerA} <-> ${report.providerB}`); + console.log(`lastSyncAt: ${report.lastSyncAt ?? '(none)'}`); + console.log(`newLastSyncAt: ${report.newLastSyncAt}`); + console.log(`dryRun: ${report.dryRun}`); + + console.log('\ncounts:'); + for (const k of Object.keys(report.counts) as Array) { + 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 8ab601e..c8f9797 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,10 @@ export const EnvSchema = z.object({ TASK_SYNC_PROVIDER_A: z.enum(['google', 'microsoft']).optional(), TASK_SYNC_PROVIDER_B: z.enum(['google', 'microsoft']).optional(), + // behavior + TASK_SYNC_LOG_LEVEL: z.enum(['silent', 'error', 'warn', 'info', 'debug']).optional(), + TASK_SYNC_STATE_DIR: str.optional(), + // Google Tasks (scaffold) TASK_SYNC_GOOGLE_CLIENT_ID: str.optional(), TASK_SYNC_GOOGLE_CLIENT_SECRET: str.optional(), diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..6c406ca --- /dev/null +++ b/src/env.ts @@ -0,0 +1,47 @@ +import { readFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Minimal .env loader (no external deps). + * + * - Reads KEY=VALUE lines + * - Ignores comments and empty lines + * - Does not override existing process.env keys + */ +export function loadEnvFiles( + filenames: string[] = ['.env', '.env.local'], + cwd: string = process.cwd(), +): { loaded: string[] } { + const loaded: string[] = []; + + for (const name of filenames) { + const filePath = path.join(cwd, name); + if (!existsSync(filePath)) continue; + + const raw = readFileSync(filePath, 'utf8'); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + + // strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (!key) continue; + if (process.env[key] === undefined) process.env[key] = value; + } + + loaded.push(name); + } + + return { loaded }; +} diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..9166bc6 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,56 @@ +export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; + +const ORDER: Record, number> = { + error: 1, + warn: 2, + info: 3, + debug: 4, +}; + +export interface Logger { + error(msg: string, meta?: unknown): void; + warn(msg: string, meta?: unknown): void; + info(msg: string, meta?: unknown): void; + debug(msg: string, meta?: unknown): void; +} + +function fmtMeta(meta: unknown) { + if (meta === undefined) return ''; + if (typeof meta === 'string') return ` ${meta}`; + try { + return ` ${JSON.stringify(meta)}`; + } catch { + return ' [meta-unserializable]'; + } +} + +export function createLogger(level: LogLevel = 'info'): Logger { + if (level === 'silent') { + return { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + }; + } + + const threshold = ORDER[level]; + const prefix = (lvl: string) => `${new Date().toISOString()} ${lvl.toUpperCase()} `; + + const can = (lvl: Exclude) => ORDER[lvl] <= threshold; + + return { + error: (msg, meta) => { + if (can('error')) console.error(prefix('error') + msg + fmtMeta(meta)); + }, + warn: (msg, meta) => { + if (can('warn')) console.warn(prefix('warn') + msg + fmtMeta(meta)); + }, + info: (msg, meta) => { + if (can('info')) console.log(prefix('info') + msg + fmtMeta(meta)); + }, + debug: (msg, meta) => { + if (can('debug')) console.log(prefix('debug') + msg + fmtMeta(meta)); + }, + }; +} diff --git a/src/sync/engine.ts b/src/sync/engine.ts index 532824f..37c9ba0 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -9,19 +9,35 @@ export interface SyncOptions { conflictPolicy?: ConflictPolicy; } +export type SyncActionKind = 'create' | 'update' | 'delete' | 'recreate' | 'noop'; + +export interface SyncAction { + kind: SyncActionKind; + executed: boolean; + source: { provider: string; id: string }; + target: { provider: string; id?: string }; + title?: string; + detail: string; +} + export interface SyncReport { dryRun: boolean; providerA: string; providerB: string; lastSyncAt?: string; newLastSyncAt: string; - actions: Array<{ action: string; detail: string }>; + counts: Record; + actions: SyncAction[]; } function newer(a: string, b: string) { return Date.parse(a) > Date.parse(b); } +function indexById(tasks: Task[]) { + return new Map(tasks.map((t) => [t.id, t] as const)); +} + export class SyncEngine { constructor(private store = new JsonStore()) {} @@ -30,20 +46,59 @@ export class SyncEngine { const state = await this.store.load(); const lastSyncAt = state.lastSyncAt; - const actions: SyncReport['actions'] = []; + 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]++; + }; - const [aTasks, bTasks] = await Promise.all([a.listTasks(lastSyncAt), b.listTasks(lastSyncAt)]); + // 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 aTasks) { + for (const t of aChanges) { if (this.store.isTombstoned(state, a.name, t.id)) continue; - await this.reconcileOne({ source: a, target: b, state, task: t, dryRun, actions }); + await this.reconcileOne({ + source: a, + target: b, + targetIndex: bIndex, + state, + task: t, + dryRun, + push, + }); } // 2) Process tasks from B -> A - for (const t of bTasks) { + for (const t of bChanges) { if (this.store.isTombstoned(state, b.name, t.id)) continue; - await this.reconcileOne({ source: b, target: a, state, task: t, dryRun, actions }); + await this.reconcileOne({ + source: b, + target: a, + targetIndex: aIndex, + state, + task: t, + dryRun, + push, + }); } const newLastSyncAt = new Date().toISOString(); @@ -56,6 +111,7 @@ export class SyncEngine { providerB: b.name, lastSyncAt, newLastSyncAt, + counts, actions, }; } @@ -63,12 +119,13 @@ export class SyncEngine { private async reconcileOne(params: { source: TaskProvider; target: TaskProvider; + targetIndex: Map; state: SyncState; task: Task; dryRun: boolean; - actions: Array<{ action: string; detail: string }>; + push: (a: SyncAction) => void; }) { - const { source, target, state, task, dryRun, actions } = params; + 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]; @@ -79,18 +136,35 @@ export class SyncEngine { if (targetId) this.store.addTombstone(state, target.name, targetId); if (targetId) { - actions.push({ - action: dryRun ? 'would-delete' : 'delete', + 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)`, + }); } return; } if (!targetId) { - actions.push({ - action: dryRun ? 'would-create' : 'create', + 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}"`, }); @@ -108,16 +182,16 @@ export class SyncEngine { return; } - // If both sides exist, we do simple LWW based on updatedAt - // NOTE: This is intentionally minimal for MVP. - // A richer approach would compare field-by-field and/or keep per-field clocks. - const targetTasks = await target.listTasks(undefined); - const targetTask = targetTasks.find((t) => t.id === targetId); + 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; - actions.push({ - action: dryRun ? 'would-recreate' : 'recreate', + 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) { @@ -134,9 +208,14 @@ export class SyncEngine { return; } + // If both sides exist, we do simple LWW based on updatedAt. if (newer(task.updatedAt, targetTask.updatedAt)) { - actions.push({ - action: dryRun ? 'would-update' : 'update', + 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) { @@ -149,6 +228,15 @@ export class SyncEngine { updatedAt: task.updatedAt, }); } + } 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}`, + }); } } } diff --git a/test/engine.test.ts b/test/engine.test.ts index 264a63a..4898a11 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -18,7 +18,7 @@ describe('SyncEngine', () => { const b = new MockProvider({ name: 'mockB', tasks: [] }); const report = await engine.sync(a, b, { dryRun: true }); - expect(report.actions.some((x) => x.action === 'would-create')).toBe(true); + expect(report.actions.some((x) => x.kind === 'create' && x.executed === false)).toBe(true); }); it('tombstones completed tasks and deletes on the other side (dry-run)', async () => { @@ -41,6 +41,6 @@ describe('SyncEngine', () => { await store.save(s); const report = await engine.sync(a, b, { dryRun: true }); - expect(report.actions.some((x) => x.action === 'would-delete')).toBe(true); + expect(report.actions.some((x) => x.kind === 'delete' && x.executed === false)).toBe(true); }); }); From 3a9b82619ac3d14a91c3254945fe6c57b2d37d24 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sat, 7 Feb 2026 00:09:41 +0300 Subject: [PATCH 03/12] feat: 3-provider sync + oauth helpers + Habitica provider --- README.md | 172 +++++++++++++++++----------- package.json | 4 +- scripts/google_oauth.ts | 120 ++++++++++++++++++++ scripts/microsoft_oauth.ts | 117 +++++++++++++++++++ src/cli.ts | 198 ++++++++++++++++++++------------- src/config.ts | 44 +++++--- src/http.ts | 58 ++++++++++ src/model.ts | 2 +- src/providers/google.ts | 151 +++++++++++++++++++++---- src/providers/habitica.ts | 142 +++++++++++++++++++++++ src/providers/microsoft.ts | 191 ++++++++++++++++++++++++++----- src/providers/mock.ts | 6 +- src/sync/engine.ts | 87 ++++++++------- test/engine.test.ts | 20 +++- test/googleProvider.test.ts | 60 ++++++++++ test/habiticaProvider.test.ts | 46 ++++++++ test/microsoftProvider.test.ts | 66 +++++++++++ 17 files changed, 1231 insertions(+), 253 deletions(-) create mode 100644 scripts/google_oauth.ts create mode 100644 scripts/microsoft_oauth.ts create mode 100644 src/http.ts create mode 100644 src/providers/habitica.ts create mode 100644 test/googleProvider.test.ts create mode 100644 test/habiticaProvider.test.ts create mode 100644 test/microsoftProvider.test.ts diff --git a/README.md b/README.md index 286545a..e84b193 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,12 @@ # task-sync -Sync tasks between **Microsoft To Do (Microsoft Graph)** and **Google Tasks**. +Sync tasks across **Google Tasks**, **Microsoft To Do (Microsoft Graph)**, and an optional 3rd provider. -This repo currently contains a solid **MVP scaffolding**: +Currently implemented 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) +- Habitica Todos (API token) ## Quickstart @@ -45,45 +20,81 @@ 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 +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 +``` + +### Dry-run + +Dry-run still uses your configured providers, but **does not write** any changes. ```bash -npm run build node dist/cli.js sync --dry-run ``` -You should see a JSON report describing the actions the engine would take. +## Configuration (.env) -## Configuration (for when real providers are implemented) +Create a `.env.local` (recommended) or `.env`: -Set these env vars (placeholders for next steps): +### Provider selection (2-3 providers) -### Provider selection +```bash +TASK_SYNC_PROVIDER_A=google +TASK_SYNC_PROVIDER_B=microsoft +TASK_SYNC_PROVIDER_C=habitica # optional +``` -- `TASK_SYNC_PROVIDER_A` = `google` | `microsoft` -- `TASK_SYNC_PROVIDER_B` = `google` | `microsoft` +### State -### Google Tasks (scaffold) +```bash +TASK_SYNC_STATE_DIR=.task-sync +TASK_SYNC_LOG_LEVEL=info +``` -- `TASK_SYNC_GOOGLE_CLIENT_ID` -- `TASK_SYNC_GOOGLE_CLIENT_SECRET` -- `TASK_SYNC_GOOGLE_REFRESH_TOKEN` -- `TASK_SYNC_GOOGLE_TASKLIST_ID` (optional; defaults to `@default`) +### Google Tasks -### Microsoft Graph / To Do (scaffold) +```bash +TASK_SYNC_GOOGLE_CLIENT_ID=... +TASK_SYNC_GOOGLE_CLIENT_SECRET=... +TASK_SYNC_GOOGLE_REFRESH_TOKEN=... +TASK_SYNC_GOOGLE_TASKLIST_ID=@default # optional +``` -- `TASK_SYNC_MS_CLIENT_ID` -- `TASK_SYNC_MS_TENANT_ID` -- `TASK_SYNC_MS_REFRESH_TOKEN` -- `TASK_SYNC_MS_LIST_ID` (optional) +### Microsoft To Do (Graph) + +```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) +``` + +### Habitica + +```bash +TASK_SYNC_HABITICA_USER_ID=... +TASK_SYNC_HABITICA_API_TOKEN=... +``` Run: @@ -93,7 +104,54 @@ task-sync doctor to see what’s missing. -## How state works (.task-sync/) +## 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` + +2) Run: + +```bash +export TASK_SYNC_MS_CLIENT_ID=... +export TASK_SYNC_MS_TENANT_ID=common +npm run oauth:microsoft +``` + +## Notes on Habitica mapping + +Habitica tasks are synced as **Todos**. + +- `Task.title` ↔ Habitica `text` +- `Task.notes` ↔ Habitica `notes` (human notes only) +- Extra fields are preserved by packing JSON into the Habitica `notes` field under a `--- task-sync ---` block. + +## How state works `task-sync` writes local state under: @@ -105,7 +163,7 @@ This includes: - `mappings`: links a canonical ID to provider IDs - `tombstones`: prevents resurrecting completed/deleted tasks -You can delete `.task-sync/` to reset state. +Delete `.task-sync/` to reset sync state. ## Development @@ -117,16 +175,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.json b/package.json index 6b3ec5e..4b37600 100644 --- a/package.json +++ b/package.json @@ -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/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 { + 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..2bf64c9 --- /dev/null +++ b/scripts/microsoft_oauth.ts @@ -0,0 +1,117 @@ +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); +} + +const scopes = ['offline_access', 'User.Read', '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 { + 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..5530a26 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { createLogger } from './log.js'; import { MockProvider } from './providers/mock.js'; import { GoogleTasksProvider } from './providers/google.js'; import { MicrosoftTodoProvider } from './providers/microsoft.js'; +import { HabiticaProvider } from './providers/habitica.js'; import { SyncEngine } from './sync/engine.js'; import { JsonStore } from './store/jsonStore.js'; @@ -14,7 +15,7 @@ const program = new Command(); program .name('task-sync') - .description('Sync tasks between providers (MVP: dry-run with mock providers)') + .description('Sync tasks between providers (Google Tasks, Microsoft To Do, Habitica)') .version('0.1.0'); program @@ -40,84 +41,134 @@ 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 ', 'Override state dir (default: .task-sync or TASK_SYNC_STATE_DIR)') .option('--format ', 'Output format: pretty|json', 'pretty') - .action(async (opts: { dryRun?: boolean; stateDir?: string; format?: string }) => { + .option('--poll ', '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, env.TASK_SYNC_PROVIDER_C].filter( + Boolean, + ) as Array<'google' | 'microsoft' | 'habitica'>; + + if (providers.length < 2) { + console.error('Need at least 2 providers. Set TASK_SYNC_PROVIDER_A + TASK_SYNC_PROVIDER_B (and optional _C).'); + 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' | 'habitica') => { + 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, + }); + } + if (p === 'microsoft') { + 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, + }); + } + return new HabiticaProvider({ + userId: env.TASK_SYNC_HABITICA_USER_ID!, + apiToken: env.TASK_SYNC_HABITICA_API_TOKEN!, + }); + }; + + 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 }); + + 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('\ncounts:'); + for (const k of Object.keys(report.counts) as Array) { + 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}"` : ''}`, + ); + } } + + 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 3-provider dry-run using in-memory mock providers (for demos/tests)') + .option('--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: [] }); + const c = new MockProvider({ name: 'habitica', tasks: [] }); + + logger.info('mock sync start', { providers: [a.name, b.name, c.name] }); + const report = await engine.syncMany([a, b, c], { dryRun: true }); if ((opts.format ?? 'pretty') === 'json') { console.log(JSON.stringify(report, null, 2)); @@ -125,22 +176,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) { - 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..7a893ac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,25 +2,34 @@ import { z } from 'zod'; const str = z.string().min(1); +export const ProviderSchema = z.enum(['google', 'microsoft', 'habitica']); + export const EnvSchema = z.object({ - TASK_SYNC_PROVIDER_A: z.enum(['google', 'microsoft']).optional(), - TASK_SYNC_PROVIDER_B: z.enum(['google', 'microsoft']).optional(), + // providers (supports up to 3 for MVP) + TASK_SYNC_PROVIDER_A: ProviderSchema.optional(), + TASK_SYNC_PROVIDER_B: ProviderSchema.optional(), + TASK_SYNC_PROVIDER_C: 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(), - // 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(), + + // Habitica (token auth) + TASK_SYNC_HABITICA_USER_ID: str.optional(), + TASK_SYNC_HABITICA_API_TOKEN: str.optional(), }); export type EnvConfig = z.infer; @@ -30,17 +39,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, env.TASK_SYNC_PROVIDER_C].filter( + Boolean, + ) as Array>; 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 (and optional _C) to choose providers (google|microsoft|habitica).'); } - 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 +61,21 @@ 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).'); + } + if (p === 'habitica') { + if (!env.TASK_SYNC_HABITICA_USER_ID) missing.push('TASK_SYNC_HABITICA_USER_ID'); + if (!env.TASK_SYNC_HABITICA_API_TOKEN) missing.push('TASK_SYNC_HABITICA_API_TOKEN'); } } return { - providers: { a: providerA, b: providerB }, - missing, - notes, + providers: { + a: env.TASK_SYNC_PROVIDER_A, + b: env.TASK_SYNC_PROVIDER_B, + c: env.TASK_SYNC_PROVIDER_C, + }, + missing: [...new Set(missing)], + notes: [...new Set(notes)], }; } diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..09e270d --- /dev/null +++ b/src/http.ts @@ -0,0 +1,58 @@ +export type FetchLike = typeof fetch; + +export interface JsonRequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record; + query?: Record; + body?: unknown; +} + +export class HttpError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly url: string, + public readonly responseText?: string, + ) { + 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(); +} + +export async function requestJson( + url: string, + opts: JsonRequestOptions = {}, + fetcher: FetchLike = fetch, +): Promise { + const finalUrl = withQuery(url, opts.query); + 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); + throw new HttpError(`HTTP ${res.status} for ${finalUrl}`, res.status, finalUrl, txt); + } + + // 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; +} diff --git a/src/model.ts b/src/model.ts index 4f06772..05688f4 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1,4 +1,4 @@ -export type ProviderName = 'mockA' | 'mockB' | 'google' | 'microsoft'; +export type ProviderName = 'mockA' | 'mockB' | 'google' | 'microsoft' | 'habitica'; export type TaskStatus = 'active' | 'completed' | 'deleted'; diff --git a/src/providers/google.ts b/src/providers/google.ts index fcd10f0..5de3635 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,142 @@ 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 { + 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 { - throw new Error( - 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + private async api(path: string, init?: Parameters>[1]): Promise { + const token = await this.getAccessToken(); + const base = `https://tasks.googleapis.com/tasks/v1`; + return requestJson(`${base}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, ...(init?.headers ?? {}) } }, this.fetcher); + } + + async listTasks(since?: string): Promise { + const tasklistId = this.opts.tasklistId ?? '@default'; + + const out: Task[] = []; + let pageToken: string | undefined; + + do { + const res = await this.api(`/lists/${encodeURIComponent(tasklistId)}/tasks`, { + query: { + maxResults: 100, + showCompleted: true, + showHidden: true, + pageToken, + }, + }); + + for (const t of res.items ?? []) out.push(toCanonical(t)); + pageToken = res.nextPageToken; + } while (pageToken); + + if (!since) return out; + const sinceMs = Date.parse(since); + return out.filter((t) => Date.parse(t.updatedAt) >= sinceMs); } - async upsertTask(_input: Omit & { updatedAt?: string }): Promise { - throw new Error( - 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async upsertTask(input: Omit & { updatedAt?: string }): Promise { + const tasklistId = this.opts.tasklistId ?? '@default'; + const isCreate = !input.id; + + const payload: Partial = { + title: input.title, + notes: input.notes, + status: input.status === 'completed' ? 'completed' : 'needsAction', + due: input.dueAt, + }; + + const res = isCreate + ? await this.api(`/lists/${encodeURIComponent(tasklistId)}/tasks`, { + method: 'POST', + body: payload, + }) + : await this.api( + `/lists/${encodeURIComponent(tasklistId)}/tasks/${encodeURIComponent(input.id)}`, + { + method: 'PATCH', + body: payload, + }, + ); + + // Google sets updated server-side. + return toCanonical(res); } - async deleteTask(_id: string): Promise { - throw new Error( - 'GoogleTasksProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async deleteTask(id: string): Promise { + const tasklistId = this.opts.tasklistId ?? '@default'; + await this.api(`/lists/${encodeURIComponent(tasklistId)}/tasks/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); } } diff --git a/src/providers/habitica.ts b/src/providers/habitica.ts new file mode 100644 index 0000000..979cf44 --- /dev/null +++ b/src/providers/habitica.ts @@ -0,0 +1,142 @@ +import type { Task } from '../model.js'; +import type { TaskProvider } from './provider.js'; +import { requestJson, type FetchLike } from '../http.js'; + +export interface HabiticaProviderOptions { + userId: string; + apiToken: string; + /** Inject fetch for tests */ + fetcher?: FetchLike; +} + +// Habitica API shape (subset) +interface HabiticaTask { + id: string; + type: 'todo' | string; + text: string; + notes?: string; + completed: boolean; + date?: string; // due + updatedAt: string; + priority?: number; + tags?: string[]; +} + +interface HabiticaApiResponse { + success: boolean; + data: T; +} + +function packNotes(humanNotes: string | undefined, extra: Record) { + const meta = JSON.stringify(extra); + const block = `\n\n--- task-sync ---\n${meta}\n--- /task-sync ---\n`; + const base = (humanNotes ?? '').trim(); + if (!base) return block.trimStart(); + + // avoid duplicating our own block + if (base.includes('--- task-sync ---')) return base; + return base + block; +} + +function unpackNotes(notes?: string): { human?: string; meta?: Record } { + if (!notes) return {}; + const start = notes.indexOf('--- task-sync ---'); + const end = notes.indexOf('--- /task-sync ---'); + if (start === -1 || end === -1 || end < start) return { human: notes }; + + const human = notes.slice(0, start).trim() || undefined; + const metaRaw = notes.slice(start + '--- task-sync ---'.length, end).trim(); + try { + const meta = JSON.parse(metaRaw) as Record; + return { human, meta }; + } catch { + return { human: notes }; + } +} + +function toCanonical(t: HabiticaTask): Task { + const unpacked = unpackNotes(t.notes); + return { + id: t.id, + title: t.text, + notes: unpacked.human, + status: t.completed ? 'completed' : 'active', + dueAt: t.date, + updatedAt: t.updatedAt, + }; +} + +/** + * Habitica provider (Todos). + * + * Auth: X-API-User + X-API-Key headers. + * + * Notes packing: + * - We keep human notes in Task.notes. + * - We store extra Habitica-only fields (priority/tags) inside the Habitica task notes + * in a JSON block to preserve data round-trips. + */ +export class HabiticaProvider implements TaskProvider { + readonly name = 'habitica' as const; + private fetcher: FetchLike; + + constructor(private opts: HabiticaProviderOptions) { + this.fetcher = opts.fetcher ?? fetch; + } + + private headers() { + return { + 'x-api-user': this.opts.userId, + 'x-api-key': this.opts.apiToken, + }; + } + + private async api(path: string, init?: Parameters>>[1]): Promise { + const base = 'https://habitica.com/api/v3'; + const res = await requestJson>( + `${base}${path}`, + { ...init, headers: { ...this.headers(), ...(init?.headers ?? {}) } }, + this.fetcher, + ); + return res.data; + } + + async listTasks(since?: string): Promise { + const todos = await this.api(`/tasks/user`, { + query: { type: 'todos' }, + }); + + const out = todos.filter((t) => t.type === 'todo').map(toCanonical); + if (!since) return out; + + const sinceMs = Date.parse(since); + return out.filter((t) => Date.parse(t.updatedAt) >= sinceMs); + } + + async upsertTask(input: Omit & { updatedAt?: string }): Promise { + const isCreate = !input.id; + + // Preserve habitica-specific fields if they exist in existing notes meta. + // (If caller provides packed notes already, we just pass it through.) + const { human, meta } = unpackNotes(input.notes); + const packed = packNotes(human, meta ?? {}); + + const body: Partial & { type?: string } = { + type: 'todo', + text: input.title, + notes: packed, + date: input.dueAt, + completed: input.status === 'completed', + }; + + const task = isCreate + ? await this.api(`/tasks/user`, { method: 'POST', body }) + : await this.api(`/tasks/${encodeURIComponent(input.id)}`, { method: 'PUT', body }); + + return toCanonical(task); + } + + async deleteTask(id: string): Promise { + await this.api(`/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' }); + } +} diff --git a/src/providers/microsoft.ts b/src/providers/microsoft.ts index c37d0eb..ef1ecef 100644 --- a/src/providers/microsoft.ts +++ b/src/providers/microsoft.ts @@ -1,49 +1,188 @@ 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; +} + +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 }; + lastModifiedDateTime: string; + createdDateTime: string; +} + +interface GraphListTasksResponse { + value: GraphTask[]; + '@odata.nextLink'?: string; +} + +function toCanonical(t: GraphTask): Task { + return { + id: t.id, + title: t.title, + notes: t.body?.content, + status: t.completedDateTime ? 'completed' : 'active', + dueAt: t.dueDateTime?.dateTime, + updatedAt: t.lastModifiedDateTime, + }; } -/** - * 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 - */ 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 { + 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. Graph To Do requires Tasks.ReadWrite. + scope: 'offline_access Tasks.ReadWrite 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; + this.accessToken = { token: json.access_token, expMs: now + json.expires_in * 1000 }; + return json.access_token; + } + + private async api(pathOrUrl: string, init?: Parameters>[1]): Promise { + const token = await this.getAccessToken(); + const base = `https://graph.microsoft.com/v1.0`; + const url = pathOrUrl.startsWith('https://') ? pathOrUrl : `${base}${pathOrUrl}`; + return requestJson(url, { ...init, headers: { authorization: `Bearer ${token}`, ...(init?.headers ?? {}) } }, this.fetcher); + } + + private async getListId(): Promise { + if (this.resolvedListId) return this.resolvedListId; + if (this.opts.listId) { + this.resolvedListId = this.opts.listId; + return this.opts.listId; + } + + const lists = await this.api(`/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 listTasks(_since?: string): Promise { - throw new Error( - 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async listTasks(since?: string): Promise { + const listId = await this.getListId(); + + const out: Task[] = []; + let next: string | undefined = `/me/todo/lists/${encodeURIComponent(listId)}/tasks?$top=100`; + + while (next) { + const url = next; + const res: GraphListTasksResponse = await this.api(url); + for (const t of res.value ?? []) out.push(toCanonical(t)); + next = res['@odata.nextLink']; + } + + if (!since) return out; + const sinceMs = Date.parse(since); + return out.filter((t) => Date.parse(t.updatedAt) >= sinceMs); } - async upsertTask(_input: Omit & { updatedAt?: string }): Promise { - throw new Error( - 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async upsertTask(input: Omit & { updatedAt?: string }): Promise { + const listId = await this.getListId(); + const isCreate = !input.id; + + const payload: Partial & { body?: GraphBody } = { + title: input.title, + body: input.notes + ? { + contentType: 'text', + content: input.notes, + } + : undefined, + dueDateTime: input.dueAt + ? { + dateTime: input.dueAt, + timeZone: 'UTC', + } + : undefined, + completedDateTime: input.status === 'completed' ? { dateTime: new Date().toISOString(), timeZone: 'UTC' } : undefined, + }; + + const res = isCreate + ? await this.api(`/me/todo/lists/${encodeURIComponent(listId)}/tasks`, { + method: 'POST', + body: payload, + }) + : await this.api( + `/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(input.id)}`, + { + method: 'PATCH', + body: payload, + }, + ); + + return toCanonical(res); } - async deleteTask(_id: string): Promise { - throw new Error( - 'MicrosoftTodoProvider not implemented in MVP. Use `task-sync sync --dry-run` or implement provider.' - ); + async deleteTask(id: string): Promise { + const listId = await this.getListId(); + await this.api(`/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(); - 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/sync/engine.ts b/src/sync/engine.ts index 37c9ba0..d0faa03 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -22,8 +22,7 @@ export interface SyncAction { export interface SyncReport { dryRun: boolean; - providerA: string; - providerB: string; + providers: string[]; lastSyncAt?: string; newLastSyncAt: string; counts: Record; @@ -41,7 +40,19 @@ function indexById(tasks: Task[]) { export class SyncEngine { constructor(private store = new JsonStore()) {} + /** + * Back-compat: two-way sync. + */ async sync(a: TaskProvider, b: TaskProvider, opts: SyncOptions = {}): Promise { + return this.syncMany([a, b], opts); + } + + /** + * N-way sync (MVP: 2-3 providers). For every provider, reconcile its changes into every other provider. + */ + async syncMany(providers: TaskProvider[], opts: SyncOptions = {}): Promise { + if (providers.length < 2) throw new Error('syncMany requires at least 2 providers'); + const dryRun = !!opts.dryRun; const state = await this.store.load(); @@ -61,44 +72,37 @@ export class SyncEngine { 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, - }); - } - - // 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, - }); + // Preload changes + snapshots for all providers. + const snapshots = new Map }>(); + + await Promise.all( + providers.map(async (p) => { + const [changes, all] = await Promise.all([p.listTasks(lastSyncAt), p.listTasks(undefined)]); + snapshots.set(p.name, { changes, all, index: indexById(all) }); + }), + ); + + // For each provider -> reconcile into every other. + for (const source of providers) { + const snap = snapshots.get(source.name)!; + for (const task of snap.changes) { + if (this.store.isTombstoned(state, source.name, task.id)) continue; + + for (const target of providers) { + if (target.name === source.name) continue; + + const targetSnap = snapshots.get(target.name)!; + await this.reconcileOne({ + source, + target, + targetIndex: targetSnap.index, + state, + task, + dryRun, + push, + }); + } + } } const newLastSyncAt = new Date().toISOString(); @@ -107,8 +111,7 @@ export class SyncEngine { return { dryRun, - providerA: a.name, - providerB: b.name, + providers: providers.map((p) => p.name), lastSyncAt, newLastSyncAt, counts, diff --git a/test/engine.test.ts b/test/engine.test.ts index 4898a11..fdc98c0 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -19,6 +19,7 @@ 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 () => { @@ -34,7 +35,7 @@ describe('SyncEngine', () => { tasks: [{ id: 'b1', title: 'B', status: 'active', updatedAt: new Date().toISOString() }], }); - // pre-create mapping by running a dry sync once for active task to establish linkage + // pre-create mapping by storing linkage const s = await store.load(); const map = store.ensureMapping(s, 'mockA', 'a1'); store.upsertProviderId(s, map.canonicalId, 'mockB', 'b1'); @@ -43,4 +44,21 @@ describe('SyncEngine', () => { const report = await engine.sync(a, b, { dryRun: true }); expect(report.actions.some((x) => x.kind === 'delete' && x.executed === false)).toBe(true); }); + + it('3-way: plans create into both targets (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 c = new MockProvider({ name: 'habitica', tasks: [] }); + + const report = await engine.syncMany([a, b, c], { dryRun: true }); + + const creates = report.actions.filter((x) => x.kind === 'create'); + // a1 should be created into both b and c + expect(creates.length).toBeGreaterThanOrEqual(2); + expect(new Set(creates.map((x) => x.target.provider))).toEqual(new Set(['mockB', 'habitica'])); + }); }); 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/habiticaProvider.test.ts b/test/habiticaProvider.test.ts new file mode 100644 index 0000000..5fef334 --- /dev/null +++ b/test/habiticaProvider.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { HabiticaProvider } from '../src/providers/habitica.js'; + +function jsonResponse(obj: unknown, status = 200) { + return new Response(JSON.stringify(obj), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('HabiticaProvider', () => { + it('lists todos and maps fields', async () => { + const fetcher: typeof fetch = async (url) => { + const u = String(url); + if (u.startsWith('https://habitica.com/api/v3/tasks/user')) { + return jsonResponse({ + success: true, + data: [ + { + id: 'h1', + type: 'todo', + text: 'Do it', + notes: 'note', + completed: false, + date: '2026-02-10T00:00:00.000Z', + updatedAt: '2026-02-06T00:00:00.000Z', + }, + ], + }); + } + return new Response('not found', { status: 404 }); + }; + + const p = new HabiticaProvider({ userId: 'u', apiToken: 'k', fetcher }); + const tasks = await p.listTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ + id: 'h1', + title: 'Do it', + notes: 'note', + status: 'active', + dueAt: '2026-02-10T00:00:00.000Z', + updatedAt: '2026-02-06T00:00:00.000Z', + }); + }); +}); diff --git a/test/microsoftProvider.test.ts b/test/microsoftProvider.test.ts new file mode 100644 index 0000000..dddcf85 --- /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', + }); + }); +}); From 294974b32a28cdc88355da78626d0aff02fe43e8 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sat, 7 Feb 2026 00:38:51 +0300 Subject: [PATCH 04/12] fix: use graph resource scopes; add habitica x-client header --- package-lock.json | 4 ++-- scripts/microsoft_oauth.ts | 7 ++++++- src/providers/habitica.ts | 3 +++ src/providers/microsoft.ts | 10 ++++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) 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/scripts/microsoft_oauth.ts b/scripts/microsoft_oauth.ts index 2bf64c9..f3fb887 100644 --- a/scripts/microsoft_oauth.ts +++ b/scripts/microsoft_oauth.ts @@ -12,7 +12,12 @@ if (!clientId) { process.exit(2); } -const scopes = ['offline_access', 'User.Read', 'Tasks.ReadWrite']; +// 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); diff --git a/src/providers/habitica.ts b/src/providers/habitica.ts index 979cf44..c67f03f 100644 --- a/src/providers/habitica.ts +++ b/src/providers/habitica.ts @@ -85,9 +85,12 @@ export class HabiticaProvider implements TaskProvider { } private headers() { + // Habitica requires an x-client header in addition to the API user/key. + // Format: " ()". Keep it stable. return { 'x-api-user': this.opts.userId, 'x-api-key': this.opts.apiToken, + 'x-client': 'task-sync (salaamdev)', }; } diff --git a/src/providers/microsoft.ts b/src/providers/microsoft.ts index ef1ecef..0636b00 100644 --- a/src/providers/microsoft.ts +++ b/src/providers/microsoft.ts @@ -21,6 +21,8 @@ interface MsTokenResponse { 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 { @@ -82,8 +84,8 @@ export class MicrosoftTodoProvider implements TaskProvider { client_id: this.opts.clientId, refresh_token: this.opts.refreshToken, grant_type: 'refresh_token', - // Keep scopes aligned with initial consent. Graph To Do requires Tasks.ReadWrite. - scope: 'offline_access Tasks.ReadWrite User.Read', + // 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`; @@ -99,6 +101,10 @@ export class MicrosoftTodoProvider implements TaskProvider { } 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; } From 7ecf244393bfeb44a4ae7bec8bb1d3a94ba415b7 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sat, 7 Feb 2026 00:55:24 +0300 Subject: [PATCH 05/12] Harden sync engine: delete-wins, TTL tombstones, field-level merge, locking --- src/cli.ts | 18 +- src/config.ts | 3 + src/http.ts | 110 +++++- src/model.ts | 5 + src/store/jsonStore.ts | 86 ++++- src/store/lock.ts | 54 +++ src/sync/engine.ts | 636 ++++++++++++++++++++++++---------- test/engine.hardening.test.ts | 158 +++++++++ 8 files changed, 864 insertions(+), 206 deletions(-) create mode 100644 src/store/lock.ts create mode 100644 test/engine.hardening.test.ts diff --git a/src/cli.ts b/src/cli.ts index 5530a26..3df73b2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -111,7 +111,11 @@ program runCount++; logger.info(`sync start (dryRun=${dryRun}, run=${runCount})`, { providers }); - const report = await engine.syncMany(providerInstances, { dryRun }); + 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)); @@ -121,18 +125,28 @@ program 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) { 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}"` : ''}`, + `- [${exec}] ${a.kind} ${tgt} <= ${a.source.provider}:${a.source.id} ${a.title ? `"${a.title}"` : ''} :: ${a.detail}`, ); } } diff --git a/src/config.ts b/src/config.ts index 7a893ac..ead0849 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,9 @@ export const EnvSchema = z.object({ 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 TASK_SYNC_GOOGLE_CLIENT_ID: str.optional(), diff --git a/src/http.ts b/src/http.ts index 09e270d..b51471f 100644 --- a/src/http.ts +++ b/src/http.ts @@ -5,6 +5,12 @@ export interface JsonRequestOptions { headers?: Record; query?: Record; 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 { @@ -13,6 +19,7 @@ export class HttpError extends Error { public readonly status: number, public readonly url: string, public readonly responseText?: string, + public readonly retryAfterMs?: number, ) { super(message); } @@ -28,31 +35,96 @@ function withQuery(url: string, query?: JsonRequestOptions['query']) { return u.toString(); } +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Simple global limiter keyed by origin. +const lastRequestAt = new Map(); + +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( url: string, opts: JsonRequestOptions = {}, fetcher: FetchLike = fetch, ): Promise { const finalUrl = withQuery(url, opts.query); - 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); - throw new HttpError(`HTTP ${res.status} for ${finalUrl}`, res.status, finalUrl, txt); - } + const retries = opts.retries ?? 3; + const backoffMs = opts.backoffMs ?? 200; - // empty body - if (res.status === 204) return undefined as T; + let attempt = 0; + // eslint-disable-next-line no-constant-condition + 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 text = await res.text(); - if (!text) return undefined as T; - return JSON.parse(text) as T; + 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) { + // network/parse 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 05688f4..d67382a 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; updatedAt: string; // ISO } 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>; + /** Last canonical snapshot we synced to (used for field-level diffing). */ + canonical?: Omit; 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> & { 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 { 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 { + 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 { 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): 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..5c225c5 --- /dev/null +++ b/src/store/lock.ts @@ -0,0 +1,54 @@ +import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; + +export interface LockHandle { + path: string; + release(): Promise; +} + +export async function acquireLock(dir: string, filename = 'lock'): Promise { + 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. + try { + const raw = await readFile(lockPath, 'utf8'); + const parsed = JSON.parse(raw) as { pid?: number }; + const otherPid = parsed.pid; + if (otherPid && isProcessAlive(otherPid)) { + throw new Error(`Another task-sync process is running (pid=${otherPid}).`); + } + } catch (err) { + // If unreadable/invalid, treat as stale. + void err; + } + + // 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 d0faa03..8a9df0f 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -1,12 +1,20 @@ +import { appendFile } from 'node:fs/promises'; import type { Task } from '../model.js'; import type { TaskProvider } from '../providers/provider.js'; -import { JsonStore, type SyncState } from '../store/jsonStore.js'; +import { JsonStore, type MappingRecord, type SyncState } 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,6 +28,14 @@ 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; providers: string[]; @@ -27,6 +43,9 @@ export interface SyncReport { newLastSyncAt: string; counts: Record; actions: SyncAction[]; + conflicts: SyncConflict[]; + errors: Array<{ provider: string; stage: 'listChanges' | 'listAll' | 'write'; error: string }>; + durationMs: number; } function newer(a: string, b: string) { @@ -37,209 +56,466 @@ 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(); +} + +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; changeIndex: Map }; + export class SyncEngine { constructor(private store = new JsonStore()) {} - /** - * Back-compat: two-way sync. - */ + /** Back-compat: two-way sync. */ async sync(a: TaskProvider, b: TaskProvider, opts: SyncOptions = {}): Promise { return this.syncMany([a, b], opts); } - /** - * N-way sync (MVP: 2-3 providers). For every provider, reconcile its changes into every other provider. - */ + /** N-way sync. */ async syncMany(providers: TaskProvider[], opts: SyncOptions = {}): Promise { + 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]++; - }; - - // Preload changes + snapshots for all providers. - const snapshots = new Map }>(); - - await Promise.all( - providers.map(async (p) => { - const [changes, all] = await Promise.all([p.listTasks(lastSyncAt), p.listTasks(undefined)]); - snapshots.set(p.name, { changes, all, index: indexById(all) }); - }), - ); - - // For each provider -> reconcile into every other. - for (const source of providers) { - const snap = snapshots.get(source.name)!; - for (const task of snap.changes) { - if (this.store.isTombstoned(state, source.name, task.id)) continue; - - for (const target of providers) { - if (target.name === source.name) continue; - - const targetSnap = snapshots.get(target.name)!; - await this.reconcileOne({ - source, - target, - targetIndex: targetSnap.index, - state, - task, - dryRun, - push, + const mode: SyncMode = opts.mode ?? 'bidirectional'; + const tombstoneTtlDays = opts.tombstoneTtlDays ?? 30; + + const lock = await acquireLock(this.store.getDir()); + try { + const state = await this.store.load(); + const lastSyncAt = state.lastSyncAt; + + 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) => { + 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(); + const listAllFailed = new Set(); + + 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), }); + }), + ); + + // 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>(); + 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 as any, first.task.id); + for (const g of group.slice(1)) { + rec.byProvider[g.provider as any] = 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, + }; } } - } - const newLastSyncAt = new Date().toISOString(); - state.lastSyncAt = newLastSyncAt; - if (!dryRun) await this.store.save(state); - - return { - dryRun, - providers: providers.map((p) => p.name), - lastSyncAt, - newLastSyncAt, - counts, - actions, - }; - } + // Helper: get mapping record for a provider task id. + const mappingFor = (provider: string, id: string): MappingRecord => + this.store.ensureMapping(state, provider as any, id); - private async reconcileOne(params: { - source: TaskProvider; - target: TaskProvider; - targetIndex: Map; - 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)`, - }); + // 4) Zombie prevention (delete-wins): process deletions/completions first. + const isTerminal = (t: Task) => t.status === 'deleted' || t.status === 'completed'; + + const tombstoneCanonicalIds = new Set(); + + 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)) { + if (!pid) continue; + this.store.addTombstone(state, prov as any, pid); + } + } } - 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); + // 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; - } - 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, + // 5) Orphan detection: mappings that point to tasks missing in ALL providers. + for (const m of [...state.mappings]) { + const existsSomewhere = Object.entries(m.byProvider).some(([prov, pid]) => { + if (!pid) return false; + return snapshots.get(prov)?.index.has(pid) ?? false; }); - this.store.upsertProviderId(state, map.canonicalId, target.name, created.id); + + if (!existsSomewhere && Object.keys(m.byProvider).length) { + // Tombstone the mapped ids (defensive), then remove mapping. + for (const [prov, pid] of Object.entries(m.byProvider)) { + if (!pid) continue; + this.store.addTombstone(state, prov as any, pid); + } + this.store.removeMapping(state, m.canonicalId); + } } - 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, - }); + // 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).some(([prov, pid]) => pid && this.store.isTombstoned(state, prov as any, pid)); + if (tombstoned) continue; + + const baseline = m.canonical; + + // Build per-provider task snapshots for this mapping. + const byProvTask = new Map(); + for (const [prov, pid] of Object.entries(m.byProvider)) { + if (!pid) continue; + const t = snapshots.get(prov)?.index.get(pid); + if (t) byProvTask.set(prov, t); + } + + // If mapping has only one provider task and we're in a restricted mode, still propagate. + if (byProvTask.size === 0) continue; + + // Determine canonical as baseline with field-level merges. + const fields: Array<'title' | 'notes' | 'dueAt' | 'status'> = ['title', 'notes', 'dueAt', 'status']; + + const canonical: Omit = { + title: baseline?.title ?? [...byProvTask.values()][0]!.title, + notes: baseline?.notes, + dueAt: baseline?.dueAt, + status: baseline?.status ?? [...byProvTask.values()][0]!.status, + metadata: baseline?.metadata, + updatedAt: baseline?.updatedAt ?? [...byProvTask.values()][0]!.updatedAt, + }; + + const changedBy = new Map>(); + + for (const [prov, t] of byProvTask.entries()) { + const set = new Set<(typeof fields)[number]>(); + for (const f of fields) { + const baseVal = baseline ? (baseline as any)[f] : undefined; + const val = (t as any)[f]; + if (baseVal !== val) set.add(f); + } + if (set.size) changedBy.set(prov, set); + } + + // Field-level resolve + for (const f of fields) { + const contenders: Array<{ prov: string; t: Task }> = []; + for (const [prov, set] of changedBy.entries()) { + if (set.has(f)) contenders.push({ prov, t: byProvTask.get(prov)! }); + } + + if (contenders.length === 0) continue; + if (contenders.length === 1) { + (canonical as any)[f] = (contenders[0]!.t as any)[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]!; + (canonical as any)[f] = (winner.t as any)[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 as any)[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); + + // 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. + const differs = + existing.title !== canonical.title || + existing.notes !== canonical.notes || + existing.dueAt !== canonical.dueAt || + 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..dec76e9 --- /dev/null +++ b/test/engine.hardening.test.ts @@ -0,0 +1,158 @@ +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('delete-wins-over-update: terminal status prevents resurrection', 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', + status: 'active', + updatedAt: t0, + } as any); + await store.save(s); + + // Simulate B attempting an update (but A completed should win) + await b.upsertTask({ id: 'b1', title: 'A updated', status: 'active', updatedAt: t1 }); + + const report = await engine.syncMany([a, b], { dryRun: false }); + expect(report.actions.some((x) => x.kind === 'delete')).toBe(true); + + const bAll = await b.listTasks(); + const bTask = bAll.find((t) => t.id === 'b1')!; + expect(bTask.status).toBe('deleted'); + }); + + 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', + status: 'active', + updatedAt: baseAt, + } as any); + 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 b = new MockProvider({ name: 'mockB', tasks: [] }); + + const down: TaskProvider = { + name: 'habitica', + listTasks: async () => { + throw new Error('Habitica down'); + }, + upsertTask: async (_: any): Promise => { + throw new Error('Habitica down'); + }, + deleteTask: async () => { + throw new Error('Habitica down'); + }, + }; + + const report = await engine.syncMany([a, b, down], { dryRun: true }); + expect(report.providers).toEqual(['mockA', 'mockB']); + expect(report.errors.some((e) => e.provider === 'habitica')).toBe(true); + }); +}); From c4104231d10f719dc16ddcc52a984f68185ac276 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sat, 7 Feb 2026 01:03:48 +0300 Subject: [PATCH 06/12] Fix types/lint; rerun full checks --- src/http.ts | 1 - src/sync/engine.ts | 80 +++++++++++++++++++++------------- test/engine.hardening.test.ts | 11 +++-- test/microsoftProvider.test.ts | 2 +- 4 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/http.ts b/src/http.ts index b51471f..cffcd94 100644 --- a/src/http.ts +++ b/src/http.ts @@ -82,7 +82,6 @@ export async function requestJson( const backoffMs = opts.backoffMs ?? 200; let attempt = 0; - // eslint-disable-next-line no-constant-condition while (true) { attempt++; try { diff --git a/src/sync/engine.ts b/src/sync/engine.ts index 8a9df0f..b09d7fa 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -1,7 +1,7 @@ import { appendFile } from 'node:fs/promises'; -import type { Task } from '../model.js'; +import type { ProviderName, Task } from '../model.js'; import type { TaskProvider } from '../providers/provider.js'; -import { JsonStore, type MappingRecord, 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'; @@ -160,7 +160,7 @@ export class SyncEngine { // 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>(); + const buckets = new Map>(); for (const p of healthyProviders) { const snap = snapshots.get(p.name)!; for (const t of snap.all) { @@ -175,9 +175,9 @@ export class SyncEngine { 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 as any, first.task.id); + const rec = this.store.ensureMapping(state, first.provider, first.task.id); for (const g of group.slice(1)) { - rec.byProvider[g.provider as any] = g.task.id; + rec.byProvider[g.provider] = g.task.id; } rec.canonical = { title: first.task.title, @@ -191,8 +191,7 @@ export class SyncEngine { } // Helper: get mapping record for a provider task id. - const mappingFor = (provider: string, id: string): MappingRecord => - this.store.ensureMapping(state, provider as any, id); + const mappingFor = (provider: ProviderName, id: string): MappingRecord => this.store.ensureMapping(state, provider, id); // 4) Zombie prevention (delete-wins): process deletions/completions first. const isTerminal = (t: Task) => t.status === 'deleted' || t.status === 'completed'; @@ -207,9 +206,9 @@ export class SyncEngine { tombstoneCanonicalIds.add(map.canonicalId); // Tombstone all known provider ids for this canonical task. - for (const [prov, pid] of Object.entries(map.byProvider)) { + for (const [prov, pid] of Object.entries(map.byProvider) as Array<[ProviderName, string]>) { if (!pid) continue; - this.store.addTombstone(state, prov as any, pid); + this.store.addTombstone(state, prov, pid); } } } @@ -248,16 +247,16 @@ export class SyncEngine { // 5) Orphan detection: mappings that point to tasks missing in ALL providers. for (const m of [...state.mappings]) { - const existsSomewhere = Object.entries(m.byProvider).some(([prov, pid]) => { + const existsSomewhere = (Object.entries(m.byProvider) as Array<[ProviderName, string]>).some(([prov, pid]) => { if (!pid) return false; return snapshots.get(prov)?.index.has(pid) ?? false; }); if (!existsSomewhere && Object.keys(m.byProvider).length) { // Tombstone the mapped ids (defensive), then remove mapping. - for (const [prov, pid] of Object.entries(m.byProvider)) { + for (const [prov, pid] of Object.entries(m.byProvider) as Array<[ProviderName, string]>) { if (!pid) continue; - this.store.addTombstone(state, prov as any, pid); + this.store.addTombstone(state, prov, pid); } this.store.removeMapping(state, m.canonicalId); } @@ -278,41 +277,45 @@ export class SyncEngine { 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).some(([prov, pid]) => pid && this.store.isTombstoned(state, prov as any, pid)); + 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(); - for (const [prov, pid] of Object.entries(m.byProvider)) { + const byProvTask = new Map(); + 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 mapping has only one provider task and we're in a restricted mode, still propagate. if (byProvTask.size === 0) continue; - // Determine canonical as baseline with field-level merges. - const fields: Array<'title' | 'notes' | 'dueAt' | 'status'> = ['title', 'notes', 'dueAt', 'status']; + type CanonicalData = Omit; + const fields = ['title', 'notes', 'dueAt', 'status'] as const; + type Field = (typeof fields)[number]; - const canonical: Omit = { - title: baseline?.title ?? [...byProvTask.values()][0]!.title, + const firstTask = [...byProvTask.values()][0]!; + + const canonical: CanonicalData = { + title: baseline?.title ?? firstTask.title, notes: baseline?.notes, dueAt: baseline?.dueAt, - status: baseline?.status ?? [...byProvTask.values()][0]!.status, + status: baseline?.status ?? firstTask.status, metadata: baseline?.metadata, - updatedAt: baseline?.updatedAt ?? [...byProvTask.values()][0]!.updatedAt, + updatedAt: baseline?.updatedAt ?? firstTask.updatedAt, }; - const changedBy = new Map>(); + const changedBy = new Map>(); for (const [prov, t] of byProvTask.entries()) { - const set = new Set<(typeof fields)[number]>(); + const set = new Set(); for (const f of fields) { - const baseVal = baseline ? (baseline as any)[f] : undefined; - const val = (t as any)[f]; + const baseVal = baseline ? baseline[f] : undefined; + const val = t[f]; if (baseVal !== val) set.add(f); } if (set.size) changedBy.set(prov, set); @@ -320,14 +323,31 @@ export class SyncEngine { // Field-level resolve for (const f of fields) { - const contenders: Array<{ prov: string; t: Task }> = []; + 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) { - (canonical as any)[f] = (contenders[0]!.t as any)[f]; + assign(f, contenders[0]!.t[f]); canonical.updatedAt = contenders[0]!.t.updatedAt; continue; } @@ -335,7 +355,7 @@ export class SyncEngine { // 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]!; - (canonical as any)[f] = (winner.t as any)[f]; + assign(f, winner.t[f]); conflicts.push({ canonicalId: m.canonicalId, @@ -344,7 +364,7 @@ export class SyncEngine { provider: c.prov, id: c.t.id, updatedAt: c.t.updatedAt, - value: (c.t as any)[f], + 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 })), diff --git a/test/engine.hardening.test.ts b/test/engine.hardening.test.ts index dec76e9..f03b1e1 100644 --- a/test/engine.hardening.test.ts +++ b/test/engine.hardening.test.ts @@ -34,9 +34,12 @@ describe('SyncEngine hardening', () => { // baseline canonical store.upsertCanonicalSnapshot(s, map.canonicalId, { title: 'A', + notes: undefined, + dueAt: undefined, status: 'active', + metadata: undefined, updatedAt: t0, - } as any); + }); await store.save(s); // Simulate B attempting an update (but A completed should win) @@ -113,9 +116,11 @@ describe('SyncEngine hardening', () => { store.upsertCanonicalSnapshot(s, map.canonicalId, { title: 'Title', notes: 'n0', + dueAt: undefined, status: 'active', + metadata: undefined, updatedAt: baseAt, - } as any); + }); await store.save(s); await engine.syncMany([a, b], { dryRun: false }); @@ -143,7 +148,7 @@ describe('SyncEngine hardening', () => { listTasks: async () => { throw new Error('Habitica down'); }, - upsertTask: async (_: any): Promise => { + upsertTask: async (_input: unknown): Promise => { throw new Error('Habitica down'); }, deleteTask: async () => { diff --git a/test/microsoftProvider.test.ts b/test/microsoftProvider.test.ts index dddcf85..c7dc263 100644 --- a/test/microsoftProvider.test.ts +++ b/test/microsoftProvider.test.ts @@ -10,7 +10,7 @@ function jsonResponse(obj: unknown, status = 200) { describe('MicrosoftTodoProvider', () => { it('lists tasks from first list and maps fields', async () => { - const fetcher: typeof fetch = async (url, init) => { + const fetcher: typeof fetch = async (url, _init) => { const u = String(url); if (u.includes('/oauth2/v2.0/token')) { From aff0740e19ff6fd8ecd5c704d00343e4a71090e8 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sat, 7 Feb 2026 11:14:55 +0300 Subject: [PATCH 07/12] fix(habitica): normalize ISO due dates to milliseconds --- src/providers/habitica.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/providers/habitica.ts b/src/providers/habitica.ts index c67f03f..c33ae86 100644 --- a/src/providers/habitica.ts +++ b/src/providers/habitica.ts @@ -54,6 +54,21 @@ function unpackNotes(notes?: string): { human?: string; meta?: Record 2026-02-08T00:00:00.000Z + // - 2026-02-08T00:00:00.1234567Z -> 2026-02-08T00:00:00.123Z + const m = iso.match(/^(.*?)(\.(\d+))Z$/); + if (!m) return iso; + const base = m[1]; + const frac = m[3] ?? ''; + const ms = (frac + '000').slice(0, 3); + return `${base}.${ms}Z`; +} + function toCanonical(t: HabiticaTask): Task { const unpacked = unpackNotes(t.notes); return { @@ -61,7 +76,7 @@ function toCanonical(t: HabiticaTask): Task { title: t.text, notes: unpacked.human, status: t.completed ? 'completed' : 'active', - dueAt: t.date, + dueAt: normalizeIso(t.date), updatedAt: t.updatedAt, }; } From 6088ccbf0bd704b68563c5d42e6b7ef821eb9cf7 Mon Sep 17 00:00:00 2001 From: deeqdev Date: Sat, 7 Feb 2026 11:30:14 +0300 Subject: [PATCH 08/12] chore(dev): add e2e helper scripts + env example --- .env.local.example | 24 ++++ scripts/dev/mutate.ts | 100 ++++++++++++++++ scripts/dev/purge_all_lists.ts | 209 +++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 .env.local.example create mode 100644 scripts/dev/mutate.ts create mode 100644 scripts/dev/purge_all_lists.ts diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..d102acd --- /dev/null +++ b/.env.local.example @@ -0,0 +1,24 @@ +# Copy to .env.local and fill values. Do NOT commit .env.local + +TASK_SYNC_PROVIDER_A=google +TASK_SYNC_PROVIDER_B=microsoft +# TASK_SYNC_PROVIDER_C=habitica + +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= + +# Habitica +# TASK_SYNC_HABITICA_USER_ID= +# TASK_SYNC_HABITICA_API_TOKEN= diff --git a/scripts/dev/mutate.ts b/scripts/dev/mutate.ts new file mode 100644 index 0000000..9033614 --- /dev/null +++ b/scripts/dev/mutate.ts @@ -0,0 +1,100 @@ +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'; +import { HabiticaProvider } from '../../src/providers/habitica.js'; + +/** + * Dev helper: mutate a single task by exact title match. + * + * Usage: + * tsx scripts/dev/mutate.ts [noteText] + */ + +type ProviderKey = 'google' | 'microsoft' | 'habitica'; +type Action = 'delete' | 'complete' | 'activate' | 'note'; + +function usage(): never { + console.error('Usage: tsx scripts/dev/mutate.ts <google|microsoft|habitica> <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 habitica = new HabiticaProvider({ + userId: env.TASK_SYNC_HABITICA_USER_ID!, + apiToken: env.TASK_SYNC_HABITICA_API_TOKEN!, + }); + + const map: Record<ProviderKey, TaskProvider> = { google, microsoft, habitica }; + 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..279faa8 --- /dev/null +++ b/scripts/dev/purge_all_lists.ts @@ -0,0 +1,209 @@ +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 purgeHabiticaAllTodos(env: ReturnType<typeof readEnv>) { + console.log('HABITICA: purge all todos'); + const base = 'https://habitica.com/api/v3'; + const headers = { + 'x-api-user': env.TASK_SYNC_HABITICA_USER_ID!, + 'x-api-key': env.TASK_SYNC_HABITICA_API_TOKEN!, + 'x-client': 'task-sync (salaamdev)', + }; + + const res = await requestJson<{ success: boolean; data: Array<{ id: string; text: string; type: string }> }>(`${base}/tasks/user`, { + method: 'GET', + headers, + query: { type: 'todos' }, + }); + + const todos = (res.data ?? []).filter((t) => t.type === 'todo'); + console.log(`HABITICA: found ${todos.length} todos`); + + let ok = 0; + for (const t of todos) { + await requestJson<void>(`${base}/tasks/${encodeURIComponent(t.id)}`, { method: 'DELETE', headers }); + ok++; + } + console.log(`HABITICA: deleted ${ok} todos`); +} + +async function main() { + loadEnvFiles(); + const env = readEnv(); + + console.log('PURGE ALL LISTS: deleting tasks across ALL lists in Google Tasks + Microsoft To Do, and all Habitica todos.'); + + await purgeGoogleAllLists(env); + await purgeMicrosoftAllLists(env); + await purgeHabiticaAllTodos(env); + + console.log('PURGE ALL LISTS DONE'); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); From 5d7654e8877a5ed77ac7df89c1f67e4ec1f69bf8 Mon Sep 17 00:00:00 2001 From: deeqdev <abdisalamxhassan@gmail.com> Date: Sat, 7 Feb 2026 11:31:39 +0300 Subject: [PATCH 09/12] chore(dev): add e2e seed/list/cleanup script; ignore env files --- .gitignore | 4 ++ scripts/dev/e2e.ts | 134 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 scripts/dev/e2e.ts 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/scripts/dev/e2e.ts b/scripts/dev/e2e.ts new file mode 100644 index 0000000..49c49e0 --- /dev/null +++ b/scripts/dev/e2e.ts @@ -0,0 +1,134 @@ +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 { HabiticaProvider } from '../../src/providers/habitica.js'; +import type { Task } from '../../src/model.js'; + +const PREFIX = '[task-sync e2e]'; + +type ProviderKey = 'google' | 'microsoft' | 'habitica'; + +function usage(): never { + console.error('Usage: tsx scripts/dev/e2e.ts <seed|list|cleanup> [provider]'); + console.error(' provider optional: google|microsoft|habitica (default: all configured)'); + process.exit(2); +} + +function makeProviders(env: ReturnType<typeof readEnv>) { + const providers = new Map<ProviderKey, { name: ProviderKey; p: { listTasks(): Promise<Task[]>; upsertTask(i: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task>; deleteTask(id: string): Promise<void> } }>(); + + 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, + }), + }); + + providers.set('habitica', { + name: 'habitica', + p: new HabiticaProvider({ + userId: env.TASK_SYNC_HABITICA_USER_ID!, + apiToken: env.TASK_SYNC_HABITICA_API_TOKEN!, + }), + }); + + return providers; +} + +function configuredProviders(env: ReturnType<typeof readEnv>): ProviderKey[] { + const list = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B, env.TASK_SYNC_PROVIDER_C].filter(Boolean) as ProviderKey[]; + // de-dupe + return [...new Set(list)]; +} + +async function seedOne(p: { upsertTask(i: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> }, 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<Task[]> }): Promise<Task[]> { + const tasks = await p.listTasks(); + return tasks.filter((t) => t.title.startsWith(PREFIX)); +} + +async function cleanupTagged(p: { listTasks(): Promise<Task[]>; deleteTask(id: string): Promise<void> }) { + 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(/C).'); + 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; +}); From b620a24cc44eda2c4a407a8769361640d4b5ce61 Mon Sep 17 00:00:00 2001 From: deeqdev <abdisalamxhassan@gmail.com> Date: Sat, 7 Feb 2026 11:39:01 +0300 Subject: [PATCH 10/12] fix(microsoft): normalize dueDateTime to RFC3339 (append Z for UTC) --- src/providers/microsoft.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/providers/microsoft.ts b/src/providers/microsoft.ts index 0636b00..6cbe690 100644 --- a/src/providers/microsoft.ts +++ b/src/providers/microsoft.ts @@ -54,13 +54,31 @@ interface GraphListTasksResponse { '@odata.nextLink'?: string; } +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). + const raw = dt.dateTime; + + // If already has timezone info, keep as-is. + if (/[zZ]$/.test(raw) || /[+-]\d\d:\d\d$/.test(raw)) return raw; + + // Normalize UTC-like values to Z. + if (dt.timeZone?.toUpperCase() === 'UTC') return `${raw}Z`; + + // 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.completedDateTime ? 'completed' : 'active', - dueAt: t.dueDateTime?.dateTime, + dueAt: normalizeGraphDate(t.dueDateTime), updatedAt: t.lastModifiedDateTime, }; } From 3cb003326651ef2cd99696f41111b6985fe9257a Mon Sep 17 00:00:00 2001 From: deeqdev <abdisalamxhassan@gmail.com> Date: Sat, 7 Feb 2026 13:50:25 +0300 Subject: [PATCH 11/12] fix: sync completed status instead of deleting, remove Habitica, optimize providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Completed tasks now propagate status via field-level update instead of being tombstoned and hard-deleted from all providers (data loss bug) - Remove Habitica provider entirely — only Google Tasks and Microsoft To Do remain - Add server-side filtering: Google uses updatedMin, Microsoft uses $filter on lastModifiedDateTime to avoid fetching full task lists - Noops no longer clutter the sync report actions array (still counted) - Updated all tests, dev scripts, README, and env examples Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.local.example | 5 -- README.md | 27 ++---- package.json | 2 +- scripts/dev/e2e.ts | 18 +--- scripts/dev/mutate.ts | 13 +-- scripts/dev/purge_all_lists.ts | 29 +----- src/cli.ts | 34 +++---- src/config.ts | 17 +--- src/http.ts | 4 +- src/model.ts | 2 +- src/providers/google.ts | 8 +- src/providers/habitica.ts | 160 --------------------------------- src/providers/microsoft.ts | 45 +++++++--- src/store/lock.ts | 10 ++- src/sync/engine.ts | 126 ++++++++++++++++++++++---- test/engine.hardening.test.ts | 27 +++--- test/engine.test.ts | 34 ++++--- test/habiticaProvider.test.ts | 46 ---------- 18 files changed, 229 insertions(+), 378 deletions(-) delete mode 100644 src/providers/habitica.ts delete mode 100644 test/habiticaProvider.test.ts diff --git a/.env.local.example b/.env.local.example index d102acd..4a00273 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,7 +2,6 @@ TASK_SYNC_PROVIDER_A=google TASK_SYNC_PROVIDER_B=microsoft -# TASK_SYNC_PROVIDER_C=habitica TASK_SYNC_STATE_DIR=.task-sync TASK_SYNC_LOG_LEVEL=info @@ -18,7 +17,3 @@ TASK_SYNC_MS_CLIENT_ID= TASK_SYNC_MS_TENANT_ID=common TASK_SYNC_MS_REFRESH_TOKEN= # TASK_SYNC_MS_LIST_ID= - -# Habitica -# TASK_SYNC_HABITICA_USER_ID= -# TASK_SYNC_HABITICA_API_TOKEN= diff --git a/README.md b/README.md index e84b193..2cd9ac6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ # task-sync -Sync tasks across **Google Tasks**, **Microsoft To Do (Microsoft Graph)**, and an optional 3rd provider. +Sync tasks between **Google Tasks** and **Microsoft To Do**. -Currently implemented providers: +Providers: - Google Tasks (OAuth refresh-token) - Microsoft To Do via Microsoft Graph (OAuth refresh-token) -- Habitica Todos (API token) ## Quickstart @@ -56,12 +55,11 @@ node dist/cli.js sync --dry-run Create a `.env.local` (recommended) or `.env`: -### Provider selection (2-3 providers) +### Provider selection ```bash TASK_SYNC_PROVIDER_A=google TASK_SYNC_PROVIDER_B=microsoft -TASK_SYNC_PROVIDER_C=habitica # optional ``` ### State @@ -89,20 +87,13 @@ TASK_SYNC_MS_REFRESH_TOKEN=... TASK_SYNC_MS_LIST_ID=... # optional (defaults to first list) ``` -### Habitica - -```bash -TASK_SYNC_HABITICA_USER_ID=... -TASK_SYNC_HABITICA_API_TOKEN=... -``` - Run: ```bash task-sync doctor ``` -to see what’s missing. +to see what's missing. ## OAuth helper scripts (refresh tokens) @@ -143,14 +134,6 @@ export TASK_SYNC_MS_TENANT_ID=common npm run oauth:microsoft ``` -## Notes on Habitica mapping - -Habitica tasks are synced as **Todos**. - -- `Task.title` ↔ Habitica `text` -- `Task.notes` ↔ Habitica `notes` (human notes only) -- Extra fields are preserved by packing JSON into the Habitica `notes` field under a `--- task-sync ---` block. - ## How state works `task-sync` writes local state under: @@ -161,7 +144,7 @@ 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 Delete `.task-sync/` to reset sync state. diff --git a/package.json b/package.json index 4b37600..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", diff --git a/scripts/dev/e2e.ts b/scripts/dev/e2e.ts index 49c49e0..0c1106b 100644 --- a/scripts/dev/e2e.ts +++ b/scripts/dev/e2e.ts @@ -2,16 +2,15 @@ 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 { HabiticaProvider } from '../../src/providers/habitica.js'; import type { Task } from '../../src/model.js'; const PREFIX = '[task-sync e2e]'; -type ProviderKey = 'google' | 'microsoft' | 'habitica'; +type ProviderKey = 'google' | 'microsoft'; function usage(): never { console.error('Usage: tsx scripts/dev/e2e.ts <seed|list|cleanup> [provider]'); - console.error(' provider optional: google|microsoft|habitica (default: all configured)'); + console.error(' provider optional: google|microsoft (default: all configured)'); process.exit(2); } @@ -38,20 +37,11 @@ function makeProviders(env: ReturnType<typeof readEnv>) { }), }); - providers.set('habitica', { - name: 'habitica', - p: new HabiticaProvider({ - userId: env.TASK_SYNC_HABITICA_USER_ID!, - apiToken: env.TASK_SYNC_HABITICA_API_TOKEN!, - }), - }); - return providers; } function configuredProviders(env: ReturnType<typeof readEnv>): ProviderKey[] { - const list = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B, env.TASK_SYNC_PROVIDER_C].filter(Boolean) as ProviderKey[]; - // de-dupe + const list = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B].filter(Boolean) as ProviderKey[]; return [...new Set(list)]; } @@ -86,7 +76,7 @@ async function main() { const configured = providerArg ? [providerArg] : configuredProviders(env); if (!configured.length) { - console.error('No providers configured. Set TASK_SYNC_PROVIDER_A/B(/C).'); + console.error('No providers configured. Set TASK_SYNC_PROVIDER_A/B.'); process.exit(2); } diff --git a/scripts/dev/mutate.ts b/scripts/dev/mutate.ts index 9033614..ca1fb9b 100644 --- a/scripts/dev/mutate.ts +++ b/scripts/dev/mutate.ts @@ -4,20 +4,19 @@ 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'; -import { HabiticaProvider } from '../../src/providers/habitica.js'; /** * Dev helper: mutate a single task by exact title match. * * Usage: - * tsx scripts/dev/mutate.ts <google|microsoft|habitica> <delete|complete|activate|note> <title> [noteText] + * tsx scripts/dev/mutate.ts <google|microsoft> <delete|complete|activate|note> <title> [noteText] */ -type ProviderKey = 'google' | 'microsoft' | 'habitica'; +type ProviderKey = 'google' | 'microsoft'; type Action = 'delete' | 'complete' | 'activate' | 'note'; function usage(): never { - console.error('Usage: tsx scripts/dev/mutate.ts <google|microsoft|habitica> <delete|complete|activate|note> <title> [noteText]'); + console.error('Usage: tsx scripts/dev/mutate.ts <google|microsoft> <delete|complete|activate|note> <title> [noteText]'); process.exit(2); } @@ -43,12 +42,8 @@ async function main() { refreshToken: env.TASK_SYNC_MS_REFRESH_TOKEN!, listId: env.TASK_SYNC_MS_LIST_ID, }); - const habitica = new HabiticaProvider({ - userId: env.TASK_SYNC_HABITICA_USER_ID!, - apiToken: env.TASK_SYNC_HABITICA_API_TOKEN!, - }); - const map: Record<ProviderKey, TaskProvider> = { google, microsoft, habitica }; + const map: Record<ProviderKey, TaskProvider> = { google, microsoft }; const p = map[providerName]; const tasks: Task[] = await p.listTasks(); diff --git a/scripts/dev/purge_all_lists.ts b/scripts/dev/purge_all_lists.ts index 279faa8..36d91f5 100644 --- a/scripts/dev/purge_all_lists.ts +++ b/scripts/dev/purge_all_lists.ts @@ -164,41 +164,14 @@ async function purgeMicrosoftAllLists(env: ReturnType<typeof readEnv>) { console.log(`MICROSOFT: deleted ${totalDeleted} tasks across all lists`); } -async function purgeHabiticaAllTodos(env: ReturnType<typeof readEnv>) { - console.log('HABITICA: purge all todos'); - const base = 'https://habitica.com/api/v3'; - const headers = { - 'x-api-user': env.TASK_SYNC_HABITICA_USER_ID!, - 'x-api-key': env.TASK_SYNC_HABITICA_API_TOKEN!, - 'x-client': 'task-sync (salaamdev)', - }; - - const res = await requestJson<{ success: boolean; data: Array<{ id: string; text: string; type: string }> }>(`${base}/tasks/user`, { - method: 'GET', - headers, - query: { type: 'todos' }, - }); - - const todos = (res.data ?? []).filter((t) => t.type === 'todo'); - console.log(`HABITICA: found ${todos.length} todos`); - - let ok = 0; - for (const t of todos) { - await requestJson<void>(`${base}/tasks/${encodeURIComponent(t.id)}`, { method: 'DELETE', headers }); - ok++; - } - console.log(`HABITICA: deleted ${ok} todos`); -} - async function main() { loadEnvFiles(); const env = readEnv(); - console.log('PURGE ALL LISTS: deleting tasks across ALL lists in Google Tasks + Microsoft To Do, and all Habitica todos.'); + console.log('PURGE ALL LISTS: deleting tasks across ALL lists in Google Tasks + Microsoft To Do.'); await purgeGoogleAllLists(env); await purgeMicrosoftAllLists(env); - await purgeHabiticaAllTodos(env); console.log('PURGE ALL LISTS DONE'); } diff --git a/src/cli.ts b/src/cli.ts index 3df73b2..7b65b2d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,6 @@ import { createLogger } from './log.js'; import { MockProvider } from './providers/mock.js'; import { GoogleTasksProvider } from './providers/google.js'; import { MicrosoftTodoProvider } from './providers/microsoft.js'; -import { HabiticaProvider } from './providers/habitica.js'; import { SyncEngine } from './sync/engine.js'; import { JsonStore } from './store/jsonStore.js'; @@ -15,7 +14,7 @@ const program = new Command(); program .name('task-sync') - .description('Sync tasks between providers (Google Tasks, Microsoft To Do, Habitica)') + .description('Sync tasks between Google Tasks and Microsoft To Do') .version('0.1.0'); program @@ -61,12 +60,12 @@ program const dryRun = !!opts.dryRun; - const providers = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B, env.TASK_SYNC_PROVIDER_C].filter( + const providers = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B].filter( Boolean, - ) as Array<'google' | 'microsoft' | 'habitica'>; + ) as Array<'google' | 'microsoft'>; if (providers.length < 2) { - console.error('Need at least 2 providers. Set TASK_SYNC_PROVIDER_A + TASK_SYNC_PROVIDER_B (and optional _C).'); + console.error('Need at least 2 providers. Set TASK_SYNC_PROVIDER_A=google + TASK_SYNC_PROVIDER_B=microsoft.'); process.exitCode = 2; return; } @@ -78,7 +77,7 @@ program return; } - const makeProvider = (p: 'google' | 'microsoft' | 'habitica') => { + const makeProvider = (p: 'google' | 'microsoft') => { if (p === 'google') { return new GoogleTasksProvider({ clientId: env.TASK_SYNC_GOOGLE_CLIENT_ID!, @@ -87,17 +86,11 @@ program tasklistId: env.TASK_SYNC_GOOGLE_TASKLIST_ID, }); } - if (p === 'microsoft') { - 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, - }); - } - return new HabiticaProvider({ - userId: env.TASK_SYNC_HABITICA_USER_ID!, - apiToken: env.TASK_SYNC_HABITICA_API_TOKEN!, + 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, }); }; @@ -161,7 +154,7 @@ program program .command('mock') - .description('Run a 3-provider dry-run using in-memory mock providers (for demos/tests)') + .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'); @@ -179,10 +172,9 @@ program ], }); const b = new MockProvider({ name: 'mockB', tasks: [] }); - const c = new MockProvider({ name: 'habitica', tasks: [] }); - logger.info('mock sync start', { providers: [a.name, b.name, c.name] }); - const report = await engine.syncMany([a, b, c], { dryRun: true }); + 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)); diff --git a/src/config.ts b/src/config.ts index ead0849..729bd82 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,13 +2,12 @@ import { z } from 'zod'; const str = z.string().min(1); -export const ProviderSchema = z.enum(['google', 'microsoft', 'habitica']); +export const ProviderSchema = z.enum(['google', 'microsoft']); export const EnvSchema = z.object({ - // providers (supports up to 3 for MVP) + // providers TASK_SYNC_PROVIDER_A: ProviderSchema.optional(), TASK_SYNC_PROVIDER_B: ProviderSchema.optional(), - TASK_SYNC_PROVIDER_C: ProviderSchema.optional(), // behavior TASK_SYNC_LOG_LEVEL: z.enum(['silent', 'error', 'warn', 'info', 'debug']).optional(), @@ -30,9 +29,6 @@ export const EnvSchema = z.object({ TASK_SYNC_MS_REFRESH_TOKEN: str.optional(), TASK_SYNC_MS_LIST_ID: str.optional(), - // Habitica (token auth) - TASK_SYNC_HABITICA_USER_ID: str.optional(), - TASK_SYNC_HABITICA_API_TOKEN: str.optional(), }); export type EnvConfig = z.infer<typeof EnvSchema>; @@ -42,7 +38,7 @@ export function readEnv(env = process.env): EnvConfig { } export function doctorReport(env = readEnv()) { - const providers = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B, env.TASK_SYNC_PROVIDER_C].filter( + const providers = [env.TASK_SYNC_PROVIDER_A, env.TASK_SYNC_PROVIDER_B].filter( Boolean, ) as Array<z.infer<typeof ProviderSchema>>; @@ -50,7 +46,7 @@ export function doctorReport(env = readEnv()) { const notes: string[] = []; if (providers.length < 2) { - notes.push('Set TASK_SYNC_PROVIDER_A + TASK_SYNC_PROVIDER_B (and optional _C) to choose providers (google|microsoft|habitica).'); + notes.push('Set TASK_SYNC_PROVIDER_A + TASK_SYNC_PROVIDER_B to choose providers (google|microsoft).'); } for (const p of providers) { @@ -66,17 +62,12 @@ export function doctorReport(env = readEnv()) { if (!env.TASK_SYNC_MS_REFRESH_TOKEN) missing.push('TASK_SYNC_MS_REFRESH_TOKEN'); notes.push('Microsoft: TASK_SYNC_MS_LIST_ID optional (defaults to first list).'); } - if (p === 'habitica') { - if (!env.TASK_SYNC_HABITICA_USER_ID) missing.push('TASK_SYNC_HABITICA_USER_ID'); - if (!env.TASK_SYNC_HABITICA_API_TOKEN) missing.push('TASK_SYNC_HABITICA_API_TOKEN'); - } } return { providers: { a: env.TASK_SYNC_PROVIDER_A, b: env.TASK_SYNC_PROVIDER_B, - c: env.TASK_SYNC_PROVIDER_C, }, missing: [...new Set(missing)], notes: [...new Set(notes)], diff --git a/src/http.ts b/src/http.ts index cffcd94..cf8e097 100644 --- a/src/http.ts +++ b/src/http.ts @@ -117,7 +117,9 @@ export async function requestJson<T>( if (!text) return undefined as T; return JSON.parse(text) as T; } catch (e) { - // network/parse errors + // 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); diff --git a/src/model.ts b/src/model.ts index d67382a..3cd7264 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1,4 +1,4 @@ -export type ProviderName = 'mockA' | 'mockB' | 'google' | 'microsoft' | 'habitica'; +export type ProviderName = 'mockA' | 'mockB' | 'google' | 'microsoft'; export type TaskStatus = 'active' | 'completed' | 'deleted'; diff --git a/src/providers/google.ts b/src/providers/google.ts index 5de3635..44b6d44 100644 --- a/src/providers/google.ts +++ b/src/providers/google.ts @@ -103,6 +103,10 @@ export class GoogleTasksProvider implements TaskProvider { 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 } : {}), }, }); @@ -110,9 +114,7 @@ export class GoogleTasksProvider implements TaskProvider { pageToken = res.nextPageToken; } while (pageToken); - if (!since) return out; - const sinceMs = Date.parse(since); - return out.filter((t) => Date.parse(t.updatedAt) >= sinceMs); + return out; } async upsertTask(input: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> { diff --git a/src/providers/habitica.ts b/src/providers/habitica.ts deleted file mode 100644 index c33ae86..0000000 --- a/src/providers/habitica.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { Task } from '../model.js'; -import type { TaskProvider } from './provider.js'; -import { requestJson, type FetchLike } from '../http.js'; - -export interface HabiticaProviderOptions { - userId: string; - apiToken: string; - /** Inject fetch for tests */ - fetcher?: FetchLike; -} - -// Habitica API shape (subset) -interface HabiticaTask { - id: string; - type: 'todo' | string; - text: string; - notes?: string; - completed: boolean; - date?: string; // due - updatedAt: string; - priority?: number; - tags?: string[]; -} - -interface HabiticaApiResponse<T> { - success: boolean; - data: T; -} - -function packNotes(humanNotes: string | undefined, extra: Record<string, unknown>) { - const meta = JSON.stringify(extra); - const block = `\n\n--- task-sync ---\n${meta}\n--- /task-sync ---\n`; - const base = (humanNotes ?? '').trim(); - if (!base) return block.trimStart(); - - // avoid duplicating our own block - if (base.includes('--- task-sync ---')) return base; - return base + block; -} - -function unpackNotes(notes?: string): { human?: string; meta?: Record<string, unknown> } { - if (!notes) return {}; - const start = notes.indexOf('--- task-sync ---'); - const end = notes.indexOf('--- /task-sync ---'); - if (start === -1 || end === -1 || end < start) return { human: notes }; - - const human = notes.slice(0, start).trim() || undefined; - const metaRaw = notes.slice(start + '--- task-sync ---'.length, end).trim(); - try { - const meta = JSON.parse(metaRaw) as Record<string, unknown>; - return { human, meta }; - } catch { - return { human: notes }; - } -} - -function normalizeIso(iso?: string): string | undefined { - if (!iso) return undefined; - // Habitica can return fractional seconds with 7 digits, e.g. ".0000000Z". - // Normalize to RFC3339 milliseconds to keep other providers (notably Google Tasks) happy. - // Examples: - // - 2026-02-08T00:00:00.0000000Z -> 2026-02-08T00:00:00.000Z - // - 2026-02-08T00:00:00.1234567Z -> 2026-02-08T00:00:00.123Z - const m = iso.match(/^(.*?)(\.(\d+))Z$/); - if (!m) return iso; - const base = m[1]; - const frac = m[3] ?? ''; - const ms = (frac + '000').slice(0, 3); - return `${base}.${ms}Z`; -} - -function toCanonical(t: HabiticaTask): Task { - const unpacked = unpackNotes(t.notes); - return { - id: t.id, - title: t.text, - notes: unpacked.human, - status: t.completed ? 'completed' : 'active', - dueAt: normalizeIso(t.date), - updatedAt: t.updatedAt, - }; -} - -/** - * Habitica provider (Todos). - * - * Auth: X-API-User + X-API-Key headers. - * - * Notes packing: - * - We keep human notes in Task.notes. - * - We store extra Habitica-only fields (priority/tags) inside the Habitica task notes - * in a JSON block to preserve data round-trips. - */ -export class HabiticaProvider implements TaskProvider { - readonly name = 'habitica' as const; - private fetcher: FetchLike; - - constructor(private opts: HabiticaProviderOptions) { - this.fetcher = opts.fetcher ?? fetch; - } - - private headers() { - // Habitica requires an x-client header in addition to the API user/key. - // Format: "<app-name> (<your-user-id-or-email>)". Keep it stable. - return { - 'x-api-user': this.opts.userId, - 'x-api-key': this.opts.apiToken, - 'x-client': 'task-sync (salaamdev)', - }; - } - - private async api<T>(path: string, init?: Parameters<typeof requestJson<HabiticaApiResponse<T>>>[1]): Promise<T> { - const base = 'https://habitica.com/api/v3'; - const res = await requestJson<HabiticaApiResponse<T>>( - `${base}${path}`, - { ...init, headers: { ...this.headers(), ...(init?.headers ?? {}) } }, - this.fetcher, - ); - return res.data; - } - - async listTasks(since?: string): Promise<Task[]> { - const todos = await this.api<HabiticaTask[]>(`/tasks/user`, { - query: { type: 'todos' }, - }); - - const out = todos.filter((t) => t.type === 'todo').map(toCanonical); - if (!since) return out; - - const sinceMs = Date.parse(since); - return out.filter((t) => Date.parse(t.updatedAt) >= sinceMs); - } - - async upsertTask(input: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> { - const isCreate = !input.id; - - // Preserve habitica-specific fields if they exist in existing notes meta. - // (If caller provides packed notes already, we just pass it through.) - const { human, meta } = unpackNotes(input.notes); - const packed = packNotes(human, meta ?? {}); - - const body: Partial<HabiticaTask> & { type?: string } = { - type: 'todo', - text: input.title, - notes: packed, - date: input.dueAt, - completed: input.status === 'completed', - }; - - const task = isCreate - ? await this.api<HabiticaTask>(`/tasks/user`, { method: 'POST', body }) - : await this.api<HabiticaTask>(`/tasks/${encodeURIComponent(input.id)}`, { method: 'PUT', body }); - - return toCanonical(task); - } - - async deleteTask(id: string): Promise<void> { - await this.api<void>(`/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' }); - } -} diff --git a/src/providers/microsoft.ts b/src/providers/microsoft.ts index 6cbe690..af4e085 100644 --- a/src/providers/microsoft.ts +++ b/src/providers/microsoft.ts @@ -45,6 +45,8 @@ interface GraphTask { 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; } @@ -54,19 +56,33 @@ interface GraphListTasksResponse { '@odata.nextLink'?: string; } +/** + * 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). - const raw = dt.dateTime; + let raw = dt.dateTime; - // If already has timezone info, keep as-is. - if (/[zZ]$/.test(raw) || /[+-]\d\d:\d\d$/.test(raw)) return raw; + // 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') return `${raw}Z`; + if (dt.timeZone?.toUpperCase() === 'UTC') { + raw = `${raw}Z`; + return normalizeIsoPrecision(raw); + } // Fallback: keep raw (better than guessing an offset). return raw; @@ -77,7 +93,7 @@ function toCanonical(t: GraphTask): Task { id: t.id, title: t.title, notes: t.body?.content, - status: t.completedDateTime ? 'completed' : 'active', + status: t.status === 'completed' || t.completedDateTime ? 'completed' : 'active', dueAt: normalizeGraphDate(t.dueDateTime), updatedAt: t.lastModifiedDateTime, }; @@ -152,7 +168,17 @@ export class MicrosoftTodoProvider implements TaskProvider { const listId = await this.getListId(); const out: Task[] = []; - let next: string | undefined = `/me/todo/lists/${encodeURIComponent(listId)}/tasks?$top=100`; + + // 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; @@ -161,9 +187,7 @@ export class MicrosoftTodoProvider implements TaskProvider { next = res['@odata.nextLink']; } - if (!since) return out; - const sinceMs = Date.parse(since); - return out.filter((t) => Date.parse(t.updatedAt) >= sinceMs); + return out; } async upsertTask(input: Omit<Task, 'updatedAt'> & { updatedAt?: string }): Promise<Task> { @@ -184,7 +208,8 @@ export class MicrosoftTodoProvider implements TaskProvider { timeZone: 'UTC', } : undefined, - completedDateTime: input.status === 'completed' ? { dateTime: new Date().toISOString(), timeZone: 'UTC' } : undefined, + // Graph expects status mutations, not completedDateTime writes. + status: input.status === 'completed' ? 'completed' : 'notStarted', }; const res = isCreate diff --git a/src/store/lock.ts b/src/store/lock.ts index 5c225c5..8f36bca 100644 --- a/src/store/lock.ts +++ b/src/store/lock.ts @@ -20,16 +20,20 @@ export async function acquireLock(dir: string, filename = 'lock'): Promise<LockH 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)) { - throw new Error(`Another task-sync process is running (pid=${otherPid}).`); + isHeld = true; } - } catch (err) { + } catch { // If unreadable/invalid, treat as stale. - void err; + } + + if (isHeld) { + throw new Error(`Another task-sync process is running. Remove ${lockPath} if this is wrong.`); } // Stale lock: overwrite. diff --git a/src/sync/engine.ts b/src/sync/engine.ts index b09d7fa..c2de0d8 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -60,6 +60,37 @@ 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)}`; } @@ -112,6 +143,11 @@ export class SyncEngine { }; 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]++; }; @@ -193,8 +229,11 @@ export class SyncEngine { // 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 deletions/completions first. - const isTerminal = (t: Task) => t.status === 'deleted' || t.status === 'completed'; + // 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>(); @@ -245,20 +284,67 @@ export class SyncEngine { } } - // 5) Orphan detection: mappings that point to tasks missing in ALL providers. + // 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 existsSomewhere = (Object.entries(m.byProvider) as Array<[ProviderName, string]>).some(([prov, pid]) => { - if (!pid) return false; - return snapshots.get(prov)?.index.has(pid) ?? false; - }); - - if (!existsSomewhere && Object.keys(m.byProvider).length) { - // Tombstone the mapped ids (defensive), then remove mapping. - for (const [prov, pid] of Object.entries(m.byProvider) as Array<[ProviderName, string]>) { - if (!pid) continue; + 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), + }); + } + } + } } } @@ -316,7 +402,7 @@ export class SyncEngine { for (const f of fields) { const baseVal = baseline ? baseline[f] : undefined; const val = t[f]; - if (baseVal !== val) set.add(f); + if (!fieldEqual(f, baseVal as string | undefined, val as string | undefined)) set.add(f); } if (set.size) changedBy.set(prov, set); } @@ -374,6 +460,10 @@ export class SyncEngine { // 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]; @@ -451,12 +541,12 @@ export class SyncEngine { continue; } - // Update only if any field differs. + // Update only if any field differs (using semantic comparison). const differs = - existing.title !== canonical.title || - existing.notes !== canonical.notes || - existing.dueAt !== canonical.dueAt || - existing.status !== canonical.status; + !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({ diff --git a/test/engine.hardening.test.ts b/test/engine.hardening.test.ts index f03b1e1..913934d 100644 --- a/test/engine.hardening.test.ts +++ b/test/engine.hardening.test.ts @@ -9,7 +9,7 @@ import type { Task } from '../src/model.js'; import type { TaskProvider } from '../src/providers/provider.js'; describe('SyncEngine hardening', () => { - it('delete-wins-over-update: terminal status prevents resurrection', async () => { + 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); @@ -42,15 +42,19 @@ describe('SyncEngine hardening', () => { }); await store.save(s); - // Simulate B attempting an update (but A completed should win) + // 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 }); - expect(report.actions.some((x) => x.kind === 'delete')).toBe(true); + // 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')!; - expect(bTask.status).toBe('deleted'); + // 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 () => { @@ -141,23 +145,22 @@ describe('SyncEngine hardening', () => { 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 down: TaskProvider = { - name: 'habitica', + name: 'mockB', listTasks: async () => { - throw new Error('Habitica down'); + throw new Error('Provider down'); }, upsertTask: async (_input: unknown): Promise<Task> => { - throw new Error('Habitica down'); + throw new Error('Provider down'); }, deleteTask: async () => { - throw new Error('Habitica down'); + throw new Error('Provider down'); }, }; - const report = await engine.syncMany([a, b, down], { dryRun: true }); - expect(report.providers).toEqual(['mockA', 'mockB']); - expect(report.errors.some((e) => e.provider === 'habitica')).toBe(true); + 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 fdc98c0..fb75ee1 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -22,43 +22,55 @@ describe('SyncEngine', () => { 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 storing 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('3-way: plans create into both targets (dry-run)', async () => { + 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 c = new MockProvider({ name: 'habitica', tasks: [] }); - const report = await engine.syncMany([a, b, c], { dryRun: true }); + const report = await engine.syncMany([a, b], { dryRun: true }); const creates = report.actions.filter((x) => x.kind === 'create'); - // a1 should be created into both b and c - expect(creates.length).toBeGreaterThanOrEqual(2); - expect(new Set(creates.map((x) => x.target.provider))).toEqual(new Set(['mockB', 'habitica'])); + // 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/habiticaProvider.test.ts b/test/habiticaProvider.test.ts deleted file mode 100644 index 5fef334..0000000 --- a/test/habiticaProvider.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { HabiticaProvider } from '../src/providers/habitica.js'; - -function jsonResponse(obj: unknown, status = 200) { - return new Response(JSON.stringify(obj), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -describe('HabiticaProvider', () => { - it('lists todos and maps fields', async () => { - const fetcher: typeof fetch = async (url) => { - const u = String(url); - if (u.startsWith('https://habitica.com/api/v3/tasks/user')) { - return jsonResponse({ - success: true, - data: [ - { - id: 'h1', - type: 'todo', - text: 'Do it', - notes: 'note', - completed: false, - date: '2026-02-10T00:00:00.000Z', - updatedAt: '2026-02-06T00:00:00.000Z', - }, - ], - }); - } - return new Response('not found', { status: 404 }); - }; - - const p = new HabiticaProvider({ userId: 'u', apiToken: 'k', fetcher }); - const tasks = await p.listTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0]).toMatchObject({ - id: 'h1', - title: 'Do it', - notes: 'note', - status: 'active', - dueAt: '2026-02-10T00:00:00.000Z', - updatedAt: '2026-02-06T00:00:00.000Z', - }); - }); -}); From d4cd5aff9dc5892f25074912b412b84c5fd343b4 Mon Sep 17 00:00:00 2001 From: deeqdev <abdisalamxhassan@gmail.com> Date: Sat, 7 Feb 2026 14:02:02 +0300 Subject: [PATCH 12/12] ci: add GitHub Actions workflow (lint, typecheck, test, build) Co-authored-by: Cursor <cursoragent@cursor.com> --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/ci.yml 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