├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── index.d.ts ├── index.js ├── license ├── package.json ├── readme.md └── tests.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | - pull_request 4 | - push 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version-file: package.json 14 | - run: npm install 15 | - run: npm test 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | Desktop.ini 4 | ._* 5 | Thumbs.db 6 | *.tmp 7 | *.bak 8 | *.log 9 | *.lock 10 | logs 11 | package-lock.json 12 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type ListGithubDirectoryOptions = { 2 | user: string; 3 | repository: string; 4 | ref?: string; 5 | directory: string; 6 | token?: string; 7 | getFullData?: boolean; 8 | }; 9 | 10 | export type TreeResult = { 11 | truncated: boolean; 12 | } & T[]; 13 | 14 | export type TreeResponseObject = { 15 | path: string; 16 | mode: string; 17 | type: string; 18 | size: number; 19 | sha: string; 20 | url: string; 21 | }; 22 | 23 | export type ContentsReponseObject = { 24 | name: string; 25 | path: string; 26 | sha: string; 27 | size: number; 28 | url: string; 29 | html_url: string; 30 | git_url: string; 31 | download_url: string; 32 | type: string; 33 | _links: { 34 | self: string; 35 | git: string; 36 | html: string; 37 | }; 38 | }; 39 | 40 | export function getDirectoryContentViaContentsApi(options: T): 41 | T['getFullData'] extends true ? 42 | Promise : 43 | Promise; 44 | 45 | export function getDirectoryContentViaTreesApi(options: T): 46 | T['getFullData'] extends true ? 47 | Promise> : 48 | Promise>; 49 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | async function api(endpoint, token) { 2 | const response = await fetch(`https://api.github.com/repos/${endpoint}`, { 3 | headers: token ? { 4 | Authorization: `Bearer ${token}`, 5 | } : undefined, 6 | }); 7 | return response.json(); 8 | } 9 | 10 | // Great for downloads with few sub directories on big repos 11 | // Cons: many requests if the repo has a lot of nested dirs 12 | export async function getDirectoryContentViaContentsApi({ 13 | user, 14 | repository, 15 | ref: reference = 'HEAD', 16 | directory, 17 | token, 18 | getFullData = false, 19 | }) { 20 | const files = []; 21 | const requests = []; 22 | const contents = await api(`${user}/${repository}/contents/${directory}?ref=${reference}`, token); 23 | 24 | if (contents.message === 'Not Found') { 25 | return []; 26 | } 27 | 28 | if (contents.message) { 29 | throw new Error(contents.message); 30 | } 31 | 32 | for (const item of contents) { 33 | if (item.type === 'file') { 34 | files.push(getFullData ? item : item.path); 35 | } else if (item.type === 'dir') { 36 | requests.push(getDirectoryContentViaContentsApi({ 37 | user, 38 | repository, 39 | ref: reference, 40 | directory: item.path, 41 | token, 42 | getFullData, 43 | })); 44 | } 45 | } 46 | 47 | return [...files, ...await Promise.all(requests)].flat(); 48 | } 49 | 50 | // Great for downloads with many sub directories 51 | // Pros: one request + maybe doesn't require token 52 | // Cons: huge on huge repos + may be truncated 53 | export async function getDirectoryContentViaTreesApi({ 54 | user, 55 | repository, 56 | ref: reference = 'HEAD', 57 | directory, 58 | token, 59 | getFullData = false, 60 | }) { 61 | if (!directory.endsWith('/')) { 62 | directory += '/'; 63 | } 64 | 65 | const files = []; 66 | const contents = await api(`${user}/${repository}/git/trees/${reference}?recursive=1`, token); 67 | if (contents.message) { 68 | throw new Error(contents.message); 69 | } 70 | 71 | for (const item of contents.tree) { 72 | if (item.type === 'blob' && item.path.startsWith(directory)) { 73 | files.push(getFullData ? item : item.path); 74 | } 75 | } 76 | 77 | files.truncated = contents.truncated; 78 | return files; 79 | } 80 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Federico Brigante (https://fregante.com) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "list-github-dir-content", 3 | "version": "4.0.4", 4 | "description": "List all the files in a GitHub repo’s directory", 5 | "keywords": [ 6 | "api", 7 | "contents", 8 | "dir", 9 | "directory", 10 | "folder", 11 | "github", 12 | "listing" 13 | ], 14 | "repository": "fregante/list-github-dir-content", 15 | "license": "MIT", 16 | "author": "Federico Brigante (https://fregante.com)", 17 | "type": "module", 18 | "exports": "./index.js", 19 | "files": [ 20 | "index.js", 21 | "index.d.ts" 22 | ], 23 | "scripts": { 24 | "manual-test": "node tests.js", 25 | "test": "xo" 26 | }, 27 | "xo": { 28 | "envs": [ 29 | "browser" 30 | ] 31 | }, 32 | "devDependencies": { 33 | "xo": "^0.58.0" 34 | }, 35 | "engines": { 36 | "node": ">=18" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # list-github-dir-content 2 | 3 | > List all the files in a GitHub repo’s directory 4 | 5 | ## Install 6 | 7 | ``` 8 | npm install list-github-dir-content 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import { 15 | getDirectoryContentViaTreesApi, 16 | getDirectoryContentViaContentsApi 17 | } from 'list-github-dir-content'; 18 | 19 | const myToken = '000'; // https://github.com/settings/tokens 20 | 21 | // They have the same output 22 | const filesArray = await getDirectoryContentViaTreesApi({ 23 | user: 'microsoft', 24 | repository: 'vscode', 25 | directory: 'src', 26 | token: myToken 27 | }); 28 | // OR 29 | const filesArray = await getDirectoryContentViaContentsApi({ 30 | user: 'microsoft', 31 | repository: 'vscode', 32 | directory: 'src', 33 | token: myToken 34 | }); 35 | // OR 36 | const filesArray = await getDirectoryContentViaContentsApi({ 37 | user: 'microsoft', 38 | repository: 'vscode', 39 | ref: 'master', 40 | directory: 'src', 41 | token: myToken 42 | }); 43 | 44 | // ['src/file.js', 'src/styles/main.css', ...] 45 | 46 | // getDirectoryContentViaTreesApi also adds a `truncated` property 47 | if (filesArray.truncated) { 48 | // Perhaps try with viaContentsApi? 49 | } 50 | ``` 51 | 52 | 53 | ## API 54 | 55 | ### getDirectoryContentViaTreesApi(options) 56 | ### getDirectoryContentViaContentsApi(options) 57 | 58 | Both methods return a Promise that resolves with an array of all the files in the chosen directory. They just vary in GitHub API method used. The paths will be relative to root (i.e. if `directory` is `dist/images`, the array will be `['dist/images/1.png', 'dist/images/2.png']`) 59 | 60 | `viaTreesApi` is preferred when there are a lot of nested directories. This will try to make a single HTTPS request **for the whole repo**, regardless of what directory was picked. On big repos this may be of a few megabytes. ([GitHub API v3 reference](https://developer.github.com/v3/git/trees/#get-a-tree-recursively)) 61 | 62 | `viaContentsApi` is preferred when you're downloading a small part of a huge repo. This will make a request for each subfolder requested, which may mean dozens or hundreds of HTTPS requests. ([GitHub API v3 reference](https://developer.github.com/v3/repos/contents/#get-contents)) 63 | 64 | **Notice:** while they work differently, they have the same output if no limit was reached. 65 | 66 | Known issues: 67 | 68 | - `viaContentsApi` is limited to 1000 files _per directory_ 69 | - `viaTreesApi` is limited to around 60,000 files _per repo_ 70 | 71 | The following properties are available on the `options` object: 72 | 73 | #### user 74 | 75 | Type: `string` 76 | 77 | GitHub user or organization, such as `microsoft`. 78 | 79 | #### repository 80 | 81 | Type: `string` 82 | 83 | The user's repository to read, like `vscode`. 84 | 85 | #### ref 86 | 87 | Type: `string` 88 | 89 | Default: `"HEAD"` 90 | 91 | The reference to use, for example a pointer (`"HEAD"`), a branch name (`"master"`) or a commit hash (`"71705e0"`). 92 | 93 | #### directory 94 | 95 | Type: `string` 96 | 97 | The directory to download, like `docs` or `dist/images` 98 | 99 | #### token 100 | 101 | Type: `string` 102 | 103 | A GitHub personal token, get one here: https://github.com/settings/tokens 104 | 105 | #### getFullData 106 | 107 | Type: `boolean` 108 | 109 | Default: `false` 110 | 111 | When set to `true`, an array of metadata objects is returned instead of an array of file paths. Note that the metadata objects of `viaTreesApi` and `viaContentsApi` are different. 112 | 113 | Take a look at the docs for either the [Git Trees API](https://developer.github.com/v3/git/trees/#response) and the [Contents API](https://developer.github.com/v3/repos/contents/#response) to see how the respective metadata is structured. 114 | 115 | 116 | ## License 117 | 118 | MIT © [Federico Brigante](https://fregante.com) 119 | 120 | -------------------------------------------------------------------------------- /tests.js: -------------------------------------------------------------------------------- 1 | // Manual tests, sorry! 2 | 3 | import {getDirectoryContentViaTreesApi, getDirectoryContentViaContentsApi} from './index.js'; 4 | 5 | async function init() { 6 | let data; 7 | 8 | data = await getDirectoryContentViaTreesApi({ 9 | user: 'sindresorhus', 10 | repository: 'refined-github', 11 | directory: 'source/helpers', 12 | }); 13 | console.log('\nviaTreesApi\n', data); 14 | 15 | data = await getDirectoryContentViaTreesApi({ 16 | user: 'sindresorhus', 17 | repository: 'refined-github', 18 | directory: 'source/helpers', 19 | getFullData: true, 20 | }); 21 | console.log('\nviaTreesApi (detailed)\n', data); 22 | 23 | data = await getDirectoryContentViaTreesApi({ 24 | user: 'sindresorhus', 25 | repository: 'refined-github', 26 | directory: 'missing/dir', 27 | }); 28 | console.log('\nviaTreesApi (404)\n', data); 29 | 30 | try { 31 | await getDirectoryContentViaTreesApi({ 32 | token: 'broken', 33 | user: 'sindresorhus', 34 | repository: 'refined-github', 35 | directory: 'source/helpers', 36 | }); 37 | throw new Error('An error was expected'); 38 | } catch (error) { 39 | if (error.message === 'Bad credentials') { 40 | console.log('\nviaTreesApi (bad token) OK\n'); 41 | } else { 42 | throw error; 43 | } 44 | } 45 | 46 | data = await getDirectoryContentViaContentsApi({ 47 | user: 'sindresorhus', 48 | repository: 'refined-github', 49 | directory: 'source/helpers', 50 | }); 51 | console.log('\nviaContentsApi\n', data); 52 | 53 | data = await getDirectoryContentViaContentsApi({ 54 | user: 'sindresorhus', 55 | repository: 'refined-github', 56 | directory: 'source/helpers', 57 | getFullData: true, 58 | }); 59 | console.log('\nviaContentsApi (detailed)\n', data); 60 | 61 | data = await getDirectoryContentViaContentsApi({ 62 | user: 'sindresorhus', 63 | repository: 'refined-github', 64 | directory: 'missing/dir', 65 | }); 66 | console.log('\nviaContentsApi (404)\n', data); 67 | 68 | try { 69 | await getDirectoryContentViaContentsApi({ 70 | token: 'broken', 71 | user: 'sindresorhus', 72 | repository: 'refined-github', 73 | directory: 'source/helpers', 74 | }); 75 | throw new Error('An error was expected'); 76 | } catch (error) { 77 | if (error.message === 'Bad credentials') { 78 | console.log('\ngetDirectoryContentViaContentsApi (bad token) OK\n'); 79 | } else { 80 | throw error; 81 | } 82 | } 83 | } 84 | 85 | await init(); 86 | --------------------------------------------------------------------------------