From b3352ea0658a95b6506b27b73dd8947cbdbae948 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:15:35 +0000 Subject: [PATCH 1/9] Add TypeScript project scaffolding and tooling Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- .gitignore | 4 + .nvmrc | 1 + biome.json | 8 + package-lock.json | 2209 +++++++++++++++++++++++++++++++ package.json | 32 + plans/README.md | 2 +- plans/faz-01-iskelet-ve-list.md | 2 +- src/cli.ts | 1 + test/unit/smoke.test.ts | 2 + tsconfig.build.json | 10 + tsconfig.json | 20 + vitest.config.ts | 8 + 12 files changed, 2297 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 biome.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 test/unit/smoke.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c19bb02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +*.tsbuildinfo diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f69933a --- /dev/null +++ b/biome.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { "includes": ["**", "!plans", "!package-lock.json"] }, + "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "assist": { "actions": { "source": { "organizeImports": "on" } } } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a9b4673 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2209 @@ +{ + "name": "github-backup-cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "github-backup-cli", + "version": "0.1.0", + "dependencies": { + "commander": "^15.0.0", + "zod": "^4.6.7" + }, + "bin": { + "github-backup": "dist/cli.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.15", + "@types/node": "^22.20.5", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "^5.0.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz", + "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.15", + "@biomejs/cli-darwin-x64": "2.5.15", + "@biomejs/cli-linux-arm64": "2.5.15", + "@biomejs/cli-linux-arm64-musl": "2.5.15", + "@biomejs/cli-linux-x64": "2.5.15", + "@biomejs/cli-linux-x64-musl": "2.5.15", + "@biomejs/cli-win32-arm64": "2.5.15", + "@biomejs/cli-win32-x64": "2.5.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz", + "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz", + "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz", + "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz", + "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz", + "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz", + "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz", + "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz", + "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "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/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad31d40 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "github-backup-cli", + "version": "0.1.0", + "description": "Back up GitHub repositories (with wikis and LFS) to local disk and S3-compatible storage as git bundle snapshots.", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "bin": { + "github-backup": "dist/cli.js" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsx src/cli.ts", + "lint": "biome check .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "commander": "^15.0.0", + "zod": "^4.6.7" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.15", + "@types/node": "^22.20.5", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "^5.0.4" + } +} diff --git a/plans/README.md b/plans/README.md index 5a3050b..d8a4436 100644 --- a/plans/README.md +++ b/plans/README.md @@ -44,7 +44,7 @@ Fazlar tablodaki sırayla uygulanır; sıranın tek kaynağı bu tablodur. Her f | Faz | Başlık | Durum | Dosya | | --- | --- | --- | --- | -| 1 | İskelet, config ve `list` | Bekliyor | [faz-01-iskelet-ve-list.md](faz-01-iskelet-ve-list.md) | +| 1 | İskelet, config ve `list` | Devam ediyor | [faz-01-iskelet-ve-list.md](faz-01-iskelet-ve-list.md) | | 2 | Mirror, snapshot ve `local` hedefi | Bekliyor | [faz-02-mirror-ve-local.md](faz-02-mirror-ve-local.md) | | 3 | `s3` hedefi | Bekliyor | [faz-03-s3.md](faz-03-s3.md) | | 4 | Git LFS | Bekliyor | [faz-04-lfs.md](faz-04-lfs.md) | diff --git a/plans/faz-01-iskelet-ve-list.md b/plans/faz-01-iskelet-ve-list.md index 78d4fbf..7796fbe 100644 --- a/plans/faz-01-iskelet-ve-list.md +++ b/plans/faz-01-iskelet-ve-list.md @@ -1,6 +1,6 @@ # Faz 1 — İskelet, config ve `list` -**Durum:** Bekliyor +**Durum:** Devam ediyor ## Amaç diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/cli.ts @@ -0,0 +1 @@ +export {}; diff --git a/test/unit/smoke.test.ts b/test/unit/smoke.test.ts new file mode 100644 index 0000000..816b51d --- /dev/null +++ b/test/unit/smoke.test.ts @@ -0,0 +1,2 @@ +import { it, expect } from "vitest"; +it("runs", () => expect(1).toBe(1)); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..9b73fe4 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "sourceMap": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..537e17e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); From 94c74984c24545727225d1b10d3bba48809ae9cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:16:23 +0000 Subject: [PATCH 2/9] Add exec, logger and glob filter utilities with tests Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- biome.json | 2 +- src/util/exec.ts | 96 ++++++++++++++++++++++++++++++++++++++++ src/util/filter.ts | 50 +++++++++++++++++++++ src/util/log.ts | 65 +++++++++++++++++++++++++++ test/unit/exec.test.ts | 47 ++++++++++++++++++++ test/unit/filter.test.ts | 69 +++++++++++++++++++++++++++++ test/unit/log.test.ts | 51 +++++++++++++++++++++ test/unit/smoke.test.ts | 2 - 8 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 src/util/exec.ts create mode 100644 src/util/filter.ts create mode 100644 src/util/log.ts create mode 100644 test/unit/exec.test.ts create mode 100644 test/unit/filter.test.ts create mode 100644 test/unit/log.test.ts delete mode 100644 test/unit/smoke.test.ts diff --git a/biome.json b/biome.json index f69933a..febc540 100644 --- a/biome.json +++ b/biome.json @@ -3,6 +3,6 @@ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": ["**", "!plans", "!package-lock.json"] }, "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, - "linter": { "enabled": true, "rules": { "recommended": true } }, + "linter": { "enabled": true, "rules": { "preset": "none" } }, "assist": { "actions": { "source": { "organizeImports": "on" } } } } diff --git a/src/util/exec.ts b/src/util/exec.ts new file mode 100644 index 0000000..47db494 --- /dev/null +++ b/src/util/exec.ts @@ -0,0 +1,96 @@ +import { spawn } from "node:child_process"; + +export interface ExecOptions { + env?: Record; + cwd?: string; + timeoutMs?: number; +} + +export interface ExecResult { + stdout: string; + stderr: string; +} + +/** Runs an external command. Injected into modules so tests can fake it. */ +export type Exec = (cmd: string, args: string[], opts?: ExecOptions) => Promise; + +export class ExecError extends Error { + constructor( + message: string, + readonly cmd: string, + /** Exit code, or a string such as `ENOENT` / `ETIMEDOUT` when no exit code exists. */ + readonly code: number | string | null, + readonly stderr: string, + ) { + super(message); + this.name = "ExecError"; + } +} + +/** Removes `user:pass@` from URLs so credentials never reach logs or error messages. */ +export function redactUrls(text: string): string { + return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1"); +} + +function describeCommand(cmd: string, args: string[]): string { + return redactUrls([cmd, ...args].join(" ")); +} + +export const exec: Exec = (cmd, args, opts = {}) => + new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let timedOut = false; + const timer = opts.timeoutMs + ? setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, opts.timeoutMs) + : undefined; + + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.on("error", (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + reject( + new ExecError( + `${cmd}: ${redactUrls(err.message)}`, + cmd, + err.code ?? null, + redactUrls(err.message), + ), + ); + }); + child.on("close", (code) => { + clearTimeout(timer); + const out = Buffer.concat(stdout).toString("utf8"); + const err = redactUrls(Buffer.concat(stderr).toString("utf8")).trim(); + if (timedOut) { + reject( + new ExecError( + `${describeCommand(cmd, args)} timed out after ${opts.timeoutMs} ms`, + cmd, + "ETIMEDOUT", + err, + ), + ); + } else if (code !== 0) { + const detail = err ? `: ${err}` : ""; + reject( + new ExecError( + `${describeCommand(cmd, args)} failed (exit ${code})${detail}`, + cmd, + code, + err, + ), + ); + } else { + resolve({ stdout: out, stderr: err }); + } + }); + }); diff --git a/src/util/filter.ts b/src/util/filter.ts new file mode 100644 index 0000000..4667cd9 --- /dev/null +++ b/src/util/filter.ts @@ -0,0 +1,50 @@ +export interface FilterableRepo { + fullName: string; + isArchived: boolean; + isFork: boolean; +} + +export interface RepoFilter { + include: string[]; + exclude: string[]; + archived: boolean; + forks: boolean; +} + +/** + * Compiles an `owner/repo` glob to a case-insensitive regex. + * `*` and `?` stay within one path segment; `**` crosses `/`. + */ +export function globToRegExp(glob: string): RegExp { + let source = ""; + for (let i = 0; i < glob.length; i++) { + const ch = glob.charAt(i); + if (ch === "*") { + if (glob.charAt(i + 1) === "*") { + source += ".*"; + i++; + } else { + source += "[^/]*"; + } + } else if (ch === "?") { + source += "[^/]"; + } else { + source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${source}$`, "i"); +} + +export function matchesAny(fullName: string, globs: string[]): boolean { + return globs.some((glob) => globToRegExp(glob).test(fullName)); +} + +/** Applies archived/fork flags, then include globs (empty = all), then exclude globs. */ +export function filterRepos(repos: T[], filter: RepoFilter): T[] { + return repos.filter((repo) => { + if (!filter.archived && repo.isArchived) return false; + if (!filter.forks && repo.isFork) return false; + if (filter.include.length > 0 && !matchesAny(repo.fullName, filter.include)) return false; + return !matchesAny(repo.fullName, filter.exclude); + }); +} diff --git a/src/util/log.ts b/src/util/log.ts new file mode 100644 index 0000000..b666544 --- /dev/null +++ b/src/util/log.ts @@ -0,0 +1,65 @@ +export type LogLevel = "quiet" | "normal" | "verbose"; + +type Severity = "error" | "warn" | "info" | "debug"; + +export interface Logger { + error(message: string, scope?: string): void; + warn(message: string, scope?: string): void; + info(message: string, scope?: string): void; + /** Printed only with `--verbose` (git commands and other detail). */ + debug(message: string, scope?: string): void; + /** Printed at every level, including quiet (end-of-run summary). */ + summary(message: string): void; + /** Values (tokens, keys) replaced with `***` in every later message. */ + addSecret(value: string | undefined): void; +} + +export interface LoggerOptions { + level: LogLevel; + write?: (line: string) => void; + now?: () => Date; +} + +const SHOWN: Record = { + quiet: ["error"], + normal: ["error", "warn", "info"], + verbose: ["error", "warn", "info", "debug"], +}; + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +export function formatTime(date: Date): string { + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +export function createLogger(options: LoggerOptions): Logger { + const write = options.write ?? ((line: string) => process.stderr.write(`${line}\n`)); + const now = options.now ?? (() => new Date()); + const secrets = new Set(); + + const mask = (text: string): string => { + let out = text; + for (const secret of secrets) out = out.split(secret).join("***"); + return out; + }; + + const emit = (severity: Severity, message: string, scope?: string): void => { + if (!SHOWN[options.level].includes(severity)) return; + const label = severity.toUpperCase().padEnd(5); + const prefix = scope ? `${scope}: ` : ""; + write(mask(`[${formatTime(now())}] ${label} ${prefix}${message}`)); + }; + + return { + error: (message, scope) => emit("error", message, scope), + warn: (message, scope) => emit("warn", message, scope), + info: (message, scope) => emit("info", message, scope), + debug: (message, scope) => emit("debug", message, scope), + summary: (message) => write(mask(message)), + addSecret(value) { + if (value) secrets.add(value); + }, + }; +} diff --git a/test/unit/exec.test.ts b/test/unit/exec.test.ts new file mode 100644 index 0000000..d94fb9e --- /dev/null +++ b/test/unit/exec.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { ExecError, exec, redactUrls } from "../../src/util/exec.js"; + +describe("redactUrls", () => { + it.each([ + ["clone https://user:secret@github.com/a/b.git", "clone https://github.com/a/b.git"], + ["https://x-access-token@github.com/a/b", "https://github.com/a/b"], + ["no urls here a@b", "no urls here a@b"], + ])("%s", (input, expected) => { + expect(redactUrls(input)).toBe(expected); + }); +}); + +describe("exec", () => { + it("captures stdout and merges env", async () => { + const result = await exec("node", ["-e", "process.stdout.write(process.env.FOO ?? '')"], { + env: { FOO: "bar" }, + }); + expect(result.stdout).toBe("bar"); + }); + + it("rejects with command and stderr on non-zero exit", async () => { + const err = await exec("node", ["-e", "console.error('boom'); process.exit(3)"]).catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(ExecError); + const execError = err as ExecError; + expect(execError.code).toBe(3); + expect(execError.stderr).toBe("boom"); + expect(execError.message).toContain("node"); + expect(execError.message).toContain("boom"); + }); + + it("reports a missing binary as ENOENT", async () => { + const err = (await exec("definitely-not-a-real-binary", []).catch( + (e: unknown) => e, + )) as ExecError; + expect(err.code).toBe("ENOENT"); + }); + + it("kills the process on timeout", async () => { + const err = (await exec("node", ["-e", "setTimeout(() => {}, 10000)"], { + timeoutMs: 100, + }).catch((e: unknown) => e)) as ExecError; + expect(err.code).toBe("ETIMEDOUT"); + }); +}); diff --git a/test/unit/filter.test.ts b/test/unit/filter.test.ts new file mode 100644 index 0000000..ffd6b74 --- /dev/null +++ b/test/unit/filter.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { filterRepos, globToRegExp, matchesAny } from "../../src/util/filter.js"; + +const repo = (fullName: string, isArchived = false, isFork = false) => ({ + fullName, + isArchived, + isFork, +}); + +describe("globToRegExp", () => { + it.each([ + ["*/*", "acme/app", true], + ["*/*", "acme", false], + ["acme/*", "acme/app", true], + ["acme/*", "other/app", false], + ["acme/*", "acme/a/b", false], + ["acme/**", "acme/a/b", true], + ["ACME/App", "acme/app", true], + ["acme/app-?", "acme/app-1", true], + ["acme/app-?", "acme/app-12", false], + ["acme/a.b", "acme/axb", false], + ["acme/a.b", "acme/a.b", true], + ["*/*-infra", "acme/core-infra", true], + ])("%s vs %s -> %s", (glob, name, expected) => { + expect(globToRegExp(glob).test(name)).toBe(expected); + }); +}); + +describe("matchesAny", () => { + it("is false for an empty list", () => { + expect(matchesAny("a/b", [])).toBe(false); + }); +}); + +describe("filterRepos", () => { + const repos = [ + repo("me/one"), + repo("me/two", true), + repo("acme/three", false, true), + repo("acme/four"), + repo("Acme/Five"), + ]; + const base = { include: ["*/*"], exclude: [], archived: true, forks: true }; + const names = (r: ReturnType[]) => r.map((x) => x.fullName); + + it.each([ + ["everything by default", base, ["me/one", "me/two", "acme/three", "acme/four", "Acme/Five"]], + [ + "empty include means all", + { ...base, include: [] }, + ["me/one", "me/two", "acme/three", "acme/four", "Acme/Five"], + ], + [ + "no archived", + { ...base, archived: false }, + ["me/one", "acme/three", "acme/four", "Acme/Five"], + ], + ["no forks", { ...base, forks: false }, ["me/one", "me/two", "acme/four", "Acme/Five"]], + ["include narrows", { ...base, include: ["acme/*"] }, ["acme/three", "acme/four", "Acme/Five"]], + [ + "exclude after include", + { ...base, include: ["acme/*"], exclude: ["acme/f*"] }, + ["acme/three"], + ], + ["exclude only", { ...base, exclude: ["acme/*"] }, ["me/one", "me/two"]], + ])("%s", (_label, filter, expected) => { + expect(names(filterRepos(repos, filter))).toEqual(expected); + }); +}); diff --git a/test/unit/log.test.ts b/test/unit/log.test.ts new file mode 100644 index 0000000..6892741 --- /dev/null +++ b/test/unit/log.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { createLogger, type LogLevel } from "../../src/util/log.js"; + +function setup(level: LogLevel) { + const lines: string[] = []; + const logger = createLogger({ + level, + write: (line) => lines.push(line), + now: () => new Date(2026, 9, 14, 8, 5, 9), + }); + return { lines, logger }; +} + +function emitAll(logger: ReturnType) { + logger.error("e"); + logger.warn("w"); + logger.info("i"); + logger.debug("d"); +} + +describe("logger", () => { + it.each([ + ["quiet", ["ERROR e"]], + ["normal", ["ERROR e", "WARN w", "INFO i"]], + ["verbose", ["ERROR e", "WARN w", "INFO i", "DEBUG d"]], + ] as const)("level %s", (level, expected) => { + const { lines, logger } = setup(level); + emitAll(logger); + expect(lines).toEqual(expected.map((line) => `[08:05:09] ${line}`)); + }); + + it("prefixes the scope", () => { + const { lines, logger } = setup("normal"); + logger.info("fetched", "github/acme/app"); + expect(lines).toEqual(["[08:05:09] INFO github/acme/app: fetched"]); + }); + + it("prints summary at every level", () => { + const { lines, logger } = setup("quiet"); + logger.summary("done"); + expect(lines).toEqual(["done"]); + }); + + it("masks registered secrets", () => { + const { lines, logger } = setup("normal"); + logger.addSecret("s3cr3t"); + logger.info("key=s3cr3t and again s3cr3t"); + logger.summary("s3cr3t"); + expect(lines).toEqual(["[08:05:09] INFO key=*** and again ***", "***"]); + }); +}); diff --git a/test/unit/smoke.test.ts b/test/unit/smoke.test.ts deleted file mode 100644 index 816b51d..0000000 --- a/test/unit/smoke.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { it, expect } from "vitest"; -it("runs", () => expect(1).toBe(1)); From 1a269bae6711e92d40247583d8c7483942b2e7bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:16:31 +0000 Subject: [PATCH 3/9] Use the recommended Biome lint preset Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index febc540..b2090f3 100644 --- a/biome.json +++ b/biome.json @@ -3,6 +3,6 @@ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": ["**", "!plans", "!package-lock.json"] }, "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, - "linter": { "enabled": true, "rules": { "preset": "none" } }, + "linter": { "enabled": true, "rules": { "preset": "recommended" } }, "assist": { "actions": { "source": { "organizeImports": "on" } } } } From 0015b5eda3e9c28566597e38ddff6b2c03ab286f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:18:17 +0000 Subject: [PATCH 4/9] Add config schema and loader with flag > env > file precedence Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- src/config/load.ts | 310 +++++++++++++++++++++++++++++++++++ src/config/schema.ts | 72 +++++++++ src/util/errors.ts | 33 ++++ test/unit/config.test.ts | 342 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 757 insertions(+) create mode 100644 src/config/load.ts create mode 100644 src/config/schema.ts create mode 100644 src/util/errors.ts create mode 100644 test/unit/config.test.ts diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..def73bc --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,310 @@ +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { ZodError } from "zod"; +import { ConfigError } from "../util/errors.js"; +import { type Config, configSchema, type TargetConfig } from "./schema.js"; + +/** Values from CLI flags. Numbers may arrive as strings from commander. */ +export interface ConfigFlags { + config?: string; + cacheDir?: string; + concurrency?: number | string; + keep?: number | string; + wiki?: boolean; + lfs?: boolean; + include?: string[]; + exclude?: string[]; + archived?: boolean; + forks?: boolean; + /** Names of the targets to run. */ + target?: string[]; + /** Adds a temporary `local` target at this path. */ + local?: string; +} + +export interface LoadInput { + flags: ConfigFlags; + env: Record; + homeDir?: string; + cwd?: string; +} + +export interface LoadedConfig { + config: Config; + /** Absolute path of the config file that was read, if any. */ + configPath?: string; + warnings: string[]; + /** Credential values that must be masked in logs. */ + secrets: string[]; +} + +/** Settings that can come from a flag or an environment variable. */ +export interface Overrides { + config?: string; + cacheDir?: string; + concurrency?: number; + keep?: number; + wiki?: boolean; + lfs?: boolean; + include?: string[]; + exclude?: string[]; + archived?: boolean; + forks?: boolean; + target?: string[]; + local?: string; +} + +export function parseBoolean(name: string, value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + throw new ConfigError(`${name}: expected true, false, 1 or 0, got "${value}"`); +} + +export function parseInteger(name: string, value: string | number): number { + const n = typeof value === "number" ? value : Number(value.trim()); + if (!Number.isInteger(n)) throw new ConfigError(`${name}: expected an integer, got "${value}"`); + return n; +} + +export function parseList(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export function overridesFromEnv(env: LoadInput["env"]): Overrides { + const out: Overrides = {}; + const get = (key: string) => { + const value = env[key]; + return value === undefined || value === "" ? undefined : value; + }; + const config = get("GHBACKUP_CONFIG"); + if (config !== undefined) out.config = config; + const cacheDir = get("GHBACKUP_CACHE_DIR"); + if (cacheDir !== undefined) out.cacheDir = cacheDir; + const concurrency = get("GHBACKUP_CONCURRENCY"); + if (concurrency !== undefined) + out.concurrency = parseInteger("GHBACKUP_CONCURRENCY", concurrency); + const keep = get("GHBACKUP_KEEP"); + if (keep !== undefined) out.keep = parseInteger("GHBACKUP_KEEP", keep); + const wiki = get("GHBACKUP_WIKI"); + if (wiki !== undefined) out.wiki = parseBoolean("GHBACKUP_WIKI", wiki); + const lfs = get("GHBACKUP_LFS"); + if (lfs !== undefined) out.lfs = parseBoolean("GHBACKUP_LFS", lfs); + const include = get("GHBACKUP_INCLUDE"); + if (include !== undefined) out.include = parseList(include); + const exclude = get("GHBACKUP_EXCLUDE"); + if (exclude !== undefined) out.exclude = parseList(exclude); + const target = get("GHBACKUP_TARGETS"); + if (target !== undefined) out.target = parseList(target); + return out; +} + +export function overridesFromFlags(flags: ConfigFlags): Overrides { + const out: Overrides = {}; + if (flags.config !== undefined) out.config = flags.config; + if (flags.cacheDir !== undefined) out.cacheDir = flags.cacheDir; + if (flags.concurrency !== undefined) { + out.concurrency = parseInteger("--concurrency", flags.concurrency); + } + if (flags.keep !== undefined) out.keep = parseInteger("--keep", flags.keep); + if (flags.wiki !== undefined) out.wiki = flags.wiki; + if (flags.lfs !== undefined) out.lfs = flags.lfs; + if (flags.include?.length) out.include = flags.include; + if (flags.exclude?.length) out.exclude = flags.exclude; + if (flags.archived !== undefined) out.archived = flags.archived; + if (flags.forks !== undefined) out.forks = flags.forks; + if (flags.target?.length) out.target = flags.target; + if (flags.local !== undefined) out.local = flags.local; + return out; +} + +/** Flag beats environment variable. */ +export function mergeOverrides(env: Overrides, flags: Overrides): Overrides { + return { ...env, ...flags }; +} + +export function defaultConfigPath(env: LoadInput["env"], home: string): string { + const xdg = env.XDG_CONFIG_HOME; + const base = xdg ? xdg : path.join(home, ".config"); + return path.join(base, "github-backup-cli", "config.json"); +} + +/** Expands `~`, `$VAR` and `${VAR}`, then resolves the result against `cwd`. */ +export function expandPath( + value: string, + env: LoadInput["env"], + home: string, + cwd: string, +): string { + const withVars = value.replace( + /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, + (_m, a, b) => { + const name = (a ?? b) as string; + const resolved = env[name]; + if (resolved === undefined) { + throw new ConfigError(`environment variable ${name} is not set (used in "${value}")`); + } + return resolved; + }, + ); + const withHome = + withVars === "~" || withVars.startsWith("~/") ? home + withVars.slice(1) : withVars; + return path.resolve(cwd, withHome); +} + +type RawConfig = Record; + +/** Applies flag/env overrides onto the raw file contents (pure). */ +export function applyOverrides(raw: RawConfig, o: Overrides): RawConfig { + const out: RawConfig = { ...raw }; + if (o.cacheDir !== undefined) out.cacheDir = o.cacheDir; + if (o.concurrency !== undefined) out.concurrency = o.concurrency; + if (o.keep !== undefined) out.keep = o.keep; + if (o.wiki !== undefined) out.wiki = o.wiki; + if (o.lfs !== undefined) out.lfs = o.lfs; + + const filterOverrides: RawConfig = {}; + if (o.include !== undefined) filterOverrides.include = o.include; + if (o.exclude !== undefined) filterOverrides.exclude = o.exclude; + if (o.archived !== undefined) filterOverrides.archived = o.archived; + if (o.forks !== undefined) filterOverrides.forks = o.forks; + if (Object.keys(filterOverrides).length > 0) { + const sources = Array.isArray(out.sources) ? out.sources : [{ type: "github" }]; + out.sources = sources.map((source: unknown) => + isRecord(source) && source.type === "github" ? { ...source, ...filterOverrides } : source, + ); + } + return out; +} + +function isRecord(value: unknown): value is RawConfig { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function formatZodError(error: ZodError): string { + return error.issues + .map((issue) => { + const where = issue.path.length > 0 ? issue.path.join(".") : "(root)"; + const keys = + issue.code === "unrecognized_keys" ? ` ${issue.keys.map((k) => `"${k}"`).join(", ")}` : ""; + return ` - ${where}: ${issue.message}${keys}`; + }) + .join("\n"); +} + +function readConfigFile(file: string, explicit: boolean): RawConfig | undefined { + let text: string; + try { + text = readFileSync(file, "utf8"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + if (explicit) throw new ConfigError(`config file not found: ${file}`); + return undefined; + } + throw new ConfigError(`cannot read config file ${file}: ${(err as Error).message}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new ConfigError(`invalid JSON in ${file}: ${(err as Error).message}`); + } + if (!isRecord(parsed)) throw new ConfigError(`${file}: the top level must be a JSON object`); + return parsed; +} + +function fileHasS3Keys(raw: RawConfig): boolean { + const targets = Array.isArray(raw.targets) ? raw.targets : []; + return targets.some( + (t) => + isRecord(t) && + t.type === "s3" && + (t.accessKeyId !== undefined || t.secretAccessKey !== undefined), + ); +} + +export function loadConfig(input: LoadInput): LoadedConfig { + const home = input.homeDir ?? homedir(); + const cwd = input.cwd ?? process.cwd(); + const env = input.env; + const warnings: string[] = []; + + const overrides = mergeOverrides(overridesFromEnv(env), overridesFromFlags(input.flags)); + + const explicit = overrides.config !== undefined; + const configPath = explicit + ? expandPath(overrides.config as string, env, home, cwd) + : defaultConfigPath(env, home); + const raw = readConfigFile(configPath, explicit); + + if (raw && fileHasS3Keys(raw)) { + try { + if (statSync(configPath).mode & 0o077) { + warnings.push( + `${configPath} contains S3 credentials and is readable by other users; run: chmod 600 ${configPath}`, + ); + } + } catch { + // The permission check is best-effort. + } + } + + const parsed = configSchema.safeParse(applyOverrides(raw ?? {}, overrides)); + if (!parsed.success) { + const origin = raw ? configPath : "flags and environment"; + throw new ConfigError(`invalid configuration (${origin}):\n${formatZodError(parsed.error)}`); + } + const config = parsed.data; + + config.cacheDir = expandPath(config.cacheDir, env, home, cwd); + config.targets = config.targets.map((target) => resolveTarget(target, env, home, cwd)); + + if (overrides.local !== undefined) { + if (config.targets.some((t) => t.name === "local")) { + throw new ConfigError('--local conflicts with a configured target named "local"'); + } + config.targets.push({ + type: "local", + name: "local", + path: expandPath(overrides.local, env, home, cwd), + }); + } + + if (overrides.target !== undefined) { + const known = new Set(config.targets.map((t) => t.name)); + const unknown = overrides.target.filter((name) => !known.has(name)); + if (unknown.length > 0) { + const available = [...known].join(", ") || "none configured"; + throw new ConfigError( + `unknown target ${unknown.map((n) => `"${n}"`).join(", ")} (available: ${available})`, + ); + } + config.targets = config.targets.filter((t) => overrides.target?.includes(t.name)); + } + + const secrets: string[] = []; + for (const target of config.targets) { + if (target.type !== "s3") continue; + target.accessKeyId ??= env.GHBACKUP_S3_ACCESS_KEY_ID || undefined; + target.secretAccessKey ??= env.GHBACKUP_S3_SECRET_ACCESS_KEY || undefined; + if (target.accessKeyId) secrets.push(target.accessKeyId); + if (target.secretAccessKey) secrets.push(target.secretAccessKey); + } + + return { config, configPath: raw ? configPath : undefined, warnings, secrets }; +} + +function resolveTarget( + target: TargetConfig, + env: LoadInput["env"], + home: string, + cwd: string, +): TargetConfig { + if (target.type === "local") return { ...target, path: expandPath(target.path, env, home, cwd) }; + return target; +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..57b1a23 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +const positiveInt = z.number().int().positive(); + +export const githubSourceSchema = z.strictObject({ + type: z.literal("github"), + include: z.array(z.string().min(1)).default(["*/*"]), + exclude: z.array(z.string().min(1)).default([]), + archived: z.boolean().default(true), + forks: z.boolean().default(true), +}); + +export const sourceSchema = z.discriminatedUnion("type", [githubSourceSchema]); + +export const localTargetSchema = z.strictObject({ + type: z.literal("local"), + name: z.string().min(1), + path: z.string().min(1), + keep: positiveInt.optional(), +}); + +export const s3TargetSchema = z.strictObject({ + type: z.literal("s3"), + name: z.string().min(1), + bucket: z.string().min(1), + prefix: z.string().default(""), + endpoint: z.url().optional(), + region: z.string().min(1).optional(), + forcePathStyle: z.boolean().default(false), + accessKeyId: z.string().min(1).optional(), + secretAccessKey: z.string().min(1).optional(), + keep: positiveInt.optional(), +}); + +export const targetSchema = z.discriminatedUnion("type", [localTargetSchema, s3TargetSchema]); + +export const DEFAULT_CACHE_DIR = "~/.cache/github-backup-cli"; + +export const configSchema = z + .strictObject({ + cacheDir: z.string().min(1).default(DEFAULT_CACHE_DIR), + concurrency: positiveInt.max(64).default(4), + wiki: z.boolean().default(true), + lfs: z.boolean().default(true), + keep: positiveInt.default(7), + sources: z + .array(sourceSchema) + .min(1) + .default(() => [githubSourceSchema.parse({ type: "github" })]), + targets: z.array(targetSchema).default([]), + }) + .check((ctx) => { + const seen = new Set(); + ctx.value.targets.forEach((target, index) => { + if (seen.has(target.name)) { + ctx.issues.push({ + code: "custom", + message: `duplicate target name "${target.name}"`, + path: ["targets", index, "name"], + input: ctx.value, + }); + } + seen.add(target.name); + }); + }); + +export type GithubSourceConfig = z.infer; +export type SourceConfig = z.infer; +export type LocalTargetConfig = z.infer; +export type S3TargetConfig = z.infer; +export type TargetConfig = z.infer; +export type Config = z.infer; diff --git a/src/util/errors.ts b/src/util/errors.ts new file mode 100644 index 0000000..de5cb32 --- /dev/null +++ b/src/util/errors.ts @@ -0,0 +1,33 @@ +/** Process exit codes shared by all commands (K-020). */ +export const ExitCode = { + Success: 0, + PartialFailure: 1, + Usage: 2, + Locked: 3, + Interrupted: 130, +} as const; + +/** An error that ends the command with a specific exit code and a readable message. */ +export class CliError extends Error { + constructor( + message: string, + readonly exitCode: number, + ) { + super(message); + this.name = new.target.name; + } +} + +/** Invalid configuration, flag or environment value. */ +export class ConfigError extends CliError { + constructor(message: string) { + super(message, ExitCode.Usage); + } +} + +/** A required external tool or login is missing. */ +export class PrerequisiteError extends CliError { + constructor(message: string) { + super(message, ExitCode.Usage); + } +} diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts new file mode 100644 index 0000000..27caf13 --- /dev/null +++ b/test/unit/config.test.ts @@ -0,0 +1,342 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + type ConfigFlags, + defaultConfigPath, + expandPath, + loadConfig, + parseBoolean, + parseInteger, + parseList, +} from "../../src/config/load.js"; +import { ConfigError, ExitCode } from "../../src/util/errors.js"; + +let home: string; + +beforeEach(() => { + home = mkdtempSync(path.join(tmpdir(), "ghb-config-")); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +function writeConfig(content: unknown, mode = 0o600, file = "config.json"): string { + const target = path.join(home, file); + writeFileSync(target, typeof content === "string" ? content : JSON.stringify(content), { mode }); + chmodSync(target, mode); + return target; +} + +function load(flags: ConfigFlags = {}, env: Record = {}) { + return loadConfig({ flags, env, homeDir: home, cwd: home }); +} + +describe("parsers", () => { + it.each([ + ["true", true], + ["TRUE", true], + ["1", true], + ["false", false], + ["0", false], + [" False ", false], + ])("parseBoolean(%s)", (input, expected) => { + expect(parseBoolean("X", input)).toBe(expected); + }); + + it.each(["yes", "2", ""])("parseBoolean rejects %j", (input) => { + expect(() => parseBoolean("X", input)).toThrow(ConfigError); + }); + + it("parseInteger", () => { + expect(parseInteger("X", "8")).toBe(8); + expect(parseInteger("X", 3)).toBe(3); + expect(() => parseInteger("X", "abc")).toThrow(/X: expected an integer/); + expect(() => parseInteger("X", "1.5")).toThrow(ConfigError); + }); + + it("parseList trims and drops empty items", () => { + expect(parseList(" a/*, ,b/c ,")).toEqual(["a/*", "b/c"]); + }); +}); + +describe("expandPath", () => { + const env = { FOO: "/srv/foo", EMPTY: "" }; + it.each([ + ["~", "/home/u"], + ["~/x", "/home/u/x"], + ["$FOO/y", "/srv/foo/y"], + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal config syntax under test + ["${FOO}/y", "/srv/foo/y"], + ["rel/dir", "/work/rel/dir"], + ["/abs/dir", "/abs/dir"], + ["~other/x", "/work/~other/x"], + ])("%s", (input, expected) => { + expect(expandPath(input, env, "/home/u", "/work")).toBe(expected); + }); + + it("rejects an unset variable", () => { + expect(() => expandPath("$NOPE/x", env, "/home/u", "/work")).toThrow(/NOPE is not set/); + }); +}); + +describe("defaultConfigPath", () => { + it("uses XDG_CONFIG_HOME when set", () => { + expect(defaultConfigPath({ XDG_CONFIG_HOME: "/xdg" }, "/home/u")).toBe( + "/xdg/github-backup-cli/config.json", + ); + }); + it("falls back to ~/.config", () => { + expect(defaultConfigPath({}, "/home/u")).toBe("/home/u/.config/github-backup-cli/config.json"); + }); +}); + +describe("loadConfig defaults and files", () => { + it("returns defaults when the default file does not exist", () => { + const { config, configPath, warnings } = load(); + expect(configPath).toBeUndefined(); + expect(warnings).toEqual([]); + expect(config).toMatchObject({ + concurrency: 4, + wiki: true, + lfs: true, + keep: 7, + cacheDir: path.join(home, ".cache/github-backup-cli"), + targets: [], + sources: [{ type: "github", include: ["*/*"], exclude: [], archived: true, forks: true }], + }); + }); + + it("reads the default file from XDG_CONFIG_HOME", () => { + const dir = path.join(home, "xdg", "github-backup-cli"); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, "config.json"), JSON.stringify({ keep: 3 })); + const { config, configPath } = load({}, { XDG_CONFIG_HOME: path.join(home, "xdg") }); + expect(config.keep).toBe(3); + expect(configPath).toBe(path.join(dir, "config.json")); + }); + + it("fails when an explicit --config file is missing", () => { + expect(() => load({ config: "missing.json" })).toThrow(/config file not found/); + }); + + it("fails when GHBACKUP_CONFIG points to a missing file", () => { + expect(() => load({}, { GHBACKUP_CONFIG: path.join(home, "nope.json") })).toThrow( + /config file not found/, + ); + }); + + it("reports invalid JSON", () => { + writeConfig("{ nope"); + expect(() => load({ config: "config.json" })).toThrow(/invalid JSON/); + }); + + it("requires a JSON object", () => { + writeConfig("[]"); + expect(() => load({ config: "config.json" })).toThrow(/top level must be a JSON object/); + }); + + it("expands ~ and variables in cacheDir and local target paths", () => { + writeConfig({ + cacheDir: "~/cache", + targets: [{ type: "local", name: "disk", path: "$BACKUP_ROOT/git" }], + }); + const { config } = load({ config: "config.json" }, { BACKUP_ROOT: "/mnt/backup" }); + expect(config.cacheDir).toBe(path.join(home, "cache")); + expect(config.targets[0]).toMatchObject({ path: "/mnt/backup/git" }); + }); +}); + +describe("loadConfig precedence: flag > env > file > default", () => { + const cases: Array<{ + label: string; + file?: number; + env?: string; + flag?: number; + expected: number; + }> = [ + { label: "default", expected: 4 }, + { label: "file", file: 2, expected: 2 }, + { label: "env over file", file: 2, env: "3", expected: 3 }, + { label: "flag over env and file", file: 2, env: "3", flag: 5, expected: 5 }, + { label: "flag over default", flag: 6, expected: 6 }, + ]; + + it.each(cases)("concurrency: $label", ({ file, env, flag, expected }) => { + if (file !== undefined) writeConfig({ concurrency: file }); + const result = load( + { config: file !== undefined ? "config.json" : undefined, concurrency: flag }, + env !== undefined ? { GHBACKUP_CONCURRENCY: env } : {}, + ); + expect(result.config.concurrency).toBe(expected); + }); + + it("flag strings from the command line are parsed as numbers", () => { + expect(load({ concurrency: "7", keep: "9" }).config).toMatchObject({ concurrency: 7, keep: 9 }); + }); + + it("booleans: env over file, flag over env", () => { + writeConfig({ wiki: true, lfs: true }); + const env = { GHBACKUP_WIKI: "0", GHBACKUP_LFS: "false" }; + expect(load({ config: "config.json" }, env).config).toMatchObject({ wiki: false, lfs: false }); + expect(load({ config: "config.json", lfs: true }, env).config).toMatchObject({ + wiki: false, + lfs: true, + }); + }); + + it("include/exclude: flag over env over file, applied to every github source", () => { + writeConfig({ sources: [{ type: "github", include: ["file/*"], exclude: ["file/x"] }] }); + const env = { GHBACKUP_INCLUDE: "env/*,env2/*", GHBACKUP_EXCLUDE: "env/skip" }; + const fromEnv = load({ config: "config.json" }, env).config.sources[0]; + expect(fromEnv).toMatchObject({ include: ["env/*", "env2/*"], exclude: ["env/skip"] }); + const fromFlag = load({ config: "config.json", include: ["flag/*"] }, env).config.sources[0]; + expect(fromFlag).toMatchObject({ include: ["flag/*"], exclude: ["env/skip"] }); + }); + + it("--no-archived / --no-forks override the file", () => { + writeConfig({ sources: [{ type: "github", archived: true, forks: true }] }); + const source = load({ config: "config.json", archived: false, forks: false }).config.sources[0]; + expect(source).toMatchObject({ archived: false, forks: false }); + }); + + it("creates the default github source when only filters are overridden", () => { + expect(load({ exclude: ["org/*"] }).config.sources).toEqual([ + { type: "github", include: ["*/*"], exclude: ["org/*"], archived: true, forks: true }, + ]); + }); + + it("rejects a malformed env value", () => { + expect(() => load({}, { GHBACKUP_CONCURRENCY: "many" })).toThrow(/GHBACKUP_CONCURRENCY/); + expect(() => load({}, { GHBACKUP_WIKI: "maybe" })).toThrow(/GHBACKUP_WIKI/); + }); + + it("ignores empty env values", () => { + expect(load({}, { GHBACKUP_CONCURRENCY: "" }).config.concurrency).toBe(4); + }); +}); + +describe("loadConfig validation errors", () => { + it.each([ + ["wrong type", { concurrency: "four" }, "concurrency:"], + ["out of range", { keep: 0 }, "keep:"], + ["unknown top-level key", { concurency: 3 }, '"concurency"'], + ["unknown source type", { sources: [{ type: "gitlab" }] }, "sources.0.type"], + ["missing s3 bucket", { targets: [{ type: "s3", name: "r2" }] }, "targets.0.bucket"], + [ + "bad endpoint", + { targets: [{ type: "s3", name: "r2", bucket: "b", endpoint: "nope" }] }, + "targets.0.endpoint", + ], + [ + "duplicate target names", + { + targets: [ + { type: "local", name: "a", path: "/x" }, + { type: "local", name: "a", path: "/y" }, + ], + }, + 'duplicate target name "a"', + ], + ])("%s", (_label, content, fragment) => { + writeConfig(content); + let error: unknown; + try { + load({ config: "config.json" }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(ConfigError); + expect((error as ConfigError).exitCode).toBe(ExitCode.Usage); + expect((error as ConfigError).message).toContain(fragment); + expect((error as ConfigError).message).toContain("invalid configuration"); + }); +}); + +describe("loadConfig targets", () => { + const targets = [ + { type: "local", name: "disk", path: "/backups" }, + { type: "s3", name: "r2", bucket: "b", keep: 14 }, + ]; + + it("--target / GHBACKUP_TARGETS select targets by name", () => { + writeConfig({ targets }); + expect( + load({ config: "config.json", target: ["r2"] }).config.targets.map((t) => t.name), + ).toEqual(["r2"]); + expect( + load({ config: "config.json" }, { GHBACKUP_TARGETS: "disk" }).config.targets.map( + (t) => t.name, + ), + ).toEqual(["disk"]); + }); + + it("rejects an unknown target name and lists the available ones", () => { + writeConfig({ targets }); + expect(() => load({ config: "config.json", target: ["nas"] })).toThrow( + /unknown target "nas" \(available: disk, r2\)/, + ); + }); + + it("--local adds a temporary local target", () => { + writeConfig({ targets }); + const result = load({ config: "config.json", local: "out" }); + expect(result.config.targets.map((t) => t.name)).toEqual(["disk", "r2", "local"]); + expect(result.config.targets[2]).toMatchObject({ type: "local", path: path.join(home, "out") }); + }); + + it("--local can be combined with --target", () => { + writeConfig({ targets }); + const names = load({ + config: "config.json", + local: "out", + target: ["local"], + }).config.targets.map((t) => t.name); + expect(names).toEqual(["local"]); + }); + + it('--local conflicts with a target named "local"', () => { + writeConfig({ targets: [{ type: "local", name: "local", path: "/x" }] }); + expect(() => load({ config: "config.json", local: "out" })).toThrow(/conflicts/); + }); +}); + +describe("loadConfig S3 credentials and file permissions", () => { + const s3 = (extra: object = {}) => ({ + targets: [{ type: "s3", name: "r2", bucket: "b", ...extra }], + }); + + it("falls back to env credentials; file values win", () => { + writeConfig(s3({ accessKeyId: "FILEKEY" })); + const { config, secrets } = load( + { config: "config.json" }, + { GHBACKUP_S3_ACCESS_KEY_ID: "ENVKEY", GHBACKUP_S3_SECRET_ACCESS_KEY: "ENVSECRET" }, + ); + expect(config.targets[0]).toMatchObject({ + accessKeyId: "FILEKEY", + secretAccessKey: "ENVSECRET", + }); + expect(secrets).toEqual(["FILEKEY", "ENVSECRET"]); + }); + + it("leaves credentials unset for the SDK default chain", () => { + writeConfig(s3()); + const { config, secrets } = load({ config: "config.json" }); + expect(config.targets[0]).not.toHaveProperty("accessKeyId", expect.anything()); + expect(secrets).toEqual([]); + }); + + it.each([ + ["world-readable with keys", 0o644, s3({ secretAccessKey: "x" }), true], + ["group-readable with keys", 0o640, s3({ accessKeyId: "x" }), true], + ["private with keys", 0o600, s3({ secretAccessKey: "x" }), false], + ["world-readable without keys", 0o644, s3(), false], + ])("permission warning: %s", (_label, mode, content, warns) => { + writeConfig(content, mode); + const { warnings } = load({ config: "config.json" }); + expect(warnings.length > 0).toBe(warns); + if (warns) expect(warnings[0]).toContain("chmod 600"); + }); +}); From 9b1de44b1279eed43078b2d92a1eaa5af201af53 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:19:21 +0000 Subject: [PATCH 5/9] Add Source interface, github source and repository discovery Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- src/core/discover.ts | 57 +++++++++ src/sources/github.ts | 135 ++++++++++++++++++++ src/sources/index.ts | 14 +++ src/sources/types.ts | 34 +++++ test/unit/discover.test.ts | 106 ++++++++++++++++ test/unit/github-source.test.ts | 215 ++++++++++++++++++++++++++++++++ 6 files changed, 561 insertions(+) create mode 100644 src/core/discover.ts create mode 100644 src/sources/github.ts create mode 100644 src/sources/index.ts create mode 100644 src/sources/types.ts create mode 100644 test/unit/discover.test.ts create mode 100644 test/unit/github-source.test.ts diff --git a/src/core/discover.ts b/src/core/discover.ts new file mode 100644 index 0000000..c0d1aaf --- /dev/null +++ b/src/core/discover.ts @@ -0,0 +1,57 @@ +import type { SourceConfig } from "../config/schema.js"; +import { createSource } from "../sources/index.js"; +import type { ListFailure, RepoInfo, Source } from "../sources/types.js"; +import type { Exec } from "../util/exec.js"; +import { filterRepos } from "../util/filter.js"; +import type { Logger } from "../util/log.js"; + +export interface DiscoverDeps { + exec: Exec; + log: Logger; + /** Overridable in tests. */ + createSource?: (config: SourceConfig, deps: { exec: Exec }) => Source; +} + +export interface DiscoveryResult { + repos: RepoInfo[]; + failures: ListFailure[]; + /** Sources by id, kept so later steps can ask for `gitEnv()`. */ + sources: Map; +} + +function repoKey(repo: RepoInfo): string { + return `${repo.source}/${repo.fullName.toLowerCase()}`; +} + +/** Lists and filters the repositories of every configured source. */ +export async function discoverRepos( + sourceConfigs: SourceConfig[], + deps: DiscoverDeps, +): Promise { + const make = deps.createSource ?? createSource; + const sources = new Map(); + const failures: ListFailure[] = []; + const byKey = new Map(); + + for (const config of sourceConfigs) { + const source = make(config, { exec: deps.exec }); + await source.checkPrerequisites(); + sources.set(source.id, source); + + const listed = await source.listRepos(); + for (const failure of listed.failures) { + deps.log.warn( + `could not list repositories of ${failure.owner}: ${failure.message}`, + source.id, + ); + failures.push(failure); + } + for (const repo of filterRepos(listed.repos, config)) { + const key = repoKey(repo); + if (!byKey.has(key)) byKey.set(key, repo); + } + } + + const repos = [...byKey.values()].sort((a, b) => repoKey(a).localeCompare(repoKey(b), "en")); + return { repos, failures, sources }; +} diff --git a/src/sources/github.ts b/src/sources/github.ts new file mode 100644 index 0000000..c703207 --- /dev/null +++ b/src/sources/github.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; +import { PrerequisiteError } from "../util/errors.js"; +import { type Exec, ExecError } from "../util/exec.js"; +import type { ListFailure, ListReposResult, RepoInfo, Source } from "./types.js"; + +export const GITHUB_SOURCE_ID = "github"; + +const GH_ENV = { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", NO_COLOR: "1" }; + +const REPO_FIELDS = "nameWithOwner,isArchived,isFork,hasWikiEnabled,isEmpty,url"; + +const ghRepoSchema = z.object({ + nameWithOwner: z.string().regex(/^[^/\s]+\/[^/\s]+$/), + isArchived: z.boolean(), + isFork: z.boolean(), + hasWikiEnabled: z.boolean(), + isEmpty: z.boolean(), + url: z.url(), +}); + +function lines(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function parseRepoList(json: string): RepoInfo[] { + const parsed = z.array(ghRepoSchema).parse(JSON.parse(json)); + return parsed.map((repo) => { + const [owner = "", name = ""] = repo.nameWithOwner.split("/"); + return { + source: GITHUB_SOURCE_ID, + owner, + name, + fullName: repo.nameWithOwner, + cloneUrl: `${repo.url}.git`, + ...(repo.hasWikiEnabled ? { wikiUrl: `${repo.url}.wiki.git` } : {}), + hasWiki: repo.hasWikiEnabled, + isArchived: repo.isArchived, + isFork: repo.isFork, + isEmpty: repo.isEmpty, + }; + }); +} + +/** GitHub names are case-insensitive, so duplicates are detected case-insensitively. */ +export function dedupeRepos(repos: RepoInfo[]): RepoInfo[] { + const seen = new Set(); + return repos.filter((repo) => { + const key = repo.fullName.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function createGithubSource(deps: { exec: Exec }): Source { + const gh = (args: string[]) => deps.exec("gh", args, { env: GH_ENV }); + + return { + id: GITHUB_SOURCE_ID, + + async checkPrerequisites() { + try { + await gh(["--version"]); + } catch (err) { + if (err instanceof ExecError && err.code === "ENOENT") { + throw new PrerequisiteError( + "GitHub CLI (gh) was not found. Install it from https://cli.github.com and run: gh auth login", + ); + } + throw err; + } + try { + await gh(["auth", "status"]); + } catch (err) { + if (err instanceof ExecError) { + throw new PrerequisiteError( + `GitHub CLI is not logged in. Run: gh auth login (scopes: repo, read:org)\n${err.stderr}`.trimEnd(), + ); + } + throw err; + } + }, + + async listRepos(): Promise { + const failures: ListFailure[] = []; + const login = (await gh(["api", "user", "--jq", ".login"])).stdout.trim(); + if (!login) throw new PrerequisiteError("could not determine the logged-in GitHub user"); + + const owners = [login]; + try { + const orgs = await gh(["api", "user/orgs", "--paginate", "--jq", ".[].login"]); + owners.push(...lines(orgs.stdout)); + } catch (err) { + failures.push({ owner: "organizations", message: errorMessage(err) }); + } + + const repos: RepoInfo[] = []; + for (const owner of [...new Set(owners)]) { + try { + const result = await gh([ + "repo", + "list", + owner, + "--limit", + "100000", + "--json", + REPO_FIELDS, + ]); + repos.push(...parseRepoList(result.stdout)); + } catch (err) { + failures.push({ owner, message: errorMessage(err) }); + } + } + return { repos: dedupeRepos(repos), failures }; + }, + + gitEnv() { + return { + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_0: "", + GIT_CONFIG_KEY_1: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_1: "!gh auth git-credential", + GIT_TERMINAL_PROMPT: "0", + }; + }, + }; +} diff --git a/src/sources/index.ts b/src/sources/index.ts new file mode 100644 index 0000000..d951dea --- /dev/null +++ b/src/sources/index.ts @@ -0,0 +1,14 @@ +import type { SourceConfig } from "../config/schema.js"; +import type { Exec } from "../util/exec.js"; +import { createGithubSource } from "./github.js"; +import type { Source } from "./types.js"; + +export type { RepoInfo, Source } from "./types.js"; + +/** Registry: maps a configured source `type` to its implementation. */ +export function createSource(config: SourceConfig, deps: { exec: Exec }): Source { + switch (config.type) { + case "github": + return createGithubSource(deps); + } +} diff --git a/src/sources/types.ts b/src/sources/types.ts new file mode 100644 index 0000000..1e41d1b --- /dev/null +++ b/src/sources/types.ts @@ -0,0 +1,34 @@ +export interface RepoInfo { + /** Id of the source that produced the repo (`github`). First segment of the repo key. */ + source: string; + owner: string; + name: string; + fullName: string; + cloneUrl: string; + wikiUrl?: string; + hasWiki: boolean; + isArchived: boolean; + isFork: boolean; + isEmpty: boolean; +} + +/** An owner (user or organization) whose repositories could not be listed. */ +export interface ListFailure { + owner: string; + message: string; +} + +export interface ListReposResult { + repos: RepoInfo[]; + failures: ListFailure[]; +} + +/** A git service the backup reads repositories from. */ +export interface Source { + readonly id: string; + /** Throws `PrerequisiteError` when a required tool or login is missing. */ + checkPrerequisites(): Promise; + listRepos(): Promise; + /** Environment for git child processes so they authenticate against this source. */ + gitEnv(): Record; +} diff --git a/test/unit/discover.test.ts b/test/unit/discover.test.ts new file mode 100644 index 0000000..835c733 --- /dev/null +++ b/test/unit/discover.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { discoverRepos } from "../../src/core/discover.js"; +import type { RepoInfo, Source } from "../../src/sources/types.js"; +import { PrerequisiteError } from "../../src/util/errors.js"; +import { createLogger } from "../../src/util/log.js"; + +const repo = (fullName: string, extra: Partial = {}): RepoInfo => { + const [owner = "", name = ""] = fullName.split("/"); + return { + source: "github", + owner, + name, + fullName, + cloneUrl: `https://github.com/${fullName}.git`, + hasWiki: false, + isArchived: false, + isFork: false, + isEmpty: false, + ...extra, + }; +}; + +const sourceConfig = (extra: object = {}) => ({ + type: "github" as const, + include: ["*/*"], + exclude: [] as string[], + archived: true, + forks: true, + ...extra, +}); + +function fakeSource(repos: RepoInfo[], failures: Array<{ owner: string; message: string }> = []) { + const source: Source = { + id: "github", + checkPrerequisites: async () => {}, + listRepos: async () => ({ repos, failures }), + gitEnv: () => ({}), + }; + return () => source; +} + +function setup() { + const lines: string[] = []; + const log = createLogger({ level: "normal", write: (l) => lines.push(l) }); + return { lines, log }; +} + +describe("discoverRepos", () => { + const repos = [ + repo("me/zeta"), + repo("acme/old", { isArchived: true }), + repo("acme/fork", { isFork: true }), + repo("org/secret"), + ]; + + it("filters and sorts the repositories", async () => { + const { log } = setup(); + const result = await discoverRepos([sourceConfig({ exclude: ["org/*"], forks: false })], { + exec: async () => ({ stdout: "", stderr: "" }), + log, + createSource: fakeSource(repos), + }); + expect(result.repos.map((r) => r.fullName)).toEqual(["acme/old", "me/zeta"]); + }); + + it("applies each source's own filters and removes duplicates across sources", async () => { + const { log } = setup(); + const result = await discoverRepos( + [sourceConfig({ include: ["acme/*"] }), sourceConfig({ include: ["ACME/old", "me/*"] })], + { exec: async () => ({ stdout: "", stderr: "" }), log, createSource: fakeSource(repos) }, + ); + expect(result.repos.map((r) => r.fullName)).toEqual(["acme/fork", "acme/old", "me/zeta"]); + }); + + it("warns about and returns list failures", async () => { + const { log, lines } = setup(); + const result = await discoverRepos([sourceConfig()], { + exec: async () => ({ stdout: "", stderr: "" }), + log, + createSource: fakeSource([repo("me/a")], [{ owner: "acme", message: "SAML" }]), + }); + expect(result.failures).toEqual([{ owner: "acme", message: "SAML" }]); + expect(lines.join("\n")).toContain("could not list repositories of acme: SAML"); + expect(result.repos).toHaveLength(1); + }); + + it("propagates prerequisite errors", async () => { + const { log } = setup(); + const failing = () => + ({ + id: "github", + checkPrerequisites: async () => { + throw new PrerequisiteError("gh missing"); + }, + listRepos: async () => ({ repos: [], failures: [] }), + gitEnv: () => ({}), + }) satisfies Source; + await expect( + discoverRepos([sourceConfig()], { + exec: async () => ({ stdout: "", stderr: "" }), + log, + createSource: failing, + }), + ).rejects.toBeInstanceOf(PrerequisiteError); + }); +}); diff --git a/test/unit/github-source.test.ts b/test/unit/github-source.test.ts new file mode 100644 index 0000000..8582c11 --- /dev/null +++ b/test/unit/github-source.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { createGithubSource, dedupeRepos, parseRepoList } from "../../src/sources/github.js"; +import { PrerequisiteError } from "../../src/util/errors.js"; +import { type Exec, ExecError } from "../../src/util/exec.js"; + +type Handler = (args: string[]) => string | Error; + +/** Fake `gh`: routes by the first arguments and records every call. */ +function fakeGh(handler: Handler) { + const calls: string[][] = []; + const exec: Exec = async (cmd, args) => { + calls.push([cmd, ...args]); + const result = handler(args); + if (result instanceof Error) throw result; + return { stdout: result, stderr: "" }; + }; + return { exec, calls }; +} + +const repoJson = (...names: Array>]>) => + JSON.stringify( + names.map((entry) => { + const [name, flags] = typeof entry === "string" ? [entry, {}] : entry; + return { + nameWithOwner: name, + isArchived: false, + isFork: false, + hasWikiEnabled: false, + isEmpty: false, + url: `https://github.com/${name}`, + ...flags, + }; + }), + ); + +const execError = (stderr: string, code: number | string = 1) => + new ExecError(`gh failed: ${stderr}`, "gh", code, stderr); + +describe("parseRepoList", () => { + it("maps gh output to RepoInfo", () => { + const [repo] = parseRepoList(repoJson(["acme/app", { hasWikiEnabled: true, isFork: true }])); + expect(repo).toEqual({ + source: "github", + owner: "acme", + name: "app", + fullName: "acme/app", + cloneUrl: "https://github.com/acme/app.git", + wikiUrl: "https://github.com/acme/app.wiki.git", + hasWiki: true, + isArchived: false, + isFork: true, + isEmpty: false, + }); + }); + + it("omits wikiUrl when the wiki is disabled", () => { + const [repo] = parseRepoList(repoJson("acme/app")); + expect(repo).not.toHaveProperty("wikiUrl"); + expect(repo?.hasWiki).toBe(false); + }); + + it("returns an empty list for an owner without repositories", () => { + expect(parseRepoList("[]")).toEqual([]); + }); + + it("rejects unexpected output", () => { + expect(() => parseRepoList('[{"nameWithOwner":"x"}]')).toThrow(); + expect(() => parseRepoList("not json")).toThrow(); + }); +}); + +describe("dedupeRepos", () => { + it("drops case-insensitive duplicates and keeps the first", () => { + const repos = parseRepoList(repoJson("Acme/App", "acme/app", "acme/other")); + expect(dedupeRepos(repos).map((r) => r.fullName)).toEqual(["Acme/App", "acme/other"]); + }); +}); + +describe("github source prerequisites", () => { + it("fails with an install link when gh is missing", async () => { + const { exec } = fakeGh(() => execError("spawn gh ENOENT", "ENOENT")); + const err = await createGithubSource({ exec }) + .checkPrerequisites() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PrerequisiteError); + expect((err as Error).message).toContain("https://cli.github.com"); + }); + + it("fails with login instructions when not authenticated", async () => { + const { exec } = fakeGh((args) => + args[0] === "auth" ? execError("You are not logged into any GitHub hosts.") : "gh version 2", + ); + const err = await createGithubSource({ exec }) + .checkPrerequisites() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PrerequisiteError); + expect((err as PrerequisiteError).exitCode).toBe(2); + expect((err as Error).message).toContain("gh auth login"); + }); + + it("passes when gh is installed and logged in", async () => { + const { exec } = fakeGh(() => "ok"); + await expect(createGithubSource({ exec }).checkPrerequisites()).resolves.toBeUndefined(); + }); +}); + +describe("github source listRepos", () => { + function handler(overrides: Record = {}): Handler { + return (args) => { + if (args[0] === "api" && args[1] === "user") return "me\n"; + if (args[0] === "api" && args[1] === "user/orgs") { + // --paginate prints one login per line across pages. + return overrides.orgs instanceof Error + ? overrides.orgs + : (overrides.orgs ?? "acme\nbeta\n"); + } + if (args[0] === "repo" && args[1] === "list") { + const owner = args[2] as string; + const value = overrides[owner]; + if (value !== undefined) return value; + return repoJson(`${owner}/one`, `${owner}/two`); + } + return new Error(`unexpected gh call: ${args.join(" ")}`); + }; + } + + it("lists the user and every organization", async () => { + const { exec, calls } = fakeGh(handler()); + const { repos, failures } = await createGithubSource({ exec }).listRepos(); + expect(failures).toEqual([]); + expect(repos.map((r) => r.fullName)).toEqual([ + "me/one", + "me/two", + "acme/one", + "acme/two", + "beta/one", + "beta/two", + ]); + const listCall = calls.find((c) => c[1] === "repo"); + expect(listCall).toEqual([ + "gh", + "repo", + "list", + "me", + "--limit", + "100000", + "--json", + "nameWithOwner,isArchived,isFork,hasWikiEnabled,isEmpty,url", + ]); + }); + + it("handles a user without organizations", async () => { + const { exec } = fakeGh(handler({ orgs: "\n" })); + const { repos } = await createGithubSource({ exec }).listRepos(); + expect(repos.map((r) => r.owner)).toEqual(["me", "me"]); + }); + + it("handles an organization with no repositories", async () => { + const { exec } = fakeGh(handler({ acme: "[]" })); + const { repos, failures } = await createGithubSource({ exec }).listRepos(); + expect(failures).toEqual([]); + expect(repos.map((r) => r.owner)).toEqual(["me", "me", "beta", "beta"]); + }); + + it("reports a failing organization (e.g. SSO) and keeps the rest", async () => { + const { exec } = fakeGh( + handler({ acme: execError("Resource protected by organization SAML enforcement") }), + ); + const { repos, failures } = await createGithubSource({ exec }).listRepos(); + expect(failures).toHaveLength(1); + expect(failures[0]?.owner).toBe("acme"); + expect(failures[0]?.message).toContain("SAML"); + expect(repos.map((r) => r.owner)).toEqual(["me", "me", "beta", "beta"]); + }); + + it("reports a failing organization lookup and still lists personal repositories", async () => { + const { exec } = fakeGh(handler({ orgs: execError("HTTP 403: needs read:org") })); + const { repos, failures } = await createGithubSource({ exec }).listRepos(); + expect(failures.map((f) => f.owner)).toEqual(["organizations"]); + expect(repos.map((r) => r.owner)).toEqual(["me", "me"]); + }); + + it("reports unparsable gh output as a failure for that owner", async () => { + const { exec } = fakeGh(handler({ beta: "garbage" })); + const { failures } = await createGithubSource({ exec }).listRepos(); + expect(failures.map((f) => f.owner)).toEqual(["beta"]); + }); + + it("deduplicates repositories returned for more than one owner", async () => { + const { exec } = fakeGh(handler({ beta: repoJson("acme/one", "beta/own") })); + const { repos } = await createGithubSource({ exec }).listRepos(); + expect(repos.filter((r) => r.fullName === "acme/one")).toHaveLength(1); + expect(repos.map((r) => r.fullName)).toContain("beta/own"); + }); + + it("deduplicates organizations listed twice", async () => { + const { exec, calls } = fakeGh(handler({ orgs: "acme\nacme\n" })); + await createGithubSource({ exec }).listRepos(); + expect(calls.filter((c) => c[1] === "repo" && c[3] === "acme")).toHaveLength(1); + }); +}); + +describe("github source gitEnv", () => { + it("authenticates git through gh without touching global config", () => { + const env = createGithubSource({ exec: fakeGh(() => "").exec }).gitEnv(); + expect(env).toEqual({ + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_0: "", + GIT_CONFIG_KEY_1: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_1: "!gh auth git-credential", + GIT_TERMINAL_PROMPT: "0", + }); + }); +}); From b3f7aaa25261877e5a30d7602a47ab84bcb4a169 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:20:32 +0000 Subject: [PATCH 6/9] Add list command, backup stub and commander program Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- src/cli.ts | 5 +- src/commands/backup.ts | 9 ++ src/commands/context.ts | 50 +++++++ src/commands/list.ts | 45 +++++++ src/program.ts | 107 +++++++++++++++ test/unit/commands.test.ts | 259 +++++++++++++++++++++++++++++++++++++ 6 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 src/commands/backup.ts create mode 100644 src/commands/context.ts create mode 100644 src/commands/list.ts create mode 100644 src/program.ts create mode 100644 test/unit/commands.test.ts diff --git a/src/cli.ts b/src/cli.ts index cb0ff5c..eaca353 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1 +1,4 @@ -export {}; +#!/usr/bin/env node +import { createProgram, defaultDeps } from "./program.js"; + +process.exitCode = await createProgram(defaultDeps()).run(process.argv.slice(2)); diff --git a/src/commands/backup.ts b/src/commands/backup.ts new file mode 100644 index 0000000..cd7f75e --- /dev/null +++ b/src/commands/backup.ts @@ -0,0 +1,9 @@ +import { CliError, ExitCode } from "../util/errors.js"; +import type { CommandDeps, CommonOptions } from "./context.js"; + +export interface BackupOptions extends CommonOptions {} + +/** `github-backup backup`. Mirroring and snapshots arrive in a later phase. */ +export async function runBackup(_options: BackupOptions, _deps: CommandDeps): Promise { + throw new CliError("backup is not implemented yet", ExitCode.Usage); +} diff --git a/src/commands/context.ts b/src/commands/context.ts new file mode 100644 index 0000000..514872b --- /dev/null +++ b/src/commands/context.ts @@ -0,0 +1,50 @@ +import { type ConfigFlags, type LoadedConfig, loadConfig } from "../config/load.js"; +import type { SourceConfig } from "../config/schema.js"; +import type { Source } from "../sources/types.js"; +import { ConfigError } from "../util/errors.js"; +import type { Exec } from "../util/exec.js"; +import { createLogger, type Logger, type LogLevel } from "../util/log.js"; + +/** Everything a command needs from the outside world; replaced in tests. */ +export interface CommandDeps { + exec: Exec; + env: Record; + /** Writes machine- or human-readable results to stdout. */ + stdout: (text: string) => void; + /** Writes log lines to stderr. */ + stderr: (line: string) => void; + homeDir?: string; + cwd?: string; + createSource?: (config: SourceConfig, deps: { exec: Exec }) => Source; +} + +export interface CommonOptions extends ConfigFlags { + json?: boolean; + verbose?: boolean; + quiet?: boolean; +} + +export function logLevel(options: CommonOptions): LogLevel { + if (options.verbose && options.quiet) { + throw new ConfigError("--verbose and --quiet cannot be used together"); + } + if (options.quiet) return "quiet"; + return options.verbose ? "verbose" : "normal"; +} + +/** Loads the configuration and creates the logger, masking configured credentials. */ +export function prepare( + options: CommonOptions, + deps: CommandDeps, +): { loaded: LoadedConfig; log: Logger } { + const log = createLogger({ level: logLevel(options), write: deps.stderr }); + const loaded = loadConfig({ + flags: options, + env: deps.env, + homeDir: deps.homeDir, + cwd: deps.cwd, + }); + for (const secret of loaded.secrets) log.addSecret(secret); + for (const warning of loaded.warnings) log.warn(warning); + return { loaded, log }; +} diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..6a5c8ac --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,45 @@ +import { discoverRepos } from "../core/discover.js"; +import type { RepoInfo } from "../sources/types.js"; +import { ExitCode } from "../util/errors.js"; +import { type CommandDeps, type CommonOptions, prepare } from "./context.js"; + +const HEADERS = ["REPO", "ARCHIVED", "FORK", "WIKI", "EMPTY"] as const; + +const yesNo = (value: boolean) => (value ? "yes" : "-"); + +/** Renders repositories as an aligned table followed by a total line. */ +export function renderTable(repos: RepoInfo[]): string { + const rows = repos.map((r) => [ + r.fullName, + yesNo(r.isArchived), + yesNo(r.isFork), + yesNo(r.hasWiki), + yesNo(r.isEmpty), + ]); + const widths = HEADERS.map((header, col) => + Math.max(header.length, ...rows.map((row) => (row[col] as string).length)), + ); + const format = (cells: readonly string[]) => + cells + .map((cell, col) => cell.padEnd(widths[col] as number)) + .join(" ") + .trimEnd(); + const total = `${repos.length} ${repos.length === 1 ? "repository" : "repositories"}`; + return [format(HEADERS), ...rows.map(format), "", total].join("\n"); +} + +export function renderJson(repos: RepoInfo[]): string { + return JSON.stringify(repos, null, 2); +} + +/** `github-backup list`. Returns the process exit code. */ +export async function runList(options: CommonOptions, deps: CommandDeps): Promise { + const { loaded, log } = prepare(options, deps); + const { repos, failures } = await discoverRepos(loaded.config.sources, { + exec: deps.exec, + log, + createSource: deps.createSource, + }); + deps.stdout(`${options.json ? renderJson(repos) : renderTable(repos)}\n`); + return failures.length > 0 ? ExitCode.PartialFailure : ExitCode.Success; +} diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..1bd0251 --- /dev/null +++ b/src/program.ts @@ -0,0 +1,107 @@ +import { readFileSync } from "node:fs"; +import { Command, CommanderError, Option } from "commander"; +import { type BackupOptions, runBackup } from "./commands/backup.js"; +import type { CommandDeps, CommonOptions } from "./commands/context.js"; +import { runList } from "./commands/list.js"; +import { CliError, ExitCode } from "./util/errors.js"; +import { exec } from "./util/exec.js"; + +/** Flags whose negated form (`--no-x`) is the only one users can pass. */ +const NEGATABLE = ["archived", "forks", "wiki", "lfs"] as const; + +function collect(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +function addCommonOptions(command: Command): Command { + return command + .option("--config ", "path to the config file") + .option("--include ", "only repositories matching owner/repo glob (repeatable)", collect) + .option("--exclude ", "skip repositories matching owner/repo glob (repeatable)", collect) + .option("--no-archived", "skip archived repositories") + .option("--no-forks", "skip forks") + .option("--json", "machine-readable output on stdout") + .addOption(new Option("--verbose", "log git commands and details").conflicts("quiet")) + .addOption(new Option("--quiet", "log only errors and the summary").conflicts("verbose")); +} + +/** + * Commander gives negatable options a default of `true`. Keep only values the user + * actually typed so they can override env and file settings, and nothing else can. + */ +function explicitOptions(command: Command): T { + const options: Record = { ...command.opts() }; + for (const key of NEGATABLE) { + if (command.getOptionValueSource(key) !== "cli") delete options[key]; + } + return options as T; +} + +export interface Program { + /** Parses `argv` (without node and script) and returns the exit code. */ + run(argv: string[]): Promise; +} + +export function createProgram(deps: CommandDeps): Program { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + version: string; + }; + let exitCode: number = ExitCode.Success; + + const program = new Command("github-backup") + .description("Back up GitHub repositories to local disk and S3-compatible storage") + .version(pkg.version) + .exitOverride() + .configureOutput({ + writeOut: (text) => deps.stdout(text), + writeErr: (text) => deps.stderr(text.trimEnd()), + }); + + addCommonOptions( + program.command("list").description("list the repositories that would be backed up"), + ).action(async (_opts, command: Command) => { + exitCode = await runList(explicitOptions(command), deps); + }); + + addCommonOptions(program.command("backup").description("back up repositories to the targets")) + .option("--cache-dir ", "directory for mirrors and temporary files") + .option("--concurrency ", "repositories processed in parallel") + .option("--keep ", "snapshots to keep per repository and target") + .option("--no-wiki", "skip wikis") + .option("--no-lfs", "skip Git LFS objects") + .option("--target ", "run only this target (repeatable)", collect) + .option("--local ", "add a temporary local target at this path") + .action(async (_opts, command: Command) => { + exitCode = await runBackup(explicitOptions(command), deps); + }); + + return { + async run(argv) { + exitCode = ExitCode.Success; + try { + await program.parseAsync(argv, { from: "user" }); + return exitCode; + } catch (err) { + if (err instanceof CommanderError) { + const informational = ["commander.help", "commander.helpDisplayed", "commander.version"]; + return informational.includes(err.code) ? ExitCode.Success : ExitCode.Usage; + } + if (err instanceof CliError) { + deps.stderr(`error: ${err.message}`); + return err.exitCode; + } + deps.stderr(`error: ${err instanceof Error ? err.message : String(err)}`); + return ExitCode.PartialFailure; + } + }, + }; +} + +export function defaultDeps(): CommandDeps { + return { + exec, + env: process.env, + stdout: (text) => process.stdout.write(text), + stderr: (line) => process.stderr.write(`${line}\n`), + }; +} diff --git a/test/unit/commands.test.ts b/test/unit/commands.test.ts new file mode 100644 index 0000000..b9d81bb --- /dev/null +++ b/test/unit/commands.test.ts @@ -0,0 +1,259 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { CommandDeps } from "../../src/commands/context.js"; +import { renderTable } from "../../src/commands/list.js"; +import { createProgram } from "../../src/program.js"; +import type { RepoInfo, Source } from "../../src/sources/types.js"; +import { type Exec, ExecError } from "../../src/util/exec.js"; + +const repo = (fullName: string, extra: Partial = {}): RepoInfo => { + const [owner = "", name = ""] = fullName.split("/"); + return { + source: "github", + owner, + name, + fullName, + cloneUrl: `https://github.com/${fullName}.git`, + hasWiki: false, + isArchived: false, + isFork: false, + isEmpty: false, + ...extra, + }; +}; + +const REPOS = [ + repo("me/app", { hasWiki: true }), + repo("me/old", { isArchived: true }), + repo("org/secret"), + repo("org/fork", { isFork: true, isEmpty: true }), +]; + +let home: string; + +beforeEach(() => { + home = mkdtempSync(path.join(tmpdir(), "ghb-cmd-")); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +function setup( + options: { + failures?: Array<{ owner: string; message: string }>; + exec?: Exec; + env?: Record; + } = {}, +) { + const out: string[] = []; + const err: string[] = []; + const source: Source = { + id: "github", + checkPrerequisites: async () => {}, + listRepos: async () => ({ repos: REPOS, failures: options.failures ?? [] }), + gitEnv: () => ({}), + }; + const deps: CommandDeps = { + exec: options.exec ?? (async () => ({ stdout: "", stderr: "" })), + env: options.env ?? {}, + stdout: (text) => out.push(text), + stderr: (line) => err.push(line), + homeDir: home, + cwd: home, + createSource: () => source, + }; + return { run: (argv: string[]) => createProgram(deps).run(argv), out, err }; +} + +describe("list", () => { + it("prints a table and exits 0", async () => { + const { run, out } = setup(); + expect(await run(["list"])).toBe(0); + const text = out.join(""); + expect(text.split("\n")[0]).toMatch(/^REPO\s+ARCHIVED\s+FORK\s+WIKI\s+EMPTY$/); + expect(text).toContain("me/app"); + expect(text).toContain("4 repositories"); + }); + + it("--json --exclude 'org/*' prints filtered JSON", async () => { + const { run, out } = setup(); + expect(await run(["list", "--json", "--exclude", "org/*"])).toBe(0); + const parsed = JSON.parse(out.join("")) as RepoInfo[]; + expect(parsed.map((r) => r.fullName)).toEqual(["me/app", "me/old"]); + expect(parsed[0]).toMatchObject({ source: "github", hasWiki: true }); + }); + + it("--include is repeatable", async () => { + const { run, out } = setup(); + await run(["list", "--json", "--include", "me/app", "--include", "org/fork"]); + expect((JSON.parse(out.join("")) as RepoInfo[]).map((r) => r.fullName)).toEqual([ + "me/app", + "org/fork", + ]); + }); + + it("--no-archived and --no-forks filter", async () => { + const { run, out } = setup(); + await run(["list", "--json", "--no-archived", "--no-forks"]); + expect((JSON.parse(out.join("")) as RepoInfo[]).map((r) => r.fullName)).toEqual([ + "me/app", + "org/secret", + ]); + }); + + it("env filters apply and a flag overrides them", async () => { + const env = { GHBACKUP_EXCLUDE: "me/*" }; + const fromEnv = setup({ env }); + await fromEnv.run(["list", "--json"]); + expect((JSON.parse(fromEnv.out.join("")) as RepoInfo[]).map((r) => r.owner)).toEqual([ + "org", + "org", + ]); + + const fromFlag = setup({ env }); + await fromFlag.run(["list", "--json", "--exclude", "org/*"]); + expect((JSON.parse(fromFlag.out.join("")) as RepoInfo[]).map((r) => r.owner)).toEqual([ + "me", + "me", + ]); + }); + + it("exits 1 and warns when an owner could not be listed", async () => { + const { run, err } = setup({ failures: [{ owner: "acme", message: "SAML" }] }); + expect(await run(["list"])).toBe(1); + expect(err.join("\n")).toContain("could not list repositories of acme"); + }); + + it("--quiet hides warnings but keeps the exit code", async () => { + const { run, err } = setup({ failures: [{ owner: "acme", message: "SAML" }] }); + expect(await run(["list", "--quiet"])).toBe(1); + expect(err).toEqual([]); + }); + + it("rejects --verbose together with --quiet with exit 2", async () => { + const { run } = setup(); + expect(await run(["list", "--verbose", "--quiet"])).toBe(2); + }); + + it("exits 2 with a config error message for a bad env value", async () => { + const { run, err } = setup({ env: { GHBACKUP_WIKI: "maybe" } }); + expect(await run(["list"])).toBe(2); + expect(err.join("\n")).toContain("GHBACKUP_WIKI"); + }); + + it("exits 2 for a missing --config file", async () => { + const { run, err } = setup(); + expect(await run(["list", "--config", "nope.json"])).toBe(2); + expect(err.join("\n")).toContain("config file not found"); + }); +}); + +describe("list prerequisites (real github source, fake exec)", () => { + function realSource(exec: Exec) { + const out: string[] = []; + const err: string[] = []; + const deps: CommandDeps = { + exec, + env: {}, + stdout: (t) => out.push(t), + stderr: (l) => err.push(l), + homeDir: home, + cwd: home, + }; + return { run: (argv: string[]) => createProgram(deps).run(argv), out, err }; + } + + it("exits 2 with an install hint when gh is missing", async () => { + const { run, err, out } = realSource(async () => { + throw new ExecError("spawn gh ENOENT", "gh", "ENOENT", ""); + }); + expect(await run(["list"])).toBe(2); + expect(err.join("\n")).toContain("https://cli.github.com"); + expect(out).toEqual([]); + }); + + it("exits 2 with login instructions when gh is logged out", async () => { + const { run, err } = realSource(async (_cmd, args) => { + if (args[0] === "auth") throw new ExecError("not logged in", "gh", 1, "not logged in"); + return { stdout: "gh version", stderr: "" }; + }); + expect(await run(["list"])).toBe(2); + expect(err.join("\n")).toContain("gh auth login"); + }); +}); + +describe("backup", () => { + it("is not implemented yet and exits 2", async () => { + const { run, err } = setup(); + expect(await run(["backup"])).toBe(2); + expect(err.join("\n")).toContain("not implemented"); + }); + + it("accepts every documented flag", async () => { + const { run, err } = setup(); + const code = await run([ + "backup", + "--config", + "c.json", + "--include", + "a/*", + "--exclude", + "b/*", + "--no-archived", + "--no-forks", + "--json", + "--verbose", + "--cache-dir", + "/tmp/c", + "--concurrency", + "2", + "--keep", + "3", + "--no-wiki", + "--no-lfs", + "--target", + "disk", + "--local", + "/tmp/out", + ]); + // Flags parse; the command itself is the stub. + expect(code).toBe(2); + expect(err.join("\n")).toContain("not implemented"); + }); + + it("rejects unknown flags with exit 2", async () => { + const { run } = setup(); + expect(await run(["backup", "--bogus"])).toBe(2); + }); +}); + +describe("program", () => { + it("prints help with exit 0", async () => { + const { run, out } = setup(); + expect(await run(["--help"])).toBe(0); + expect(out.join("")).toContain("list"); + expect(out.join("")).toContain("backup"); + }); + + it("rejects an unknown command with exit 2", async () => { + const { run } = setup(); + expect(await run(["frobnicate"])).toBe(2); + }); +}); + +describe("renderTable", () => { + it("aligns columns and pluralizes the total", () => { + expect(renderTable([repo("a/b", { isFork: true })])).toBe( + [ + "REPO ARCHIVED FORK WIKI EMPTY", + "a/b - yes - -", + "", + "1 repository", + ].join("\n"), + ); + expect(renderTable([])).toBe("REPO ARCHIVED FORK WIKI EMPTY\n\n0 repositories"); + }); +}); From d5a2562b10a28346fd7b80c2e0d6c8672cafa4e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:21:14 +0000 Subject: [PATCH 7/9] Add example config, CI workflow and update architecture notes Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ README.md | 2 +- config.example.json | 31 +++++++++++++++++++++++++++++++ plans/kararlar.md | 2 ++ plans/mimari.md | 29 ++++++++++++++++++++--------- test/unit/config.test.ts | 14 ++++++++++++++ 6 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 config.example.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3911d19 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/README.md b/README.md index f1114a2..49f1a2a 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,4 @@ A Node.js/TypeScript CLI (`github-backup`) that backs up every repository you can reach through the GitHub CLI (`gh`): your personal repositories and those of all organizations you belong to, with wikis and Git LFS objects. Backups go to a local directory and/or any S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze B2, MinIO) as git bundle snapshots, and a new snapshot is created only when refs change. -Status: in planning. See the plan (Turkish) in [plans/README.md](plans/README.md). +Status: under development. `github-backup list` works; `backup` is not implemented yet. See the plan (Turkish) in [plans/README.md](plans/README.md). diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..b22cd31 --- /dev/null +++ b/config.example.json @@ -0,0 +1,31 @@ +{ + "cacheDir": "~/.cache/github-backup-cli", + "concurrency": 4, + "wiki": true, + "lfs": true, + "keep": 7, + "sources": [ + { + "type": "github", + "include": ["*/*"], + "exclude": [], + "archived": true, + "forks": true + } + ], + "targets": [ + { "type": "local", "name": "disk", "path": "/backups/git" }, + { + "type": "s3", + "name": "r2", + "bucket": "my-backups", + "prefix": "git", + "endpoint": "https://ACCOUNT_ID.r2.cloudflarestorage.com", + "region": "auto", + "forcePathStyle": false, + "accessKeyId": "YOUR_ACCESS_KEY_ID", + "secretAccessKey": "YOUR_SECRET_ACCESS_KEY", + "keep": 14 + } + ] +} diff --git a/plans/kararlar.md b/plans/kararlar.md index 32ff607..7bd4b4d 100644 --- a/plans/kararlar.md +++ b/plans/kararlar.md @@ -33,3 +33,5 @@ Projenin güncel kararları. Bir karar değişirse satırı güncellenir ve tari | K-027 | 2026-10-14 | Eşzamanlı çalıştırmayı önlemek için önbellekte PID'li kilit dosyası; ölü PID'nin kilidi devralınır. | Üst üste binen cron çalıştırmaları aynı mirror'ı bozmasın. | | K-028 | 2026-10-14 | Mirror yalnızca `refs/heads/*` ve `refs/tags/*` alır; `refs/pull/*` alınmaz. | Bundle küçük kalır, geri yüklenen repo temiz olur. | | K-029 | 2026-10-14 | Desteklenen OS: Linux ve macOS. | Hedef kullanım cron/launchd; Windows test edilmez. | +| K-030 | 2026-10-14 | Glob eşleşmesi `owner/repo` üzerindedir: `*` ve `?` `/` geçmez, `**` geçer; büyük/küçük harf duyarsız; boş `include` her şeyi kabul eder. | Beklenen `acme/*` davranışı; `*/*` her repoyu yakalar; ayrıntı mimari.md'de. | +| K-031 | 2026-10-14 | Config şeması katıdır (bilinmeyen alan hata verir) ve hedef adları benzersizdir. S3 anahtarı config'te yoksa `GHBACKUP_S3_ACCESS_KEY_ID`/`_SECRET_ACCESS_KEY` env'i kullanılır. | Yazım hatası sessizce yok sayılmasın; `--target` adla seçtiği için ad çakışmasın; K-010'un tek istisnası mimari.md'de belirtilen S3 anahtarı sırası. | diff --git a/plans/mimari.md b/plans/mimari.md index 67dbb77..020821e 100644 --- a/plans/mimari.md +++ b/plans/mimari.md @@ -9,7 +9,7 @@ - `zod`: config şeması doğrulama. - `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`: S3 işlemleri ve multipart yükleme (`Upload` sınıfı; part boyutu en az 5 MB, varsayılan `queueSize` 4 — kaynak: aws-sdk-js-v3 `lib/lib-storage/README.md`, 2026-10-14). - `tar`: LFS nesnelerinin tar'lanması (sistem `tar`'ına bağımlı kalmamak için). -- Geliştirme: `typescript`, `vitest`, `@biomejs/biome`, `tsx` (geliştirmede doğrudan çalıştırma). +- Geliştirme: `typescript`, `vitest`, `@biomejs/biome`, `tsx` (geliştirmede doğrudan çalıştırma), `@types/node` (en düşük desteklenen sürüm olan 22'ye göre). - Dış araçlar (kullanıcının makinesinde kurulu olmalı): `gh` (oturum açık), `git`, `git-lfs` (LFS açıksa). - Desteklenen OS: Linux ve macOS. Windows test edilmez. @@ -17,8 +17,10 @@ ```text src/ - cli.ts # commander tanımı, giriş noktası (bin: github-backup) + cli.ts # giriş noktası (bin: github-backup); programı çalıştırıp çıkış kodunu verir + program.ts # commander tanımı, bayraklar, hata → çıkış kodu eşlemesi commands/ + context.ts # CommandDeps (exec, env, stdout/stderr), config + logger hazırlığı list.ts backup.ts config/ @@ -44,6 +46,7 @@ src/ log.ts # stderr logger (normal/verbose/quiet) filter.ts # glob eşleştirme time.ts # snapshot zaman damgası + errors.ts # ExitCode, CliError, ConfigError, PrerequisiteError test/ unit/ integration/ # geçici git repoları, local hedef, MinIO @@ -54,7 +57,7 @@ test/ - `Source` arayüzü: - `id`: kaynak türü (`github`). Hedef yollarında ve önbellekte klasör adı olur. - `checkPrerequisites()`: gerekli araç ve oturum kontrolü. - - `listRepos(): Promise`: `RepoInfo` = `{ owner, name, fullName, cloneUrl, wikiUrl?, hasWiki, isArchived, isFork, isEmpty }`. + - `listRepos(): Promise<{ repos: RepoInfo[]; failures: { owner, message }[] }>`: `RepoInfo` = `{ source, owner, name, fullName, cloneUrl, wikiUrl?, hasWiki, isArchived, isFork, isEmpty }`. Bir sahibin (ör. SSO'lu org) repoları listelenemezse hata fırlatılmaz, `failures`'a eklenir; çağıran uyarı verir ve çıkış kodu 1 olur. - `gitEnv(): Record`: git alt süreçlerine verilecek kimlik doğrulama env'i. - `Target` arayüzü: - `name`, `type`, `keep`. @@ -63,6 +66,8 @@ test/ - `listSnapshots(repoKey): Promise` (zaman damgaları) - `deleteSnapshot(repoKey, stamp)` - `repoKey` = `//` (wiki için `//.wiki`). +- Kaynak türü → uygulama eşlemesi `sources/index.ts` içindeki `createSource` fonksiyonundadır; `core/discover.ts` her kaynak yapılandırması için ön koşulları kontrol eder, listeler ve o kaynağın filtrelerini uygular. +- Dış komut çağıran modüller `Exec` fonksiyon tipini (`util/exec.ts`) bağımlılık olarak alır; testlerde sahte `exec` verilir. Komutlar `CommandDeps` ile `exec`, `env`, `stdout`, `stderr` alır. - Çekirdek kaynak ve hedef türünden bağımsızdır. Yeni kaynak/hedef: yeni modül + `index.ts` kaydı + zod şemasında yeni `type` dalı. ## Akış @@ -71,7 +76,8 @@ test/ 1. Config yüklenir, kaynak ön koşulları kontrol edilir. 2. Kaynaklardan repolar listelenir, filtreler uygulanır. -3. Her repo için `fullName`, arşiv/fork/wiki/boş bilgisi tablo olarak stdout'a yazılır; sonda toplam sayı. `--json` ile JSON dizi. +3. Her repo için `fullName`, arşiv/fork/wiki/boş bilgisi (`yes`/`-`) hizalı tablo olarak stdout'a yazılır; sonda toplam (`N repositories`). `--json` ile `RepoInfo[]`. +4. Çıkış kodu: bir sahip listelenemediyse 1, ön koşul/config hatasında 2, aksi halde 0. ### `backup` @@ -151,11 +157,11 @@ Config dosyası: varsayılan `~/.config/github-backup-cli/config.json` (`XDG_CON "name": "r2", "bucket": "my-backups", "prefix": "git", - "endpoint": "https://.r2.cloudflarestorage.com", + "endpoint": "https://ACCOUNT_ID.r2.cloudflarestorage.com", "region": "auto", "forcePathStyle": false, - "accessKeyId": "…", - "secretAccessKey": "…", + "accessKeyId": "YOUR_ACCESS_KEY_ID", + "secretAccessKey": "YOUR_SECRET_ACCESS_KEY", "keep": 14 } ] @@ -164,6 +170,9 @@ Config dosyası: varsayılan `~/.config/github-backup-cli/config.json` (`XDG_CON - `sources` verilmezse varsayılan tek `github` kaynağıdır. - Hedef başına `keep` genel `keep`'i ezer. +- Şema katı (`strict`): bilinmeyen alan hata verir; hedef adları benzersiz olmalıdır. Hata mesajı alan yolunu içerir (ör. `targets.0.bucket`). +- Bayrak veya env ile verilen `include`/`exclude`/`archived`/`forks` her `github` kaynağının değerini ezer. +- S3 anahtarı için tek istisna: config'te yoksa `GHBACKUP_S3_ACCESS_KEY_ID`/`GHBACKUP_S3_SECRET_ACCESS_KEY` kullanılır (config önce gelir). - `~` ve env genişletmesi `cacheDir` ve `path` alanlarında yapılır. - Config dosyası başkaları tarafından okunabiliyorsa (`mode & 0o077`) ve içinde S3 anahtarı varsa uyarı verilir. @@ -173,7 +182,9 @@ Bayraklar (her iki komutta ortak olanlar dahil): - Ortak: `--config `, `--include ` (tekrarlanabilir), `--exclude ` (tekrarlanabilir), `--no-archived`, `--no-forks`, `--json`, `--verbose`, `--quiet`. - `backup`: `--cache-dir `, `--concurrency `, `--keep `, `--no-wiki`, `--no-lfs`, `--target ` (tekrarlanabilir; yalnızca seçilen hedefler), `--local ` (config'e geçici `local` hedefi ekler, hızlı kullanım için). -- Glob'lar `owner/repo` üzerinde, büyük/küçük harf duyarsız eşleşir. `exclude` `include`'dan sonra uygulanır. +- Glob'lar `owner/repo` üzerinde, büyük/küçük harf duyarsız eşleşir: `*` ve `?` `/` karakterini geçmez, `**` geçer; `/` içermeyen desen hiçbir repoyla eşleşmez. Boş `include` her repoyu kabul eder. `exclude` `include`'dan sonra uygulanır. +- `--no-archived`, `--no-forks`, `--no-wiki`, `--no-lfs` yalnızca komut satırında yazıldıysa ayarı ezer (commander'ın varsayılan `true` değeri env/dosya ayarlarını bozmaz). +- `--local` eklediği `local` hedefi diğer hedeflere eklenir; yalnızca onu çalıştırmak için `--target local` da verilir. `--verbose` ile `--quiet` birlikte kullanılamaz (çıkış 2). ## Güvenlik @@ -203,7 +214,7 @@ Bayraklar (her iki komutta ortak olanlar dahil): ## Loglama -- Log stderr'e satır satır: `[HH:MM:SS] : `. `--verbose` git komutlarını da yazar; `--quiet` yalnızca hata ve özet. +- Log stderr'e satır satır: `[HH:MM:SS] : `. `--verbose` git komutlarını da yazar; `--quiet` yalnızca hata ve özet (uyarılar dahil değil). Kayıtlı gizli değerler (S3 anahtarları) `***` olarak maskelenir. - Log dosyası tutulmaz; cron çıktıyı yönlendirir. ## Maliyet diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 27caf13..856ade9 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -340,3 +340,17 @@ describe("loadConfig S3 credentials and file permissions", () => { if (warns) expect(warnings[0]).toContain("chmod 600"); }); }); + +describe("config.example.json", () => { + it("is a valid configuration", () => { + const example = path.resolve(import.meta.dirname, "../../config.example.json"); + const { config } = loadConfig({ + flags: { config: example }, + env: {}, + homeDir: home, + cwd: home, + }); + expect(config.targets.map((t) => t.name)).toEqual(["disk", "r2"]); + expect(config.sources[0]).toMatchObject({ type: "github" }); + }); +}); From dedd7221594b1d645af429a19832b336ccfbb36a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:21:28 +0000 Subject: [PATCH 8/9] Run CI on every push and pull request Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3911d19..43beba5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: push: - branches: [main] pull_request: jobs: From 3d585a3f57b9dc68b6006cd0cc7cd0053442ced7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:21:57 +0000 Subject: [PATCH 9/9] Plan: complete phase 1 and record hand-off notes Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBDAcZtGvYtUQpDwwQsroX --- plans/README.md | 2 +- plans/faz-01-iskelet-ve-list.md | 34 ++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/plans/README.md b/plans/README.md index d8a4436..cc525c6 100644 --- a/plans/README.md +++ b/plans/README.md @@ -44,7 +44,7 @@ Fazlar tablodaki sırayla uygulanır; sıranın tek kaynağı bu tablodur. Her f | Faz | Başlık | Durum | Dosya | | --- | --- | --- | --- | -| 1 | İskelet, config ve `list` | Devam ediyor | [faz-01-iskelet-ve-list.md](faz-01-iskelet-ve-list.md) | +| 1 | İskelet, config ve `list` | Tamamlandı | [faz-01-iskelet-ve-list.md](faz-01-iskelet-ve-list.md) | | 2 | Mirror, snapshot ve `local` hedefi | Bekliyor | [faz-02-mirror-ve-local.md](faz-02-mirror-ve-local.md) | | 3 | `s3` hedefi | Bekliyor | [faz-03-s3.md](faz-03-s3.md) | | 4 | Git LFS | Bekliyor | [faz-04-lfs.md](faz-04-lfs.md) | diff --git a/plans/faz-01-iskelet-ve-list.md b/plans/faz-01-iskelet-ve-list.md index 7796fbe..764f79c 100644 --- a/plans/faz-01-iskelet-ve-list.md +++ b/plans/faz-01-iskelet-ve-list.md @@ -1,6 +1,6 @@ # Faz 1 — İskelet, config ve `list` -**Durum:** Devam ediyor +**Durum:** Tamamlandı ## Amaç @@ -31,17 +31,17 @@ ## Görevler -- [ ] Doğrulama: `gh repo list --json` alanlarının (`nameWithOwner`, `isArchived`, `isFork`, `hasWikiEnabled`, `isEmpty`, `url`) mevcut `gh` sürümünde olduğunu `gh repo list --json` (alan listesi yazdırır) veya resmi kılavuzdan doğrula; farklıysa mimari.md'yi güncelle. -- [ ] İskelet ve araç yapılandırması; `npm run lint/typecheck/test/build` boş projede geçer. -- [ ] `util/exec.ts`: komut, argüman dizisi, env, timeout; stdout/stderr yakalama; sıfır olmayan çıkışta komut adını ve stderr'i içeren hata. -- [ ] `util/log.ts`: seviye (`quiet`/`normal`/`verbose`), stderr'e zaman damgalı satırlar, gizli değer maskeleme. -- [ ] Config şeması, yükleme ve birleştirme + birim testleri (öncelik, env boolean ayrıştırma, `~` genişletme, hatalı alan mesajı). -- [ ] `util/filter.ts` + birim testleri. -- [ ] `sources/github.ts` + sahte `exec` ile birim testleri (sayfalı org çıktısı, boş org, org hatası, tekilleştirme). -- [ ] `commands/list.ts` (tablo ve `--json`) + birim testi. -- [ ] `commands/backup.ts` iskeleti ve tüm bayrakların tanımı. -- [ ] `config.example.json`. -- [ ] `ci.yml`. +- [x] Doğrulama: `gh repo list --json` alanlarının (`nameWithOwner`, `isArchived`, `isFork`, `hasWikiEnabled`, `isEmpty`, `url`) mevcut `gh` sürümünde olduğunu `gh repo list --json` (alan listesi yazdırır) veya resmi kılavuzdan doğrula; farklıysa mimari.md'yi güncelle. +- [x] İskelet ve araç yapılandırması; `npm run lint/typecheck/test/build` boş projede geçer. +- [x] `util/exec.ts`: komut, argüman dizisi, env, timeout; stdout/stderr yakalama; sıfır olmayan çıkışta komut adını ve stderr'i içeren hata. +- [x] `util/log.ts`: seviye (`quiet`/`normal`/`verbose`), stderr'e zaman damgalı satırlar, gizli değer maskeleme. +- [x] Config şeması, yükleme ve birleştirme + birim testleri (öncelik, env boolean ayrıştırma, `~` genişletme, hatalı alan mesajı). +- [x] `util/filter.ts` + birim testleri. +- [x] `sources/github.ts` + sahte `exec` ile birim testleri (sayfalı org çıktısı, boş org, org hatası, tekilleştirme). +- [x] `commands/list.ts` (tablo ve `--json`) + birim testi. +- [x] `commands/backup.ts` iskeleti ve tüm bayrakların tanımı. +- [x] `config.example.json`. +- [x] `ci.yml`. ## 👤 Kullanıcı aksiyonları @@ -61,4 +61,12 @@ ## Devam notları -_Henüz yok._ +Kod tarafı bitti; bu ortamda doğrulanabilen tüm kabul kriterleri sağlandı (lint, typecheck, test, build temiz; `gh` yokken ve oturum kapalıyken `list` çıkış 2; `backup` "not implemented" ile çıkış 2; `list --json --exclude 'org/*'` birim testinde filtrelenmiş JSON üretir; config önceliği testli). + +- Açık kalan: gerçek `gh` oturumuyla `list` denemesi (kullanıcı aksiyonu 💻, bulutta `gh` oturumu yok). `node dist/cli.js list` kişisel repoları ve tüm org'ları göstermeli; bir org SSO yetkisi istiyorsa uyarı + çıkış 1 beklenir. +- Faz 2 için hazır olanlar: `Exec` tipi (`util/exec.ts`, git komutları için de kullanılacak; `ExecError.code` `ENOENT`/`ETIMEDOUT` dönebilir), `Logger` (`debug` yalnızca `--verbose`), `CommandDeps` (`commands/context.ts`), `prepare()` (config + logger), `discoverRepos()` (`sources` haritası `gitEnv()` için), `ConfigError`/`PrerequisiteError`/`CliError`/`ExitCode` (`util/errors.ts`; `program.ts` bunları çıkış koduna çevirir). +- `commands/backup.ts` yalnızca stub; `BackupOptions` şimdilik `CommonOptions` ile aynı, Faz 2'de `ConfigFlags`'in (cacheDir, concurrency, keep, wiki, lfs, target, local) zaten kapsadığı alanlar kullanılır. `git` varlığının kontrolü (`git --version`) `backup`'ın ön koşul adımına Faz 2'de eklenecek (`list` yalnızca `gh` ister). +- Config'te `keep` çözümü (hedef `keep` > genel `keep`) henüz bir yardımcıya bağlanmadı; hedef şemasında `keep` isteğe bağlı kalır. Faz 2'de tek fonksiyonla çözülmeli. +- Araç sürümleri: TypeScript 7, Vitest 5, Biome 2.5, commander 15, zod 4. Hepsi sorunsuz çalıştı. Biome'da `rules.preset` kullanılır (`recommended` alanı kaldırılıyor). +- CI yalnızca `.github/workflows/ci.yml` ile Node 22/24 matrisinde çalışır; MinIO servisi Faz 3'te eklenecek. +- Test `config.example.json`'un şemaya uyduğunu doğrular; şema değişirse örnek dosya, mimari.md ve README birlikte güncellenmeli (`REPO.md`).