├── .tool-versions ├── .vscode ├── settings.json └── launch.json ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── test.yml │ ├── release.yml │ └── update-changesets.yml └── FUNDING.yml ├── deno.json ├── LICENSE ├── .gitignore ├── copilot-instructions.md ├── src ├── utils.ts ├── unityChangeset.ts ├── cli.ts ├── unityGraphQL.ts ├── index.ts └── unityChangeset.test.ts ├── README.md └── deno.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.20.8 2 | deno 2.5.2 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": [], 5 | "editor.formatOnSave": true, 6 | "editor.defaultFormatter": "denoland.vscode-deno" 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{ts,js,json}] 10 | indent_style = space 11 | indent_size = 2 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about this project 4 | title: '' 5 | labels: question 6 | assignees: mob-sakai 7 | 8 | --- 9 | 10 | NOTE: Your issue may already be reported! Please search on the [issue tracker](../) before creating one. 11 | 12 | **Describe what help do you need** 13 | A description of the question. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the question here. 17 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "dnt": "https://deno.land/x/dnt@0.40.0/mod.ts", 4 | "graphql-request": "npm:graphql-request@6.1.0", 5 | "cliffy/command": "https://deno.land/x/cliffy@v0.25.7/command/command.ts", 6 | "std/path": "https://deno.land/std@0.181.0/path/mod.ts", 7 | "std/testing/asserts": "https://deno.land/std@0.181.0/testing/asserts.ts" 8 | }, 9 | "tasks": { 10 | "run": "deno run -A src/cli.ts", 11 | "build": "deno run -A build/build_npm.ts", 12 | "test": "deno test -A src/", 13 | "lint": "deno lint src/ && deno fmt src/" 14 | } 15 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "**.ts" 7 | push: 8 | branches: 9 | - "develop" 10 | paths: 11 | - "**.ts" 12 | workflow_dispatch: 13 | 14 | jobs: 15 | test: 16 | if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]') }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v5 20 | 21 | - uses: asdf-vm/actions/install@v4 22 | 23 | - run: deno lint src/ 24 | 25 | - run: deno fmt --check src/ 26 | 27 | - run: deno task test 28 | 29 | - run: deno task build 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mob-sakai # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: mob-sakai # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - beta 8 | tags-ignore: 9 | - "**" 10 | 11 | jobs: 12 | release: 13 | if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]') }} 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - uses: asdf-vm/actions/install@v4 19 | 20 | - run: deno task build 21 | 22 | - uses: actions/setup-node@v5 23 | with: 24 | node-version: "24" 25 | 26 | - uses: cycjimmy/semantic-release-action@v5 27 | with: 28 | working_directory: npm 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 31 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: mob-sakai 7 | 8 | --- 9 | 10 | NOTE: Your issue may already be reported! Please search on the [issue tracker](../) before creating one. 11 | 12 | **Is your feature request related to a problem? Please describe.** 13 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: mob-sakai 7 | 8 | --- 9 | 10 | NOTE: Your issue may already be reported! Please search on the [issue tracker](../) before creating one. 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Environment (please complete the following information):** 29 | - OS: [e.g. Windows 10, MacOS 10.14] 30 | - Node: [e.g. 8.15] 31 | - Version: [e.g. 1.0.0] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 mob-sakai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/update-changesets.yml: -------------------------------------------------------------------------------- 1 | name: update-changesets 2 | 3 | env: 4 | WORKFLOW_FILE: update-changesets.yml 5 | 6 | on: 7 | issue_comment: 8 | types: 9 | - created 10 | 11 | jobs: 12 | build: 13 | if: startsWith(github.event.comment.body, '/update-changesets') && github.event.issue.locked 14 | name: 🛠️ Update changesets 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | actions: read 19 | steps: 20 | - name: 🚚 Checkout (gh_pages) 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | ref: gh_pages 25 | 26 | - name: 🔍 Check other workflows 27 | run: | 28 | # Get in-progress or queued workflows. 29 | gh auth login --with-token < <(echo ${{ github.token }}) 30 | RESULT=`gh run list --workflow ${{ env.WORKFLOW_FILE }} --json status --jq '[.[] | select(.status == "in_progress")] | length == 1'` 31 | 32 | # [ERROR] Other workflows are in progress. 33 | [ "$RESULT" = "false" ] && echo "::error::Other '${{ env.WORKFLOW_FILE }}' workflows are in progress." && exit 1 || : 34 | 35 | - name: 🛠️ Update changesets file 36 | run: | 37 | npx unity-changeset@latest list --all --all-lifecycles --pretty-json > dbV3 38 | jq -r '.[] | "\(.version)\t\(.changeset)"' dbV3 > db 39 | 40 | - name: Commit & Push changes 41 | uses: actions-js/push@master 42 | with: 43 | github_token: ${{ github.token }} 44 | amend: true 45 | force: true 46 | branch: gh_pages 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | .DS_Store 106 | /npm 107 | -------------------------------------------------------------------------------- /copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot Instructions for Unity Changeset Project 2 | 3 | ## Overview 4 | This project is a TypeScript library and CLI tool for retrieving and listing Unity editor changesets. It supports both Deno and Node.js environments, using Deno for development and building npm packages via dnt. The codebase follows Deno's conventions and uses modern TypeScript features. 5 | 6 | ## Coding Standards 7 | - **Language**: TypeScript with Deno runtime 8 | - **Style**: Follow Deno's linting rules (`deno lint`) and formatting (`deno fmt`) 9 | - **Imports**: Use Deno-style imports from `deno.land` or npm specifiers 10 | - **Error Handling**: Use explicit error handling with try-catch blocks; prefer throwing errors over returning error objects 11 | - **Naming**: Use camelCase for variables/functions, PascalCase for classes/interfaces 12 | - **Async/Await**: Prefer async/await over promises for asynchronous operations 13 | - **Types**: Use strict TypeScript typing; avoid `any` unless necessary 14 | - **Modules**: Keep modules focused and single-responsible; export only what's needed 15 | 16 | ## Testing 17 | - Use Deno's built-in testing framework (`deno test`) 18 | - Write tests in `.test.ts` files alongside source files 19 | - Use `std/testing/asserts` for assertions 20 | - Cover both unit tests and integration tests where applicable 21 | - Run tests with `deno task test` before committing 22 | 23 | ## Build and Deployment 24 | - Build npm packages using dnt (Deno to Node Transform) 25 | - Run `deno task build` to generate npm distribution 26 | - Follow semantic versioning for releases 27 | - Use conventional commit messages for automated releases 28 | 29 | ## Dependencies 30 | - Core: Deno standard library (`std/path`, `std/testing/asserts`) 31 | - CLI: Cliffy for command-line interface 32 | - GraphQL: graphql-request for Unity API interactions 33 | - Build: dnt for npm package generation 34 | 35 | ## Communication 36 | - **Ask Questions When Needed**: If user requirements are unclear, ask clarifying questions to ensure accurate implementation 37 | - **Think in English**: All internal reasoning and planning should be conducted in English to maintain consistency and clarity 38 | 39 | ## Best Practices 40 | - Keep code modular and reusable 41 | - Document complex logic with comments 42 | - Ensure cross-platform compatibility (macOS, Windows, Linux) 43 | - Validate inputs and handle edge cases 44 | - Follow security best practices, especially when handling external data 45 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "request": "launch", 6 | "name": "list", 7 | "type": "node", 8 | "program": "${workspaceFolder}/src/cli.ts", 9 | "cwd": "${workspaceFolder}", 10 | "env": {}, 11 | "runtimeExecutable": "deno", 12 | "runtimeArgs": [ 13 | "run", 14 | "--unstable", 15 | "--inspect-brk", 16 | "--allow-all" 17 | ], 18 | "args": [ 19 | "list" 20 | ], 21 | "attachSimplePort": 9229 22 | }, 23 | { 24 | "request": "launch", 25 | "name": "list --versions --all --all-lifecycles", 26 | "type": "node", 27 | "program": "${workspaceFolder}/src/cli.ts", 28 | "cwd": "${workspaceFolder}", 29 | "env": {}, 30 | "runtimeExecutable": "deno", 31 | "runtimeArgs": [ 32 | "run", 33 | "--unstable", 34 | "--inspect-brk", 35 | "--allow-all" 36 | ], 37 | "args": [ 38 | "list", 39 | "--versions", 40 | "--all", 41 | "--all-lifecycles" 42 | ], 43 | "attachSimplePort": 9229 44 | }, 45 | { 46 | "request": "launch", 47 | "name": "6000.0.25f1", 48 | "type": "node", 49 | "program": "${workspaceFolder}/src/cli.ts", 50 | "cwd": "${workspaceFolder}", 51 | "env": {}, 52 | "runtimeExecutable": "deno", 53 | "runtimeArgs": [ 54 | "run", 55 | "--unstable", 56 | "--inspect-brk", 57 | "--allow-all" 58 | ], 59 | "args": [ 60 | "6000.0.25f1" 61 | ], 62 | "attachSimplePort": 9229 63 | }, 64 | { 65 | "request": "launch", 66 | "name": "6000.0.25f2", 67 | "type": "node", 68 | "program": "${workspaceFolder}/src/cli.ts", 69 | "cwd": "${workspaceFolder}", 70 | "env": {}, 71 | "runtimeExecutable": "deno", 72 | "runtimeArgs": [ 73 | "run", 74 | "--unstable", 75 | "--inspect-brk", 76 | "--allow-all" 77 | ], 78 | "args": [ 79 | "6000.0.25f2" 80 | ], 81 | "attachSimplePort": 9229 82 | }, 83 | { 84 | "request": "launch", 85 | "name": "--help", 86 | "type": "node", 87 | "program": "${workspaceFolder}/src/cli.ts", 88 | "cwd": "${workspaceFolder}", 89 | "env": {}, 90 | "runtimeExecutable": "deno", 91 | "runtimeArgs": [ 92 | "run", 93 | "--unstable", 94 | "--inspect-brk", 95 | "--allow-all" 96 | ], 97 | "args": [ 98 | "--help" 99 | ], 100 | "attachSimplePort": 9229 101 | } 102 | ] 103 | } 104 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { UnityReleaseStream } from "./unityChangeset.ts"; 2 | import { SearchMode } from "./index.ts"; 3 | 4 | /** 5 | * Group array of objects by key 6 | */ 7 | export const groupBy = ( 8 | arr: T[], 9 | key: (i: T) => K, 10 | ): Record => 11 | arr.reduce((groups, item) => { 12 | (groups[key(item)] ||= []).push(item); 13 | return groups; 14 | }, {} as Record); 15 | 16 | /** 17 | * Convert search mode to array of streams 18 | */ 19 | export function searchModeToStreams( 20 | searchMode: SearchMode, 21 | ): UnityReleaseStream[] { 22 | switch (searchMode) { 23 | case SearchMode.All: 24 | return [ 25 | UnityReleaseStream.LTS, 26 | UnityReleaseStream.SUPPORTED, 27 | UnityReleaseStream.TECH, 28 | UnityReleaseStream.BETA, 29 | UnityReleaseStream.ALPHA, 30 | ]; 31 | case SearchMode.Default: 32 | return [ 33 | UnityReleaseStream.LTS, 34 | UnityReleaseStream.SUPPORTED, 35 | UnityReleaseStream.TECH, 36 | ]; 37 | case SearchMode.PreRelease: 38 | return [ 39 | UnityReleaseStream.ALPHA, 40 | UnityReleaseStream.BETA, 41 | ]; 42 | case SearchMode.LTS: 43 | return [ 44 | UnityReleaseStream.LTS, 45 | ]; 46 | case SearchMode.Supported: 47 | return [ 48 | UnityReleaseStream.SUPPORTED, 49 | ]; 50 | default: 51 | throw new Error( 52 | `The given search mode '${searchMode}' was not supported`, 53 | ); 54 | } 55 | } 56 | 57 | /** 58 | * Sanitize version string to prevent injection attacks 59 | */ 60 | export function sanitizeVersion(version: string): string { 61 | if (typeof version !== "string") { 62 | throw new Error("Version must be a string"); 63 | } 64 | // Allow only alphanumeric, dots, and specific characters 65 | const sanitized = version.replace(/[^a-zA-Z0-9.\-]/g, ""); 66 | if (sanitized !== version) { 67 | throw new Error("Version contains invalid characters"); 68 | } 69 | return sanitized; 70 | } 71 | 72 | /** 73 | * Validate filter options 74 | */ 75 | export function validateFilterOptions( 76 | options: { min?: string; max?: string; grep?: string }, 77 | ): void { 78 | if (options.min && typeof options.min !== "string") { 79 | throw new Error("Min version must be a string"); 80 | } 81 | if (options.max && typeof options.max !== "string") { 82 | throw new Error("Max version must be a string"); 83 | } 84 | if (options.grep && typeof options.grep !== "string") { 85 | throw new Error("Grep pattern must be a string"); 86 | } 87 | // Validate regex pattern safety 88 | if (options.grep) { 89 | try { 90 | new RegExp(options.grep, "i"); 91 | } catch { 92 | throw new Error("Invalid grep pattern"); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/unityChangeset.ts: -------------------------------------------------------------------------------- 1 | const REGEXP_UNITY = /^(\d+)\.(\d+)\.(\d+)([a-zA-Z]+)(\d+)/; 2 | const REGEXP_UNITY_NUM = /^(\d+)\.?(\d+)?\.?(\d+)?([a-zA-Z]+)?(\d+)?/; 3 | 4 | /* 5 | Unity Release Stream 6 | */ 7 | export enum UnityReleaseStream { 8 | SUPPORTED = "SUPPORTED", 9 | LTS = "LTS", 10 | TECH = "TECH", 11 | BETA = "BETA", 12 | ALPHA = "ALPHA", 13 | UNDEFINED = "UNDEFINED", 14 | } 15 | 16 | /* 17 | Unity Release Entitlement 18 | */ 19 | export enum UnityReleaseEntitlement { 20 | XLTS = "XLTS", 21 | U7_ALPHA = "U7_ALPHA", 22 | } 23 | 24 | /** 25 | * Represents a Unity changeset with version, changeset hash, and metadata. 26 | */ 27 | export class UnityChangeset { 28 | version = ""; 29 | changeset = ""; 30 | versionNumber = 0; 31 | minor = ""; 32 | lifecycle = ""; 33 | lts = false; 34 | stream: UnityReleaseStream = UnityReleaseStream.UNDEFINED; 35 | entitlements: UnityReleaseEntitlement[] = []; 36 | xlts = false; 37 | 38 | /** 39 | * Creates a new UnityChangeset instance. 40 | * @param version - The Unity version string. 41 | * @param changeset - The changeset hash. 42 | * @param stream - The release stream. 43 | * @param entitlements - The entitlements array. 44 | * @throws Error if version or changeset is invalid. 45 | */ 46 | constructor( 47 | version: string, 48 | changeset: string, 49 | stream: UnityReleaseStream = UnityReleaseStream.UNDEFINED, 50 | entitlements: UnityReleaseEntitlement[] = [], 51 | ) { 52 | if (!version || typeof version !== "string") { 53 | throw new Error("Version must be a non-empty string"); 54 | } 55 | if (!changeset || typeof changeset !== "string") { 56 | throw new Error("Changeset must be a non-empty string"); 57 | } 58 | if (!Array.isArray(entitlements)) { 59 | throw new Error("Entitlements must be an array"); 60 | } 61 | 62 | Object.assign(this, { version, changeset, stream, entitlements }); 63 | 64 | this.lts = stream === UnityReleaseStream.LTS; 65 | this.xlts = entitlements.includes(UnityReleaseEntitlement.XLTS); 66 | 67 | const match = this.version.match(REGEXP_UNITY); 68 | if (match) { 69 | this.versionNumber = UnityChangeset.toNumber(this.version, false); 70 | this.minor = `${match[1]}.${match[2]}`; 71 | this.lifecycle = `${match[4]}`; 72 | } 73 | } 74 | 75 | /** 76 | * Returns a string representation of the changeset in the format "version\tchangeset". 77 | * @returns The string representation. 78 | */ 79 | toString = (): string => { 80 | return `${this.version}\t${this.changeset}`; 81 | }; 82 | 83 | /** 84 | * Converts a Unity version string to a numerical representation for comparison. 85 | * @param version - The Unity version string. 86 | * @param max - If true, treats missing parts as maximum values; otherwise, as minimum. 87 | * @returns The numerical representation of the version. 88 | */ 89 | static toNumber = (version: string, max: boolean): number => { 90 | const match = version.toString().match(REGEXP_UNITY_NUM); 91 | if (match === null) return 0; 92 | 93 | return parseInt(match[1] || (max ? "9999" : "0")) * 100 * 100 * 100 * 100 + 94 | parseInt(match[2] || (max ? "99" : "0")) * 100 * 100 * 100 + 95 | parseInt(match[3] || (max ? "99" : "0")) * 100 * 100 + 96 | ((match[4] || (max ? "z" : "a")).toUpperCase().charCodeAt(0) - 65) * 100 + 97 | parseInt(match[5] || (max ? "99" : "0")); 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | // deno-fmt-ignore-file 2 | import { Command } from "cliffy/command"; 3 | import { resolve } from "std/path"; 4 | import { 5 | getUnityChangeset, 6 | listChangesets, 7 | SearchMode, 8 | FilterOptions, 9 | GroupMode, 10 | OutputMode, 11 | FormatMode, 12 | } from "./index.ts"; 13 | 14 | function getPackageVersion(): string { 15 | try { 16 | return JSON.parse(Deno.readTextFileSync( 17 | resolve(new URL(import.meta.url).pathname, "../../package.json"), 18 | )).version; 19 | } catch { 20 | return "-"; 21 | } 22 | } 23 | 24 | new Command() 25 | /* 26 | * Main command 27 | */ 28 | .name("unity-changeset") 29 | .version(getPackageVersion) 30 | .description("Find Unity changesets.") 31 | .example("unity-changeset 2018.4.36f1", "Get changeset of Unity 2018.4.36f1 ('6cd387d23174' will be output).") 32 | .arguments("") 33 | .option("--db [url]", "Use cached database instead of GraphQL API. If `url` is not specified, use the default database.") 34 | .action((options, version) => { 35 | getUnityChangeset(version, options.db) 36 | .then((c) => console.log(c.changeset)) 37 | .catch(() => { 38 | console.error("The given version was not found."); 39 | Deno.exit(1); 40 | }); 41 | }) 42 | /* 43 | * Sub command: list. 44 | */ 45 | .command( 46 | "list", 47 | new Command() 48 | .description("List Unity changesets.") 49 | .example("unity-changeset list", "List changesets.") 50 | .example("unity-changeset list --all --json", "List changesets of all versions in json format.") 51 | .example("unity-changeset list --version-only --min 2018.3 --max 2019.4", "List all versions from 2018.3 to 2019.4.") 52 | .example("unity-changeset list --version-only --grep '(2018.4|2019.4)'", "List all versions in 2018.4 and 2019.4.") 53 | .example("unity-changeset list --lts --latest-patch", "List changesets of the latest patch versions (LTS only).") 54 | // Search options. 55 | .group("Search options") 56 | .option("--all", "Search in all streams (alpha/beta included)") 57 | .option("--supported", "Search in the 'SUPPORTED' stream (including Unity 6000)", { conflicts: ["all", "pre-release", "lts"] }) 58 | .option("--lts", "Search in the 'LTS' stream", { conflicts: ["all", "supported", "pre-release"] }) 59 | .option("--pre-release, --beta", "Search in the 'ALPHA' and 'BETA' streams", { conflicts: ["all", "supported", "lts"] }) 60 | // Filter options. 61 | .group("Filter options") 62 | .option("--xlts", "Include XLTS entitlement versions (require 'Enterprise' or 'Industry' license to install XLTS version)") 63 | .option("--min ", "Minimum version (included)") 64 | .option("--max ", "Maximum version (included)") 65 | .option("--grep ", "Regular expression (e.g. '20(18|19).4.*')") 66 | .option("--latest-lifecycle", "Only the latest lifecycle (default)") 67 | .option("--all-lifecycles", "All lifecycles", { conflicts: ["latest-lifecycle"] }) 68 | // Group options. 69 | .group("Group options") 70 | .option("--latest-patch", "The latest patch versions only") 71 | .option("--oldest-patch", "The oldest patch versions in lateat lifecycle only", { conflicts: ["latest-patch"] }) 72 | // Output options. 73 | .group("Output options") 74 | .option("--version-only, --versions", "Outputs only the version (no changesets)") 75 | .option("--minor-version-only, --minor-versions", "Outputs only the minor version (no changesets)", { conflicts: ["version-only"] }) 76 | .option("--json", "Output in json format") 77 | .option("--pretty-json", "Output in pretty json format") 78 | // Database options. 79 | .group("Database options") 80 | .option("--db [url]", "Use cached database instead of GraphQL API. If `url` is not specified, use the default database.") 81 | .action((options) => { 82 | // Search mode. 83 | const searchMode = options.all 84 | ? SearchMode.All 85 | : options.preRelease 86 | ? SearchMode.PreRelease 87 | : options.lts 88 | ? SearchMode.LTS 89 | : options.supported 90 | ? SearchMode.Supported 91 | : SearchMode.Default; 92 | 93 | // Group mode. 94 | const groupMode = (options.latestPatch || options.minorVersionOnly) 95 | ? GroupMode.LatestPatch 96 | : options.oldestPatch 97 | ? GroupMode.OldestPatch 98 | : GroupMode.All; 99 | 100 | // Filter options. 101 | const filterOptions: FilterOptions = { 102 | min: options.min || "", 103 | max: options.max || "", 104 | grep: options.grep || "", 105 | allLifecycles: (options.allLifecycles && !options.latestLifecycle) 106 | ? true 107 | : false, 108 | xlts: options.xlts || false, 109 | }; 110 | 111 | // Output mode. 112 | const outputMode = options.versionOnly 113 | ? OutputMode.VersionOnly 114 | : options.minorVersionOnly 115 | ? OutputMode.MinorVersionOnly 116 | : OutputMode.Changeset; 117 | 118 | // Format mode. 119 | const formatMode = options.json 120 | ? FormatMode.Json 121 | : options.prettyJson 122 | ? FormatMode.PrettyJson 123 | : FormatMode.None; 124 | 125 | listChangesets(searchMode, filterOptions, groupMode, outputMode, formatMode, options.db) 126 | .then((result) => console.log(result)); 127 | }), 128 | ) 129 | /* 130 | * Run with arguments. 131 | */ 132 | .parse(Deno.args); 133 | -------------------------------------------------------------------------------- /src/unityGraphQL.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UnityChangeset, 3 | UnityReleaseEntitlement, 4 | UnityReleaseStream, 5 | } from "./unityChangeset.ts"; 6 | import { ClientError, gql, GraphQLClient } from "graphql-request"; 7 | 8 | const UNITY_GRAPHQL_ENDPOINT: string = "https://services.unity.com/graphql"; 9 | 10 | // Cache for API responses 11 | const cache = new Map< 12 | string, 13 | { 14 | data: UnityReleasesResponse | UnityReleasesMajorVersionsResponse; 15 | timestamp: number; 16 | } 17 | >(); 18 | const CACHE_TTL = 5 * 60 * 1000; // 5 minutes 19 | 20 | interface UnityReleasesResponse { 21 | getUnityReleases: { 22 | totalCount: number; 23 | edges: { 24 | node: { 25 | version: string; 26 | shortRevision: string; 27 | stream: UnityReleaseStream; 28 | entitlements: UnityReleaseEntitlement[]; 29 | }; 30 | }[]; 31 | pageInfo: { hasNextPage: boolean }; 32 | }; 33 | } 34 | 35 | interface UnityReleasesMajorVersionsResponse { 36 | getUnityReleaseMajorVersions: { version: string }[]; 37 | } 38 | 39 | // Helper function to get cache key 40 | function getCacheKey( 41 | query: string, 42 | variables: Record, 43 | ): string { 44 | return JSON.stringify({ query, variables }); 45 | } 46 | 47 | // Helper function to check if cache is valid 48 | function isCacheValid(timestamp: number): boolean { 49 | return Date.now() - timestamp < CACHE_TTL; 50 | } 51 | 52 | // Enhanced error handling for GraphQL requests 53 | async function requestWithErrorHandling( 54 | client: GraphQLClient, 55 | query: string, 56 | variables: Record, 57 | ): Promise { 58 | try { 59 | const response = await client.request(query, variables); 60 | return response; 61 | } catch (error: unknown) { 62 | if (error instanceof ClientError) { 63 | if (error.response?.errors) { 64 | const graphQLErrors = error.response.errors.map((e) => e.message).join( 65 | ", ", 66 | ); 67 | throw new Error(`GraphQL API error: ${graphQLErrors}`); 68 | } else if (error.response?.status) { 69 | throw new Error( 70 | `HTTP error: ${error.response.status} ${error.response.statusText}`, 71 | ); 72 | } 73 | } 74 | if ( 75 | error instanceof Error && 76 | (error.message.includes("ECONNREFUSED") || 77 | error.message.includes("ENOTFOUND")) 78 | ) { 79 | throw new Error( 80 | `Network error: Unable to connect to Unity GraphQL API. Please check your internet connection.`, 81 | ); 82 | } 83 | throw new Error( 84 | `Unexpected error: ${ 85 | error instanceof Error ? error.message : "Unknown error occurred" 86 | }`, 87 | ); 88 | } 89 | } 90 | 91 | /** 92 | * Retrieves Unity releases from the GraphQL API based on version, stream, and entitlements. 93 | * @param version - The version pattern to search for. 94 | * @param stream - The array of release streams to filter by. 95 | * @param entitlements - The array of entitlements to filter by. 96 | * @returns A Promise that resolves to an array of UnityChangeset objects. 97 | * @throws Error if the API response is invalid. 98 | */ 99 | export async function getUnityReleases( 100 | version: string, 101 | stream: UnityReleaseStream[] = [], 102 | entitlements: UnityReleaseEntitlement[] = [], 103 | ): Promise { 104 | const client = new GraphQLClient(UNITY_GRAPHQL_ENDPOINT); 105 | const query = gql` 106 | query GetRelease($limit: Int, $skip: Int, $version: String!, $stream: [UnityReleaseStream!], $entitlements: [UnityReleaseEntitlement!]) 107 | { 108 | getUnityReleases( 109 | limit: $limit 110 | skip: $skip 111 | stream: $stream 112 | version: $version 113 | entitlements: $entitlements 114 | ) { 115 | totalCount 116 | edges { 117 | node { 118 | version 119 | shortRevision 120 | stream 121 | entitlements 122 | } 123 | } 124 | pageInfo { 125 | hasNextPage 126 | } 127 | } 128 | } 129 | `; 130 | 131 | const variables = { 132 | limit: 250, 133 | skip: 0, 134 | version: version, 135 | stream: stream, 136 | entitlements: entitlements, 137 | }; 138 | 139 | const results: UnityChangeset[] = []; 140 | while (true) { 141 | const cacheKey = getCacheKey(query, variables); 142 | let data: UnityReleasesResponse; 143 | 144 | const cached = cache.get(cacheKey); 145 | if (cached && isCacheValid(cached.timestamp)) { 146 | data = cached.data as UnityReleasesResponse; 147 | } else { 148 | data = await requestWithErrorHandling( 149 | client, 150 | query, 151 | variables, 152 | ); 153 | cache.set(cacheKey, { data, timestamp: Date.now() }); 154 | } 155 | 156 | if (!data.getUnityReleases || !data.getUnityReleases.edges) { 157 | throw new Error( 158 | "Invalid response from Unity GraphQL API: missing getUnityReleases.edges", 159 | ); 160 | } 161 | 162 | results.push( 163 | ...data.getUnityReleases.edges.map((edge) => { 164 | if (!edge.node) { 165 | throw new Error( 166 | "Invalid response from Unity GraphQL API: missing edge.node", 167 | ); 168 | } 169 | return new UnityChangeset( 170 | edge.node.version, 171 | edge.node.shortRevision, 172 | edge.node.stream, 173 | edge.node.entitlements, 174 | ); 175 | }), 176 | ); 177 | if (data.getUnityReleases.pageInfo.hasNextPage === false) { 178 | break; 179 | } 180 | 181 | variables.skip += variables.limit; 182 | } 183 | 184 | return results; 185 | } 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unity-changeset 2 | 3 | Get/List Unity editor changeset 4 | 5 | [![](https://shields.io/badge/deno.land-unity__changeset-green?logo=deno&style=flat)](https://deno.land/x/unity_changeset) 6 | [![npm](https://img.shields.io/npm/v/unity-changeset)](https://www.npmjs.com/package/unity-changeset) 7 | ![license](https://img.shields.io/npm/l/unity-changeset) 8 | ![downloads](https://img.shields.io/npm/dy/unity-changeset) 9 | ![release](https://github.com/mob-sakai/unity-changeset/workflows/release/badge.svg) 10 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 11 | 12 |



