├── .changeset └── config.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── pre-push ├── .nvmrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md ├── config ├── .eslintignore ├── .github │ └── workflows │ │ └── ci.yml ├── .jstmrc.json └── .npmignore ├── demo.gif ├── package.json ├── src ├── bin.ts ├── commands.ts ├── index.ts ├── ipc.ts ├── templates.ts ├── typings.ts └── utils.ts ├── templates ├── README.md ├── assembly-x86-mac │ └── wip.asm ├── c │ └── wip.c ├── java │ └── Main.wip.java ├── js │ └── wip.js ├── nim │ └── wip.nim ├── python │ └── wip.py ├── ruby │ └── wip.rb └── rust │ ├── Cargo.toml │ └── wip.rs ├── tsconfig.json └── yarn.lock /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.4.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": true, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # DO NOT MODIFY 2 | # This file is auto-generated (make another YAML file in this directory instead 3 | # or create a file in ./config/.github/workflows/ci.yml with contents to merge) 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - "*" 13 | 14 | env: 15 | node_version: 16 16 | 17 | jobs: 18 | test: 19 | name: Test 20 | runs-on: ubuntu-latest 21 | outputs: 22 | release_required: ${{ steps.release_required.outputs.result }} 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v2 26 | with: 27 | # Fetch Git history so that Changesets can check if release is required 28 | fetch-depth: 0 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v1 31 | with: 32 | node-version: ${{ env.node_version }} 33 | - name: Install dependencies 34 | run: yarn install --frozen-lockfile 35 | - name: Test 36 | run: | 37 | set -e 38 | yarn run-if-script-exists test:ci:before 39 | yarn test:all 40 | yarn run-if-script-exists test:ci:after 41 | - name: Check if release is required 42 | uses: actions/github-script@v3 43 | id: release_required 44 | with: 45 | script: | 46 | const releaseUtils = require(process.env.GITHUB_WORKSPACE + '/node_modules/@changesets/release-utils'); 47 | const { changesets } = await releaseUtils.readChangesetState(); 48 | return changesets.length > 0; 49 | 50 | release: 51 | name: Release 52 | runs-on: ubuntu-latest 53 | needs: test 54 | if: ${{ github.ref == 'refs/heads/master' && needs.test.outputs.release_required == 'true' }} 55 | environment: Release 56 | outputs: 57 | release_upload_url: ${{ steps.create_release.outputs.upload_url }} 58 | steps: 59 | - name: Checkout repository 60 | uses: actions/checkout@v2 61 | with: 62 | # Fetch Git history so that Changesets can generate changelogs with correct commits 63 | fetch-depth: 0 64 | - name: Setup Node.js 65 | uses: actions/setup-node@v1 66 | with: 67 | node-version: ${{ env.node_version }} 68 | - name: Install dependencies 69 | run: yarn install --frozen-lockfile 70 | - name: Bump versions according to changeset 71 | run: | 72 | set -e 73 | git config --global user.name "github-actions[bot]" 74 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 75 | yarn changeset version 76 | git push --no-verify 77 | - name: Publish to npm 78 | id: publish 79 | run: | 80 | set -e 81 | echo '_authToken=${NODE_AUTH_TOKEN}' > ~/.npmrc 82 | yarn run-if-script-exists release:ci:before 83 | yarn release 84 | echo "::set-output name=version_tag::$(git describe --tags --abbrev=0)" 85 | echo "::set-output name=release_changelog::$(yarn --silent ci-github-print-changelog)" 86 | yarn run-if-script-exists release:ci:after 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 90 | - name: Create release 91 | id: create_release 92 | uses: actions/create-release@v1 93 | env: 94 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 95 | with: 96 | tag_name: ${{ steps.publish.outputs.version_tag }} 97 | release_name: ${{ steps.publish.outputs.version_tag }} 98 | body: ${{ steps.publish.outputs.release_changelog }} 99 | release_binaries: 100 | strategy: 101 | matrix: 102 | arch: [x64] 103 | os: [ubuntu-latest, windows-latest, macos-latest] 104 | include: 105 | - os: ubuntu-latest 106 | os-display-name: Linux 107 | os-pkg-name: linux 108 | - os: windows-latest 109 | os-display-name: Windows 110 | os-pkg-name: win 111 | - os: macos-latest 112 | os-display-name: macOS 113 | os-pkg-name: macos 114 | 115 | name: Release binary for ${{ matrix.os-display-name }} 116 | needs: release 117 | 118 | runs-on: ${{ matrix.os }} 119 | 120 | steps: 121 | - name: Checkout repository 122 | uses: actions/checkout@v2 123 | - name: Setup Node.js 124 | uses: actions/setup-node@v1 125 | with: 126 | node-version: ${{ env.node_version }} 127 | - name: Install dependencies 128 | run: yarn install --frozen-lockfile 129 | - name: Build JavaScript 130 | run: yarn build 131 | - name: Build binary 132 | id: build 133 | shell: bash 134 | run: | 135 | pkg_arch="node${{ env.node_version }}-${{ matrix.os-pkg-name }}-${{ matrix.arch }}" 136 | pkg_out="./dist-bin/aoc-${{ matrix.os-pkg-name }}-${{ matrix.arch }}" 137 | yarn pkg -c package.json -t "$pkg_arch" -o "$pkg_out" ./dist/bin.js 138 | echo "::set-output name=bin_file::$(ls -1 ./dist-bin/ | head -n1)" 139 | - name: Upload binary to release 140 | uses: actions/upload-release-asset@v1 141 | env: 142 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 143 | with: 144 | upload_url: ${{ needs.release.outputs.release_upload_url }} 145 | asset_path: ./dist-bin/${{ steps.build.outputs.bin_file }} 146 | asset_name: ${{ steps.build.outputs.bin_file }} 147 | asset_content_type: application/octet-stream 148 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # === Generated Ignore Patterns (do not modify) === 2 | /dist/ 3 | node_modules/ 4 | .*cache 5 | .jest 6 | .yarn 7 | *.tsbuildinfo 8 | *.log 9 | .coverage/ 10 | .DS_Store 11 | CVS 12 | .svn 13 | .hg 14 | .lock-wscript 15 | .wafpickle-N 16 | .*.swp 17 | ._* 18 | .npmrc 19 | config.gypi 20 | /.npmrc 21 | /.npmignore 22 | /.nvmrc 23 | /.yvmrc 24 | /.eslintrc.js 25 | /.prettierrc.js 26 | /.eslintignore 27 | /.prettierignore 28 | /.lintstagedrc.js 29 | /.husky/pre-commit 30 | /.husky/pre-push 31 | /.husky/.gitignore 32 | /tsconfig.build.json 33 | /tsconfig.base.json 34 | /jest.config.js 35 | # === (end generated patterns) === 36 | 37 | dist-bin/ 38 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # DO NOT MODIFY 4 | # This file is auto-generated (make changes to pre-commit-custom instead) 5 | 6 | . "$(dirname "$0")/_/husky.sh" 7 | 8 | "$(dirname "$0")/../node_modules/.bin/lint-staged" 9 | 10 | CUSTOM_SCRIPT="$(dirname "$0")/pre-commit-custom" 11 | if [ -x "$CUSTOM_SCRIPT" ]; then 12 | "$CUSTOM_SCRIPT" 13 | fi 14 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # DO NOT MODIFY 4 | # This file is auto-generated (make changes to pre-push-custom instead) 5 | 6 | . "$(dirname "$0")/_/husky.sh" 7 | 8 | if [ -t 1 ]; then 9 | exec /**"], 12 | "program": "${workspaceFolder}/dist/bin.js", 13 | "preLaunchTask": "tsc: build - tsconfig.json", 14 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "eslint.format.enable": true, 5 | "[javascript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, 6 | "[javascriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, 7 | "[typescript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, 8 | "[typescriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @jakzo/aoc 2 | 3 | ## 1.3.1 4 | 5 | ### Patch Changes 6 | 7 | - 3db13dd: Attempt to fix dependency versions. 8 | 9 | ## 1.3.0 10 | 11 | ### Minor Changes 12 | 13 | - e576257: Fixed dependency issue and minor change to user agent. 14 | 15 | ## 1.2.0 16 | 17 | ### Minor Changes 18 | 19 | - a415f39: Saves sample input to file now. 20 | 21 | ## 1.1.0 22 | 23 | ### Minor Changes 24 | 25 | - 6e79ecd: Local language templates can now be used. 26 | 27 | ## 1.0.12 28 | 29 | ### Patch Changes 30 | 31 | - 063c58f: Reprompts for session token if saved one is invalid. 32 | 33 | ## 1.0.11 34 | 35 | ### Patch Changes 36 | 37 | - 77a94a2: Better error handling. 38 | 39 | ## 1.0.10 40 | 41 | ### Patch Changes 42 | 43 | - 51360ac: Defaults to day 1 if day not provided and other minor fixes. 44 | 45 | ## 1.0.9 46 | 47 | ### Patch Changes 48 | 49 | - 51a19d5: Fixed templates not being included in npm module. 50 | 51 | ## 1.0.8 52 | 53 | ### Patch Changes 54 | 55 | - a70bc9d: Do not download input file if it already exists. 56 | 57 | ## 1.0.7 58 | 59 | ### Patch Changes 60 | 61 | - b06e241: Added custom user agent 62 | 63 | ## 1.0.6 64 | 65 | ### Patch Changes 66 | 67 | - c2edbc6: Fixed bug in node module due to templates not being included in the package. 68 | 69 | ## 1.0.5 70 | 71 | ### Patch Changes 72 | 73 | - b323258: Rerelease now that GitHub actions mac build is working. 74 | 75 | ## 1.0.4 76 | 77 | ### Patch Changes 78 | 79 | - 185fcc7: Removed debugging log accidentally displayed in countdown command. 80 | 81 | ## 1.0.3 82 | 83 | ### Patch Changes 84 | 85 | - b1b92a3: Updated pkg setup to use node 14 in binaries and use pkg v5 which works for native node modules instead of the fork. 86 | 87 | ## 1.0.2 88 | 89 | ### Patch Changes 90 | 91 | - 98a1800: Updated to a fixed version of jstm (no change to functionality). 92 | 93 | ## 1.0.1 94 | 95 | ### Patch Changes 96 | 97 | - b174da5: Internal change which shouldn't affect usage: use jstm for repo config. 98 | - 719015c: Fixed calculation of start time of previous year's last challenge. 99 | 100 | ## 1.0.0 101 | 102 | ### Major Changes 103 | 104 | - 8f5bd58: Minor fixes and stable release 105 | 106 | ## 0.3.1 107 | 108 | ### Patch Changes 109 | 110 | - 8b60ca8: Code quality changes 111 | 112 | ## 0.3.0 113 | 114 | ### Minor Changes 115 | 116 | - 650a373: First stable release 117 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributions are welcome! 😄 Feel free to open a PR for small fixes or open an issue for bigger changes, discussion or if unsure about how to implement something. 2 | 3 | ## Dev Instructions 4 | 5 | Install dependencies with: 6 | 7 | ```sh 8 | yarn 9 | ``` 10 | 11 | To test the tool during development instead of running `aoc [args...]` run: 12 | 13 | ```sh 14 | yarn dev [args...] 15 | ``` 16 | 17 | To test the tool in production mode run: 18 | 19 | ```sh 20 | yarn build 21 | yarn link 22 | ``` 23 | 24 | Now you will be able to use the tool with `aoc [args...]` from the files in `dist/`. If you update the source code you will need to run `yarn build` again to update the files in `dist/`. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Advent of Code CLI 2 | 3 | Full Advent of Code dev loop in a CLI which: 4 | 5 | - Prints challenge description in the terminal 6 | - Downloads input to a file 7 | - Creates a source file for your favourite language and runs it on change 8 | - Submits answers 9 | 10 | ![aoc demo](./demo.gif) 11 | 12 | ## Recommended Usage 13 | 14 | Install from [binary below](#Installation) or (preferably) with Node like so: 15 | 16 | ```sh 17 | npm i -g @jakzo/aoc 18 | ``` 19 | 20 | A few minutes before the challenge starts, open two terminals and run these commands: 21 | 22 | ```sh 23 | # Terminal 1 24 | aoc 25 | 26 | # Terminal 2 27 | aoc start js 28 | ``` 29 | 30 | This will prompt you for your session token if you haven't provided it before and save it in your operating system's credential manager. 31 | 32 | From here the first terminal will: 33 | 34 | - Count down until the challenge starts 35 | - Print the description in the terminal once the challenge starts 36 | - Download the input to a local file called `input.txt` 37 | - Prompt for an answer to submit 38 | - When a correct answer is submitted, repeats these steps for part 2 39 | 40 | And the second terminal will: 41 | 42 | - Create a new source file for your chosen language (JavaScript in the example) 43 | - Run the created source file and rerun when any changes are saved 44 | 45 | ## Installation 46 | 47 | If you have [Node.js](https://nodejs.org/) installed the easiest way is with: 48 | 49 | ```sh 50 | npm i -g @jakzo/aoc 51 | ``` 52 | 53 | Or if you prefer you can install the binary for your platform: 54 | 55 | ### Linux 56 | 57 | 1. Download `aoc-linux-x64` from the [releases](https://github.com/jakzo/aoc/releases) page 58 | 1. `chmod +x aoc-linux-x64` 59 | 1. `mv aoc-linux-x64 /usr/local/bin/aoc` 60 | 61 | ### MacOS 62 | 63 | 1. Download `aoc-macos-x64` from the [releases](https://github.com/jakzo/aoc/releases) page 64 | 1. Give execute permission: `chmod +x aoc-macos-x64 && xattr -d com.apple.quarantine aoc-macos-x64` 65 | 1. Add to path: `mv aoc-macos-x64 /usr/local/bin/aoc` 66 | 67 | ### Windows 68 | 69 | 1. Download `aoc-win-x64.exe` from the [releases](https://github.com/jakzo/aoc/releases) page 70 | 1. Open Command Prompt as administrator then run: 71 | 1. `mkdir aoc` (feel free to swap `aoc` for any directory you want to install it to) 72 | 1. `move aoc-win-x64.exe aoc\aoc.exe` 73 | 1. Add to path: `cd aoc && setx /M PATH "%PATH%;%CD%"` 74 | 75 | ## Language Templates 76 | 77 | See [./templates](./templates) for a list of possible languages. Each folder name is a valid argument you can provide to `aoc start`. 78 | 79 | You can also create local templates so you can use languages not built-in to this tool. To do this: 80 | 81 | 1. Create a folder containing your template files 82 | 1. Make sure the source file(s) contain `wip` (case-insensitive) in their filename 83 | - This is how the tool determines which files to save after a successful submission 84 | 1. Create an `aoc.json` file in this directory to specify commands 85 | - These commands will be rerun on changes 86 | - Use `{{TEMP_DIR}}` to insert the path of a temporary directory for saving build output 87 | - Example `aoc.json` which compiles and runs the code in `wip.c`: 88 | ```json 89 | { 90 | "commands": ["clang -o '{{TEMP_DIR}}/wip' wip.c", "'{{TEMP_DIR}}/wip'"] 91 | } 92 | ``` 93 | 1. Use your local template by passing in the path to your template folder instead of the language name 94 | - Example: `aoc start ./my-template-folder` 95 | 96 | ## Individual Commands 97 | 98 | The tool also exposes individual commands in case you want to compose some functionality together with another tool. For example you can save the input for a challenge to another file with `aoc input --year 2016 --day 5 > another-file.txt`. 99 | 100 | Documentation for individual commands can be found by running `aoc --help`: 101 | 102 | ``` 103 | $ aoc --help 104 | Commands: 105 | aoc Counts down, saves input, prints 106 | description and prompts for 107 | answers to the upcoming challenge 108 | [default] 109 | aoc start [language] Creates and run files from a 110 | template for a language (does not 111 | overwrite) 112 | aoc login Prompts for a new session token 113 | aoc template Copies a template folder (does 114 | not overwrite) 115 | aoc countdown Counts down until the next 116 | challenge starts then exits 117 | aoc description Prints the description of a 118 | challenge 119 | aoc input Prints the input to a challenge 120 | aoc submit [answer] Submits an answer to a challenge 121 | aoc leaderboard Outputs a CSV of times to 122 | completion for a private 123 | leaderboard 124 | 125 | Options: 126 | -y, --year The year of the challenge [number] 127 | -d, --day The day of the challenge [number] 128 | -h, --help Show help [boolean] 129 | -v, --version Show version number [boolean] 130 | ``` 131 | 132 | Individual commands can also be accessed from the npm module like: 133 | 134 | ```js 135 | const { printDescription } = require("@jakzo/aoc"); 136 | 137 | printDescription(2020, 5); 138 | ``` 139 | -------------------------------------------------------------------------------- /config/.eslintignore: -------------------------------------------------------------------------------- 1 | /templates/ 2 | -------------------------------------------------------------------------------- /config/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | release_binaries: 3 | strategy: 4 | matrix: 5 | arch: [x64] 6 | os: [ubuntu-latest, windows-latest, macos-latest] 7 | include: 8 | - os: ubuntu-latest 9 | os-display-name: Linux 10 | os-pkg-name: linux 11 | - os: windows-latest 12 | os-display-name: Windows 13 | os-pkg-name: win 14 | - os: macos-latest 15 | os-display-name: macOS 16 | os-pkg-name: macos 17 | 18 | name: Release binary for ${{ matrix.os-display-name }} 19 | needs: release 20 | 21 | runs-on: ${{ matrix.os }} 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v2 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v1 28 | with: 29 | node-version: ${{ env.node_version }} 30 | - name: Install dependencies 31 | run: yarn install --frozen-lockfile 32 | - name: Build JavaScript 33 | run: yarn build 34 | - name: Build binary 35 | id: build 36 | shell: bash 37 | run: | 38 | pkg_arch="node${{ env.node_version }}-${{ matrix.os-pkg-name }}-${{ matrix.arch }}" 39 | pkg_out="./dist-bin/aoc-${{ matrix.os-pkg-name }}-${{ matrix.arch }}" 40 | yarn pkg -c package.json -t "$pkg_arch" -o "$pkg_out" ./dist/bin.js 41 | echo "::set-output name=bin_file::$(ls -1 ./dist-bin/ | head -n1)" 42 | - name: Upload binary to release 43 | uses: actions/upload-release-asset@v1 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | with: 47 | upload_url: ${{ needs.release.outputs.release_upload_url }} 48 | asset_path: ./dist-bin/${{ steps.build.outputs.bin_file }} 49 | asset_name: ${{ steps.build.outputs.bin_file }} 50 | asset_content_type: application/octet-stream 51 | -------------------------------------------------------------------------------- /config/.jstmrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /config/.npmignore: -------------------------------------------------------------------------------- 1 | !/templates/**/* 2 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakzo/aoc/b3bbb12a07d6458b699edb0213b82f4d59db1cdc/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jakzo/aoc", 3 | "version": "1.3.1", 4 | "description": "Advent of Code CLI for reading, running and submitting.", 5 | "keywords": [], 6 | "homepage": "https://github.com/jakzo/aoc#readme", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/jakzo/aoc.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/jakzo/aoc/issues" 13 | }, 14 | "author": "Jack Field", 15 | "license": "GPL-3.0-only", 16 | "main": "dist/index.js", 17 | "types": "dist/index.d.ts", 18 | "engines": { 19 | "node": ">=10" 20 | }, 21 | "publishConfig": { 22 | "access": "public", 23 | "registry": "https://registry.npmjs.org" 24 | }, 25 | "bin": { 26 | "aoc": "dist/bin.js" 27 | }, 28 | "pkg": { 29 | "assets": "templates/**/*" 30 | }, 31 | "scripts": { 32 | "=== Generated Scripts (do not modify) ===": "", 33 | "lint:eslint": "project && eslint --cache --ext js,jsx,ts,tsx ./", 34 | "lint:prettier": "project && prettier -c \"./**/*{.json,.md}\"", 35 | "lint:fix": "project && eslint --cache --ext js,jsx,ts,tsx ./ --fix && prettier -c \"./**/*{.json,.md}\" --write && run-if-script-exists lint:fix:custom", 36 | "lint": "project && eslint --cache --ext js,jsx,ts,tsx ./ && prettier -c \"./**/*{.json,.md}\" && run-if-script-exists lint:custom", 37 | "build:clean": "project && rimraf \"./dist\" *.tsbuildinfo && run-if-script-exists build:clean:custom", 38 | "build:typescript": "project && tsc -p ./tsconfig.build.json", 39 | "build:watch": "project && tsc -p ./tsconfig.build.json -w", 40 | "build": "project && run-if-script-exists build:custom-before && tsc -p ./tsconfig.build.json && run-if-script-exists build:custom", 41 | "test:jest": "project && jest --passWithNoTests", 42 | "test:watch": "project && jest --passWithNoTests --watch", 43 | "test": "project && jest --passWithNoTests && run-if-script-exists test:custom", 44 | "test:typecheck": "project && tsc -p ./tsconfig.json --noEmit && tsc -p ./tsconfig.build.json --noEmit", 45 | "test:all": "project && tsc -p ./tsconfig.json --noEmit && tsc -p ./tsconfig.build.json --noEmit && eslint --cache --ext js,jsx,ts,tsx ./ && prettier -c \"./**/*{.json,.md}\" && run-if-script-exists lint:custom && jest --passWithNoTests && run-if-script-exists test:custom", 46 | "release": "project && rimraf \"./dist\" *.tsbuildinfo && run-if-script-exists build:clean:custom && run-if-script-exists build:custom-before && tsc -p ./tsconfig.build.json && run-if-script-exists build:custom && changeset publish && run-if-script-exists release:custom", 47 | "prepare": "project && husky install && run-if-script-exists prepare:custom", 48 | "=== (end generated scripts) ===": "", 49 | "": "", 50 | "dev": "ts-node -T ./src/bin.ts", 51 | "build:binary": "pkg -c package.json -t node14-linux-x64,node14-macos-x64,node14-win-x64 -o dist-bin/aoc ./dist/bin.js" 52 | }, 53 | "dependencies": { 54 | "axios": "0.21.1", 55 | "chalk": "4.1.0", 56 | "cheerio": "1.0.0-rc.6", 57 | "chokidar": "3.5.1", 58 | "cli-html": "jakzo/cli-html", 59 | "date-fns": "2.21.1", 60 | "fs-extra": "9.1.0", 61 | "inquirer": "7.3.3", 62 | "keytar": "7.6.0", 63 | "string-argv": "0.3.1", 64 | "tempy": "1.0.1", 65 | "yargs": "16.2.0" 66 | }, 67 | "devDependencies": { 68 | "@changesets/cli": "2.14.1", 69 | "@changesets/get-release-plan": "2.0.1", 70 | "@changesets/release-utils": "0.1.0", 71 | "@jstm/preset-node": "0.3.14", 72 | "@types/cheerio": "^0.22.22", 73 | "@types/fs-extra": "^9.0.4", 74 | "@types/inquirer": "^7.3.1", 75 | "@types/jest": "26.0.21", 76 | "@types/node": "14.14.37", 77 | "@types/yargs": "^15.0.11", 78 | "@typescript-eslint/eslint-plugin": "4.18.0", 79 | "@typescript-eslint/parser": "4.18.0", 80 | "bufferutil": "4.0.3", 81 | "canvas": "2.7.0", 82 | "eslint": "7.22.0", 83 | "eslint-config-prettier": "8.1.0", 84 | "eslint-import-resolver-typescript": "2.4.0", 85 | "eslint-plugin-import": "2.22.1", 86 | "eslint-plugin-jest": "24.3.2", 87 | "eslint-plugin-only-warn": "1.0.2", 88 | "eslint-plugin-prettier": "3.3.1", 89 | "husky": "6.0.0", 90 | "jest": "26.6.3", 91 | "lint-staged": "10.5.4", 92 | "node-notifier": "9.0.1", 93 | "pkg": "^5.4.1", 94 | "prettier": "2.2.1", 95 | "rimraf": "3.0.2", 96 | "ts-jest": "26.5.4", 97 | "ts-node": "9.1.1", 98 | "ts-node-dev": "1.1.6", 99 | "typescript": "4.2.3", 100 | "utf-8-validate": "5.0.4" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import chalk from "chalk"; 3 | import inquirer from "inquirer"; 4 | import yargs from "yargs"; 5 | 6 | import { 7 | copyTemplates, 8 | countdownToStart, 9 | getInput, 10 | loginPrompt, 11 | main, 12 | printDescription, 13 | privateLeaderboardTimesToCsv, 14 | start, 15 | submit, 16 | } from "./commands"; 17 | import { normalizeTemplate } from "./utils"; 18 | 19 | const readStdin = async (): Promise => { 20 | const chunks = []; 21 | for await (const chunk of process.stdin) chunks.push(chunk); 22 | return Buffer.concat(chunks).toString("utf8").trim(); 23 | }; 24 | 25 | const cliHandler = (fn: (args: T) => Promise) => (args: T) => { 26 | fn(args).catch((err) => { 27 | console.error(chalk.red(String(err))); 28 | const isDebug = process.argv[1]?.endsWith("/bin.ts"); 29 | if (isDebug) console.error(err); 30 | process.exit(1); 31 | }); 32 | }; 33 | 34 | yargs 35 | .scriptName("aoc") 36 | .epilog("Reads, runs and submits Advent of Code challenges") 37 | .command( 38 | "*", 39 | "Counts down, saves input, prints description and prompts for answers to the upcoming challenge", 40 | (yargs) => 41 | yargs 42 | .option("year", { 43 | alias: "y", 44 | type: "number", 45 | description: "The year of the challenge", 46 | }) 47 | .option("day", { 48 | alias: "d", 49 | type: "number", 50 | description: "The day of the challenge", 51 | }), 52 | cliHandler(async (args) => main(args.year, args.day)) 53 | ) 54 | .command( 55 | "start [language]", 56 | "Creates and run files from a template for a language (does not overwrite)", 57 | (yargs) => 58 | yargs 59 | .positional("language", { 60 | type: "string", 61 | description: 62 | "Name of built-in language template or path to local template folder", 63 | }) 64 | .option("day", { 65 | alias: "d", 66 | type: "number", 67 | description: "The day of the challenge", 68 | }), 69 | cliHandler(async (args) => { 70 | await start(args.language, args.year, args.day); 71 | }) 72 | ) 73 | .command( 74 | "login", 75 | "Prompts for a new session token", 76 | (yargs) => yargs, 77 | cliHandler(async () => loginPrompt()) 78 | ) 79 | .command( 80 | "template ", 81 | "Copies a template folder (does not overwrite)", 82 | (yargs) => 83 | yargs 84 | .positional("output", { 85 | type: "string", 86 | demandOption: true, 87 | description: 88 | "Path to the directory to create and fill with the template contents", 89 | }) 90 | .option("template", { 91 | alias: "t", 92 | type: "string", 93 | description: "Path to the template directory", 94 | }) 95 | .option("language", { 96 | alias: "l", 97 | type: "string", 98 | description: "Name of built-in language template", 99 | }), 100 | cliHandler(async (args) => { 101 | if (!args.template && !args.language) 102 | throw new Error( 103 | "Either the 'template' or 'language' option must be set" 104 | ); 105 | await copyTemplates( 106 | args.output, 107 | args.template 108 | ? args.template 109 | : (await normalizeTemplate(args.language!)).path 110 | ); 111 | }) 112 | ) 113 | .command( 114 | "countdown", 115 | "Counts down until the next challenge starts then exits", 116 | (yargs) => 117 | yargs 118 | .option("margin", { 119 | alias: "m", 120 | type: "number", 121 | default: 23, 122 | description: 123 | "End immediately if the previous challenge started less than a certain number of hours ago", 124 | }) 125 | .example([ 126 | ["$0 countdown", "Counts down until the next challenge starts"], 127 | ]), 128 | cliHandler(async (args) => countdownToStart(args.margin * 1000 * 60 * 60)) 129 | ) 130 | .command( 131 | "description", 132 | "Prints the description of a challenge", 133 | (yargs) => 134 | yargs 135 | .option("year", { 136 | alias: "y", 137 | type: "number", 138 | description: "The year of the challenge", 139 | }) 140 | .option("day", { 141 | alias: "d", 142 | type: "number", 143 | description: "The day of the challenge", 144 | }) 145 | .option("part", { 146 | alias: "p", 147 | type: "number", 148 | description: "The part number of the challenge (eg. 1 or 2)", 149 | }) 150 | .example([ 151 | [ 152 | "$0 description", 153 | "Print both parts (if available) of the description to today's challenge", 154 | ], 155 | [ 156 | "$0 description --year 2019 --day 3 --part 2", 157 | "Print the description of a specific challenge", 158 | ], 159 | ]), 160 | cliHandler(async (args) => { 161 | await printDescription(args.year, args.day, args.part); 162 | }) 163 | ) 164 | .command( 165 | "input", 166 | "Prints the input to a challenge", 167 | (yargs) => 168 | yargs 169 | .option("year", { 170 | alias: "y", 171 | type: "number", 172 | description: "The year of the challenge", 173 | }) 174 | .option("day", { 175 | alias: "d", 176 | type: "number", 177 | description: "The day of the challenge", 178 | }) 179 | .example([ 180 | ["$0 input", "Print the input to today's challenge"], 181 | [ 182 | "$0 input --year 2019 --day 3", 183 | "Print the input to a specific challenge", 184 | ], 185 | ]), 186 | cliHandler(async (args) => { 187 | console.log(await getInput(args.year, args.day)); 188 | }) 189 | ) 190 | .command( 191 | "submit [answer]", 192 | "Submits an answer to a challenge", 193 | (yargs) => 194 | yargs 195 | .option("year", { 196 | alias: "y", 197 | type: "number", 198 | description: "The year of the challenge", 199 | }) 200 | .option("day", { 201 | alias: "d", 202 | type: "number", 203 | description: "The day of the challenge", 204 | }) 205 | .option("part", { 206 | alias: "p", 207 | type: "number", 208 | description: "The part number to submit (eg. 1 or 2)", 209 | }) 210 | .positional("answer", { 211 | type: "string", 212 | description: 213 | "The answer to submit, if not provided it is read from input", 214 | }) 215 | .demandOption("part") 216 | .example([ 217 | [ 218 | "$0 submit --part 1", 219 | "Prompt for answer to today's challenge from input", 220 | ], 221 | ['echo "my answer" | $0 submit --part 1', "Provide answer as input"], 222 | ['$0 submit --part 1 "my answer"', "Provide answer as an argument"], 223 | [ 224 | "$0 submit --year 2019 --day 3 --part 2", 225 | "Give an answer to a specific challenge", 226 | ], 227 | ]), 228 | cliHandler(async (args) => { 229 | const answer = 230 | args.answer || 231 | (await readStdin()) || 232 | ( 233 | await inquirer.prompt<{ answer: string }>([ 234 | { name: "answer", message: "Enter your answer:" }, 235 | ]) 236 | ).answer; 237 | await submit(args.part, answer.trim(), args.year, args.day); 238 | }) 239 | ) 240 | .command( 241 | "leaderboard ", 242 | "Outputs a CSV of times to completion for a private leaderboard", 243 | (yargs) => 244 | yargs 245 | .option("year", { 246 | alias: "y", 247 | type: "number", 248 | description: "The year of the times to output", 249 | }) 250 | .positional("id", { 251 | type: "string", 252 | demandOption: true, 253 | description: "Private leaderboard ID (find it in the URL)", 254 | }), 255 | cliHandler(async (args) => { 256 | const csv = await privateLeaderboardTimesToCsv(args.id, args.year); 257 | for (const line of csv) { 258 | console.log(line.join(",")); 259 | } 260 | }) 261 | ) 262 | .alias("h", "help") 263 | .alias("v", "version") 264 | .parse(); 265 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import path from "path"; 3 | 4 | import chalk from "chalk"; 5 | import cheerio from "cheerio"; 6 | import * as chokidar from "chokidar"; 7 | import { formatDistanceToNowStrict } from "date-fns"; 8 | import * as fse from "fs-extra"; 9 | import inquirer from "inquirer"; 10 | import * as keytar from "keytar"; 11 | import tempy from "tempy"; 12 | 13 | import { CommandBuilder } from "./templates"; 14 | import { 15 | DEFAULT_ACCOUNT, 16 | KEYTAR_SERVICE_NAME, 17 | getChallengeStartTime, 18 | getCurrentChallengeStartTime, 19 | getCurrentDay, 20 | getCurrentYear, 21 | getDirForDay, 22 | getLocalTemplateFiles, 23 | getSessionToken, 24 | logHtml, 25 | makeRequest, 26 | normalizeTemplate, 27 | padZero, 28 | validateDayAndYear, 29 | } from "./utils"; 30 | 31 | export const main = async ( 32 | year = getCurrentChallengeStartTime().getUTCFullYear(), 33 | day = getCurrentChallengeStartTime().getUTCDate(), 34 | account?: string 35 | ): Promise => { 36 | await getSessionToken(account, true); 37 | await countdownToStart(undefined, getChallengeStartTime(year, day).getTime()); 38 | // TODO: Continue to part 2 if part 1 is already completed 39 | let part = 1; 40 | const desc = await printDescription(year, day, part, account); 41 | const dir = getDirForDay(day); 42 | await fse.ensureDir(dir); 43 | const inputFile = path.join(dir, "input.txt"); 44 | if (!(await fse.pathExists(inputFile))) 45 | await fse.writeFile(inputFile, await getInput(year, day)); 46 | const inputSampleFile = path.join(dir, "input-sample.txt"); 47 | if (!(await fse.pathExists(inputSampleFile))) { 48 | const sampleInput = readSampleInput(...desc); 49 | if (sampleInput) await fse.writeFile(inputSampleFile, sampleInput); 50 | } 51 | while (true) { 52 | while (true) { 53 | console.log(""); 54 | const { answer } = await inquirer.prompt<{ answer: string }>([ 55 | { name: "answer", message: "Enter your answer:" }, 56 | ]); 57 | const { isCorrect, isDone } = await submit( 58 | part, 59 | answer, 60 | year, 61 | day, 62 | true, 63 | account 64 | ); 65 | if (isCorrect) { 66 | const wipRegex = /\bwip\b/i; 67 | for (const filename of await fse.readdir(dir)) { 68 | if (!wipRegex.test(filename)) continue; 69 | await fse.copy( 70 | path.join(dir, filename), 71 | path.join(dir, filename.replace(wipRegex, `part${part}`)) 72 | ); 73 | } 74 | if (isDone) return; 75 | part++; 76 | break; 77 | } 78 | } 79 | await printDescription(year, day, part, account); 80 | } 81 | }; 82 | 83 | export const start = async ( 84 | templateNameOrPath = "js", 85 | year = getCurrentYear(), 86 | day = getCurrentDay(year) 87 | ): Promise => { 88 | const dir = getDirForDay(day); 89 | const normalizedTemplate = await normalizeTemplate(templateNameOrPath); 90 | await copyTemplates(dir, normalizedTemplate.path); 91 | return runAndWatch( 92 | normalizedTemplate.commandBuilder, 93 | dir, 94 | normalizedTemplate.files 95 | ); 96 | }; 97 | 98 | export const loginPrompt = async (account = DEFAULT_ACCOUNT): Promise => { 99 | await keytar.deletePassword(KEYTAR_SERVICE_NAME, account); 100 | await getSessionToken(account, true); 101 | }; 102 | 103 | export const copyTemplates = async ( 104 | outputPath: string, 105 | templatePath: string 106 | ): Promise => { 107 | for (const name of await getLocalTemplateFiles(templatePath)) { 108 | await fse.copy(path.join(templatePath, name), path.join(outputPath, name), { 109 | overwrite: false, 110 | }); 111 | } 112 | }; 113 | 114 | // TODO: Synchronize with AoC time 115 | export const countdownToStart = async ( 116 | margin = 1000 * 60 * 60 * 23, 117 | startTime = getCurrentChallengeStartTime(margin).getTime() 118 | ): Promise => 119 | new Promise((resolve) => { 120 | const tick = (): void => { 121 | process.stdout.clearLine(0); 122 | process.stdout.cursorTo(0); 123 | const now = Date.now(); 124 | if (now >= startTime) { 125 | console.log("Challenge starting now!"); 126 | resolve(); 127 | } else { 128 | process.stdout.write( 129 | `Challenge starts in ${formatDistanceToNowStrict(startTime + 500)}` 130 | ); 131 | // Align the ticks to the startTime 132 | setTimeout( 133 | tick, 134 | 1000 - (((now % 1000) - (startTime % 1000) + 1000) % 1000) 135 | ); 136 | } 137 | }; 138 | 139 | if (Date.now() > startTime) resolve(); 140 | else tick(); 141 | }); 142 | 143 | export const getInput = async ( 144 | year = getCurrentYear(), 145 | day = getCurrentDay(year), 146 | account?: string 147 | ): Promise => { 148 | validateDayAndYear(day, year); 149 | return makeRequest( 150 | `/${year}/day/${day}/input`, 151 | await getSessionToken(account) 152 | ); 153 | }; 154 | 155 | export const getSampleInput = async ( 156 | year = getCurrentYear(), 157 | day = getCurrentDay(year), 158 | account?: string 159 | ): Promise => 160 | readSampleInput(...(await fetchDescriptionParts(year, day, account))); 161 | 162 | const readSampleInput = ( 163 | $: cheerio.Root, 164 | partEls: cheerio.Cheerio 165 | ): string | undefined => { 166 | const sampleInput = $(partEls[0]).find("pre").first(); 167 | if (!sampleInput) return undefined; 168 | return $(sampleInput).text(); 169 | }; 170 | 171 | export const printDescription = async ( 172 | year = getCurrentYear(), 173 | day = getCurrentDay(year), 174 | /** Part number to print or leave `undefined` to print all parts. */ 175 | partNum?: number, 176 | account?: string 177 | ): Promise<[cheerio.Root, cheerio.Cheerio]> => { 178 | const [$, partEls] = await fetchDescriptionParts(year, day, account); 179 | if (partNum) { 180 | const partEl = partEls[partNum - 1]; 181 | if (!partEl) throw new Error(`cannot find part ${partNum} on page`); 182 | logHtml($(partEl).html()!); 183 | } else { 184 | partEls.each((i, el) => logHtml($(el).html()!)); 185 | } 186 | return [$, partEls]; 187 | }; 188 | 189 | const fetchDescriptionParts = async ( 190 | year = getCurrentYear(), 191 | day = getCurrentDay(year), 192 | account?: string 193 | ): Promise<[cheerio.Root, cheerio.Cheerio]> => { 194 | validateDayAndYear(day, year); 195 | const $ = cheerio.load( 196 | await makeRequest(`/${year}/day/${day}`, await getSessionToken(account)) 197 | ); 198 | return [$, $(".day-desc")]; 199 | }; 200 | 201 | export const submit = async ( 202 | partNum: number, 203 | answer: string, 204 | year = getCurrentYear(), 205 | day = getCurrentDay(year), 206 | logFeedback = true, 207 | account?: string 208 | ): Promise<{ isCorrect: boolean; isDone: boolean; message: string }> => { 209 | validateDayAndYear(day, year); 210 | const $ = cheerio.load( 211 | await makeRequest( 212 | `/${year}/day/${day}/answer`, 213 | await getSessionToken(account), 214 | { 215 | level: String(partNum), 216 | answer, 217 | } 218 | ) 219 | ); 220 | const main = $("main"); 221 | 222 | // Remove useless links (since you cannot use them in the terminal) 223 | $(main) 224 | .find("a") 225 | .filter((i, el) => /^\s*\[.+\]\s*$/.test($(el).text())) 226 | .remove(); 227 | const html = $(main) 228 | .html()! 229 | .replace(/If\s+you[^]{1,8}re\s+stuck[^]+?subreddit[^]+?\.\s*/, "") 230 | .replace(/You\s+can[^]+?this\s+victory[^]+?\.\s*/, ""); 231 | $(main).html(html); 232 | const text = $(main).text(); 233 | 234 | if (logFeedback) logHtml(html); 235 | const isCorrect = text.includes("That's the right answer"); 236 | return { 237 | isCorrect, 238 | isDone: isCorrect && partNum === 2, 239 | message: text.substr(0, text.indexOf(".")), 240 | }; 241 | }; 242 | 243 | export const runAndWatch = ( 244 | commandBuilder: CommandBuilder, 245 | dir = process.cwd(), 246 | filesToWatch = [dir] 247 | ): chokidar.FSWatcher => { 248 | let tempDir: undefined | string; 249 | const commands = commandBuilder({ 250 | get tempDir() { 251 | if (!tempDir) tempDir = tempy.directory({ prefix: "aoc" }); 252 | return tempDir; 253 | }, 254 | }); 255 | 256 | let killPrevRun: undefined | (() => void); 257 | return chokidar 258 | .watch(filesToWatch.map((file) => path.resolve(dir, file))) 259 | .on("all", () => { 260 | if (killPrevRun) killPrevRun(); 261 | 262 | let i = -1; 263 | let killed = false; 264 | const runNextCommand = (code?: number): void => { 265 | if (killed) return; 266 | 267 | if (code) { 268 | console.warn(chalk.yellow("Finished with error")); 269 | return; 270 | } 271 | if (++i >= commands.length) { 272 | console.log(chalk.yellow("Finished")); 273 | return; 274 | } 275 | 276 | const { command, args = [], cwd = dir } = commands[i]; 277 | console.log( 278 | chalk.yellow( 279 | i === 0 ? "Running command on file change:" : "Running:" 280 | ), 281 | [command, ...args].join(" ") 282 | ); 283 | const cp = spawn(command, args, { cwd, stdio: "inherit" }); 284 | cp.on("close", runNextCommand); 285 | killPrevRun = () => { 286 | // TODO: How do I handle this gracefully? 287 | cp.kill(); 288 | killed = true; 289 | }; 290 | }; 291 | runNextCommand(); 292 | }); 293 | }; 294 | 295 | interface PrivateLeaderboardJson { 296 | members: Record< 297 | string, 298 | { 299 | name: string; 300 | completion_day_level: Record< 301 | string, 302 | Record 303 | >; 304 | } 305 | >; 306 | } 307 | 308 | export const privateLeaderboardTimesToCsv = async ( 309 | boardId: string, 310 | year = getCurrentYear(), 311 | account?: string 312 | ): Promise => { 313 | validateDayAndYear(1, year); 314 | const res = JSON.parse( 315 | await makeRequest( 316 | `/${year}/leaderboard/private/view/${boardId}.json`, 317 | await getSessionToken(account) 318 | ) 319 | ) as PrivateLeaderboardJson; 320 | 321 | const csv = [["Name"]]; 322 | for (let day = 1; day <= 25; day++) { 323 | for (const part of ["A", "B"]) { 324 | csv[0].push(`${padZero(day)} - ${part}`); 325 | } 326 | } 327 | for (const member of Object.values(res.members)) { 328 | const entry = [member.name, ...(Array(25 * 2).fill("") as string[])]; 329 | for (const [day, dayEntry] of Object.entries(member.completion_day_level)) { 330 | const challengeStart = Date.UTC( 331 | new Date().getUTCFullYear(), 332 | 11, 333 | +day, 334 | 5, 335 | 0, 336 | 0, 337 | 0 338 | ); 339 | for (const [part, { get_star_ts }] of Object.entries(dayEntry)) { 340 | const submitTime = +get_star_ts * 1000; 341 | const submitElapsed = Math.min( 342 | submitTime - challengeStart, 343 | 1000 * 60 * 60 * 24 - 1 344 | ); 345 | const d = new Date(submitElapsed); 346 | entry[(+day - 1) * 2 + (+part - 1) + 1] = `${padZero( 347 | d.getUTCHours() 348 | )}:${padZero(d.getUTCMinutes())}:${padZero( 349 | d.getUTCSeconds() 350 | )}.${padZero(d.getUTCMilliseconds(), 3)}`; 351 | } 352 | } 353 | csv.push(entry); 354 | } 355 | return csv; 356 | }; 357 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./commands"; 2 | export * from "./templates"; 3 | -------------------------------------------------------------------------------- /src/ipc.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // This file is unused right now but if I ever want to integrate the test runner with solution 3 | // submission I'll use this to automatically print the next part of the description 4 | import net, { Socket } from "net"; 5 | import os from "os"; 6 | 7 | export type TerminalId = "description" | "runner"; 8 | 9 | const callbackToAsyncIterator = () => { 10 | // The last item in the promiseQueue should always be an unresolved promise 11 | const promiseQueue: Promise>[] = []; 12 | let resolveLatest: (value: T) => void; 13 | 14 | const addPromiseToQueue = () => { 15 | let resolve: (value: IteratorResult) => void; 16 | promiseQueue.push(new Promise>((r) => (resolve = r))); 17 | resolveLatest = (value) => resolve({ done: false, value }); 18 | }; 19 | addPromiseToQueue(); 20 | 21 | const iterator: AsyncIterator = { 22 | next: () => 23 | promiseQueue.shift() || Promise.resolve({ done: true, value: undefined }), 24 | }; 25 | const iterable: AsyncIterable = { 26 | [Symbol.asyncIterator]: () => iterator, 27 | }; 28 | return { 29 | callback: (value: T) => { 30 | resolveLatest(value); 31 | addPromiseToQueue(); 32 | }, 33 | iterator, 34 | iterable, 35 | }; 36 | }; 37 | 38 | const getPipeName = (id: TerminalId) => 39 | os.platform() === "win32" ? `\\\\.\\pipe\\aoc-${id}` : `/tmp/aoc-${id}.sock`; 40 | 41 | const isTerminalRunning = async (id: TerminalId) => 42 | new Promise((resolve, reject) => { 43 | const client = net.createConnection(getPipeName(id), () => {}); 44 | client.on("error", (err) => { 45 | if (err && (err as any).code === "ENOENT") { 46 | resolve(false); 47 | } else { 48 | reject(err); 49 | } 50 | }); 51 | client.on("connect", () => { 52 | resolve(true); 53 | client.end(); 54 | }); 55 | }); 56 | 57 | const createIpcServer = async ( 58 | id: TerminalId, 59 | onConnection: (socket: Socket) => void 60 | ) => 61 | new Promise((resolve, reject) => { 62 | const server = net.createServer().listen(getPipeName(id)); 63 | server.on("error", reject); 64 | server.on("connection", onConnection); 65 | server.on("listening", resolve); 66 | }); 67 | 68 | export const startIpcServer = async () => { 69 | if (!(await isTerminalRunning("description"))) { 70 | const { 71 | callback, 72 | iterable: answersToSubmit, 73 | } = callbackToAsyncIterator(); 74 | await createIpcServer("description", (socket) => { 75 | socket.on("data", (data) => { 76 | callback(data.toString()); 77 | }); 78 | }); 79 | return { 80 | id: "description", 81 | answersToSubmit, 82 | }; 83 | } 84 | 85 | if (!(await isTerminalRunning("runner"))) { 86 | await createIpcServer("runner", (socket) => { 87 | socket.end(); 88 | }); 89 | return { id: "runner" }; 90 | } 91 | 92 | throw new Error("Description and runner terminals are both already running"); 93 | }; 94 | -------------------------------------------------------------------------------- /src/templates.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | const declareTemplates = ( 5 | commands: Record 6 | ): Record => commands; 7 | 8 | const templateCommands = declareTemplates({ 9 | "assembly-x86-mac": (ctx) => [ 10 | { 11 | command: "nasm", 12 | args: [ 13 | "-f", 14 | "macho64", 15 | "-g", 16 | "-F", 17 | "dwarf", 18 | "-o", 19 | path.join(ctx.tempDir, "wip.o"), 20 | "wip.asm", 21 | ], 22 | }, 23 | { 24 | command: "ld", 25 | args: [ 26 | "-macosx_version_min", 27 | "10.8", 28 | "-no_pie", 29 | "-static", 30 | "-o", 31 | path.join(ctx.tempDir, "wip"), 32 | path.join(ctx.tempDir, "wip.o"), 33 | ], 34 | }, 35 | { command: path.join(ctx.tempDir, "wip") }, 36 | ], 37 | c: (ctx) => [ 38 | { command: "clang", args: ["-o", path.join(ctx.tempDir, "wip"), "wip.c"] }, 39 | { command: path.join(ctx.tempDir, "wip") }, 40 | ], 41 | js: () => [{ command: "node", args: ["wip.js"] }], 42 | java: (ctx) => [ 43 | { command: "javac", args: ["-d", ctx.tempDir, "Main.wip.java"] }, 44 | { command: "java", args: ["-classpath", ctx.tempDir, "Main"] }, 45 | ], 46 | nim: () => [{ command: "nim", args: ["--hints:off", "r", "wip.nim"] }], 47 | python: () => [{ command: "python", args: ["wip.py"] }], 48 | ruby: () => [{ command: "ruby", args: ["wip.rb"] }], 49 | rust: (ctx) => [ 50 | { command: "rustc", args: ["-o", path.join(ctx.tempDir, "wip"), "wip.rs"] }, 51 | { command: path.join(ctx.tempDir, "wip") }, 52 | ], 53 | }); 54 | 55 | export type CommandBuilder = (ctx: { 56 | tempDir: string; 57 | }) => { 58 | command: string; 59 | args?: string[]; 60 | cwd?: string; 61 | }[]; 62 | 63 | export interface AocTemplateNormalized { 64 | path: string; 65 | commandBuilder: CommandBuilder; 66 | files?: string[]; 67 | } 68 | export type AocTemplate = AocTemplateNormalized | AocTemplateBuiltin; 69 | 70 | export type AocTemplateBuiltin = keyof typeof templateCommands; 71 | 72 | export const builtinTemplates = Object.assign( 73 | {}, 74 | ...Object.entries(templateCommands).map( 75 | ([name, commandBuilder]): Record => { 76 | const templatePath = path.join(__dirname, "..", "templates", name); 77 | return { 78 | [name]: { 79 | path: templatePath, 80 | commandBuilder, 81 | files: fs.readdirSync(templatePath), 82 | }, 83 | }; 84 | } 85 | ) 86 | ) as Record; 87 | -------------------------------------------------------------------------------- /src/typings.ts: -------------------------------------------------------------------------------- 1 | declare module "cli-html" { 2 | export default function cliHtml(html: string): string; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import axios from "axios"; 4 | import chalk from "chalk"; 5 | import cliHtml from "cli-html"; 6 | import * as fse from "fs-extra"; 7 | import inquirer from "inquirer"; 8 | import * as keytar from "keytar"; 9 | import stringArgv from "string-argv"; 10 | 11 | import { 12 | AocTemplate, 13 | AocTemplateBuiltin, 14 | AocTemplateNormalized, 15 | builtinTemplates, 16 | } from "./templates"; 17 | 18 | export const KEYTAR_SERVICE_NAME = "jakzo-aoc"; 19 | export const DEFAULT_ACCOUNT = "_default"; 20 | export const TEMPLATE_JSON = "aoc.json"; 21 | const BASE_URL = "https://adventofcode.com"; 22 | const BACKOFF_RATE = 1.2; 23 | const BACKOFF_INITIAL = 1000; 24 | const BACKOFF_MAX = 30000; 25 | 26 | // eslint-disable-next-line @typescript-eslint/no-var-requires 27 | const PACKAGE_JSON = require("../package.json") as { 28 | name: string; 29 | version: string; 30 | homepage: string; 31 | }; 32 | 33 | export const logHtml = (html: string): void => { 34 | console.log(cliHtml(html).replace(/\n+$/, "")); 35 | }; 36 | 37 | export const getCurrentDay = (year: number): number => { 38 | const now = new Date(); 39 | if (year === now.getUTCFullYear()) { 40 | if (now.getUTCMonth() !== 11) 41 | throw new Error("Advent of Code has not started yet"); 42 | const day = now.getUTCDate(); 43 | if (day > 25) throw new Error("Advent of Code is over"); 44 | return day; 45 | } else { 46 | console.warn(chalk.yellow("No day given. Defaulting to day 1...")); 47 | return 1; 48 | } 49 | }; 50 | 51 | export const getCurrentYear = (): number => new Date().getUTCFullYear(); 52 | 53 | const isTokenValid = async (token: string): Promise => { 54 | const res = await axios({ 55 | url: BASE_URL, 56 | headers: { cookie: `session=${token}` }, 57 | validateStatus: () => true, 58 | }); 59 | return res.status < 300; 60 | }; 61 | 62 | export const promptForToken = async (verifyToken = false): Promise => { 63 | let input = await inquirer.prompt<{ token: string }>([ 64 | { 65 | name: "token", 66 | message: "Enter your session token:", 67 | suffix: " (use browser dev tools and find the `session` cookie)", 68 | transformer: (token: string) => token.trim(), 69 | validate: (token?: string) => (token ? true : "Token is required"), 70 | }, 71 | ]); 72 | while (verifyToken && !(await isTokenValid(input.token))) { 73 | input = await inquirer.prompt<{ token: string }>([ 74 | { 75 | name: "token", 76 | message: "Token invalid. Please try again:", 77 | transformer: (token: string) => token.trim(), 78 | validate: (token?: string) => (token ? true : "Token is required"), 79 | }, 80 | ]); 81 | } 82 | return input.token; 83 | }; 84 | 85 | export const getSessionToken = async ( 86 | account = DEFAULT_ACCOUNT, 87 | verifyToken = false 88 | ): Promise => { 89 | const token = await keytar.getPassword(KEYTAR_SERVICE_NAME, account); 90 | if (token) { 91 | if (verifyToken && !(await isTokenValid(token))) 92 | throw new Error("token is not valid"); 93 | return token; 94 | } 95 | 96 | return getNewSessionToken(account, verifyToken); 97 | }; 98 | 99 | export const getNewSessionToken = async ( 100 | account = DEFAULT_ACCOUNT, 101 | verifyToken = false 102 | ): Promise => { 103 | const inputToken = await promptForToken(verifyToken); 104 | await keytar.setPassword(KEYTAR_SERVICE_NAME, account, inputToken); 105 | return inputToken; 106 | }; 107 | 108 | export const sleep = (ms: number): Promise => 109 | new Promise((resolve) => setTimeout(resolve, ms)); 110 | 111 | export const padZero = (n: number, length = 2): string => 112 | `${n}`.padStart(length, "0"); 113 | 114 | export const formUrlEncoded = (data: Record): string => 115 | Object.entries(data) 116 | .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) 117 | .join("&"); 118 | 119 | /** Makes a request and retries quickly on 5XX. After some time of failure it will wait longer to retry. */ 120 | export const makeRequest = async ( 121 | url: string, 122 | token: string, 123 | data?: Record 124 | ): Promise => { 125 | let timeOfLastRequest = 0; 126 | let currentWait = BACKOFF_INITIAL; 127 | while (true) { 128 | const now = Date.now(); 129 | const timeOfNextRequest = timeOfLastRequest + currentWait; 130 | if (timeOfNextRequest > now) await sleep(timeOfNextRequest - now); 131 | currentWait = Math.min(currentWait * BACKOFF_RATE, BACKOFF_MAX); 132 | timeOfLastRequest = now; 133 | 134 | let res: { status: number; data: Buffer }; 135 | try { 136 | res = await axios({ 137 | url: `${BASE_URL}${url}`, 138 | method: data ? "POST" : "GET", 139 | headers: { 140 | "User-Agent": `Mozilla/5.0 (compatible; ${PACKAGE_JSON.name}:${ 141 | PACKAGE_JSON.version 142 | }; +${PACKAGE_JSON.homepage.replace(/#.+/, "")})`, 143 | ...(data 144 | ? { "Content-Type": "application/x-www-form-urlencoded" } 145 | : undefined), 146 | ...(token ? { cookie: `session=${token}` } : undefined), 147 | }, 148 | responseType: "arraybuffer", 149 | timeout: Math.max(5000, currentWait), 150 | data: data ? formUrlEncoded(data) : undefined, 151 | }); 152 | } catch (err) { 153 | const response = (err as { 154 | response?: { 155 | status: number; 156 | statusText: string; 157 | headers: { "set-cookie"?: string[] }; 158 | }; 159 | })?.response; 160 | if ( 161 | response && 162 | response.status && 163 | response.status >= 300 && 164 | response.status < 500 165 | ) { 166 | if ( 167 | response.status === 401 || 168 | (response.status === 400 && 169 | token && 170 | response.headers["set-cookie"]?.some((value) => 171 | value.startsWith("session=;") 172 | )) 173 | ) { 174 | // TODO: Use appropriate account 175 | token = await getNewSessionToken(); 176 | continue; 177 | } 178 | throw new Error(`Request failed: ${err}`); 179 | } 180 | console.warn(`Request failed and will retry: ${err}`); 181 | continue; 182 | } 183 | 184 | const responseText = res.data.toString(); 185 | if (res.status >= 300) throw new Error(responseText); 186 | return responseText; 187 | } 188 | }; 189 | 190 | export const validateDayAndYear = (day: number, year: number): void => { 191 | if (day < 1 || day > 25) throw new Error("day must be between 1 and 25"); 192 | if (year < 2015) throw new Error("year must be 2015 or greater"); 193 | if (year > new Date().getUTCFullYear()) 194 | throw new Error("year must not be in the future"); 195 | }; 196 | 197 | const getNextChallengeStart = (): Date => { 198 | const now = new Date(); 199 | const curYear = now.getUTCFullYear(); 200 | const firstChallengeOfYear = new Date(Date.UTC(curYear, 11, 1, 5, 0, 0, 0)); 201 | const lastChallengeOfYear = new Date(Date.UTC(curYear, 11, 25, 5, 0, 0, 0)); 202 | const firstChallengeOfNextYear = new Date( 203 | Date.UTC(curYear + 1, 11, 1, 5, 0, 0, 0) 204 | ); 205 | if (now < firstChallengeOfYear) return firstChallengeOfYear; 206 | if (now > lastChallengeOfYear) return firstChallengeOfNextYear; 207 | return new Date( 208 | Date.UTC( 209 | curYear, 210 | 11, 211 | now.getUTCDate() + (now.getUTCHours() >= 5 ? 1 : 0), 212 | 5, 213 | 0, 214 | 0, 215 | 0 216 | ) 217 | ); 218 | }; 219 | 220 | const getPrevChallengeStart = (): Date => { 221 | const now = new Date(); 222 | const curYear = now.getUTCFullYear(); 223 | const firstChallengeOfYear = new Date(Date.UTC(curYear, 11, 1, 5, 0, 0, 0)); 224 | const lastChallengeOfYear = new Date(Date.UTC(curYear, 11, 25, 5, 0, 0, 0)); 225 | const lastChallengeOfLastYear = new Date( 226 | Date.UTC(curYear - 1, 11, 25, 5, 0, 0, 0) 227 | ); 228 | if (now < firstChallengeOfYear) return lastChallengeOfLastYear; 229 | if (now > lastChallengeOfYear) return lastChallengeOfYear; 230 | return new Date( 231 | Date.UTC( 232 | curYear, 233 | 11, 234 | now.getUTCDate() - (now.getUTCHours() < 5 ? 1 : 0), 235 | 5, 236 | 0, 237 | 0, 238 | 0 239 | ) 240 | ); 241 | }; 242 | 243 | export const getCurrentChallengeStartTime = ( 244 | margin = 1000 * 60 * 60 * 23 245 | ): Date => { 246 | const next = getNextChallengeStart(); 247 | const prev = getPrevChallengeStart(); 248 | return Date.now() - prev.getTime() < margin ? prev : next; 249 | }; 250 | 251 | export const getChallengeStartTime = (year: number, day: number): Date => 252 | new Date(Date.UTC(year, 11, day, 5, 0, 0, 0)); 253 | 254 | export const normalizeTemplate = async ( 255 | template: AocTemplate | string 256 | ): Promise => { 257 | if (typeof template === "string") { 258 | if (Object.prototype.hasOwnProperty.call(builtinTemplates, template)) 259 | return builtinTemplates[template as AocTemplateBuiltin]; 260 | const localTemplatePath = path.resolve(template); 261 | const localJsonPath = path.join(localTemplatePath, TEMPLATE_JSON); 262 | const localJson = (await fse.readJson(localJsonPath).catch((err) => { 263 | if ((err as { code?: string })?.code !== "ENOENT") throw err; 264 | })) as { commands: string[] } | undefined; 265 | if (localJson !== undefined) { 266 | if (!Array.isArray(localJson?.commands)) 267 | throw new Error(`Template has no commands at '${localJsonPath}'`); 268 | return { 269 | path: localTemplatePath, 270 | commandBuilder: ({ tempDir }) => 271 | localJson.commands.map((cmd) => { 272 | const [command, ...args] = stringArgv( 273 | cmd.replace(/\{\{TEMP_DIR\}\}/g, tempDir) 274 | ); 275 | return { command, args }; 276 | }), 277 | files: await getLocalTemplateFiles(localTemplatePath), 278 | }; 279 | } 280 | throw new Error( 281 | `built-in template '${template}' does not exist, nor does '${localJsonPath}'` 282 | ); 283 | } 284 | 285 | return template; 286 | }; 287 | 288 | export const getLocalTemplateFiles = async ( 289 | templateDir: string 290 | ): Promise => { 291 | const allFiles = await fse.readdir(templateDir); 292 | return allFiles.filter((name) => name !== TEMPLATE_JSON); 293 | }; 294 | 295 | export const buildCommand = (command: string, srcPath: string): string => { 296 | const vars: Record = { 297 | src: path.relative(process.cwd(), srcPath), 298 | }; 299 | return command.replace(/\{([^}]+)\}/g, (_match, _i, key: string) => { 300 | if (!Object.prototype.hasOwnProperty.call(vars, key)) 301 | throw new Error(`unknown variable '${key}' in template command`); 302 | return vars[key]; 303 | }); 304 | }; 305 | 306 | export const getDirForDay = (day: number): string => 307 | path.resolve(padZero(day, 2)); 308 | -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | > **NOTE: A lot of these templates are unfinished and may not work correctly.** 2 | 3 | Each directory here holds the files for a language template. 4 | 5 | ## Creating a New Template 6 | 7 | - Create a directory here with the name of the template. 8 | - Add the source template file here, along with any other supporting files (if necessary). 9 | - The source file should read all input from `input.txt` into a string and output a result of `0`. 10 | - Try to add only a single source file. Do not add package manager or project boilerplate since it will complicate this tool and require specific setups from end users. The idea is that a user could easily switch between languages for a single challenge and the directory would contain a list of source files, rather than projects. Rust is an exception since VSCode extensions require a `Cargo.toml` to work correctly (I'd like to get rid of it if possible). 11 | - Include `wip` (case-insensitive) in the source file name if possible since that is how the tool identifies the source file which should be saved on successful submission. 12 | - After adding the files to a folder in this directory, add the run command(s) for the template to [`src/templates.ts`](../src/templates.ts). 13 | - If intermediary files are created during compilation, save them in `ctx.tempDir`. 14 | -------------------------------------------------------------------------------- /templates/assembly-x86-mac/wip.asm: -------------------------------------------------------------------------------- 1 | ; Syscall Macros 2 | ; https://github.com/opensource-apple/xnu/blob/master/bsd/kern/syscalls.master 3 | SYSCALL_CLASS_UNIX equ 0x02000000 4 | %macro oscall 2 5 | mov rax, SYSCALL_CLASS_UNIX | %1 6 | mov rdi, %2 7 | syscall 8 | %endmacro 9 | %macro oscall 4 10 | mov rax, SYSCALL_CLASS_UNIX | %1 11 | mov rdi, %2 12 | mov rsi, %3 13 | mov rdx, %4 14 | syscall 15 | %endmacro 16 | %macro oscall 7 17 | mov rax, SYSCALL_CLASS_UNIX | %1 18 | mov rdi, %2 19 | mov rsi, %3 20 | mov rdx, %4 21 | mov r10, %5 22 | mov r8, %6 23 | mov r9, %7 24 | syscall 25 | %endmacro 26 | %define exit(rval) oscall 1, rval 27 | %define write(fd, cbuf, nbyte) oscall 4, fd, cbuf, nbyte 28 | PROT_READ equ 0x01 29 | PROT_WRITE equ 0x02 30 | MAP_ANON equ 0x1000 31 | %define mmap(addr, len, prot, flags, fd, pos) oscall 197, addr, len, prot, flags, fd, pos 32 | 33 | global start 34 | 35 | section .text 36 | 37 | %macro int_to_str 0 38 | mov rcx, 10 39 | %%push_digit: 40 | xor rdx, rdx 41 | div rcx 42 | add rdx, '0' 43 | push rdx 44 | cmp rax, 0 45 | jnz %%push_digit 46 | %endmacro 47 | 48 | %macro print_int 1 49 | push rax 50 | push rbx 51 | push rcx 52 | push rdx 53 | mov rax, %1 54 | mov rbx, rsp 55 | push 10 56 | int_to_str 57 | sub rbx, rsp 58 | write(1, rsp, rbx) 59 | add rsp, rbx 60 | pop rdx 61 | pop rcx 62 | pop rbx 63 | pop rax 64 | %endmacro 65 | 66 | %macro solution 2 67 | move rax, 123 68 | %endmacro 69 | 70 | %macro part 2 71 | solution input, %2 72 | mov rbx, rsp 73 | ; TODO: Print properly 74 | push 10 75 | int_to_str 76 | push ": " 77 | push '0' + %1 78 | push " " 79 | push "Part" 80 | sub rbx, rsp 81 | write(1, rsp, rbx) 82 | add rsp, rbx 83 | %endmacro 84 | 85 | start: 86 | part 1, 2020 87 | part 2, 30000000 88 | exit(0) 89 | 90 | section .data 91 | 92 | section .rodata 93 | 94 | input: 95 | ; TODO: Read from file 96 | dq 1, 2, 3 97 | -------------------------------------------------------------------------------- /templates/c/wip.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | int main() 6 | { 7 | // TODO: Read from file 8 | int result = 0; 9 | printf("Result: %ld\n", result); 10 | } 11 | -------------------------------------------------------------------------------- /templates/java/Main.wip.java: -------------------------------------------------------------------------------- 1 | import java.io.IOException; 2 | import java.nio.file.Files; 3 | import java.nio.file.Paths; 4 | 5 | class Main { 6 | public static void main(String args[]) throws IOException { 7 | String input = new String(Files.readAllBytes(Paths.get("input.txt"))).strip(); 8 | 9 | long result = 0; 10 | System.out.println("Result: " + result); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /templates/js/wip.js: -------------------------------------------------------------------------------- 1 | const input = require('fs') 2 | .readFileSync(require('path').join(__dirname, 'input.txt'), 'utf8') 3 | .trim() 4 | 5 | let result = 0 6 | console.log({ result }) 7 | -------------------------------------------------------------------------------- /templates/nim/wip.nim: -------------------------------------------------------------------------------- 1 | readFile("input.txt").strip() 2 | -------------------------------------------------------------------------------- /templates/python/wip.py: -------------------------------------------------------------------------------- 1 | input = open('input.txt', 'r').read().strip() 2 | 3 | result = 0 4 | print("Result: {}".format(result)) 5 | -------------------------------------------------------------------------------- /templates/ruby/wip.rb: -------------------------------------------------------------------------------- 1 | input = File.read 'input.txt', encoding: 'utf-8' 2 | 3 | result = 0 4 | puts 'Result:', result 5 | -------------------------------------------------------------------------------- /templates/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aoc" 3 | version = "0.1.0" 4 | 5 | [[bin]] 6 | path = "wip.rs" 7 | name = "wip" 8 | -------------------------------------------------------------------------------- /templates/rust/wip.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io; 3 | use std::io::prelude::*; 4 | use std::path::Path; 5 | 6 | fn main() -> io::Result<()> { 7 | let mut file = File::open(Path::new("input.txt"))?; 8 | let mut input = String::new(); 9 | file.read_to_string(&mut input)?; 10 | 11 | let result = 0; 12 | println!("Result: {}", result); 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { "extends": "./tsconfig.base.json" } 2 | --------------------------------------------------------------------------------