├── .gitattributes ├── version.ts ├── .github ├── updater.json ├── showcase.png ├── workflow_permission.png ├── dependabot.yml └── workflows │ ├── update.yml │ ├── check.yml │ └── publish.yml ├── .vscode └── settings.json ├── script ├── mod.ts ├── stat.ts ├── registries.ts ├── parseConfig.ts ├── registry.ts ├── getNextVersion.ts ├── checkImport.ts ├── update.ts └── main.ts ├── mod.ts ├── docs ├── semver_ranges.md └── alternative_uses.md ├── registries ├── denopkg.com.ts ├── deno.re.ts ├── raw.githubusercontent.com.ts ├── esm.sh.ts ├── jsr.ts ├── npm.ts ├── deno.land.ts └── cdn.jsdelivr.net.ts ├── deno.json ├── license ├── schema.json ├── schema.ts ├── test ├── getNextVersion.test.ts └── checkImport.test.ts ├── action.yml ├── readme.md └── changelog.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /version.ts: -------------------------------------------------------------------------------- 1 | export const version = 'v0.22.0' 2 | -------------------------------------------------------------------------------- /.github/updater.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": "**/*.md" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /.github/showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boywithkeyboard-archive/updater/HEAD/.github/showcase.png -------------------------------------------------------------------------------- /script/mod.ts: -------------------------------------------------------------------------------- 1 | export { checkImport } from './checkImport.ts' 2 | export { update } from './update.ts' 3 | -------------------------------------------------------------------------------- /.github/workflow_permission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boywithkeyboard-archive/updater/HEAD/.github/workflow_permission.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /script/stat.ts: -------------------------------------------------------------------------------- 1 | export function stat(path: string) { 2 | try { 3 | return Deno.statSync(path) 4 | } catch (_err) { 5 | return null 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { cli } from './script/main.ts' 2 | 3 | if (import.meta.main) { 4 | cli() 5 | } 6 | 7 | export { checkImport } from './script/checkImport.ts' 8 | export { update } from './script/update.ts' 9 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: update 2 | 3 | on: 4 | schedule: 5 | - cron: 0 0 * * * 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | update: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Run updater 20 | uses: boywithkeyboard/updater@v0 21 | -------------------------------------------------------------------------------- /docs/semver_ranges.md: -------------------------------------------------------------------------------- 1 | ### Version ranges 2 | 3 | **updater** uses [std@0.190.0](https://deno.land/std@0.190.0/semver/mod.ts) to 4 | determine the latest version. Therefore, you should read the JSDoc documentation 5 | [here](https://github.com/denoland/deno_std/blob/0.190.0/semver/mod.ts) or 6 | [here](https://deno.land/std@0.190.0/semver/mod.ts) to learn more about what 7 | version ranges the script supports. 8 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Setup Deno 15 | uses: denoland/setup-deno@v1 16 | with: 17 | deno-version: v1.x 18 | 19 | - name: Run deno fmt 20 | run: deno fmt --check 21 | 22 | - name: Run deno lint 23 | run: deno lint 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - id: publish 16 | name: Publish Release 17 | uses: boywithkeyboard/publisher@v3 18 | 19 | - name: Bump Major Version 20 | run: | 21 | full="${{ steps.publish.outputs.tag_name }}" 22 | short="${full:0:2}" 23 | git tag $short -f 24 | git push --tags -f 25 | -------------------------------------------------------------------------------- /script/registries.ts: -------------------------------------------------------------------------------- 1 | import { cdn_jsdelivr_net } from '../registries/cdn.jsdelivr.net.ts' 2 | import { deno_land } from '../registries/deno.land.ts' 3 | import { deno_re } from '../registries/deno.re.ts' 4 | import { denopkg_com } from '../registries/denopkg.com.ts' 5 | import { esm_sh } from '../registries/esm.sh.ts' 6 | import { jsr } from '../registries/jsr.ts' 7 | import { npm } from '../registries/npm.ts' 8 | import { raw_githubusercontent_com } from '../registries/raw.githubusercontent.com.ts' 9 | 10 | export const registries = [ 11 | cdn_jsdelivr_net, 12 | deno_land, 13 | esm_sh, 14 | npm, 15 | raw_githubusercontent_com, 16 | jsr, 17 | denopkg_com, 18 | deno_re, 19 | ] 20 | -------------------------------------------------------------------------------- /script/parseConfig.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '../schema.ts' 2 | import { Value } from 'typebox/value' 3 | import { Static } from 'typebox' 4 | 5 | function readConfigFile(path: string): Static | null { 6 | try { 7 | const str = Deno.readTextFileSync(path) 8 | 9 | const json = JSON.parse(str) 10 | 11 | // @ts-ignore: 12 | if (Value.Check(schema, json)) { 13 | return json 14 | } 15 | 16 | return null 17 | } catch (_err) { 18 | return null 19 | } 20 | } 21 | 22 | export function parseConfig() { 23 | let config = readConfigFile('./updater.json') 24 | 25 | if (config === null) { 26 | config = readConfigFile('./.github/updater.json') 27 | } 28 | 29 | return config ?? {} 30 | } 31 | -------------------------------------------------------------------------------- /registries/denopkg.com.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '../script/registry.ts' 2 | import { raw_githubusercontent_com } from './raw.githubusercontent.com.ts' 3 | 4 | export const denopkg_com = new Registry({ 5 | config: { 6 | name: 'denopkg.com', 7 | importType: 'url', 8 | regex: /^https:\/\/denopkg\.com(\/[^\/]+){2}@[^\/@]+(\/[^\/]+)+$/, 9 | }, 10 | 11 | versions(moduleName) { 12 | return raw_githubusercontent_com.versions(moduleName) 13 | }, 14 | 15 | repositoryUrl(moduleName) { 16 | return `https://github.com/${moduleName}` 17 | }, 18 | 19 | parseImport({ importUrl }) { 20 | const { pathname } = importUrl 21 | 22 | const arr = pathname.split('/') 23 | 24 | return { 25 | moduleName: arr[1] + '/' + arr[2].split('@')[0], 26 | version: arr[2].split('@')[1], 27 | } 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "semiColons": false, 4 | "singleQuote": true, 5 | "exclude": [ 6 | "changelog.md", 7 | "schema.json" 8 | ] 9 | }, 10 | "imports": { 11 | "gitbeaker": "https://esm.sh/@gitbeaker/rest@39.34.3?target=es2022", 12 | "js-imports": "https://deno.re/boywithkeyboard/js-imports@v0.2.0", 13 | "octokit": "https://esm.sh/octokit@3.2.0?target=es2022", 14 | "semver": "https://deno.land/std@0.190.0/semver/mod.ts#pin", 15 | "slash": "https://esm.sh/slash@5.1.0?target=es2022", 16 | "std/": "https://deno.land/std@0.223.0/", 17 | "typebox": "https://esm.sh/@sinclair/typebox@0.32.22", 18 | "typebox/value": "https://esm.sh/@sinclair/typebox@0.32.22/value" 19 | }, 20 | "lock": false, 21 | "tasks": { 22 | "test": "deno test -A", 23 | "build:schema": "deno run -A schema.ts", 24 | "build": "deno task build:schema" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /script/registry.ts: -------------------------------------------------------------------------------- 1 | type ParsedData = { 2 | moduleName: string 3 | version: string 4 | } 5 | 6 | export class Registry { 7 | config 8 | versions 9 | repositoryUrl 10 | parseImport 11 | 12 | constructor( 13 | options: { 14 | config: { 15 | name: string 16 | importType: T 17 | regex: RegExp 18 | } 19 | versions: (moduleName: string) => Promise 20 | repositoryUrl: ( 21 | moduleName: string, 22 | ) => Promise | (string | undefined) 23 | parseImport: ( 24 | data: T extends 'url' ? { importUrl: URL } : { importString: string }, 25 | ) => ParsedData 26 | }, 27 | ) { 28 | this.config = options.config 29 | this.versions = options.versions 30 | this.repositoryUrl = options.repositoryUrl 31 | this.parseImport = options.parseImport 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /registries/deno.re.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '../script/registry.ts' 2 | import { raw_githubusercontent_com } from './raw.githubusercontent.com.ts' 3 | 4 | export const deno_re = new Registry({ 5 | config: { 6 | name: 'deno.re', 7 | importType: 'url', 8 | regex: 9 | /^https:\/\/deno\.re\/(std|(([a-zA-Z0-9\-]+)\/([a-zA-Z0-9._\-]+)))@[a-zA-Z0-9.*]+(\/([a-zA-Z0-9._\-]+))*$/, 10 | }, 11 | 12 | versions(moduleName) { 13 | return raw_githubusercontent_com.versions(moduleName) 14 | }, 15 | 16 | repositoryUrl(moduleName) { 17 | return `https://github.com/${moduleName}` 18 | }, 19 | 20 | parseImport({ importUrl }) { 21 | const { pathname } = importUrl 22 | 23 | const arr = pathname.split('/') 24 | 25 | if (arr[1].split('@')[0] === 'std') { 26 | return { 27 | moduleName: 'denoland/deno_std', 28 | version: arr[1].split('@')[1], 29 | } 30 | } else { 31 | return { 32 | moduleName: arr[1] + '/' + arr[2].split('@')[0], 33 | version: arr[2].split('@')[1], 34 | } 35 | } 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Samuel Kopp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /registries/raw.githubusercontent.com.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from 'octokit' 2 | import { Registry } from '../script/registry.ts' 3 | 4 | const gh = new Octokit() 5 | 6 | export const raw_githubusercontent_com = new Registry({ 7 | config: { 8 | name: 'raw.githubusercontent.com', 9 | importType: 'url', 10 | regex: /^https:\/\/raw\.githubusercontent\.com\/([^\/]+(\/)?){3,}$/, 11 | }, 12 | 13 | async versions(moduleName) { 14 | const releases = await gh.paginate(gh.rest.repos.listReleases, { 15 | owner: moduleName.split('/')[0], 16 | repo: moduleName.split('/')[1], 17 | }) 18 | 19 | if (releases.length > 0) { 20 | return releases.map((release) => release.tag_name) 21 | } 22 | 23 | const tags = await gh.paginate(gh.rest.repos.listTags, { 24 | owner: moduleName.split('/')[0], 25 | repo: moduleName.split('/')[1], 26 | }) 27 | 28 | return tags.map((tag) => tag.name) 29 | }, 30 | 31 | repositoryUrl(moduleName) { 32 | return `https://github.com/${moduleName}` 33 | }, 34 | 35 | parseImport({ importUrl }) { 36 | const { pathname } = importUrl 37 | 38 | const arr = pathname.split('/') 39 | 40 | return { 41 | moduleName: `${arr[1]}/${arr[2]}`, 42 | version: arr[3], 43 | } 44 | }, 45 | }) 46 | -------------------------------------------------------------------------------- /registries/esm.sh.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '../script/registry.ts' 2 | import { npm } from './npm.ts' 3 | 4 | export const esm_sh = new Registry({ 5 | config: { 6 | name: 'esm.sh', 7 | importType: 'url', 8 | regex: /^https:\/\/esm\.sh\/(@[0-9a-zA-Z.-]+\/)?[0-9a-zA-Z.-]+@[^@]+.*$/, 9 | }, 10 | 11 | async versions(moduleName) { 12 | return await npm.versions(moduleName) 13 | }, 14 | 15 | async repositoryUrl(moduleName) { 16 | const res = await fetch(`https://registry.npmjs.org/${moduleName}`) 17 | 18 | if (!res.ok) { 19 | await res.body?.cancel() 20 | 21 | return 22 | } 23 | 24 | const { repository } = await res.json() as { repository: { url: string } } 25 | 26 | return repository.url.replace('.git', '').replace('git+', '') 27 | }, 28 | 29 | parseImport({ importUrl }) { 30 | const { pathname } = importUrl 31 | 32 | const arr = pathname.split('/') 33 | 34 | const moduleName = arr[1].startsWith('@') 35 | ? `${arr[1]}/${arr[2].split('@')[0]}` 36 | : arr[1].split('@')[0] 37 | 38 | const version = arr[1].startsWith('@') 39 | ? arr[2].split('@')[1].split('&')[0] 40 | : arr[1].split('@')[1].split('&')[0] 41 | 42 | return { 43 | moduleName, 44 | version, 45 | } 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /registries/jsr.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '../script/registry.ts' 2 | 3 | export const jsr = new Registry({ 4 | config: { 5 | name: 'jsr', 6 | importType: 'string', 7 | regex: /^jsr:(@[0-9a-zA-Z.-]+\/)?[0-9a-zA-Z.-^]+@[^@]+$/, 8 | }, 9 | 10 | async versions(moduleName) { 11 | const res = await fetch(`https://jsr.io/${moduleName}/meta.json`, { 12 | headers: { 13 | Accept: 'application/json', 14 | }, 15 | }) 16 | 17 | if (!res.ok) { 18 | await res.body?.cancel() 19 | 20 | return [] 21 | } 22 | 23 | const { versions } = await res.json() as { 24 | versions: Record 25 | } 26 | 27 | return Object.keys(versions) 28 | }, 29 | 30 | repositoryUrl() { 31 | return undefined 32 | }, 33 | 34 | parseImport({ importString }) { 35 | let moduleName = importString.slice(4) 36 | 37 | const version = moduleName.charAt(0) === '@' 38 | ? moduleName.split('/')[1].split('@')[1] // scoped module 39 | : moduleName.split('@')[1] // non-scoped module 40 | 41 | moduleName = moduleName.startsWith('@') 42 | ? moduleName.split('@').slice(0, 2).join('@') 43 | : moduleName.split('@')[0] // non-scoped module 44 | 45 | return { 46 | moduleName, 47 | version, 48 | } 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "type": "object", 4 | "properties": { 5 | "$schema": { 6 | "type": "string" 7 | }, 8 | "include": { 9 | "description": "The files, directories and glob patterns to be included for updates.", 10 | "anyOf": [ 11 | { 12 | "type": "array", 13 | "items": { 14 | "type": "string" 15 | } 16 | }, 17 | { 18 | "type": "string" 19 | } 20 | ] 21 | }, 22 | "exclude": { 23 | "description": "The files, directories and global patterns to exclude from updates.", 24 | "anyOf": [ 25 | { 26 | "type": "array", 27 | "items": { 28 | "type": "string" 29 | } 30 | }, 31 | { 32 | "type": "string" 33 | } 34 | ] 35 | }, 36 | "allowBreaking": { 37 | "description": "Allow breaking updates (major releases).", 38 | "default": false, 39 | "type": "boolean" 40 | }, 41 | "allowUnstable": { 42 | "description": "Allow unstable updates (prereleases).", 43 | "default": false, 44 | "type": "boolean" 45 | }, 46 | "readOnly": { 47 | "description": "Perform a dry run.", 48 | "default": false, 49 | "type": "boolean" 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /schema.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'typebox' 2 | 3 | export const schema = Type.Object({ 4 | $schema: Type.Optional( 5 | Type.String(), 6 | ), 7 | include: Type.Optional( 8 | Type.Union([ 9 | Type.Array( 10 | Type.String(), 11 | ), 12 | Type.String(), 13 | ], { 14 | description: 15 | 'The files, directories and glob patterns to be included for updates.', 16 | }), 17 | ), 18 | exclude: Type.Optional( 19 | Type.Union([ 20 | Type.Array( 21 | Type.String(), 22 | ), 23 | Type.String(), 24 | ], { 25 | description: 26 | 'The files, directories and global patterns to exclude from updates.', 27 | }), 28 | ), 29 | allowBreaking: Type.Optional( 30 | Type.Boolean({ 31 | description: 'Allow breaking updates (major releases).', 32 | default: false, 33 | }), 34 | ), 35 | allowUnstable: Type.Optional( 36 | Type.Boolean({ 37 | description: 'Allow unstable updates (prereleases).', 38 | default: false, 39 | }), 40 | ), 41 | readOnly: Type.Optional( 42 | Type.Boolean({ 43 | description: 'Perform a dry run.', 44 | default: false, 45 | }), 46 | ), 47 | }, { 48 | additionalProperties: false, 49 | }) 50 | 51 | if (import.meta.main) { 52 | Deno.writeTextFile('schema.json', JSON.stringify(schema, null, 2)) 53 | } 54 | -------------------------------------------------------------------------------- /script/getNextVersion.ts: -------------------------------------------------------------------------------- 1 | import * as semver from 'semver' 2 | 3 | export function getNextVersion(args: { 4 | importSpecifier: string 5 | version: string 6 | versions: string[] 7 | allowBreaking: boolean 8 | allowUnstable: boolean 9 | }) { 10 | args.versions = args.versions.sort(semver.compare) 11 | 12 | // has (valid) semver range 13 | 14 | if (args.importSpecifier.includes('#')) { 15 | const range = semver.validRange(args.importSpecifier.split('#')[1]) 16 | 17 | if (range !== null) { 18 | return semver.maxSatisfying( 19 | args.versions, 20 | args.importSpecifier.split('#')[1], 21 | ) ?? args.version 22 | } 23 | } 24 | 25 | // has no/invalid semver range 26 | 27 | const latestVersion = args.versions[args.versions.length - 1] 28 | 29 | const diff = semver.difference(args.version, latestVersion) 30 | 31 | if (semver.gte(args.version, latestVersion)) { 32 | return latestVersion 33 | } 34 | 35 | if (latestVersion === args.version || diff === null) { 36 | return latestVersion 37 | } 38 | 39 | if ( 40 | (args.allowBreaking || args.allowUnstable) && diff === 'major' || // breaking 41 | args.allowUnstable && diff.startsWith('pre') || // unstable 42 | diff === 'minor' || diff === 'patch' 43 | ) { 44 | return latestVersion 45 | } 46 | 47 | args.versions.pop() 48 | 49 | return getNextVersion(args) 50 | } 51 | -------------------------------------------------------------------------------- /registries/npm.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '../script/registry.ts' 2 | 3 | export const npm = new Registry({ 4 | config: { 5 | name: 'npm', 6 | importType: 'string', 7 | regex: /^npm:(@[0-9a-zA-Z.-]+\/)?[0-9a-zA-Z.-^]+@[^@]+$/, 8 | }, 9 | 10 | async versions(moduleName) { 11 | const res = await fetch(`https://registry.npmjs.org/${moduleName}`) 12 | 13 | if (!res.ok) { 14 | await res.body?.cancel() 15 | 16 | return [] 17 | } 18 | 19 | const { versions } = await res.json() as { versions: string[] } 20 | 21 | return Object.keys(versions) 22 | }, 23 | 24 | async repositoryUrl(moduleName) { 25 | const res = await fetch(`https://registry.npmjs.org/${moduleName}`) 26 | 27 | if (!res.ok) { 28 | await res.body?.cancel() 29 | 30 | return 31 | } 32 | 33 | const { repository } = await res.json() as { repository: { url: string } } 34 | 35 | return repository.url.replace('.git', '').replace('git+', '') 36 | }, 37 | 38 | parseImport({ importString }) { 39 | let moduleName = importString.slice(4) 40 | 41 | const version = moduleName.charAt(0) === '@' 42 | ? moduleName.split('/')[1].split('@')[1] // scoped module 43 | : moduleName.split('@')[1] // non-scoped module 44 | 45 | moduleName = moduleName.startsWith('@') 46 | ? moduleName.split('@').slice(0, 2).join('@') 47 | : moduleName.split('@')[0] // non-scoped module 48 | 49 | return { 50 | moduleName, 51 | version, 52 | } 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /registries/deno.land.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '../script/registry.ts' 2 | 3 | export const deno_land = new Registry({ 4 | config: { 5 | name: 'deno.land', 6 | importType: 'url', 7 | regex: /^https:\/\/deno\.land\/((x\/[^\/]+)|std)@[^\/]+.*$/, 8 | }, 9 | 10 | async versions(moduleName) { 11 | const res = await fetch( 12 | `https://apiland.deno.dev/v2/modules/${moduleName}`, 13 | ) 14 | 15 | if (!res.ok) { 16 | await res.body?.cancel() 17 | 18 | return [] 19 | } 20 | 21 | const { versions } = await res.json() as { versions: string[] } 22 | 23 | return versions 24 | }, 25 | 26 | async repositoryUrl(moduleName) { 27 | if (moduleName === 'std') { 28 | return 'https://github.com/denoland/deno_std' 29 | } 30 | 31 | const res = await fetch( 32 | `https://apiland.deno.dev/v2/metrics/modules/${moduleName}`, 33 | ) 34 | 35 | if (!res.ok) { 36 | await res.body?.cancel() 37 | 38 | return 39 | } 40 | 41 | const json = await res.json() as { 42 | info: { upload_options: { repository: string } } 43 | } 44 | 45 | return `https://github.com/${json.info.upload_options.repository}` 46 | }, 47 | 48 | parseImport({ importUrl }) { 49 | const { pathname } = importUrl 50 | const arr = pathname.split('/') 51 | 52 | return { 53 | moduleName: pathname.startsWith('/std') ? 'std' : arr[2].split('@')[0], 54 | version: pathname.startsWith('/std') 55 | ? arr[1].split('@')[1] 56 | : arr[2].split('@')[1], 57 | } 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /test/getNextVersion.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from 'std/assert/mod.ts' 2 | import { getNextVersion } from '../script/getNextVersion.ts' 3 | 4 | Deno.test('getNextVersion()', () => { 5 | assertEquals( 6 | getNextVersion({ 7 | importSpecifier: '', 8 | version: 'v1.2.0', 9 | versions: ['v1.0.0', '0.9.0', 'v3.0.0', 'v3.1.0', 'v4.1.0', '0.7.0'], 10 | allowBreaking: true, 11 | allowUnstable: false, 12 | }), 13 | 'v4.1.0', 14 | ) 15 | 16 | assertEquals( 17 | getNextVersion({ 18 | importSpecifier: '', 19 | version: 'v1.2.0', 20 | versions: ['v1.0.0', '0.9.0', 'v3.0.0', 'v1.3.0', 'v1.4.0'], 21 | allowBreaking: false, 22 | allowUnstable: false, 23 | }), 24 | 'v1.4.0', 25 | ) 26 | 27 | assertEquals( 28 | getNextVersion({ 29 | importSpecifier: '', 30 | version: 'v1.2.0', 31 | versions: ['v1.0.0', '0.9.0', 'v3.0.0', 'v4.0.0-beta.0', '0.2.0'], 32 | allowBreaking: true, 33 | allowUnstable: true, 34 | }), 35 | 'v4.0.0-beta.0', 36 | ) 37 | 38 | assertEquals( 39 | getNextVersion({ 40 | importSpecifier: 'https://deno.land/x/foo@v1.2.0#~1.2', 41 | version: 'v1.0.0', 42 | versions: [ 43 | 'v1.0.0', 44 | '0.9.0', 45 | 'v3.0.0', 46 | 'v4.0.0-beta.0', 47 | '0.2.0', 48 | 'v1.3.0', 49 | 'v1.2.4', 50 | 'v1.2.6', 51 | 'v1.2.5', 52 | 'v1.2.9', 53 | ], 54 | allowBreaking: true, // should have no effect 55 | allowUnstable: false, 56 | }), 57 | 'v1.2.9', 58 | ) 59 | }) 60 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "boywithkeyboard's updater" 2 | description: "Keep the dependencies of your Deno project up-to-date." 3 | branding: 4 | icon: "package" 5 | color: "gray-dark" 6 | inputs: 7 | commitMessage: 8 | description: "The commit message and the title for the pull request." 9 | required: false 10 | default: "deno: update imports" 11 | allowBreaking: 12 | description: "Allow breaking updates (major releases)." 13 | required: false 14 | default: "false" 15 | allowUnstable: 16 | description: "Allow unstable updates (prereleases)." 17 | required: false 18 | default: "false" 19 | 20 | runs: 21 | using: composite 22 | 23 | steps: 24 | - name: Setup Deno 25 | uses: denoland/setup-deno@v1 26 | with: 27 | deno-version: v1.x 28 | 29 | - name: Update Dependencies 30 | shell: bash 31 | env: 32 | ALLOW_BREAKING: ${{ inputs.allowBreaking }} 33 | ALLOW_UNSTABLE: ${{ inputs.allowUnstable }} 34 | run: | 35 | deno run -Ar --import-map=https://deno.land/x/update/deno.json https://deno.land/x/update/mod.ts -c --breaking=$ALLOW_BREAKING --unstable=$ALLOW_UNSTABLE 36 | CHANGELOG=$(cat updates_changelog.md) 37 | echo "CHANGELOG<> $GITHUB_ENV 38 | echo "$CHANGELOG" >> $GITHUB_ENV 39 | echo "EOF" >> $GITHUB_ENV 40 | rm updates_changelog.md 41 | 42 | - name: Create Pull Request 43 | uses: peter-evans/create-pull-request@v6 44 | with: 45 | title: ${{ inputs.commitMessage }} 46 | author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" 47 | committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" 48 | commit-message: ${{ inputs.commitMessage }} 49 | body: ${{ env.CHANGELOG }} 50 | delete-branch: true 51 | branch: deno/imports 52 | -------------------------------------------------------------------------------- /docs/alternative_uses.md: -------------------------------------------------------------------------------- 1 | ## CLI 2 | 3 | The entry point can be either a directory or file. You can also specify multiple 4 | files and/or directories. 5 | 6 | ```bash 7 | deno run -Ar --import-map=https://deno.land/x/update/deno.json https://deno.land/x/update/mod.ts ./deno.json 8 | ``` 9 | 10 | **Options:** 11 | 12 | - `--breaking` / `-b` : allow breaking updates (major releases) 13 | - `--unstable` / `-u` : allow unstable updates (prereleases) 14 | - `--changelog` / `-c` : create changelog (updates_changelog.md) 15 | - `--dry-run` / `--readonly` : don't apply updates 16 | 17 | ## GitHub Workflow 18 | 19 | ```yml 20 | name: update 21 | 22 | on: 23 | schedule: 24 | - cron: '0 0 * * *' 25 | workflow_dispatch: 26 | 27 | permissions: 28 | contents: write 29 | pull-requests: write 30 | 31 | jobs: 32 | update: 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - name: Setup Deno 39 | uses: denoland/setup-deno@v1 40 | with: 41 | deno-version: v1.x 42 | 43 | - name: Run update 44 | run: | 45 | deno run -Ar --import-map=https://deno.land/x/update/deno.json https://deno.land/x/update/mod.ts -c 46 | CHANGELOG=$(cat updates_changelog.md) 47 | echo "CHANGELOG<> $GITHUB_ENV 48 | echo "$CHANGELOG" >> $GITHUB_ENV 49 | echo "EOF" >> $GITHUB_ENV 50 | rm updates_changelog.md 51 | 52 | - name: Create pull request 53 | uses: peter-evans/create-pull-request@v5 54 | with: 55 | title: 'refactor: update dependencies' 56 | author: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' 57 | committer: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' 58 | commit-message: 'refactor: update dependencies' 59 | body: '${{ env.CHANGELOG }}' 60 | labels: 'dependencies' 61 | delete-branch: true 62 | branch: 'refactor/dependencies' 63 | ``` 64 | -------------------------------------------------------------------------------- /registries/cdn.jsdelivr.net.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '../script/registry.ts' 2 | import { npm } from './npm.ts' 3 | import { raw_githubusercontent_com } from './raw.githubusercontent.com.ts' 4 | 5 | export const cdn_jsdelivr_net = new Registry({ 6 | config: { 7 | name: 'cdn.jsdelivr.net', 8 | importType: 'url', 9 | regex: 10 | /^https:\/\/cdn\.jsdelivr\.net\/((gh(\/[^\/]+){2}@[^\/]+(\/[^\/]+)+)|(npm\/((@([0-9a-zA-Z.-]+)\/)?)[0-9a-zA-Z.-]+@[^@]+(\/[^\/]+)+))$/, 11 | }, 12 | 13 | async versions(moduleName) { 14 | // github repository 15 | if (moduleName.includes('/') && !moduleName.startsWith('@')) { 16 | return await raw_githubusercontent_com.versions(moduleName) 17 | // npm package 18 | } else { 19 | return await npm.versions(moduleName) 20 | } 21 | }, 22 | 23 | async repositoryUrl(moduleName) { 24 | // github repository 25 | if (moduleName.includes('/') && !moduleName.startsWith('@')) { 26 | return `https://github.com/${moduleName}` 27 | } 28 | 29 | const res = await fetch(`https://registry.npmjs.org/${moduleName}`) 30 | 31 | if (!res.ok) { 32 | await res.body?.cancel() 33 | 34 | return 35 | } 36 | 37 | const json = await res.json() as { repository: { url: string } } 38 | 39 | return json.repository.url.replace('.git', '').replace('git+', '') 40 | }, 41 | 42 | parseImport({ importUrl }) { 43 | const { pathname } = importUrl 44 | const arr = pathname.split('/') 45 | 46 | // github repository 47 | if (pathname.startsWith('/gh')) { 48 | return { 49 | moduleName: arr[2] + '/' + 50 | arr[3].split('@')[0], 51 | version: arr[3].split('@')[1], 52 | } 53 | // npm package 54 | } else { 55 | let moduleName = pathname 56 | .split('/')[2] 57 | .split('@')[0] 58 | 59 | let version = arr[2].split('@')[1] 60 | 61 | if (moduleName.length === 0) { 62 | moduleName = arr[2] + '/' + 63 | arr[3].split('@')[0] 64 | version = arr[3].split('@')[1] 65 | } 66 | 67 | return { 68 | moduleName, 69 | version, 70 | } 71 | } 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 |

boywithkeyboard's updater

3 |
4 | 5 | ![Demo](https://raw.githubusercontent.com/boywithkeyboard/updater/main/.github/showcase.png) 6 | 7 | ## Usage 8 | 9 | The script is available as a 10 | [GitHub Action](https://docs.github.com/en/actions/learn-github-actions) for 11 | easy integration into your workflow. 12 | 13 | > [!IMPORTANT]\ 14 | > Please make sure that you have enabled the **Allow GitHub actions to create 15 | > and approve pull requests** setting, as shown 16 | > [here](https://github.com/boywithkeyboard/updater/blob/main/.github/workflow_permission.png). 17 | 18 | ```yml 19 | name: update 20 | 21 | on: 22 | schedule: 23 | - cron: '0 0 * * *' 24 | workflow_dispatch: 25 | 26 | permissions: 27 | contents: write 28 | pull-requests: write 29 | 30 | jobs: 31 | update: 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Run updater 38 | uses: boywithkeyboard/updater@v0 39 | # with: 40 | # allowBreaking: true 41 | ``` 42 | 43 | #### Options: 44 | 45 | - `commitMessage` - Commit message and title for the pull request. 46 | - `allowBreaking` - Allow breaking updates (major releases). 47 | - `allowUnstable` - Allow unstable updates (prereleases). 48 | 49 | If you prefer to use this tool in another way, please read our 50 | [alternative uses](https://github.com/boywithkeyboard/updater/blob/main/docs/alternative_uses.md). 51 | 52 | ## Configuration File 53 | 54 | The file must be named `updater.json` and be located either in the root 55 | directory of your project or in the `.github` directory. 56 | 57 | ```json 58 | { 59 | "$schema": "https://updater.mod.land/schema.json" 60 | } 61 | ``` 62 | 63 | - `include` (string or array of strings) 64 | 65 | The files, directories and glob patterns to be included for updates. 66 | 67 | - `exclude` (string or array of strings) 68 | 69 | The files, directories and global patterns to exclude from updates. The 70 | `exclude` option comes **after** `include` and overwrites the specified 71 | patterns. 72 | 73 | - `allowBreaking` (boolean) 74 | 75 | Allow breaking updates (major releases). 76 | 77 | _`false` by default_ 78 | 79 | - `allowUnstable` (boolean) 80 | 81 | Allow unstable updates (prereleases). 82 | 83 | _`false` by default_ 84 | 85 | - `readOnly` (boolean) 86 | 87 | Perform a dry run. 88 | 89 | _`false` by default_ 90 | 91 | ## Stages 92 | 93 | - **⚠️ breaking** 94 | 95 | _"This update might break your code."_ 96 | 97 | - **🚧 unstable** 98 | 99 | _"This is a prerelease and might therefore come with unwanted issues."_ 100 | 101 | - **🤞 early** 102 | 103 | _"This module doesn't strictly adhere to semver yet, so this version might 104 | break your code."_ 105 | 106 | ## Advanced Usage 107 | 108 | - **Pin a dependency** 109 | 110 | To ignore a particular import, you can append `#pin` to the url. 111 | 112 | ```ts 113 | import * as semver from 'https://deno.land/std@0.200.0/semver/mod.ts#pin' 114 | ``` 115 | 116 | - **Specify a version range** 117 | 118 | To override the default behavior, you can append a 119 | [SemVer range](https://github.com/deaddeno/update/blob/dev/docs/semver_ranges.md) 120 | to the url. 121 | 122 | ```ts 123 | import cheetah from 'https://deno.land/x/cheetah@v1.5.2/mod.ts#~v1.5' 124 | ``` 125 | 126 | ## Supported Registries 127 | 128 | - [cdn.jsdelivr.net](https://jsdelivr.com) 129 | 130 | - [deno.land](https://deno.land) 131 | 132 | - [denopkg.com](https://denopkg.com) 133 | 134 | - [esm.sh](https://esm.sh) 135 | 136 | - [jsr](https://jsr.io) 137 | 138 | `jsr:` imports are treated slightly different. If you want to pin a 139 | dependency, you must specify an **exact version**, e.g. `jsr:example@1.0.0`, 140 | and if you want to make a dependency updatable, you must add a preceding `^`, 141 | e.g. `jsr:example@^1.0.0`. 142 | 143 | - [npm](https://npmjs.com) 144 | 145 | `npm:` imports are treated the same as `jsr:` imports. 146 | 147 | - [raw.githubusercontent.com](https://raw.githubusercontent.com) 148 | 149 | 150 | -------------------------------------------------------------------------------- /script/checkImport.ts: -------------------------------------------------------------------------------- 1 | import * as semver from 'semver' 2 | import { getNextVersion } from './getNextVersion.ts' 3 | import { registries } from './registries.ts' 4 | 5 | export type CheckResult = { 6 | import: string 7 | moduleName: string 8 | registryName: string 9 | repositoryUrl: string | null 10 | oldVersion: string 11 | newVersion: string 12 | versions: string[] 13 | type: 14 | | 'unstable' 15 | | 'breaking' 16 | | 'early' 17 | | null 18 | } 19 | 20 | const cache = new Map<{ 21 | importSpecifier: string 22 | allowBreaking: boolean 23 | allowUnstable: boolean 24 | }, CheckResult | null>() 25 | 26 | /** 27 | * Specify a file or directory path to check every import in it for any available updates. 28 | * 29 | * @example 30 | * 31 | * ```ts 32 | * const result = await checkImport('npm:foo@1') 33 | * const result = await checkImport('https://den.ooo/updater@v1.0.0') 34 | * ``` 35 | */ 36 | export async function checkImport( 37 | importSpecifier: string, 38 | { 39 | allowBreaking = false, 40 | allowUnstable = false, 41 | // logging = false, 42 | }: { 43 | allowBreaking?: boolean 44 | allowUnstable?: boolean 45 | // logging?: boolean 46 | } = {}, 47 | ): Promise { 48 | try { 49 | const cachedResult = cache.get({ 50 | importSpecifier, 51 | allowBreaking, 52 | allowUnstable, 53 | }) 54 | 55 | if (cachedResult !== undefined) { 56 | return cachedResult 57 | } 58 | 59 | const registry = registries.filter((r) => 60 | r.config.regex.test(importSpecifier) 61 | )[0] 62 | 63 | if (!registry) { 64 | return null 65 | } 66 | 67 | const { moduleName, version } = registry.parseImport( 68 | // @ts-ignore: 69 | registry.config.importType === 'url' 70 | ? { importUrl: new URL(importSpecifier) } 71 | : { importString: importSpecifier }, 72 | ) 73 | 74 | let repositoryUrl: string | null = null 75 | 76 | try { 77 | repositoryUrl = await registry.repositoryUrl(moduleName) ?? null 78 | // deno-lint-ignore no-empty 79 | } catch (_) {} 80 | 81 | let newVersion = version 82 | 83 | let result: CheckResult 84 | 85 | if ( 86 | importSpecifier.endsWith('#pin') || 87 | registry.config.importType === 'string' && !version.startsWith('^') || 88 | !semver.valid(version.replace('^', '')) 89 | ) { 90 | result = { 91 | import: importSpecifier, 92 | moduleName, 93 | registryName: registry.config.name, 94 | repositoryUrl, 95 | oldVersion: version, 96 | newVersion, 97 | versions: [], 98 | type: null, 99 | } 100 | 101 | cache.set({ 102 | importSpecifier, 103 | allowBreaking, 104 | allowUnstable, 105 | }, result) 106 | 107 | return result 108 | } 109 | 110 | const versions = (await registry.versions(moduleName)).filter((v) => 111 | semver.valid(v) !== null 112 | ) 113 | 114 | const nextVersion = getNextVersion({ 115 | importSpecifier, 116 | version: version.replace('^', ''), 117 | versions, 118 | allowBreaking, 119 | allowUnstable, 120 | }) 121 | 122 | newVersion = version.startsWith('^') ? `^${nextVersion}` : nextVersion 123 | 124 | result = { 125 | import: importSpecifier, 126 | moduleName, 127 | registryName: registry.config.name, 128 | repositoryUrl, 129 | oldVersion: version, 130 | newVersion, 131 | versions, 132 | type: label(version.replace('^', ''), newVersion.replace('^', '')), 133 | } 134 | 135 | cache.set({ 136 | importSpecifier, 137 | allowBreaking, 138 | allowUnstable, 139 | }, result) 140 | 141 | return result 142 | } catch (_) { 143 | console.log(_) 144 | return null 145 | } 146 | } 147 | 148 | function label(oldVersion: string, newVersion: string): CheckResult['type'] { 149 | const diff = semver.difference(oldVersion, newVersion) 150 | const version = semver.parse(newVersion) 151 | 152 | if (!version) { 153 | return null 154 | } 155 | 156 | return diff?.startsWith('pre') 157 | ? 'unstable' 158 | : diff === 'major' 159 | ? 'breaking' 160 | : version.major === 0 161 | ? 'early' 162 | : null 163 | } 164 | -------------------------------------------------------------------------------- /test/checkImport.test.ts: -------------------------------------------------------------------------------- 1 | import * as semver from 'semver' 2 | import { assertEquals } from 'std/assert/mod.ts' 3 | import { checkImport, CheckResult } from '../script/checkImport.ts' 4 | 5 | Deno.test('cdn.jsdelivr.net', async () => { 6 | const result1 = await checkImport( 7 | 'https://cdn.jsdelivr.net/gh/jquery/jquery@3.6.4/dist/jquery.min.js', 8 | ) 9 | 10 | assertEquals(result1 !== null, true) 11 | assertEquals( 12 | semver.gt( 13 | (result1 as CheckResult).newVersion, 14 | (result1 as CheckResult).oldVersion, 15 | ), 16 | true, 17 | ) 18 | 19 | const result2 = await checkImport( 20 | 'https://cdn.jsdelivr.net/npm/jquery@3.5.0/dist/jquery.min.js', 21 | ) 22 | 23 | assertEquals(result2 !== null, true) 24 | assertEquals( 25 | semver.gt( 26 | (result2 as CheckResult).newVersion, 27 | (result2 as CheckResult).oldVersion, 28 | ), 29 | true, 30 | ) 31 | }) 32 | 33 | Deno.test('deno.land', async () => { 34 | const result1 = await checkImport('https://deno.land/x/jose@v5.0.0/mod.ts') 35 | 36 | assertEquals(result1 !== null, true) 37 | assertEquals( 38 | semver.gt( 39 | (result1 as CheckResult).newVersion, 40 | (result1 as CheckResult).oldVersion, 41 | ), 42 | true, 43 | ) 44 | 45 | const result2 = await checkImport('https://deno.land/std@0.200.0/') 46 | 47 | assertEquals(result2 !== null, true) 48 | assertEquals( 49 | semver.gt( 50 | (result2 as CheckResult).newVersion, 51 | (result2 as CheckResult).oldVersion, 52 | ), 53 | true, 54 | ) 55 | }) 56 | 57 | Deno.test('esm.sh', async () => { 58 | const result = await checkImport('https://esm.sh/jquery@3.6.0') 59 | 60 | assertEquals(result !== null, true) 61 | assertEquals( 62 | semver.gt( 63 | (result as CheckResult).newVersion, 64 | (result as CheckResult).oldVersion, 65 | ), 66 | true, 67 | ) 68 | }) 69 | 70 | Deno.test('esm.sh directory with options', async () => { 71 | const result = await checkImport( 72 | 'https://esm.sh/react-use@17.4.2&external=react,react-dom/esm/', 73 | ) 74 | 75 | assertEquals(result !== null, true) 76 | assertEquals( 77 | semver.gt( 78 | (result as CheckResult).newVersion, 79 | (result as CheckResult).oldVersion, 80 | ), 81 | true, 82 | ) 83 | }) 84 | 85 | Deno.test('npm', async () => { 86 | const result1 = await checkImport('npm:esbuild@0.19.0') 87 | 88 | assertEquals(result1 !== null, true) 89 | assertEquals(result1?.oldVersion === result1?.newVersion, true) 90 | 91 | const result2 = await checkImport('npm:esbuild@^0.19.0') 92 | 93 | assertEquals(result2 !== null, true) 94 | assertEquals( 95 | semver.gt( 96 | (result2 as CheckResult).newVersion.replace(/[^0-9a-zA-Z\-.]+/g, ''), 97 | (result2 as CheckResult).oldVersion.replace(/[^0-9a-zA-Z\-.]+/g, ''), 98 | ), 99 | true, 100 | ) 101 | }) 102 | 103 | Deno.test('raw.githubusercontent.com', async () => { 104 | const result = await checkImport( 105 | 'https://raw.githubusercontent.com/esbuild/deno-esbuild/v0.19.4/mod.js', 106 | ) 107 | 108 | assertEquals(result !== null, true) 109 | assertEquals( 110 | semver.gt( 111 | (result as CheckResult).newVersion, 112 | (result as CheckResult).oldVersion, 113 | ), 114 | true, 115 | ) 116 | }) 117 | 118 | Deno.test('jsr', async () => { 119 | const result1 = await checkImport('jsr:@std/encoding@0.219.0') 120 | 121 | assertEquals(result1 !== null, true) 122 | assertEquals(result1?.oldVersion === result1?.newVersion, true) 123 | 124 | const result2 = await checkImport('jsr:@std/encoding@^0.218.0') 125 | 126 | assertEquals(result2 !== null, true) 127 | assertEquals( 128 | semver.gt( 129 | (result2 as CheckResult).newVersion.replace(/[^0-9a-zA-Z\-.]+/g, ''), 130 | (result2 as CheckResult).oldVersion.replace(/[^0-9a-zA-Z\-.]+/g, ''), 131 | ), 132 | true, 133 | ) 134 | }) 135 | 136 | Deno.test('denopkg.com', async () => { 137 | const result1 = await checkImport( 138 | 'https://denopkg.com/boywithkeyboard/updater@v0.16.0/mod.ts', 139 | ) 140 | 141 | assertEquals(result1 !== null, true) 142 | assertEquals( 143 | semver.gt( 144 | (result1 as CheckResult).newVersion, 145 | (result1 as CheckResult).oldVersion, 146 | ), 147 | true, 148 | ) 149 | 150 | const result2 = await checkImport( 151 | 'https://denopkg.com/boywithkeyboard/updater/mod.ts', 152 | ) 153 | 154 | assertEquals(result2 === null, true) 155 | }) 156 | -------------------------------------------------------------------------------- /script/update.ts: -------------------------------------------------------------------------------- 1 | import slash from 'slash' 2 | import { gray, green, strikethrough, white } from 'std/fmt/colors.ts' 3 | import { walk } from 'std/fs/walk.ts' 4 | import { checkImport, CheckResult } from './checkImport.ts' 5 | import { rewriteIdentifiers, walkImports } from 'js-imports' 6 | 7 | type UpdateResult = CheckResult & { 8 | filePath: string 9 | } 10 | 11 | export async function update(input: string[], options: { 12 | allowBreaking?: boolean 13 | allowUnstable?: boolean 14 | logging?: boolean 15 | readOnly?: boolean 16 | } = {}) { 17 | let changes: UpdateResult[] = [] 18 | let filesChecked = 0 19 | 20 | for (const i of input) { 21 | try { 22 | const { isFile, isDirectory } = await Deno.stat(i) 23 | 24 | if (isFile) { 25 | filesChecked++ 26 | 27 | const c = await updateFile(i, options) 28 | 29 | changes = [...changes, ...c] 30 | } else if (isDirectory) { 31 | for await ( 32 | const entry of walk(i, { 33 | skip: [/^\.git.*$/, /^\.vscode.*$/], 34 | followSymlinks: false, 35 | exts: ['.js', '.ts', '.mjs', '.md', '.mdx', '.json'], 36 | }) 37 | ) { 38 | filesChecked++ 39 | 40 | const c = await updateFile(entry.path, options) 41 | 42 | changes = [...changes, ...c] 43 | } 44 | } 45 | // deno-lint-ignore no-empty 46 | } catch (_) {} 47 | } 48 | 49 | return { 50 | filesChecked, 51 | changes: changes.filter((c) => c.oldVersion !== c.newVersion), 52 | } 53 | } 54 | 55 | async function updateFile(path: string, { 56 | allowBreaking = false, 57 | allowUnstable = false, 58 | logging = false, 59 | readOnly = false, 60 | }: { 61 | allowBreaking?: boolean 62 | allowUnstable?: boolean 63 | logging?: boolean 64 | readOnly?: boolean 65 | } = {}): Promise { 66 | const normalizedPath = slash(path) 67 | 68 | try { 69 | let content = await Deno.readTextFile(path) 70 | const results: CheckResult[] = [] 71 | 72 | if (normalizedPath.endsWith('/deno.json')) { 73 | const json = JSON.parse(content) as { imports?: Record } 74 | 75 | if (!json.imports) { 76 | return [] 77 | } 78 | 79 | for (const key in json.imports) { 80 | try { 81 | const result = await checkImport(json.imports[key], { 82 | allowBreaking, 83 | allowUnstable, 84 | }) 85 | 86 | if (result) { 87 | results.push(result) 88 | } 89 | 90 | json.imports[key] = result === null 91 | ? json.imports[key] 92 | : json.imports[key].replace( 93 | `@${result.oldVersion}`, 94 | `@${result.newVersion}`, 95 | ) 96 | // deno-lint-ignore no-empty 97 | } catch (_) {} 98 | } 99 | 100 | content = content.endsWith('\n') 101 | ? JSON.stringify(json, null, 2) + '\n' 102 | : JSON.stringify(json, null, 2) 103 | } else { 104 | const identifiers: Record = {} 105 | 106 | for (const { identifier } of walkImports(content)) { 107 | try { 108 | const result = await checkImport(identifier, { 109 | allowBreaking, 110 | allowUnstable, 111 | }) 112 | 113 | if (!result) { 114 | continue 115 | } 116 | 117 | results.push(result) 118 | 119 | identifiers[identifier] = identifier.replace( 120 | `@${result.oldVersion}`, 121 | `@${result.newVersion}`, 122 | ) 123 | // deno-lint-ignore no-empty 124 | } catch (_) {} 125 | } 126 | 127 | content = rewriteIdentifiers(content, (identifier) => { 128 | return identifiers[identifier] ?? identifier 129 | }) 130 | } 131 | 132 | if (!readOnly) { 133 | await Deno.writeTextFile(path, content) 134 | } 135 | 136 | if (logging) { 137 | for (const result of results) { 138 | if (result.oldVersion !== result.newVersion) { 139 | logResult(result) 140 | } 141 | } 142 | } 143 | 144 | return results.map((result) => { 145 | // @ts-ignore: 146 | result.filePath = normalizedPath 147 | 148 | return result 149 | }) as UpdateResult[] 150 | } catch (_) { 151 | return [] 152 | } 153 | } 154 | 155 | function logResult(result: CheckResult) { 156 | console.info( 157 | gray( 158 | `${white(result.moduleName)} × ${strikethrough(result.oldVersion)} → ${ 159 | green(result.newVersion) 160 | }`, 161 | ), 162 | ) 163 | } 164 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ## [v0.22.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.22.0) 2 | 3 | - **Support for deno.re.** updater can now update `https://deno.re/...` imports. 4 | 5 | ## [v0.21.1](https://github.com/boywithkeyboard/updater/releases/tag/v0.21.1) 6 | 7 | - The error log for type checking is now logged directly on the console. This fixes the problem of exceeding the maximum character length of a pull request description. 8 | 9 | ## [v0.21.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.21.0) 10 | 11 | - The pull request now contains the error log in the event that the type check has failed. 12 | 13 | ## [v0.20.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.20.0) 14 | 15 | - **den.ooo is no longer supported.** 16 | 17 | - **`include` and `exclude` specific files and directories.** You can specify files, directories and glob patterns to include or exclude. 18 | 19 | - **updater accidentally created a schema.json file.** This bug has now been fixed. 20 | 21 | ## [v0.19.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.19.0) 22 | 23 | - **`updater.json` config file.** You can now configure updater with this configuration file. 24 | 25 | ```json 26 | { 27 | "$schema": "https://updater.mod.land/schema.json", 28 | "allowBreaking": true 29 | } 30 | ``` 31 | 32 | The file must be in the root directory of your project or in the `.github` directory. 33 | 34 | - **Short footnote in pull request**, with the used version of updater and the number of updated imports. 35 | 36 | ## [v0.18.3](https://github.com/boywithkeyboard/updater/releases/tag/v0.18.3) 37 | 38 | - **denopkg.com imports are now updated correctly.** v0.18.0 introduced support for denopkg.com imports, but updates for such imports previously failed. 39 | 40 | ## [v0.18.2](https://github.com/boywithkeyboard/updater/releases/tag/v0.18.2) 41 | 42 | - **updater's GitHub action now uses deno.land/x.** This is due to the upcoming major update of den.ooo, which could cause an interruption. 43 | 44 | ## [v0.18.1](https://github.com/boywithkeyboard/updater/releases/tag/v0.18.1) 45 | 46 | - **Correct updating of side effect imports.** v0.18.0 introduced an issue which basically replaced side effect imports with the URL without preserving the import statement. 47 | 48 | ```ts 49 | // before 50 | import 'https://esm.sh/slash@5.0.0' 51 | // after 52 | https://esm.sh/slash@5.1.0 53 | ``` 54 | 55 | v0.18.1 now fixes this behavior and preserves the import statement. 56 | 57 | ```ts 58 | // before 59 | import 'https://esm.sh/slash@5.0.0' 60 | // after 61 | import 'https://esm.sh/slash@5.1.0' 62 | ``` 63 | 64 | ## [v0.18.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.18.0) 65 | 66 | - **Regenerate `deno.lock`.** If your project has a `deno.lock` file, updater will now regenerate this file as well. 67 | 68 | - **Update side effect imports.** updater used to ignore such imports in the past due to a minor bug that occurred during the parsing of the regex matches. This issue has now been resolved. 69 | 70 | [mdn reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#import_a_module_for_its_side_effects_only) 71 | 72 | - **Support for denopkg.com.** updater can now update `https://denopkg.com/...` imports. 73 | 74 | ## [v0.17.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.17.0) 75 | 76 | - **Compatibility Checking** 77 | 78 | updater now performs a basic compatibility check (with `deno check`) and adds a warning to the changelog if there are any issues. 79 | 80 | ## [v0.16.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.16.0) 81 | 82 | - **Support for JSR** 83 | 84 | updater can now handle `jsr:` imports. Please read [the documentation](https://github.com/boywithkeyboard/updater#supported-registries) to learn more. 85 | 86 | - **Bug Fixes** 87 | 88 | - Scoped NPM modules should now be updated correctly. 89 | - In the event of an error, response body streams are now properly aborted. 90 | 91 | ## [v0.15.0](https://github.com/boywithkeyboard/updater/releases/tag/v0.15.0) 92 | 93 | - **GitHub Action** 94 | 95 | It's now easier than ever to integrate **boywithkeyboard's updater** into your workflow. 96 | 97 | ```yml 98 | name: update 99 | 100 | on: 101 | schedule: 102 | - cron: '0 0 * * *' 103 | workflow_dispatch: 104 | 105 | permissions: 106 | contents: write 107 | pull-requests: write 108 | 109 | jobs: 110 | update: 111 | runs-on: ubuntu-latest 112 | 113 | steps: 114 | - uses: actions/checkout@v4 115 | 116 | - name: Run updater 117 | uses: boywithkeyboard/updater@v0 118 | ``` 119 | 120 | [Read more](https://github.com/boywithkeyboard/updater?tab=readme-ov-file#boywithkeyboards-updater) 121 | -------------------------------------------------------------------------------- /script/main.ts: -------------------------------------------------------------------------------- 1 | import slash from 'slash' 2 | import { parseArgs } from 'std/cli/mod.ts' 3 | import { gray } from 'std/fmt/colors.ts' 4 | import { walkSync } from 'std/fs/walk.ts' 5 | import { 6 | globToRegExp, 7 | isAbsolute, 8 | isGlob, 9 | join, 10 | relative, 11 | } from 'std/path/mod.ts' 12 | import { update } from '../script/update.ts' 13 | import { version } from '../version.ts' 14 | import { parseConfig } from './parseConfig.ts' 15 | import { stat } from './stat.ts' 16 | 17 | function hasFileWithExt(ext: string) { 18 | for (const entry of walkSync(Deno.cwd())) { 19 | if (entry.isFile && entry.path.endsWith(ext)) { 20 | return true 21 | } 22 | } 23 | 24 | return false 25 | } 26 | 27 | function execTask(taskName: string) { 28 | const cmd = new Deno.Command('deno', { 29 | args: ['task', '--config', './__updater_deno.json', taskName], 30 | }) 31 | 32 | const output = cmd.outputSync() 33 | 34 | return output.success 35 | } 36 | 37 | function typeCheck(taskName: string) { 38 | const cmd = new Deno.Command('deno', { 39 | args: ['task', '--config', './__updater_deno.json', taskName], 40 | }) 41 | 42 | const output = cmd.outputSync() 43 | 44 | const stderr = new TextDecoder().decode(output.stderr) 45 | 46 | console.log(stderr) 47 | 48 | return { 49 | success: output.success, 50 | stderr, 51 | } 52 | } 53 | 54 | const labels = { 55 | unstable: '🚧', 56 | breaking: '⚠️', 57 | early: '🤞', 58 | } 59 | 60 | export async function cli() { 61 | const config = parseConfig() 62 | 63 | const args = parseArgs(Deno.args) 64 | 65 | args._ = args._.filter((i: unknown) => typeof i === 'string') 66 | 67 | const paths = args._.length > 0 ? args._ as string[] : [Deno.cwd()] 68 | 69 | const parsedArgs = { 70 | allowBreaking: args.breaking ?? args.b ?? config.allowBreaking ?? false, 71 | allowUnstable: args.unstable ?? args.u ?? config.allowUnstable ?? false, 72 | logging: true, 73 | readOnly: args['dry-run'] ?? args['readonly'] ?? config.readOnly ?? false, 74 | } 75 | 76 | if (typeof parsedArgs.allowBreaking === 'string') { 77 | parsedArgs.allowBreaking = parsedArgs.allowBreaking === 'true' 78 | } 79 | 80 | if (typeof parsedArgs.allowUnstable === 'string') { 81 | parsedArgs.allowUnstable = parsedArgs.allowUnstable === 'true' 82 | } 83 | 84 | let files: string[] = [] 85 | 86 | // resolve input files/dirs 87 | for (let path of paths) { 88 | if (!isAbsolute(path)) { 89 | path = join(Deno.cwd(), path) 90 | } 91 | 92 | const s = stat(path) 93 | 94 | if (!s) { 95 | continue 96 | } 97 | 98 | if (s.isDirectory) { 99 | for ( 100 | const entry of walkSync(path, { 101 | skip: [/\.git/, /\.vscode/], 102 | followSymlinks: false, 103 | exts: ['.jsx', '.tsx', '.js', '.ts', '.mjs', '.md', '.mdx', '.json'], 104 | }) 105 | ) { 106 | if (entry.isFile) { 107 | files.push(relative(Deno.cwd(), entry.path)) 108 | } 109 | } 110 | } else if (s.isFile) { 111 | files.push(path) 112 | } 113 | } 114 | 115 | files = files.map((path) => { 116 | if (!path.startsWith('../')) { 117 | path = './' + path 118 | } 119 | 120 | return slash(path) 121 | }) 122 | 123 | // parse include/exclude 124 | 125 | if (config.include) { 126 | if (typeof config.include === 'string') { 127 | if (isGlob(config.include)) { 128 | const regex = globToRegExp(config.include, { extended: true }) 129 | 130 | files = files.filter((path) => regex.test(path)) 131 | } else { 132 | if (isAbsolute(config.include)) { 133 | config.include = relative(Deno.cwd(), config.include) 134 | } 135 | 136 | files = files.filter((path) => path === config.include) 137 | } 138 | } else { 139 | for (let pattern of config.include) { 140 | if (isGlob(pattern)) { 141 | const regex = globToRegExp(pattern, { extended: true }) 142 | 143 | files = files.filter((path) => regex.test(path)) 144 | } else { 145 | if (isAbsolute(pattern)) { 146 | pattern = relative(Deno.cwd(), pattern) 147 | } 148 | 149 | files = files.filter((path) => path === pattern) 150 | } 151 | } 152 | } 153 | } 154 | 155 | if (config.exclude) { 156 | if (typeof config.exclude === 'string') { 157 | if (isGlob(config.exclude)) { 158 | const regex = globToRegExp(config.exclude, { extended: true }) 159 | 160 | files = files.filter((path) => !regex.test(path)) 161 | } else { 162 | if (isAbsolute(config.exclude)) { 163 | config.exclude = relative(Deno.cwd(), config.exclude) 164 | } 165 | 166 | files = files.filter((path) => path !== config.exclude) 167 | } 168 | } else { 169 | for (let pattern of config.exclude) { 170 | if (isGlob(pattern)) { 171 | const regex = globToRegExp(pattern, { extended: true }) 172 | 173 | files = files.filter((path) => !regex.test(path)) 174 | } else { 175 | if (isAbsolute(pattern)) { 176 | pattern = relative(Deno.cwd(), pattern) 177 | } 178 | 179 | files = files.filter((path) => path !== pattern) 180 | } 181 | } 182 | } 183 | } 184 | 185 | const { filesChecked, changes } = await update(files, parsedArgs) 186 | 187 | const createChangelog = args.changelog ?? args.c ?? false 188 | 189 | if (changes.length === 0) { 190 | console.info( 191 | gray( 192 | `Checked ${filesChecked} file${ 193 | filesChecked > 1 ? 's' : '' 194 | }, no updates available.`, 195 | ), 196 | ) 197 | 198 | if (createChangelog) { 199 | await Deno.writeTextFile('./updates_changelog.md', '') 200 | } 201 | 202 | Deno.exit() 203 | } 204 | 205 | // create markdown file 206 | 207 | if (!createChangelog) { 208 | Deno.exit() 209 | } 210 | 211 | const sortedChanges: Record< 212 | string, 213 | Awaited>['changes'] 214 | > = { 215 | 'cdn.jsdelivr.net': [], 216 | 'deno.land': [], 217 | 'deno.re': [], 218 | 'denopkg.com': [], 219 | 'esm.sh': [], 220 | 'jsr': [], 221 | 'npm': [], 222 | 'raw.githubusercontent.com': [], 223 | } 224 | 225 | let changelog = '#\n\n' 226 | 227 | for (const change of changes) { 228 | sortedChanges[change.registryName].push(change) 229 | } 230 | 231 | for (const [registryName, changes] of Object.entries(sortedChanges)) { 232 | if (changes.length === 0) { 233 | continue 234 | } 235 | 236 | if (changelog === '') { 237 | changelog += `- **${registryName}**\n\n` 238 | } else { 239 | changelog += `\n\n- **${registryName}**\n\n` 240 | } 241 | 242 | const arr = [ 243 | ...new Set(changes.map((change) => { 244 | return ` - [${change.moduleName}](${change.repositoryUrl}) × \`${change.oldVersion}\` → \`${ 245 | change.type ? `${labels[change.type]} ` : '' 246 | }${change.newVersion}\`` 247 | })), 248 | ] 249 | 250 | changelog += arr.join('\n') 251 | } 252 | 253 | // add temporary deno.json config 254 | Deno.writeTextFileSync( 255 | './__updater_deno.json', 256 | JSON.stringify( 257 | { 258 | tasks: { 259 | check_ts: 'deno check **/*.ts', 260 | check_js: 'deno check **/*.js', 261 | check_mjs: 'deno check **/*.mjs', 262 | cache_all: 263 | 'deno cache **/*.ts && deno cache **/*.js && deno cache **/*.mjs', 264 | }, 265 | }, 266 | null, 267 | 2, 268 | ), 269 | ) 270 | 271 | let typeCheckingSucceeded = true 272 | const failedOn: string[] = [] 273 | 274 | if (typeCheckingSucceeded && hasFileWithExt('.ts')) { 275 | const result = typeCheck('check_ts') 276 | 277 | typeCheckingSucceeded = result.success 278 | 279 | if (!typeCheckingSucceeded) { 280 | failedOn.push('.ts') 281 | } 282 | } 283 | 284 | if (typeCheckingSucceeded && hasFileWithExt('.js')) { 285 | const result = typeCheck('check_js') 286 | 287 | typeCheckingSucceeded = result.success 288 | 289 | if (!typeCheckingSucceeded) { 290 | failedOn.push('.js') 291 | } 292 | } 293 | 294 | if (typeCheckingSucceeded && hasFileWithExt('.mjs')) { 295 | const result = typeCheck('check_mjs') 296 | 297 | typeCheckingSucceeded = result.success 298 | 299 | if (!typeCheckingSucceeded) { 300 | failedOn.push('.mjs') 301 | } 302 | } 303 | 304 | if (!typeCheckingSucceeded) { 305 | changelog = 306 | `> [!CAUTION]\\\n> \`deno check\` failed on some ${ 307 | failedOn.map((item) => `\`${item}\``).join(', ').replace( 308 | /,(?=[^,]+$)/, 309 | ', and', 310 | ) 311 | } files.\n` + changelog 312 | } 313 | 314 | let importsCount = 0 315 | const _files: string[] = [] 316 | 317 | for (const change of changes) { 318 | _files.push(change.filePath) 319 | importsCount++ 320 | } 321 | 322 | changelog += 323 | `\n\n#\n\n**updater ${version}** × This pull request modifies ${importsCount} imports in ${ 324 | [...new Set(_files)].length 325 | } files.` 326 | 327 | Deno.writeTextFileSync('./updates_changelog.md', changelog) 328 | 329 | // remove old lock file 330 | let hadLockFile = false 331 | 332 | try { 333 | const stat = Deno.statSync('./deno.lock') 334 | 335 | if (stat.isFile) { 336 | hadLockFile = true 337 | 338 | Deno.removeSync('./deno.lock') 339 | } 340 | } catch (_err) { 341 | // 342 | } 343 | 344 | // generate new lock file 345 | if (hadLockFile) { 346 | execTask('cache_all') 347 | } 348 | 349 | // remove temporary deno.json config 350 | Deno.removeSync('./__updater_deno.json') 351 | 352 | Deno.exit() 353 | } 354 | --------------------------------------------------------------------------------