13 | 14 | ## Key Features 15 | 16 | - Get Unity editor changeset for specific versions 17 | - List archived and beta changesets 18 | - Available as Node.js module, Deno module, and CLI tool 19 | - Supports filtering, searching, and grouping of changesets 20 | - Integration with Unity Hub for installation 21 | 22 |



23 | 24 | ## Usage as a node module 25 | 26 | Requirement: NodeJs 18 or later 27 | 28 | ### Install 29 | 30 | ```sh 31 | npm install unity-changeset 32 | ``` 33 | 34 | ### Import 35 | 36 | ```js 37 | // javascript 38 | const { getUnityChangeset, scrapeArchivedChangesets, scrapeBetaChangesets } = require('unity-changeset'); 39 | // or, typescript 40 | const { getUnityChangeset, scrapeArchivedChangesets, scrapeBetaChangesets } = from 'unity-changeset'; 41 | ``` 42 | 43 | ### Example 44 | 45 | ```js 46 | const { getUnityChangeset, scrapeArchivedChangesets, scrapeBetaChangesets } = 47 | require("unity-changeset"); 48 | 49 | (async () => { 50 | const changeset = await getUnityChangeset("2020.1.14f1"); 51 | console.log(changeset); 52 | //=> UnityChangeset {version: '2020.1.14f1', changeset: 'd81f64f5201d'} 53 | console.log(changeset.toString()); 54 | //=> 2020.1.14f1 d81f64f5201d 55 | const changesets = await scrapeArchivedChangesets(); 56 | console.dir(changesets); 57 | //=> [ 58 | // UnityChangeset { version: '2020.1.15f1', changeset: '97d0ae02d19d' }, 59 | // UnityChangeset { version: '2020.1.14f1', changeset: 'd81f64f5201d' }, 60 | // UnityChangeset { version: '2020.1.13f1', changeset: '5e24f28bfbc0' }, 61 | // ... 62 | // ] 63 | const betaChangesets = await scrapeBetaChangesets(); 64 | console.log(betaChangesets); 65 | //=> [ 66 | // UnityChangeset { version: '2020.2.0b13', changeset: '655e1a328b90' }, 67 | // UnityChangeset { version: '2020.2.0b12', changeset: '92852ae685d8' }, 68 | // UnityChangeset { version: '2020.2.0b11', changeset: 'c499c2bf2e80' }, 69 | // ... 70 | // ] 71 | })(); 72 | ``` 73 | 74 | ## Usage as a deno module 75 | 76 | ```js 77 | const { getUnityChangeset, scrapeArchivedChangesets, scrapeBetaChangesets } = from 'https://deno.land/x/unity_changeset/src/index.ts'; 78 | 79 | // or, specific version 80 | const { getUnityChangeset, scrapeArchivedChangesets, scrapeBetaChangesets } = from 'https://deno.land/x/unity_changeset@2.0.0/src/index.ts'; 81 | ``` 82 | 83 |



