diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f587bc9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Opinionated TS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 297edf8..4489aa8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,42 @@ # write-once-github -Official GitHub repository integration for [write-once](https://github.com/opinionated-ts/write-once). +[![npm version](https://img.shields.io/npm/v/write-once-github)](https://www.npmjs.com/package/write-once-github) +[![npm downloads](https://img.shields.io/npm/dm/write-once-github)](https://www.npmjs.com/package/write-once-github) +[![CI](https://img.shields.io/github/actions/workflow/status/opinionated-ts/write-once-github/check-and-release.yml?label=CI)](https://github.com/opinionated-ts/write-once-github/actions/workflows/check-and-release.yml) +[![License](https://img.shields.io/github/license/opinionated-ts/write-once-github)](https://github.com/opinionated-ts/write-once-github/blob/main/LICENSE) -This package is under development. Its repository reference syntax, ref resolution behavior, and public API will be documented here as the integration is implemented. +A [Write Once](https://github.com/opinionated-ts/write-once) plugin for resolving files from GitHub repositories through local Git checkouts. -## Development +## Install ```sh -bun install -bun test -bun check -bun build +bun add -d write-once write-once-github ``` -## Related projects +Register the plugin in `write-once.config.ts`: -- [write-once](https://github.com/opinionated-ts/write-once) — core resource preparation, resolution, and content extraction pipeline. -- [Opinionated TS](https://github.com/opinionated-ts) — the ecosystem this package belongs to. +```ts +import { defineConfig } from "write-once"; +import { githubPlugin } from "write-once-github"; + +export default defineConfig({ plugins: [githubPlugin] }); +``` + +## References + +```text +repo:owner/repository/path/to/file.ts +repo:owner/repository@develop/path/to/file.ts +repo:owner/repository@feature/nested/path/to/file.ts +``` + +Without `@ref`, the repository's default branch is used. Explicit references can be branches, tags, or commit SHAs. Branches take precedence over same-named tags. + +The GitHub plugin only resolves the repository file. Whole-file, named-fragment, and range selection are handled by Write Once. + +## Authentication + +- Public repositories require no credentials. +- Private repositories use `GITHUB_TOKEN` from the process environment. +- The token should have access to the repository with `Contents: Read-only` and `Metadata: Read-only` permissions. +- `GITHUB_GIT_PROTOCOL=ssh` uses SSH instead of HTTPS when SSH credentials are already configured. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dee5fc6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported Versions + +Security fixes are provided for the latest released version of `write-once`. Upgrade to the latest version before reporting an issue when possible. + +| Version | Supported | +| -------------- | --------- | +| Latest release | Yes | +| Older releases | No | + +## Reporting a Vulnerability + +Please report suspected security vulnerabilities privately by emailing **[danhezcode@gmail.com](mailto:danhezcode@gmail.com)**. Do not disclose the issue publicly in a GitHub issue or discussion until it has been reviewed and a fix or mitigation can be coordinated. + +Include as much of the following information as possible: + +- The affected version and environment. +- A description of the vulnerability and its potential impact. +- Steps to reproduce the issue or a proof of concept. +- Any suggested mitigation, if known. + +You should receive an acknowledgment within 7 days. We will investigate the report, assess its impact, and coordinate next steps with you where possible. Please allow time for a fix or mitigation before sharing details publicly. + +## Scope + +Reports are especially relevant to unintended file access or modification, unsafe handling of fragment markers or configuration, and other behavior that could compromise a user's project or data. Do not include real secrets or sensitive data in reports; use sanitized examples where possible. diff --git a/bun.lock b/bun.lock index 63cba64..de6981a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,10 @@ "tsdown": "^0.23.1", "typescript": "^7.0.2", "vitest": "^5.0.3", + "write-once": "file:../write-once", + }, + "peerDependencies": { + "write-once": "*", }, }, }, @@ -274,6 +278,12 @@ "@opinionated-ts/config": ["@opinionated-ts/config@1.9.1", "", { "dependencies": { "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", "@cspell/dict-es-es": "^3.0.9", "@types/bun": "^1.4.2", "@types/node": "^26.6.4", "cspell": "^10.3.6", "lefthook": "^2.1.17", "oxfmt": "^0.72.0", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", "semantic-release": "^25.0.9" } }, "sha512-5N/oUg/UmgSQafzrBuFVvmX10i/CVhz2oIia25+j/G0wUbUAGDC7m4omH5ls0baaahBX7YQmwgTKr6NMjjwpkg=="], + "@opinionated-ts/error-factory": ["@opinionated-ts/error-factory@1.0.1", "", { "dependencies": { "@opinionated-ts/primitives": "^0.1.0" }, "peerDependencies": { "typescript": "^7.0.2" } }, "sha512-7Z0769abDhBg7XUZuwzeF19zsMI17aAli4tiZ7n0K/0ZMGgWMCfQGtw9CGQ1sciwk8Yl/s2Ehvuiy5lXreaikA=="], + + "@opinionated-ts/primitives": ["@opinionated-ts/primitives@0.1.0", "", { "peerDependencies": { "typescript": "^7.0.2" } }, "sha512-LRihuymnbn97Zc9jhIIqkAXU//ThUAvBzf52lQ590EEUY5WxNPlEqS5qg+IO1c7i0AZHfG0ftwQ8DjFRV+EFIg=="], + + "@opinionated-ts/result": ["@opinionated-ts/result@1.0.0", "", {}, "sha512-uoMfFVHYd9bKBDFWoVtSo2ZZBpNILIMqOEEGwji2O2STdFOw7i3IWt1Pid85qUKEaYBdRaiwRsqlZFu+zjy8MA=="], + "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.72.0", "", { "os": "android", "cpu": "arm" }, "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw=="], @@ -410,6 +420,8 @@ "@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], + "@semantic-release/exec": ["@semantic-release/exec@7.1.0", "", { "dependencies": { "@semantic-release/error": "^4.0.0", "aggregate-error": "^3.0.0", "debug": "^4.0.0", "execa": "^9.0.0", "lodash-es": "^4.17.21", "parse-json": "^8.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ=="], + "@semantic-release/github": ["@semantic-release/github@12.0.9", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ=="], "@semantic-release/npm": ["@semantic-release/npm@13.1.5", "", { "dependencies": { "@actions/core": "^3.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^9.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg=="], @@ -558,6 +570,8 @@ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "cac": ["cac@7.0.1", "", {}, "sha512-86diuUyeeWM8CrqhpGCaDafrzjS1s++7sWy2AbAPoFNz/xKv7ID57CR4pZsMuGCh60PtsnEx4z68b+Vgt07VPw=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -572,8 +586,12 @@ "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + "clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], @@ -592,6 +610,8 @@ "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], "content-type": ["content-type@3.0.0", "", {}, "sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw=="], @@ -644,12 +664,16 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], @@ -686,6 +710,8 @@ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-equals": ["fast-equals@6.0.2", "", {}, "sha512-sAjhj9ZhOxYCGiNMnZLaucOqf5ZeFnHNoKoAZiD9thhJ0N8RP85qJK759/97C/3L7NzzmGVB5uiX9AUpySZmUQ=="], @@ -726,6 +752,8 @@ "get-tsconfig": ["get-tsconfig@5.0.0-beta.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw=="], + "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], + "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], @@ -750,6 +778,8 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "ignore": ["ignore@7.0.12", "", {}, "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg=="], + "import-fresh": ["import-fresh@4.0.0", "", {}, "sha512-Fpi660c7VPDM3fPKYovStd9IP1CPOikf6v/dGxJJMmHPcwYQIMJ4W7kO1avBYEpMqkCh+Dx3Ln6H7VYqgztLjw=="], "import-from-esm": ["import-from-esm@2.0.0", "", { "dependencies": { "debug": "^4.3.4", "import-meta-resolve": "^4.0.0" } }, "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g=="], @@ -928,6 +958,8 @@ "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], "oxfmt": ["oxfmt@0.72.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.72.0", "@oxfmt/binding-android-arm64": "0.72.0", "@oxfmt/binding-darwin-arm64": "0.72.0", "@oxfmt/binding-darwin-x64": "0.72.0", "@oxfmt/binding-freebsd-x64": "0.72.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", "@oxfmt/binding-linux-arm64-gnu": "0.72.0", "@oxfmt/binding-linux-arm64-musl": "0.72.0", "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-musl": "0.72.0", "@oxfmt/binding-linux-s390x-gnu": "0.72.0", "@oxfmt/binding-linux-x64-gnu": "0.72.0", "@oxfmt/binding-linux-x64-musl": "0.72.0", "@oxfmt/binding-openharmony-arm64": "0.72.0", "@oxfmt/binding-win32-arm64-msvc": "0.72.0", "@oxfmt/binding-win32-ia32-msvc": "0.72.0", "@oxfmt/binding-win32-x64-msvc": "0.72.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g=="], @@ -956,7 +988,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -970,6 +1002,10 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -978,6 +1014,8 @@ "pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="], + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], @@ -992,12 +1030,16 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rc9": ["rc9@3.1.0", "", { "dependencies": { "defu": "^6.1.7", "destr": "^2.0.5" } }, "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q=="], + "read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="], "read-pkg": ["read-pkg@10.1.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.4", "normalize-package-data": "^8.0.0", "parse-json": "^8.3.0", "type-fest": "^5.4.4", "unicorn-magic": "^0.4.0" } }, "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -1158,6 +1200,8 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "write-once": ["write-once@file:../write-once", { "dependencies": { "@opinionated-ts/error-factory": "^1.0.1", "@opinionated-ts/result": "^1.0.0", "c12": "^3.3.3", "citty": "^0.2.2", "ignore": "^7.0.12", "picocolors": "^1.1.1" }, "devDependencies": { "@opinionated-ts/config": "^1.9.1", "@semantic-release/exec": "^7.1.0", "@types/bun": "^1.4.2", "@types/node": "^26.6.4", "@vitest/coverage-istanbul": "5.0.3", "skills": "^1.7.1", "tsdown": "^0.23.1", "typescript": "^7.0.2", "vitest": "^5.0.3", "zod": "^4.6.6" }, "bin": { "write-once": "dist/cli.mjs" } }], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -1182,6 +1226,8 @@ "yuku-parser": ["yuku-parser@0.18.1", "", { "dependencies": { "@yuku-toolchain/types": "^0.18.1", "yuku-core": "0.18.1" } }, "sha512-t5pANCF6k9rgJre7jk8DMAU4lEoI1oWWZgXk0fcvmCYQquFVWv0rf6Awwt56nnPiHb0E24dhtrpfgf9ChTCbrQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@actions/http-client/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.6", "", {}, "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ=="], @@ -1216,6 +1262,8 @@ "@semantic-release/commit-analyzer/conventional-commits-parser": ["conventional-commits-parser@6.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="], + "@semantic-release/exec/aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + "@semantic-release/release-notes-generator/conventional-commits-parser": ["conventional-commits-parser@6.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="], "@semantic-release/release-notes-generator/read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], @@ -1236,6 +1284,8 @@ "cosmiconfig/import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], "env-ci/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], @@ -1544,9 +1594,11 @@ "parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], - "read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], + "pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], "rolldown-plugin-dts/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], @@ -1598,6 +1650,10 @@ "@semantic-release/commit-analyzer/conventional-commits-parser/@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], + "@semantic-release/exec/aggregate-error/clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + + "@semantic-release/exec/aggregate-error/indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "@semantic-release/release-notes-generator/conventional-commits-parser/@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], "@semantic-release/release-notes-generator/read-package-up/read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], @@ -1620,6 +1676,8 @@ "cosmiconfig/import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "cosmiconfig/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "env-ci/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], "env-ci/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], @@ -1636,10 +1694,6 @@ "parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - - "read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "signale/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "signale/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], @@ -1680,8 +1734,6 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], "cli-highlight/chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1696,20 +1748,18 @@ "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cosmiconfig/parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "env-ci/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "npm/minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "npm/minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "read-pkg/parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "signale/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "cli-highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1720,8 +1770,6 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/cspell.config.ts b/cspell.config.ts index 2f65f52..d9ad72a 100644 --- a/cspell.config.ts +++ b/cspell.config.ts @@ -14,10 +14,7 @@ export default defineConfig({ // Custom import paths here ], - words: [ - ...cspellConfig.words, - // Custom words here - ], + words: [...cspellConfig.words, "extraheader", "gpgsign", "ratelimit", "symref", "unstub"], // Any additional custom configuration here }); diff --git a/package.json b/package.json index a4f05e2..46ad3de 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "write-once-github", - "description": "Official GitHub repository integration for write-once.", + "description": "Resolve GitHub repository files from a single source of truth in Write Once.", "keywords": [ + "content-synchronization", + "documentation-sync", + "fragment-reuse", "github", - "integration", - "opinionated-ts", - "plugin", - "repository", + "repository-files", + "text-reuse", "write-once" ], "homepage": "https://github.com/opinionated-ts/write-once-github#readme", @@ -18,6 +19,12 @@ "name": "Opinionated TS", "url": "https://github.com/opinionated-ts" }, + "contributors": [ + { + "name": "Daniel A. Hernández Ochoa", + "url": "https://github.com/DanhezCode" + } + ], "repository": { "type": "git", "url": "git+https://github.com/opinionated-ts/write-once-github.git" @@ -26,10 +33,14 @@ "dist" ], "type": "module", + "types": "./dist/index.d.mts", "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, + "publishConfig": { + "access": "public" + }, "scripts": { "cspell": "cspell lint .", "fmt": "oxfmt --check .", @@ -46,7 +57,8 @@ "audit": "bun audit fix --ignore GHSA-vfj7-8cjw-p6xm", "build": "tsdown", "build:watch": "tsdown --watch", - "start": "bun run build && bun ./dist/index.mjs" + "start": "bun run build && bun ./dist/index.mjs", + "prepack": "bun run build" }, "devDependencies": { "@opinionated-ts/config": "^1.9.1", @@ -56,7 +68,11 @@ "skills": "^1.7.1", "tsdown": "^0.23.1", "typescript": "^7.0.2", - "vitest": "^5.0.3" + "vitest": "^5.0.3", + "write-once": "file:../write-once" + }, + "peerDependencies": { + "write-once": "*" }, "engines": { "bun": "^1.4.0" diff --git a/src/github-api.ts b/src/github-api.ts new file mode 100644 index 0000000..025bbcb --- /dev/null +++ b/src/github-api.ts @@ -0,0 +1,287 @@ +import { execFile } from "node:child_process"; +import { access, lstat, mkdir, rename, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +import type { GitHubSourceReference } from "./reference.js"; + +import { getRepositoryUrl } from "./repository-url.js"; + +const execFileAsync = promisify(execFile); +const preparations = new Map>(); + +interface RemoteRef { + ref: string; + commit: string; + kind: "branch" | "tag" | "commit"; + path: string; +} + +interface PreparedGitHubFile { + root: string; + path: string; + resolved: { + owner: string; + repository: string; + ref: string; + commit: string; + path: string; + }; +} + +function gitEnvironment(token?: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + // Git authentication uses an HTTP header so credentials are not embedded in the remote URL. + if (token) { + env["GIT_CONFIG_COUNT"] = "1"; + env["GIT_CONFIG_KEY_0"] = "http.extraheader"; + env["GIT_CONFIG_VALUE_0"] = + `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; + } + return env; +} + +async function git( + args: string[], + options: { cwd?: string; token?: string } = {}, +): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd: options.cwd, + env: gitEnvironment(options.token), + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + return stdout.trim(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Git command failed: ${detail}`, { cause: error }); + } +} + +function safeSegment(value: string): string { + return encodeURIComponent(value); +} + +async function getRemoteRef(reference: GitHubSourceReference, token?: string): Promise { + const url = getRepositoryUrl(reference); + const requested = reference.ref; + if (!requested) { + const output = await git(["ls-remote", "--symref", url, "HEAD"], { token }); + const lines = output.split("\n").filter(Boolean); + const symbolicHead = lines.find((line) => line.startsWith("ref: ") && line.endsWith("\tHEAD")); + const headRef = symbolicHead?.slice(5).split("\t", 1)[0]; + const headSha = lines + .find((line) => line.endsWith("\tHEAD") && !line.startsWith("ref: ")) + ?.split("\t", 1)[0]; + if (!headRef || !headSha) + throw new Error( + `Could not determine the default branch for ${reference.owner}/${reference.repository}.`, + ); + return { + ref: headRef.slice("refs/heads/".length), + commit: headSha, + kind: "branch", + path: reference.path, + }; + } + + if (/^(?:[\da-f]{40}|[\da-f]{64})$/i.test(requested)) + return { ref: requested, commit: requested, kind: "commit", path: reference.path }; + + // A slash can belong to either a branch name or the file path, so match the longest remote ref prefix. + const pathParts = reference.path.split("/"); + const candidates = [ + requested, + ...pathParts + .slice(0, -1) + .map((_, index) => [requested, ...pathParts.slice(0, index + 1)].join("/")), + ]; + const patterns = candidates.flatMap((candidate) => [ + `refs/heads/${candidate}`, + `refs/tags/${candidate}`, + `refs/tags/${candidate}^{}`, + ]); + const output = await git(["ls-remote", url, ...patterns], { token }); + const refs = new Map(); + for (const line of output.split("\n").filter(Boolean)) { + const [sha, name] = line.split("\t"); + if (sha && name) refs.set(name, sha); + } + + for (let index = candidates.length - 1; index >= 0; index--) { + const candidate = candidates[index]!; + const branch = refs.get(`refs/heads/${candidate}`); + if (branch) { + return { + ref: candidate, + commit: branch, + kind: "branch", + path: pathParts.slice(index).join("/"), + }; + } + const tag = refs.get(`refs/tags/${candidate}^{}`) ?? refs.get(`refs/tags/${candidate}`); + if (tag) { + return { + ref: candidate, + commit: tag, + kind: "tag", + path: pathParts.slice(index).join("/"), + }; + } + } + throw new Error( + `Git reference ${JSON.stringify(requested)} was not found in ${reference.owner}/${reference.repository}.`, + ); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function assertSafeFile(root: string, filePath: string): Promise { + if ( + !filePath || + filePath.startsWith("/") || + filePath.includes("\\") || + filePath.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`Unsafe GitHub file path ${JSON.stringify(filePath)}.`); + } + + const parts = filePath.split("/"); + const entries = await Promise.all( + parts.map(async (_, index) => { + const path = join(root, ...parts.slice(0, index + 1)); + try { + return await lstat(path); + } catch { + throw new Error( + `File ${JSON.stringify(filePath)} was not found in the prepared GitHub repository.`, + ); + } + }), + ); + for (const [index, info] of entries.entries()) { + if (info.isSymbolicLink()) + throw new Error(`GitHub file path ${JSON.stringify(filePath)} traverses a symbolic link.`); + if (index < entries.length - 1 && !info.isDirectory()) + throw new Error( + `GitHub file path ${JSON.stringify(filePath)} contains a non-directory parent.`, + ); + if (index === entries.length - 1 && !info.isFile()) + throw new Error(`GitHub path ${JSON.stringify(filePath)} is not a regular file.`); + } +} + +async function isReusable(path: string, commit: string, token?: string): Promise { + if (!(await pathExists(join(path, ".git")))) return false; + try { + const [head, status] = await Promise.all([ + git(["rev-parse", "HEAD"], { cwd: path, token }), + git(["status", "--porcelain"], { cwd: path, token }), + ]); + return head === commit && status === ""; + } catch { + return false; + } +} + +async function checkoutRepository( + reference: GitHubSourceReference, + root: string, + remote: RemoteRef, + token?: string, +): Promise { + const parent = dirname(root); + await mkdir(parent, { recursive: true }); + const temporary = `${root}.tmp-${process.pid}-${crypto.randomUUID()}`; + await rm(temporary, { recursive: true, force: true }); + try { + await git( + [ + "clone", + "--filter=blob:none", + "--no-checkout", + "--", + getRepositoryUrl(reference), + temporary, + ], + { token }, + ); + const fetchRef = + remote.kind === "branch" + ? `refs/heads/${remote.ref}` + : remote.kind === "tag" + ? `refs/tags/${remote.ref}` + : remote.commit; + await git(["fetch", "--depth=1", "origin", fetchRef], { cwd: temporary, token }); + await git(["checkout", "--detach", remote.commit], { cwd: temporary, token }); + await rm(root, { recursive: true, force: true }); + await rename(temporary, root); + } catch (error) { + await rm(temporary, { recursive: true, force: true }); + throw error; + } +} + +/** Prepares a local checkout and returns the requested file's path within it. */ +export async function prepareGitHubFile( + reference: GitHubSourceReference, + workingDirectory: string, + token?: string, +): Promise { + if ( + !reference.path || + reference.path.startsWith("/") || + reference.path.includes("\\") || + reference.path.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`Unsafe GitHub file path ${JSON.stringify(reference.path)}.`); + } + const remote = await getRemoteRef(reference, token); + const state = + remote.kind === "commit" + ? `commit-${remote.commit}` + : `${remote.kind}-${safeSegment(remote.ref)}`; + const root = join(workingDirectory, reference.owner, reference.repository, state); + const key = `${root}\0${remote.commit}`; + let pending = preparations.get(key); + if (!pending) { + pending = (async () => { + if (!(await isReusable(root, remote.commit, token))) + await checkoutRepository(reference, root, remote, token); + return root; + })().catch((error: unknown) => { + if (preparations.get(key) === pending) preparations.delete(key); + throw error; + }); + preparations.set(key, pending); + } + try { + await pending; + } finally { + if (preparations.get(key) === pending) preparations.delete(key); + } + await assertSafeFile(root, remote.path); + return { + root, + path: remote.path, + resolved: { + owner: reference.owner, + repository: reference.repository, + ref: remote.ref, + commit: remote.commit, + path: remote.path, + }, + }; +} + +export function clearGitHubCaches(): void { + preparations.clear(); +} diff --git a/src/index.ts b/src/index.ts index 7a37a21..b302fcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,121 @@ +import type { PluginFactory, ResourceContext, SourceReferenceContext } from "write-once"; + +import { prepareGitHubFile } from "./github-api.js"; +import { isGitHubReference, parseGitHubReference } from "./reference.js"; + +interface PreparedFile { + root: string; + path: string; + owner: string; + repository: string; + ref: string; + commit: string; + sourcePath: string; +} +const PLUGIN_ID = "write-once-github"; +function isPreparedFile(value: unknown): value is PreparedFile { + if (typeof value !== "object" || value === null) return false; + return ["root", "path", "owner", "repository", "ref", "commit", "sourcePath"].every( + (key) => typeof Reflect.get(value, key) === "string", + ); +} +function getPreparedFile(source: string, context: ResourceContext): PreparedFile { + const files = context.preparation?.result.metadata?.["files"]; + if (typeof files !== "object" || files === null) + throw new Error( + `GitHub source ${JSON.stringify(source)} was not included in the assigned preparation batch.`, + ); + const file: unknown = Reflect.get(files, source); + if (!isPreparedFile(file)) + throw new Error( + `GitHub source ${JSON.stringify(source)} was not included in the assigned preparation batch.`, + ); + return file; +} + /** - * Returns a greeting for the provided name. + * Creates the GitHub repository provider for Write Once. * - * Replace this example with the package public API. + * References use `repo:owner/repository/path` or `repo:owner/repository@ref/path`. + * Public repositories work anonymously; private repositories use `GITHUB_TOKEN`. */ -export function greet(name: string): string { - return `Hello, ${name}!`; -} +export const githubPlugin: PluginFactory = () => { + return { + name: PLUGIN_ID, + preparers: [ + { + id: PLUGIN_ID, + canPrepare: (reference: SourceReferenceContext) => isGitHubReference(reference.source), + async prepare(references, context) { + const token = process.env["GITHUB_TOKEN"]; + const root = context.workingDirectory; + + // A source can feed multiple destinations; materialize it only once per run. + const uniqueReferences = new Map( + references.map((reference) => [reference.source, reference]), + ); + const preparedFiles = await Promise.all( + [...uniqueReferences.values()].map(async (reference) => { + const prepared = await prepareGitHubFile( + parseGitHubReference(reference.source), + root, + token, + ); + return { + source: reference.source, + metadata: { + root: prepared.root, + path: prepared.path, + owner: prepared.resolved.owner, + repository: prepared.resolved.repository, + ref: prepared.resolved.ref, + commit: prepared.resolved.commit, + sourcePath: prepared.resolved.path, + } satisfies PreparedFile, + }; + }), + ); + const files: Record = {}; + const resources = new Map(); + for (const file of preparedFiles) { + files[file.source] = file.metadata; + resources.set(file.metadata.root, { + root: file.metadata.root, + metadata: { provider: "github" }, + }); + } + return { resources: [...resources.values()], metadata: { files } }; + }, + }, + ], + resolvers: [ + { + id: PLUGIN_ID, + canResolve(source, context) { + return isGitHubReference(source) && context.preparation?.preparerId === PLUGIN_ID; + }, + resolve(source, context) { + const preparation = context.preparation; + if (!preparation) + throw new Error(`No GitHub preparation was assigned to ${JSON.stringify(source)}.`); + const file = getPreparedFile(source, context); + return { + root: file.root, + path: file.path, + metadata: { + provider: "github", + repository: `${file.owner}/${file.repository}`, + ref: file.ref, + commit: file.commit, + sourcePath: file.sourcePath, + }, + }; + }, + }, + ], + }; +}; + +export { prepareGitHubFile } from "./github-api.js"; +export { isGitHubReference, parseGitHubReference } from "./reference.js"; +export type { GitHubSourceReference } from "./reference.js"; diff --git a/src/reference.ts b/src/reference.ts new file mode 100644 index 0000000..f124589 --- /dev/null +++ b/src/reference.ts @@ -0,0 +1,37 @@ +export interface GitHubSourceReference { + owner: string; + repository: string; + path: string; + ref?: string; +} + +export function isGitHubReference(source: string): boolean { + return source.startsWith("repo:"); +} + +export function parseGitHubReference(source: string): GitHubSourceReference { + if (!isGitHubReference(source)) + throw new Error(`Unsupported GitHub source reference: ${JSON.stringify(source)}.`); + const segments = source.slice("repo:".length).split("/"); + const owner = segments[0]; + const repositoryAndRef = segments[1]; + if (!owner || !repositoryAndRef || owner === "." || owner === "..") { + throw new Error(`Invalid GitHub source reference: ${JSON.stringify(source)}.`); + } + const at = repositoryAndRef.indexOf("@"); + const repository = at < 0 ? repositoryAndRef : repositoryAndRef.slice(0, at); + const ref = at < 0 ? undefined : repositoryAndRef.slice(at + 1); + const path = segments.slice(2).join("/"); + if ( + !repository || + repository === "." || + repository === ".." || + !path || + path.includes("\\") || + segments.slice(2).some((segment) => !segment || segment === "." || segment === "..") || + ref === "" + ) { + throw new Error(`Invalid GitHub source reference: ${JSON.stringify(source)}.`); + } + return { owner, repository, path, ...(ref === undefined ? {} : { ref }) }; +} diff --git a/src/repository-url.ts b/src/repository-url.ts new file mode 100644 index 0000000..00c450d --- /dev/null +++ b/src/repository-url.ts @@ -0,0 +1,11 @@ +import type { GitHubSourceReference } from "./reference.js"; + +/** Builds the Git remote URL without placing credentials in the URL itself. */ +export function getRepositoryUrl(reference: GitHubSourceReference): string { + const repository = `${reference.owner}/${reference.repository}.git`; + const baseUrl = process.env["GITHUB_GIT_BASE_URL"]; + if (baseUrl) return `${baseUrl.replace(/\/$/, "")}/${repository}`; + return process.env["GITHUB_GIT_PROTOCOL"] === "ssh" + ? `git@github.com:${repository}` + : `https://github.com/${repository}`; +} diff --git a/tests/demo.test.ts b/tests/demo.test.ts deleted file mode 100644 index 19ea725..0000000 --- a/tests/demo.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { greet } from "@/index"; - -describe("greet", () => { - it("returns a greeting for the provided name", () => { - expect(greet("TypeScript")).toBe("Hello, TypeScript!"); - }); -}); diff --git a/tests/github-api.test.ts b/tests/github-api.test.ts new file mode 100644 index 0000000..0507600 --- /dev/null +++ b/tests/github-api.test.ts @@ -0,0 +1,406 @@ +import type { SourceReferenceContext } from "write-once"; + +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile, mkdir, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { clearGitHubCaches, prepareGitHubFile } from "@/github-api"; +import { githubPlugin } from "@/index"; +import { parseGitHubReference } from "@/reference"; +import { getRepositoryUrl } from "@/repository-url"; + +let temporary: string | undefined; + +function git(args: string[], cwd?: string): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +async function createRepository() { + temporary = await mkdtemp(join(tmpdir(), "write-once-github-test-")); + const remoteRoot = join(temporary, "remotes"); + const remote = join(remoteRoot, "owner", "repo.git"); + const working = join(temporary, "source"); + await mkdir(join(remoteRoot, "owner"), { recursive: true }); + git(["init", "--bare", "--initial-branch=main", remote]); + git(["init", "--initial-branch=main", working]); + git(["config", "user.name", "Test"], working); + git(["config", "user.email", "test@example.com"], working); + await mkdir(join(working, "src"), { recursive: true }); + await mkdir(join(working, "docs"), { recursive: true }); + await writeFile(join(working, "src", "index.ts"), "export const value = 'main';\n"); + await writeFile(join(working, "docs", "guide.md"), "main guide\n"); + await symlink("src/index.ts", join(working, "linked.ts")); + git(["add", "."], working); + git(["commit", "-m", "initial"], working); + const mainCommit = git(["rev-parse", "HEAD"], working); + git(["tag", "v1.0.0"], working); + git(["tag", "-a", "v2.0.0", "-m", "annotated release", "main"], working); + git(["checkout", "-b", "feature/nested", "main"], working); + git(["push", remote, "feature/nested"], working); + git(["checkout", "-b", "develop"], working); + await writeFile(join(working, "src", "index.ts"), "export const value = 'develop';\n"); + git(["commit", "-am", "develop"], working); + git(["push", remote, "main", "develop", "--tags"], working); + git(["symbolic-ref", "HEAD", "refs/heads/main"], remote); + vi.stubEnv("GITHUB_GIT_BASE_URL", `file://${remoteRoot}`); + vi.stubEnv("GITHUB_GIT_PROTOCOL", "https"); + return { working, remote, remoteRoot, mainCommit, root: join(temporary, "plugin-work") }; +} + +afterEach(async () => { + clearGitHubCaches(); + vi.unstubAllEnvs(); + if (temporary) await rm(temporary, { recursive: true, force: true }); + temporary = undefined; +}); + +function sourceReference(source: string, destination: string): SourceReferenceContext { + const location = { + resource: destination, + start: { line: 1, column: 1 }, + end: { line: 1, column: 1 }, + }; + const token = { + raw: "", + attributes: { source }, + span: { start: 0, end: 0 }, + lineSpan: { start: 0, end: 0 }, + location, + }; + return { + source, + destination, + selector: { kind: "whole" }, + marker: { + opening: { ...token, kind: "open" }, + closing: { ...token, kind: "close" }, + span: { start: 0, end: 0 }, + bodySpan: { start: 0, end: 0 }, + }, + }; +} + +describe("getRepositoryUrl", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("uses HTTPS by default and supports configured SSH", () => { + expect(getRepositoryUrl({ owner: "owner", repository: "repo", path: "README.md" })).toBe( + "https://github.com/owner/repo.git", + ); + vi.stubEnv("GITHUB_GIT_PROTOCOL", "ssh"); + expect(getRepositoryUrl({ owner: "owner", repository: "repo", path: "README.md" })).toBe( + "git@github.com:owner/repo.git", + ); + }); + + it("allows a custom Git remote base URL for testing and GitHub Enterprise", () => { + vi.stubEnv("GITHUB_GIT_BASE_URL", "https://github.example.test/"); + expect(getRepositoryUrl({ owner: "owner", repository: "repo", path: "README.md" })).toBe( + "https://github.example.test/owner/repo.git", + ); + }); +}); + +describe("prepareGitHubFile", () => { + it("materializes the default branch and returns a path inside the checkout", async () => { + const setup = await createRepository(); + const result = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/src/index.ts"), + setup.root, + ); + expect(result.root).toBe(join(setup.root, "owner", "repo", "branch-main")); + expect(result.resolved).toMatchObject({ + ref: "main", + commit: setup.mainCommit, + path: "src/index.ts", + }); + expect(await readFile(join(result.root, result.path), "utf8")).toBe( + "export const value = 'main';\n", + ); + }); + + it("resolves branch names containing slashes and keeps states in separate checkouts", async () => { + const setup = await createRepository(); + const main = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/src/index.ts"), + setup.root, + ); + const develop = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@develop/src/index.ts"), + setup.root, + ); + const nested = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts"), + setup.root, + ); + const tag = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@v1.0.0/src/index.ts"), + setup.root, + ); + const annotatedTag = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@v2.0.0/src/index.ts"), + setup.root, + ); + const commit = await prepareGitHubFile( + parseGitHubReference(`repo:owner/repo@${setup.mainCommit}/src/index.ts`), + setup.root, + ); + + expect(develop.root).toContain("branch-develop"); + expect(nested.resolved.ref).toBe("feature/nested"); + expect(nested.path).toBe("src/index.ts"); + expect(tag.root).toContain("tag-v1.0.0"); + expect(commit.root).toContain(`commit-${setup.mainCommit}`); + expect( + new Set([main.root, develop.root, nested.root, tag.root, annotatedTag.root, commit.root]) + .size, + ).toBe(6); + expect(await readFile(join(develop.root, develop.path), "utf8")).toContain("develop"); + expect(await readFile(join(tag.root, tag.path), "utf8")).toContain("main"); + expect(annotatedTag.resolved.commit).toBe(setup.mainCommit); + expect(await readFile(join(annotatedTag.root, annotatedTag.path), "utf8")).toContain("main"); + }); + + it("shares a checkout for multiple files in the same repository state", async () => { + const setup = await createRepository(); + const first = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/src/index.ts"), + setup.root, + ); + clearGitHubCaches(); + const second = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/docs/guide.md"), + setup.root, + ); + expect(second.root).toBe(first.root); + expect(second.path).toBe("docs/guide.md"); + expect(await readFile(join(second.root, second.path), "utf8")).toBe("main guide\n"); + }); + + it("refreshes a branch checkout when the remote commit changes", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const first = await prepareGitHubFile(reference, setup.root); + git(["checkout", "main"], setup.working); + await writeFile(join(setup.working, "src", "index.ts"), "export const value = 'updated';\n"); + git(["commit", "-am", "updated"], setup.working); + git(["push", setup.remote, "main"], setup.working); + clearGitHubCaches(); + const second = await prepareGitHubFile(reference, setup.root); + expect(second.root).toBe(first.root); + expect(second.resolved.commit).not.toBe(first.resolved.commit); + expect(await readFile(join(second.root, second.path), "utf8")).toContain("updated"); + }); + + it("integrates with Write Once by resolving files inside shared repository checkouts", async () => { + const setup = await createRepository(); + const plugin = githubPlugin(); + const preparer = plugin.preparers![0]!; + const resolver = plugin.resolvers![0]!; + expect(plugin.extractors).toBeUndefined(); + expect( + preparer.canPrepare(sourceReference("./local.ts", "README.md"), { cwd: setup.root }), + ).toBe(false); + const firstSource = "repo:owner/repo/src/index.ts"; + const secondSource = "repo:owner/repo/docs/guide.md"; + const references = [ + sourceReference(firstSource, "README.md"), + sourceReference(secondSource, "docs.md"), + ]; + const result = await preparer.prepare(references, { + cwd: setup.root, + workingDirectory: setup.root, + }); + expect(result.resources).toHaveLength(1); + const preparation = { preparerId: "write-once-github", workingDirectory: setup.root, result }; + expect(resolver.canResolve("./local.ts", { cwd: setup.root })).toBe(false); + expect( + resolver.canResolve(firstSource, { + cwd: setup.root, + preparation: { ...preparation, preparerId: "other" }, + }), + ).toBe(false); + expect(() => resolver.resolve(firstSource, { cwd: setup.root })).toThrow( + "No GitHub preparation was assigned", + ); + expect(() => + resolver.resolve(firstSource, { + cwd: setup.root, + preparation: { + ...preparation, + result: { resources: [], metadata: { files: { [firstSource]: { root: 42 } } } }, + }, + }), + ).toThrow("was not included in the assigned preparation batch"); + expect(() => + resolver.resolve(firstSource, { + cwd: setup.root, + preparation: { ...preparation, result: { resources: [] } }, + }), + ).toThrow("was not included in the assigned preparation batch"); + const first = await resolver.resolve(firstSource, { cwd: setup.root, preparation }); + const second = await resolver.resolve(secondSource, { cwd: setup.root, preparation }); + expect(first.root).toBe(second.root); + expect(first.path).toBe("src/index.ts"); + expect(second.path).toBe("docs/guide.md"); + expect(await readFile(join(first.root, first.path), "utf8")).toContain("main"); + }); + + it("reuses a clean checkout and accepts an HTTPS token", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const first = await prepareGitHubFile(reference, setup.root, "test-token"); + clearGitHubCaches(); + const second = await prepareGitHubFile(reference, setup.root, "test-token"); + expect(second.root).toBe(first.root); + expect(second.resolved.commit).toBe(first.resolved.commit); + }); + + it("reports when a repository has no default branch", async () => { + temporary = await mkdtemp(join(tmpdir(), "write-once-github-empty-")); + const remoteRoot = join(temporary, "remotes"); + const remote = join(remoteRoot, "owner", "repo.git"); + await mkdir(join(remoteRoot, "owner"), { recursive: true }); + git(["init", "--bare", "--initial-branch=main", remote]); + vi.stubEnv("GITHUB_GIT_BASE_URL", `file://${remoteRoot}`); + await expect( + prepareGitHubFile( + parseGitHubReference("repo:owner/repo/README.md"), + join(temporary, "plugin-work"), + ), + ).rejects.toThrow("Could not determine the default branch"); + }); + + it("reports Git failures when the repository cannot be reached", async () => { + const setup = await createRepository(); + vi.stubEnv("GITHUB_GIT_BASE_URL", join(temporary!, "missing-remote")); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts"), setup.root), + ).rejects.toThrow("Git command failed"); + }); + + it("replaces a checkout whose Git metadata is corrupted", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await rm(join(prepared.root, ".git"), { recursive: true, force: true }); + await mkdir(join(prepared.root, ".git"), { recursive: true }); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); + }); + + it("replaces a dirty checkout before resolving content", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await writeFile(join(prepared.root, prepared.path), "tampered\n"); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); + }); + + it("rejects missing refs and symbolic-link paths", async () => { + const setup = await createRepository(); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo@missing/src/index.ts"), setup.root), + ).rejects.toThrow("was not found"); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/linked.ts"), setup.root), + ).rejects.toThrow("symbolic link"); + }); + + it("reuses a clean checkout and accepts an HTTPS token", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const first = await prepareGitHubFile(reference, setup.root, "test-token"); + clearGitHubCaches(); + const second = await prepareGitHubFile(reference, setup.root, "test-token"); + expect(second.root).toBe(first.root); + expect(second.resolved.commit).toBe(first.resolved.commit); + }); + + it("reports when a repository has no default branch", async () => { + temporary = await mkdtemp(join(tmpdir(), "write-once-github-empty-")); + const remoteRoot = join(temporary, "remotes"); + const remote = join(remoteRoot, "owner", "repo.git"); + await mkdir(join(remoteRoot, "owner"), { recursive: true }); + git(["init", "--bare", "--initial-branch=main", remote]); + vi.stubEnv("GITHUB_GIT_BASE_URL", `file://${remoteRoot}`); + await expect( + prepareGitHubFile( + parseGitHubReference("repo:owner/repo/README.md"), + join(temporary, "plugin-work"), + ), + ).rejects.toThrow("Could not determine the default branch"); + }); + + it("reports Git failures when the repository cannot be reached", async () => { + const setup = await createRepository(); + vi.stubEnv("GITHUB_GIT_BASE_URL", join(temporary!, "missing-remote")); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts"), setup.root), + ).rejects.toThrow("Git command failed"); + }); + + it("replaces a checkout whose Git metadata is corrupted", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await rm(join(prepared.root, ".git"), { recursive: true, force: true }); + await mkdir(join(prepared.root, ".git"), { recursive: true }); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); + }); + + it("replaces a dirty checkout before resolving content", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await writeFile(join(prepared.root, prepared.path), "tampered\n"); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); + }); + + it("rejects missing refs and symbolic-link paths", async () => { + const setup = await createRepository(); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo@missing/src/index.ts"), setup.root), + ).rejects.toThrow("was not found"); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/linked.ts"), setup.root), + ).rejects.toThrow("symbolic link"); + }); + + it("rejects missing files and unsafe paths", async () => { + const setup = await createRepository(); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/missing.ts"), setup.root), + ).rejects.toThrow("was not found"); + await expect( + prepareGitHubFile({ owner: "owner", repository: "repo", path: "../secret" }, setup.root), + ).rejects.toThrow("Unsafe GitHub file path"); + await Promise.all( + ["/absolute", "src\\secret", "src//secret", "src/./secret", ""].map((path) => + expect( + prepareGitHubFile({ owner: "owner", repository: "repo", path }, setup.root), + ).rejects.toThrow("Unsafe GitHub file path"), + ), + ); + }, 20_000); +}); diff --git a/tests/reference.test.ts b/tests/reference.test.ts new file mode 100644 index 0000000..6164593 --- /dev/null +++ b/tests/reference.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { isGitHubReference, parseGitHubReference } from "@/reference"; + +describe("GitHub references", () => { + it("parses default branch references", () => + expect(parseGitHubReference("repo:owner/repo/src/index.ts")).toEqual({ + owner: "owner", + repository: "repo", + path: "src/index.ts", + })); + + it("preserves the remaining path for remote resolution of refs containing slashes", () => + expect(parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts")).toEqual({ + owner: "owner", + repository: "repo", + ref: "feature", + path: "nested/src/index.ts", + })); + + it.each([ + "repo:owner/repo", + "repo:owner/repo/../secret", + "repo:owner/repo/src/../secret", + "repo:owner/repo/src\\secret", + "repo:owner/repo@/src/index.ts", + "repo:/repo/src/index.ts", + ])("rejects invalid reference %s", (source) => + expect(() => parseGitHubReference(source)).toThrow(), + ); + + it("rejects sources owned by another resolver", () => { + expect(() => parseGitHubReference("./src/index.ts")).toThrow( + "Unsupported GitHub source reference", + ); + }); + + it("recognizes provider references", () => { + expect(isGitHubReference("repo:owner/repo/a.ts")).toBe(true); + expect(isGitHubReference("./a.ts")).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index bb4811d..5124945 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ tsconfigPaths: true, }, test: { + testTimeout: 20_000, coverage: { provider: "istanbul", reporter: ["text", "html", "lcov"],