84 | 85 | ## Usage as a command-line utility 86 | 87 | ### Install 88 | 89 | ```sh 90 | # Requirement: NodeJs 18 or later 91 | npm install -g unity-changeset 92 | 93 | # Use without installation 94 | npx unity-changeset ... 95 | ``` 96 | 97 | or 98 | 99 | ``` 100 | deno install -A -f -n unity-changeset https://deno.land/x/unity_changeset/src/cli.ts 101 | ``` 102 | 103 | ### Help 104 | 105 | ``` 106 | Usage: unity-changeset 107 | 108 | Description: 109 | 110 | Find Unity changesets. 111 | 112 | Options: 113 | 114 | -h, --help - Show this help. 115 | -V, --version - Show the version number for this program. 116 | --db [url] - Use cached database instead of GraphQL API. If `url` is not specified, use the default database. 117 | ``` 118 | 119 | ``` 120 | Usage: unity-changeset list 121 | 122 | Description: 123 | 124 | List Unity changesets. 125 | 126 | Options: 127 | 128 | -h, --help - Show this help. 129 | 130 | Search options: 131 | 132 | --all - Search in all streams (alpha/beta included) 133 | --supported - Search in the 'SUPPORTED' stream (including Unity 6000) (Conflicts: --all, --pre-release, --lts) 134 | --lts - Search in the 'LTS' stream (Conflicts: --all, --supported, --pre-release) 135 | --pre-release, --beta - Search in the 'ALPHA' and 'BETA' streams (Conflicts: --all, --supported, --lts) 136 | 137 | Filter options: 138 | 139 | --xlts - Include XLTS entitlement versions (require 'Enterprise' or 'Industry' license to install XLTS version) 140 | --min - Minimum version (included) 141 | --max - Maximum version (included) 142 | --grep - Regular expression (e.g. '20(18|19).4.*') 143 | --latest-lifecycle - Only the latest lifecycle (default) 144 | --all-lifecycles - All lifecycles 145 | 146 | Group options: 147 | 148 | --latest-patch - The latest patch versions only 149 | --oldest-patch - The oldest patch versions in lateat lifecycle only (Conflicts: --latest-patch) 150 | 151 | Output options: 152 | 153 | --version-only, --versions - Outputs only the version (no changesets) 154 | --minor-version-only, --minor-versions - Outputs only the minor version (no changesets) 155 | --json - Output in json format 156 | --pretty-json - Output in pretty json format 157 | ``` 158 | 159 | ### Get a changeset for specific version: 160 | 161 | ```sh 162 | $ unity-changeset 2020.2.14f1 163 | d81f64f5201d 164 | ``` 165 | 166 | ### Get a changeset for specific version 167 | 168 | ```sh 169 | $ unity-changeset list 170 | 2020.1.14f1 d81f64f5201d 171 | 2020.1.13f1 5e24f28bfbc0 172 | 2020.1.12f1 55b56f0a86e3 173 | ... 174 | 175 | # List changesets in json format: 176 | $ unity-changeset list --json 177 | [{"version":"2020.1.15f1","changeset":"97d0ae02d19d"},{"version":"2020.1.14f1","changeset":"d81f64f5201d"},...] 178 | 179 | # List changesets in pretty json format: 180 | $ unity-changeset list --pretty-json 181 | [ 182 | { 183 | "version": "2020.1.15f1", 184 | "changeset": "97d0ae02d19d" 185 | }, 186 | { 187 | "version": "2020.1.14f1", 188 | "changeset": "d81f64f5201d" 189 | }, 190 | ... 191 | ] 192 | 193 | # List changesets (alpha/beta): 194 | $ unity-changeset list --beta 195 | 2020.2.0b13 655e1a328b90 196 | 2020.2.0b12 92852ae685d8 197 | 2020.2.0b11 c499c2bf2e80 198 | ... 199 | 200 | # List changesets (all): 201 | $ unity-changeset list --all 202 | 2020.2.0b13 655e1a328b90 203 | 2020.2.0b12 92852ae685d8 204 | ... 205 | 2020.1.14f1 d81f64f5201d 206 | 2020.1.13f1 5e24f28bfbc0 207 | ... 208 | 209 | # List the available Unity versions: 210 | $ unity-changeset list --versions 211 | 2020.1.14f1 212 | 2020.1.13f1 213 | 2020.1.12f1 214 | ... 215 | 216 | # List the available Unity versions (alpha/beta): 217 | $ unity-changeset list --beta --versions 218 | 2020.2.0b13 219 | 2020.2.0b12 220 | 2020.2.0b11 221 | ... 222 | 223 | # List Unity 2018.3 or later, and 2019.1 or earlier: 224 | $ unity-changeset list --min 2018.3 --max 2019.1 225 | 2019.1.14f1 148b5891095a 226 | ... 227 | 2018.3.1f1 bb579dc42f1d 228 | 2018.3.0f2 6e9a27477296 229 | 230 | # List all Unity 2018.3 versions: 231 | $ unity-changeset list --grep 2018.3 232 | 2018.3.14f1 d0e9f15437b1 233 | 2018.3.13f1 06548a9e9582 234 | ... 235 | 2018.3.1f1 bb579dc42f1d 236 | 2018.3.0f2 6e9a27477296 237 | 238 | # List the available Unity minor versions: 239 | $ unity-changeset list --minor-versions 240 | 2020.1 241 | ... 242 | 2017.2 243 | 2017.1 244 | 245 | # List the latest Unity patch versions: 246 | $ unity-changeset list --latest-patch 247 | 2020.1.14f1 d81f64f5201d 248 | ... 249 | 2017.2.5f1 588dc79c95ed 250 | 2017.1.5f1 9758a36cfaa6 251 | ``` 252 | 253 | ### Install a specific version of Unity via UnityHub 254 | 255 | ```sh 256 | # /path/to/unity/hub: 257 | # Windows: C:\\Program\ Files\\Unity\ Hub\\Unity\ Hub.exe 258 | # MacOS: /Applications/Unity\ Hub.app/Contents/MacOS/Unity\ Hub 259 | 260 | # Show UnityHub help: 261 | $ /path/to/unity/hub -- --headless help 262 | 263 | # Install Unity 2020.1.15f1 with modules for iOS and Android: 264 | $ /path/to/unity/hub -- --headless install \ 265 | --version 2020.1.15f1 \ 266 | --changeset `unity-changeset 2020.1.15f1` \ 267 | --module ios,android 268 | ``` 269 | 270 |



271 | 272 | ## How to Develop 273 | 274 | ### Prerequisites 275 | 276 | - [asdf](https://asdf-vm.com/) (for managing Deno/Node versions) 277 | 278 | ### Setup 279 | 280 | 1. Clone the repository: 281 | ```sh 282 | git clone https://github.com/mob-sakai/unity-changeset.git 283 | cd unity-changeset 284 | ``` 285 | 286 | 2. Install dependencies: 287 | ```sh 288 | asdf install 289 | deno install 290 | ``` 291 | 292 | ### Development Tasks 293 | 294 | - Run tests: `deno task test` 295 | - Build the project: `deno task build` 296 | - Lint and format code: `deno task lint` 297 | - Run the CLI: `deno task run` 298 | 299 |



300 | 301 | ## Contributing 302 | 303 | ### Issues 304 | 305 | Issues are very valuable to this project. 306 | 307 | - Ideas are a valuable source of contributions others can make 308 | - Problems show where this project is lacking 309 | - With a question you show where contributors can improve the user experience 310 | 311 | ### Pull Requests 312 | 313 | Pull requests are, a great way to get your ideas into this repository. 314 | 315 | ### Support 316 | 317 | This is an open source project that I am developing in my spare time.\ 318 | If you like it, please support me.\ 319 | With your support, I can spend more time on development. :) 320 | 321 | [![](https://user-images.githubusercontent.com/12690315/66942881-03686280-f085-11e9-9586-fc0b6011029f.png)](https://github.com/users/mob-sakai/sponsorship) 322 | 323 |



324 | 325 | ## License 326 | 327 | - MIT 328 | 329 | ## Author 330 | 331 | - ![](https://user-images.githubusercontent.com/12690315/96986908-434a0b80-155d-11eb-8275-85138ab90afa.png) 332 | [mob-sakai](https://github.com/mob-sakai) 333 | [![](https://img.shields.io/twitter/follow/mob_sakai.svg?label=Follow&style=social)](https://twitter.com/intent/follow?screen_name=mob_sakai) 334 | ![GitHub followers](https://img.shields.io/github/followers/mob-sakai?style=social) 335 | 336 | ## See Also 337 | 338 | - GitHub page : https://github.com/mob-sakai/unity-changeset 339 | - Releases : https://github.com/mob-sakai/unity-changeset/releases 340 | - Issue tracker : https://github.com/mob-sakai/unity-changeset/issues 341 | - Change log : 342 | https://github.com/mob-sakai/unity-changeset/blob/main/CHANGELOG.md 343 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getUnityReleases } from "./unityGraphQL.ts"; 2 | import { 3 | UnityChangeset as UnityChangesetClass, 4 | UnityReleaseEntitlement, 5 | UnityReleaseStream, 6 | } from "./unityChangeset.ts"; 7 | import { 8 | groupBy, 9 | sanitizeVersion, 10 | searchModeToStreams, 11 | validateFilterOptions, 12 | } from "./utils.ts"; 13 | 14 | const UNITY_CHANGESETS_DB_URL = 15 | "https://mob-sakai.github.io/unity-changeset/dbV3"; 16 | export const UnityChangeset = UnityChangesetClass; 17 | export type UnityChangeset = UnityChangesetClass; 18 | export { UnityReleaseEntitlement, UnityReleaseStream }; 19 | 20 | /** 21 | * Retrieves the Unity changeset for a specific version. 22 | * @param version - The Unity version string (e.g., "2020.1.14f1"). 23 | * @param db - Optional database URL or true to use the default database. 24 | * @returns A Promise that resolves to the UnityChangeset object. 25 | * @throws Error if the version is not found. 26 | */ 27 | export async function getUnityChangeset( 28 | version: string, 29 | db?: string | true, 30 | ): Promise { 31 | const sanitizedVersion = sanitizeVersion(version); 32 | 33 | let changesets: UnityChangeset[]; 34 | 35 | // Database mode. 36 | if (db) { 37 | const dbUrl = db === true ? UNITY_CHANGESETS_DB_URL : db; 38 | changesets = await getAllChangesetsFromDb(dbUrl); 39 | } else { 40 | // GraphQL mode. 41 | try { 42 | changesets = await getUnityReleases( 43 | sanitizedVersion, 44 | searchModeToStreams(SearchMode.All), 45 | [UnityReleaseEntitlement.XLTS], 46 | ); 47 | } catch { 48 | // Fallback to default database mode. 49 | changesets = await getAllChangesetsFromDb(UNITY_CHANGESETS_DB_URL); 50 | } 51 | } 52 | 53 | if (!changesets || !Array.isArray(changesets)) { 54 | throw new Error("Failed to retrieve changesets"); 55 | } 56 | 57 | changesets = changesets.filter( 58 | (c) => c.version === sanitizedVersion, 59 | ); 60 | if (0 < changesets.length) { 61 | return changesets[0]; 62 | } 63 | 64 | throw Error(`The given version '${sanitizedVersion}' was not found.`); 65 | } 66 | 67 | /* 68 | * Search mode. 69 | * 70 | * All: All changesets. 71 | * Default: Only non pre-release changesets. 72 | * PreRelease: Only pre-release (alpha/beta) changesets. 73 | */ 74 | export enum SearchMode { 75 | All = 0, 76 | Default = 2, 77 | PreRelease = 3, 78 | LTS = 4, 79 | Supported = 6, 80 | } 81 | 82 | /* 83 | * Group mode. 84 | * 85 | * All: All the changesets. 86 | * OldestPatch: Only the oldest patch changesets. 87 | * LatestPatch: Only the latest patch changesets. 88 | * LatestLifecycle: Only the latest lifecycle changesets. 89 | */ 90 | export enum GroupMode { 91 | All = "all", 92 | OldestPatch = "oldest-patch", 93 | LatestPatch = "latest-patch", 94 | LatestLifecycle = "latest-lifecycle", 95 | } 96 | 97 | /* 98 | * Filter options. 99 | * 100 | * min: The minimum version. eg. 2018.4 101 | * max: The maximum version. eg. 2019.4 102 | * grep: The grep pattern. eg. 20(18|19) 103 | * allLifecycles: Include all the lifecycles. 104 | * lts: Include only the LTS versions. 105 | */ 106 | export interface FilterOptions { 107 | min: string; 108 | max: string; 109 | grep: string; 110 | allLifecycles: boolean; 111 | xlts: boolean; 112 | } 113 | 114 | /* 115 | * Output mode. 116 | * 117 | * Changeset: The changeset. 118 | * VersionOnly: Only the version. 119 | * MinorVersionOnly: Only the minor version. 120 | */ 121 | export enum OutputMode { 122 | Changeset = "changeset", 123 | VersionOnly = "version", 124 | MinorVersionOnly = "minor-version", 125 | } 126 | 127 | /* 128 | * Format mode. 129 | * 130 | * None: No format. 131 | * Json: JSON format. 132 | * PrettyJson: Pretty JSON format. 133 | */ 134 | export enum FormatMode { 135 | None = "none", 136 | Json = "json", 137 | PrettyJson = "pretty-json", 138 | } 139 | 140 | /** 141 | * Lists Unity changesets based on search, filter, group, output, and format options. 142 | * @param searchMode - The search mode to use. 143 | * @param filterOptions - The filter options to apply. 144 | * @param groupMode - The group mode to use. 145 | * @param outputMode - The output mode for the results. 146 | * @param formatMode - The format mode for the output. 147 | * @param db - Optional database URL or true to use the default database. 148 | * @returns A Promise that resolves to a formatted string of the results. 149 | */ 150 | export function listChangesets( 151 | searchMode: SearchMode, 152 | filterOptions: FilterOptions, 153 | groupMode: GroupMode, 154 | outputMode: OutputMode, 155 | formatMode: FormatMode, 156 | db?: string | true, 157 | ): Promise { 158 | return searchChangesets(searchMode, db) 159 | .then((results) => filterChangesets(results, filterOptions)) 160 | .then((results) => 161 | results.sort((a, b) => b.versionNumber - a.versionNumber) 162 | ) 163 | .then((results) => groupChangesets(results, groupMode)) 164 | .then((results) => { 165 | switch (outputMode) { 166 | case OutputMode.Changeset: 167 | return results; 168 | case OutputMode.VersionOnly: 169 | return results.map((c) => c.version); 170 | case OutputMode.MinorVersionOnly: 171 | return results.map((c) => c.minor); 172 | default: 173 | throw Error( 174 | `The given output mode '${outputMode}' was not supported`, 175 | ); 176 | } 177 | }) 178 | .then((results) => { 179 | switch (formatMode) { 180 | case FormatMode.None: 181 | return results.join("\n"); 182 | case FormatMode.Json: 183 | return JSON.stringify(results); 184 | case FormatMode.PrettyJson: 185 | return JSON.stringify(results, null, 2); 186 | default: 187 | throw Error( 188 | `The given format mode '${formatMode}' was not supported`, 189 | ); 190 | } 191 | }); 192 | } 193 | 194 | /** 195 | * Searches for Unity changesets based on the specified search mode. 196 | * @param searchMode - The search mode to use. 197 | * @param db - Optional database URL or true to use the default database. 198 | * @returns A Promise that resolves to an array of UnityChangeset objects. 199 | * @throws Error if the search mode is not supported. 200 | */ 201 | export async function searchChangesets( 202 | searchMode: SearchMode, 203 | db?: string | true, 204 | ): Promise { 205 | const streams = searchModeToStreams(searchMode); 206 | 207 | // Database mode. 208 | if (db) { 209 | const dbUrl = db === true ? UNITY_CHANGESETS_DB_URL : db; 210 | return searchChangesetsFromDb(streams, dbUrl); 211 | } 212 | 213 | try { 214 | // GraphQL mode. 215 | return await getUnityReleases(".", streams, [UnityReleaseEntitlement.XLTS]); 216 | } catch { 217 | // Fallback to default database mode. 218 | return await searchChangesetsFromDb(streams, UNITY_CHANGESETS_DB_URL); 219 | } 220 | } 221 | 222 | /** 223 | * Filters an array of Unity changesets based on the provided options. 224 | * @param changesets - The array of UnityChangeset objects to filter. 225 | * @param options - The filter options. 226 | * @returns An array of filtered UnityChangeset objects. 227 | */ 228 | export function filterChangesets( 229 | changesets: UnityChangeset[], 230 | options: FilterOptions, 231 | ): UnityChangeset[] { 232 | if (!changesets || !Array.isArray(changesets)) return []; 233 | if (changesets.length == 0) return []; 234 | 235 | validateFilterOptions(options); 236 | 237 | // Min version number 238 | const min = options.min 239 | ? UnityChangeset.toNumber(options.min, false) 240 | : Number.MIN_VALUE; 241 | // Max version number 242 | const max = options.max 243 | ? UnityChangeset.toNumber(options.max, true) 244 | : Number.MAX_VALUE; 245 | // Grep pattern 246 | const regex = options.grep ? new RegExp(options.grep, "i") : null; 247 | // Lifecycle filter 248 | const lc = options.allLifecycles 249 | ? null 250 | : Object.values(groupBy(changesets, (r) => r.minor)).map((g) => g[0]); 251 | 252 | return changesets.filter( 253 | (c) => 254 | min <= c.versionNumber && 255 | c.versionNumber <= max && 256 | (options.xlts || !c.xlts) && // Include XLTS? 257 | (!regex || regex.test(c.version)) && 258 | (!lc || lc.some((l) => l.minor == c.minor && l.lifecycle == c.lifecycle)), 259 | ); 260 | } 261 | 262 | /** 263 | * Groups an array of Unity changesets based on the specified group mode. 264 | * @param changesets - The array of UnityChangeset objects to group. 265 | * @param groupMode - The group mode to use. 266 | * @returns An array of grouped UnityChangeset objects. 267 | * @throws Error if the group mode is not supported. 268 | */ 269 | export function groupChangesets( 270 | changesets: UnityChangeset[], 271 | groupMode: GroupMode, 272 | ): UnityChangeset[] { 273 | if (!changesets || !Array.isArray(changesets)) return []; 274 | if (changesets.length == 0) return []; 275 | 276 | switch (groupMode) { 277 | case GroupMode.All: 278 | return changesets; 279 | case GroupMode.LatestLifecycle: 280 | return Object.values(groupBy(changesets, (r) => r.minor)) 281 | .map((g) => g.filter((v) => v.lifecycle == g[0].lifecycle)) 282 | .flat(); 283 | case GroupMode.LatestPatch: 284 | return Object.values(groupBy(changesets, (r) => r.minor)).map( 285 | (g) => g[0], 286 | ); 287 | case GroupMode.OldestPatch: 288 | return Object.values(groupBy(changesets, (r) => r.minor)) 289 | .map((g) => g.filter((v) => v.lifecycle == g[0].lifecycle)) 290 | .map((g) => g[g.length - 1]); 291 | default: 292 | throw Error(`The given group mode '${groupMode}' was not supported`); 293 | } 294 | } 295 | 296 | /** 297 | * Retrieves all Unity changesets from the database. 298 | * @param db - Database URL. If not specified, use the default database. 299 | * @returns A Promise that resolves to an array of all UnityChangeset objects from the database. 300 | * @throws Error if the database cannot be fetched or is invalid. 301 | */ 302 | export function getAllChangesetsFromDb( 303 | db: string = UNITY_CHANGESETS_DB_URL, 304 | ): Promise { 305 | return fetch(db) 306 | .then(async (res) => { 307 | if (!res.ok) { 308 | await res.text(); // Consume the response body to avoid leaks 309 | throw Error( 310 | `The Unity changeset database could not be fetched: ${res.status} ${res.statusText}`, 311 | ); 312 | } 313 | 314 | return res.json() as Promise; 315 | }) 316 | .then((data: unknown) => { 317 | if (!Array.isArray(data)) { 318 | throw new Error("Invalid changeset database format: expected array"); 319 | } 320 | return (data as { 321 | version: string; 322 | changeset: string; 323 | stream?: string; 324 | entitlements?: string[]; 325 | }[]).map((item) => 326 | new UnityChangeset( 327 | item.version, 328 | item.changeset, 329 | item.stream as UnityReleaseStream, 330 | item.entitlements as UnityReleaseEntitlement[], 331 | ) 332 | ); 333 | }); 334 | } 335 | 336 | /** 337 | * Searches for Unity changesets from the database based on the specified search mode. 338 | * @param streams - The array of release streams to filter by. 339 | * @param db - Database URL. If not specified, use the default database. 340 | * @returns A Promise that resolves to an array of UnityChangeset objects from the database. 341 | */ 342 | export function searchChangesetsFromDb( 343 | streams: UnityReleaseStream[], 344 | db: string = UNITY_CHANGESETS_DB_URL, 345 | ): Promise { 346 | return getAllChangesetsFromDb(db) 347 | .then((changesets: UnityChangeset[]) => { 348 | if (!changesets || !Array.isArray(changesets)) return []; 349 | if (changesets.length == 0) return []; 350 | 351 | return changesets.filter((c) => streams.includes(c.stream)); 352 | }); 353 | } 354 | -------------------------------------------------------------------------------- /src/unityChangeset.test.ts: -------------------------------------------------------------------------------- 1 | // deno-fmt-ignore-file 2 | import { 3 | assertEquals, 4 | assertNotEquals, 5 | assertRejects, 6 | assertThrows, 7 | } from "std/testing/asserts"; 8 | import { 9 | filterChangesets, 10 | getUnityChangeset, 11 | groupChangesets, 12 | listChangesets, 13 | searchChangesets, 14 | searchChangesetsFromDb, 15 | getAllChangesetsFromDb, 16 | GroupMode, 17 | OutputMode, 18 | FormatMode, 19 | SearchMode, 20 | UnityChangeset, 21 | UnityReleaseEntitlement, 22 | UnityReleaseStream, 23 | } from "./index.ts"; 24 | import { searchModeToStreams, sanitizeVersion, validateFilterOptions, groupBy } from "./utils.ts"; 25 | 26 | Deno.test("UnityChangeset.toNumber min", () => { 27 | assertEquals(UnityChangeset.toNumber("2018.3", false), 201803000000); 28 | }); 29 | 30 | Deno.test("UnityChangeset.toNumber max", () => { 31 | assertEquals(UnityChangeset.toNumber("2018.3", true), 201803992599); 32 | }); 33 | 34 | Deno.test("UnityChangeset constructor", () => { 35 | const changeset = new UnityChangeset("2018.3.0f1", "abc123"); 36 | assertEquals(changeset.version, "2018.3.0f1"); 37 | assertEquals(changeset.changeset, "abc123"); 38 | assertEquals(changeset.stream, UnityReleaseStream.UNDEFINED); 39 | assertEquals(changeset.entitlements, []); 40 | assertEquals(changeset.lts, false); 41 | assertEquals(changeset.xlts, false); 42 | }); 43 | 44 | Deno.test("UnityChangeset constructor with LTS", () => { 45 | const changeset = new UnityChangeset("2018.4.0f1", "abc123", UnityReleaseStream.LTS); 46 | assertEquals(changeset.stream, UnityReleaseStream.LTS); 47 | assertEquals(changeset.lts, true); 48 | assertEquals(changeset.xlts, false); 49 | }); 50 | 51 | Deno.test("UnityChangeset constructor with XLTS", () => { 52 | const changeset = new UnityChangeset("2018.4.0f1", "abc123", UnityReleaseStream.LTS, [UnityReleaseEntitlement.XLTS]); 53 | assertEquals(changeset.stream, UnityReleaseStream.LTS); 54 | assertEquals(changeset.lts, true); 55 | assertEquals(changeset.xlts, true); 56 | assertEquals(changeset.entitlements, [UnityReleaseEntitlement.XLTS]); 57 | }); 58 | 59 | Deno.test("UnityChangeset constructor errors", () => { 60 | assertThrows(() => new UnityChangeset("", "abc123"), Error, "Version must be a non-empty string"); 61 | assertThrows(() => new UnityChangeset("2018.3.0f1", ""), Error, "Changeset must be a non-empty string"); 62 | assertThrows(() => new UnityChangeset("2018.3.0f1", "abc123", UnityReleaseStream.LTS, "invalid" as unknown as UnityReleaseEntitlement[]), Error, "Entitlements must be an array"); 63 | }); 64 | 65 | Deno.test("UnityChangeset.toString", () => { 66 | const changeset = new UnityChangeset("2018.3.0f1", "abc123"); 67 | assertEquals(changeset.toString(), "2018.3.0f1\tabc123"); 68 | }); 69 | 70 | // getUnityChangeset 71 | [ 72 | { version: "2018.3.0f1", expected: "f023c421e164" }, 73 | { version: "2018.3.0f2", expected: "6e9a27477296" }, 74 | { version: "2018.3.0f3", expected: undefined }, // Not existing version 75 | { version: "2019.1.0a9", expected: "0acd256790e8" }, // Alpha 76 | { version: "2019.1.0b1", expected: "83b3ba1f99df" }, // Beta 77 | { version: "6000.1.0f1", expected: "9ea152932a88" }, // Supported 78 | { version: "2022.3.67f2", expected: "6bedba8691df" }, // XLTS 79 | ].forEach((testcase) => { 80 | Deno.test(`getUnityChangeset (${testcase.version})`, async () => { 81 | if (testcase.expected) { 82 | const changeset = (await getUnityChangeset(testcase.version)).changeset; 83 | assertEquals(changeset, testcase.expected); 84 | } 85 | else { 86 | await assertRejects(() => getUnityChangeset(testcase.version)); 87 | } 88 | }) 89 | }); 90 | 91 | Deno.test("scrapeArchivedChangesets", async () => { 92 | const changesets = await searchChangesets(SearchMode.Default); 93 | assertNotEquals(changesets.length, 0); 94 | }); 95 | 96 | Deno.test("scrapeBetaChangesets", async () => { 97 | const changesets = await searchChangesets(SearchMode.PreRelease); 98 | assertNotEquals(changesets.length, 0); 99 | }); 100 | 101 | // At least one changeset from unity 6000 version should be found. 102 | Deno.test("scrapeUnity6000Supported", async () => { 103 | const changesets = await searchChangesets(SearchMode.Supported); 104 | assertNotEquals(changesets.length, 0); 105 | 106 | const unity6000 = changesets.find(c => c.version.startsWith("6000")); 107 | assertNotEquals(unity6000, undefined); 108 | }); 109 | 110 | // searchChangesets 111 | [ 112 | { searchMode: SearchMode.All }, 113 | { searchMode: SearchMode.Default }, 114 | { searchMode: SearchMode.PreRelease }, 115 | { searchMode: SearchMode.Supported }, 116 | { searchMode: SearchMode.LTS }, 117 | ].forEach((testcase) => { 118 | Deno.test(`filterChangesets(${JSON.stringify(testcase.searchMode)})`, async () => { 119 | const changesets = await searchChangesets(testcase.searchMode); 120 | assertNotEquals(changesets.length, 0); 121 | }); 122 | }); 123 | 124 | const changesetsForTest = [ 125 | new UnityChangeset("2018.2.0f1", "000000000000"), 126 | new UnityChangeset("2018.2.1f1", "000000000000"), 127 | new UnityChangeset("2018.2.2f1", "000000000000"), 128 | new UnityChangeset("2018.3.0f1", "000000000000"), 129 | new UnityChangeset("2018.3.1f1", "000000000000"), 130 | new UnityChangeset("2018.3.2f1", "000000000000"), 131 | new UnityChangeset("2018.4.0f1", "000000000000", UnityReleaseStream.LTS), 132 | new UnityChangeset("2018.4.1f1", "000000000000", UnityReleaseStream.LTS), 133 | new UnityChangeset("2018.4.2f1", "000000000000", UnityReleaseStream.LTS, [UnityReleaseEntitlement.XLTS]), 134 | new UnityChangeset("2019.1.0a1", "000000000000"), 135 | new UnityChangeset("2019.1.0a2", "000000000000"), 136 | new UnityChangeset("2019.1.0b1", "000000000000"), 137 | new UnityChangeset("2019.1.0b2", "000000000000"), 138 | new UnityChangeset("2019.1.0f1", "000000000000"), 139 | new UnityChangeset("2019.1.0f2", "000000000000"), 140 | new UnityChangeset("2019.1.1f1", "000000000000"), 141 | new UnityChangeset("2019.2.0a1", "000000000000"), 142 | new UnityChangeset("2019.2.0a2", "000000000000"), 143 | new UnityChangeset("2019.2.0b1", "000000000000"), 144 | new UnityChangeset("2019.2.0b2", "000000000000"), 145 | new UnityChangeset("2019.2.0a1", "000000000000"), 146 | new UnityChangeset("2019.2.0a2", "000000000000"), 147 | ].sort((a, b) => b.versionNumber - a.versionNumber); 148 | 149 | // filterChangesets 150 | [ 151 | { options: { min: "2018.3", max: "2018.4", grep: "", allLifecycles: false, xlts: false, }, expected: 5, }, 152 | { options: { min: "2018.3", max: "", grep: "2018", allLifecycles: false, xlts: false, }, expected: 5, }, 153 | { options: { min: "2019", max: "", grep: "", allLifecycles: true, xlts: false, }, expected: 13, }, 154 | { options: { min: "2019", max: "", grep: "b", allLifecycles: true, xlts: false, }, expected: 4, }, 155 | { options: { min: "", max: "", grep: "", allLifecycles: false, xlts: true, }, expected: 14, }, 156 | { options: { min: "", max: "", grep: "2018", allLifecycles: false, xlts: false, }, expected: 8, }, 157 | ].forEach((testcase) => { 158 | Deno.test(`filterChangesets(${JSON.stringify(testcase.options)})`, () => { 159 | const changesets = filterChangesets(changesetsForTest, testcase.options); 160 | assertEquals(changesets.length, testcase.expected); 161 | }); 162 | }); 163 | 164 | // groupChangesets 165 | [ 166 | { groupMode: GroupMode.All, expected: 22 }, 167 | { groupMode: GroupMode.LatestLifecycle, expected: 14 }, 168 | { groupMode: GroupMode.LatestPatch, expected: 5 }, 169 | { groupMode: GroupMode.OldestPatch, expected: 5 }, 170 | ].forEach((testcase) => { 171 | Deno.test(`groupChangesets(${testcase.groupMode})`, () => { 172 | const changesets = groupChangesets(changesetsForTest, testcase.groupMode); 173 | assertEquals(changesets.length, testcase.expected); 174 | }); 175 | }); 176 | 177 | Deno.test("scrapeArchivedChangesetsFromDb", async () => { 178 | const changesets = await searchChangesetsFromDb(searchModeToStreams(SearchMode.Default)); 179 | assertNotEquals(changesets.length, 0); 180 | }); 181 | 182 | Deno.test("listChangesets", async () => { 183 | const result = await listChangesets( 184 | SearchMode.Default, 185 | { min: "", max: "", grep: "", allLifecycles: false, xlts: false }, 186 | GroupMode.All, 187 | OutputMode.Changeset, 188 | FormatMode.Json, 189 | ); 190 | assertNotEquals(result.length, 0); 191 | // Should be valid JSON 192 | JSON.parse(result); 193 | }); 194 | 195 | Deno.test("searchModeToStreams", () => { 196 | assertEquals(searchModeToStreams(SearchMode.All), [ 197 | UnityReleaseStream.LTS, 198 | UnityReleaseStream.SUPPORTED, 199 | UnityReleaseStream.TECH, 200 | UnityReleaseStream.BETA, 201 | UnityReleaseStream.ALPHA, 202 | ]); 203 | assertEquals(searchModeToStreams(SearchMode.Default), [ 204 | UnityReleaseStream.LTS, 205 | UnityReleaseStream.SUPPORTED, 206 | UnityReleaseStream.TECH, 207 | ]); 208 | assertEquals(searchModeToStreams(SearchMode.PreRelease), [ 209 | UnityReleaseStream.ALPHA, 210 | UnityReleaseStream.BETA, 211 | ]); 212 | assertEquals(searchModeToStreams(SearchMode.LTS), [ 213 | UnityReleaseStream.LTS, 214 | ]); 215 | assertEquals(searchModeToStreams(SearchMode.Supported), [ 216 | UnityReleaseStream.SUPPORTED, 217 | ]); 218 | }); 219 | 220 | Deno.test("sanitizeVersion", () => { 221 | assertEquals(sanitizeVersion("2018.3.0f1"), "2018.3.0f1"); 222 | assertEquals(sanitizeVersion("2019.1.0a9"), "2019.1.0a9"); 223 | assertThrows(() => sanitizeVersion("2018.3