├── .editorconfig ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── schema ├── acc-project-schema.json └── acc-template-schema.json ├── src ├── atcoder.ts ├── cli │ └── index.ts ├── commands.ts ├── config.ts ├── cookie.ts ├── di │ └── index.ts ├── facade │ └── oj.ts ├── help.ts ├── imports.ts ├── project.ts ├── session.ts └── template.ts ├── tests ├── __tests__ │ ├── __snapshots__ │ │ ├── atcoder.ts.snap │ │ ├── session.ts.snap │ │ └── template.ts.snap │ ├── atcoder.ts │ ├── cli.ts │ ├── cookie.ts │ ├── session.ts │ └── template.ts └── utils │ ├── cookie.ts │ ├── index.ts │ ├── login.ts │ ├── responseMock.ts │ └── session.ts ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | insert_final_newline = true 7 | 8 | [*.js] 9 | indent_style = tab 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | 13 | [*.json] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | bin/ 3 | src/**/*.js 4 | src/**/*.d.ts 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | message=:arrow_up: version update %s 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.2.0] 4 | ### Added 5 | - `task` and `contest` commands have some options to filter output ([#41](https://github.com/Tatamo/atcoder-cli/pull/41)) 6 | 7 | ## [2.1.1] 8 | ### Fixed 9 | - Format strings in `task.submit` parameter in `template.json` did not work ([#37](https://github.com/Tatamo/atcoder-cli/pull/37)) 10 | ### Security 11 | - Bump dependencies ([#31](https://github.com/Tatamo/atcoder-cli/pull/31), [#32](https://github.com/Tatamo/atcoder-cli/pull/32), [#33](https://github.com/Tatamo/atcoder-cli/pull/33), [#34](https://github.com/Tatamo/atcoder-cli/pull/34), [#35](https://github.com/Tatamo/atcoder-cli/pull/35)) 12 | 13 | ## [2.1.0] 14 | ### Added 15 | - Enable to pass arguments to online-judge-tools in submit command and add --skip-filename option ([#26](https://github.com/Tatamo/atcoder-cli/pull/26)) **experimental: this feature may be changed in further release** 16 | ### Security 17 | - Bump dependencies ([#24](https://github.com/Tatamo/atcoder-cli/pull/24)) 18 | 19 | ## [2.0.5] 20 | ### Added 21 | - This CHANGELOG! ([#21](https://github.com/Tatamo/atcoder-cli/pull/21)) 22 | - Update notifications are available after this version ([#23](https://github.com/Tatamo/atcoder-cli/pull/23)) 23 | ### Fixed 24 | - Non-existent option `default-new-contest-cmd` was documented in the help, now deleted. Use template functions instead ([#22](https://github.com/Tatamo/atcoder-cli/pull/22)) 25 | ### Security 26 | - Bump dependencies ([#19](https://github.com/Tatamo/atcoder-cli/pull/19)) 27 | ### Dev/Internal 28 | - Write many tests for reliability 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Tatamo 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # atcoder-cli 2 | [AtCoder](https://atcoder.jp/) command line tools 3 | - get contest information 4 | - create a project directory for contests 5 | - auto provisioning with custom templates 6 | - linkage with [online-judge-tools](https://github.com/kmyk/online-judge-tools) 7 | - submit your code without specified URL 8 | - auto downloading of sample inputs/outputs 9 | 10 | [解説記事(日本語)](http://tatamo.81.la/blog/2018/12/07/atcoder-cli/) 11 | 12 | ## Requirements 13 | node.js 14 | [online-judge-tools](https://github.com/kmyk/online-judge-tools) (optional, but recommended) 15 | 16 | ## Install 17 | ```sh 18 | $ npm install -g atcoder-cli 19 | ``` 20 | 21 | ## Usage 22 | ```sh 23 | $ acc login # login your atcoder account 24 | $ acc session # check login status 25 | $ # your login session will be saved to a local file, but your password won't be saved 26 | $ # to delete the session file, use `acc logout` 27 | $ acc new abc001 # "abc001/" directory will be created 28 | $ cd abc001/ 29 | $ acc contest # show the contest information 30 | $ acc tasks # show task list 31 | $ acc add 32 | $ cd a/ 33 | $ vim main.cpp # write your solution 34 | $ acc submit main.cpp # to use submit function, you have to install online-judge-tools 35 | ``` 36 | 37 | To get detailed information, use 38 | ```sh 39 | $ acc [COMMAND] -h 40 | ``` 41 | 42 | ## Config 43 | ```sh 44 | $ acc config -h 45 | $ acc config # show all global options 46 | $ acc config # set option 47 | $ cd `acc config-dir` 48 | $ cat config.json # global config file 49 | ``` 50 | ## Provisioning Templates 51 | With using custom templates, you can automatically prepare your template program code or build environment. 52 | 53 | When you create new task directories, atcoder-cli can do: 54 | - place the scaffold program file 55 | - copy static files 56 | - exec shell command 57 | 58 | show available templates: 59 | ```sh 60 | $ acc templates 61 | ``` 62 | 63 | use the template: 64 | ```sh 65 | $ acc new|add --template 66 | ``` 67 | 68 | Or you can set default template: 69 | ```sh 70 | $ acc config default-template 71 | ``` 72 | 73 | ### Create a new template 74 | ```sh 75 | $ cd `acc config-dir` 76 | $ mkdir 77 | $ cd 78 | $ vim template.json # write your template settings 79 | ``` 80 | 81 | ### Options in template.json 82 | ```json 83 | { 84 | "task": { 85 | "program": ["main.cpp", ["foo.cpp", "{TaskID}.cpp"]], 86 | "submit": "main.cpp", 87 | "static": ["foo", ["bar","bar_{TaskLabel}"]], 88 | "testdir": "tests_{TaskID}", 89 | "cmd": "echo Hi!" 90 | }, 91 | "contest": { 92 | "static": [["gitignore", ".gitignore"]], 93 | "cmd": "echo Ho!" 94 | } 95 | } 96 | ``` 97 | 98 | #### `"task"` (required) 99 | executed for each tasks. 100 | 101 | ##### `"program"` (required) 102 | ```ts 103 | "program": (string | [string, string])[] 104 | ``` 105 | 106 | Your main program(s). 107 | Place main.cpp in the same directory of template.json, and write 108 | ``` 109 | "program": ["main.cpp"] 110 | ``` 111 | then the program file will be copied to the task directory. 112 | 113 | You can rename the file with format strings: 114 | ``` 115 | "program": [["main.cpp", "{TaskId}.cpp"]] 116 | ``` 117 | The file name of the program file will be "A.cpp" if the task is problem A. 118 | 119 | To get detailed information about format strings, use `acc format -h`. 120 | 121 | ##### `"submit"` (required) 122 | ```ts 123 | "submit": string 124 | ``` 125 | 126 | The file name to submit. 127 | It enables to omit the filename argument to submit file, so you can run `acc submit` instead of `acc submit `. 128 | 129 | Format strings are supported. 130 | 131 | ##### `"static"` (optional) 132 | ```ts 133 | "static": (string | [string, string])[] 134 | ``` 135 | 136 | Static assets. 137 | The difference between `"program"` and `"static"` is: 138 | - `"program"` files won't be overwrited when using `acc add --force`. 139 | - `"static"` files will be overwrited when using `acc add --force`. 140 | 141 | ##### `"testdir"` (optional) 142 | ```ts 143 | "testdir": string 144 | ``` 145 | 146 | The name of the directory that sample cases will be downloaded. 147 | Without this, the directory name will be the value of `acc config default-test-dirname-format`. 148 | 149 | Format strings are supported. 150 | 151 | ##### `"cmd"` (optional) 152 | ```ts 153 | "cmd": string 154 | ``` 155 | After copying files and downloading sample cases, the specified command will be executed. 156 | 157 | The working directory is the task directory. 158 | 159 | Parameters are given as enviromental variables: 160 | `$TEMPLATE_DIR`, `$TASK_DIR`, `$TASK_ID`, `$TASK_INDEX`, `$CONTEST_DIR` and `$CONTEST_ID` 161 | 162 | #### `contest` (optional) 163 | executed only once when `acc new` command runs. 164 | 165 | ##### `"static"` (optional) 166 | Same as `tasks.static`. 167 | 168 | ##### `"cmd"` (optional) 169 | Same as `tasks.cmd`, but `$TASK_*` variables do not exist. 170 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atcoder-cli", 3 | "version": "2.2.0", 4 | "description": "AtCoder command line tools", 5 | "keywords": [ 6 | "atcoder", 7 | "competitive-programming", 8 | "cli" 9 | ], 10 | "author": "Tatamo", 11 | "license": "BSD-3-Clause", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/Tatamo/atcoder-cli.git" 15 | }, 16 | "bin": { 17 | "acc": "bin/index.js" 18 | }, 19 | "files": [ 20 | "bin/", 21 | "schema/", 22 | "package.json", 23 | "LICENSE", 24 | "README.md" 25 | ], 26 | "scripts": { 27 | "test": "jest", 28 | "test-w": "jest --watchAll", 29 | "build": "npm run clean && tsc && webpack --mode production", 30 | "clean": "rimraf bin src/**/*.{js,d.ts}", 31 | "watch:tsc": "tsc --watch", 32 | "watch:webpack": "webpack --mode development --watch", 33 | "watch": "tsc && npm-run-all -p watch:*", 34 | "prepare": "npm run build" 35 | }, 36 | "dependencies": { 37 | "ajv": "^6.10.2", 38 | "axios": "^0.21.1", 39 | "commander": "^3.0.1", 40 | "conf": "^5.0.0", 41 | "fs-extra": "^8.1.0", 42 | "inquirer": "^7.0.0", 43 | "jsdom": "^15.1.1", 44 | "mkdirp": "^0.5.1", 45 | "query-string": "^6.8.3", 46 | "typesafe-di": "^0.1.0", 47 | "update-notifier": "^4.1.0" 48 | }, 49 | "devDependencies": { 50 | "@types/fs-extra": "^8.0.0", 51 | "@types/inquirer": "6.5.0", 52 | "@types/jest": "^24.0.18", 53 | "@types/jsdom": "^12.2.4", 54 | "@types/mkdirp": "^0.5.2", 55 | "@types/mock-fs": "^3.6.30", 56 | "@types/node": "^12.7.5", 57 | "@types/update-notifier": "^4.1.0", 58 | "jest": "^24.9.0", 59 | "mock-fs": "^5.1.1", 60 | "npm-run-all": "^4.1.5", 61 | "rimraf": "^3.0.0", 62 | "ts-jest": "^24.0.2", 63 | "typescript": "^3.6.3", 64 | "webpack": "^4.39.3", 65 | "webpack-cli": "^3.3.8", 66 | "webpack-node-externals": "^1.7.2" 67 | }, 68 | "jest": { 69 | "preset": "ts-jest", 70 | "testEnvironment": "node" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /schema/acc-project-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "atcoder-cli Contest Project File", 5 | "required": [ 6 | "contest", 7 | "tasks" 8 | ], 9 | "properties": { 10 | "contest": { 11 | "$ref": "#contest" 12 | }, 13 | "tasks": { 14 | "$ref": "#tasks" 15 | } 16 | }, 17 | "definitions": { 18 | "contest": { 19 | "$id": "#contest", 20 | "type": "object", 21 | "required": [ 22 | "id", 23 | "title", 24 | "url" 25 | ], 26 | "properties": { 27 | "id": { 28 | "type": "string" 29 | }, 30 | "title": { 31 | "type": "string" 32 | }, 33 | "url": { 34 | "type": "string" 35 | } 36 | } 37 | }, 38 | "directory": { 39 | "$id": "#directory", 40 | "type": "object", 41 | "required": [ 42 | "path" 43 | ], 44 | "properties": { 45 | "path": { 46 | "type": "string" 47 | }, 48 | "submit": { 49 | "type": "string" 50 | }, 51 | "testdir": { 52 | "type": "string" 53 | } 54 | } 55 | }, 56 | "task": { 57 | "$id": "#task", 58 | "type": "object", 59 | "required": [ 60 | "id", 61 | "label", 62 | "title", 63 | "url" 64 | ], 65 | "properties": { 66 | "id": { 67 | "type": "string" 68 | }, 69 | "label": { 70 | "type": "string" 71 | }, 72 | "title": { 73 | "type": "string" 74 | }, 75 | "url": { 76 | "type": "string" 77 | }, 78 | "directory": { 79 | "$ref": "#directory" 80 | } 81 | } 82 | }, 83 | "tasks": { 84 | "$id": "#tasks", 85 | "type": "array", 86 | "items": { 87 | "$ref": "#task" 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /schema/acc-template-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "atcoder-cli Task Template File", 5 | "required": [ 6 | "task" 7 | ], 8 | "properties": { 9 | "contest": { 10 | "$ref": "#contest" 11 | }, 12 | "task": { 13 | "$ref": "#task" 14 | } 15 | }, 16 | "definitions": { 17 | "contest": { 18 | "$id": "#contest", 19 | "type": "object", 20 | "properties": { 21 | "static": { 22 | "$ref": "#copyfile-list" 23 | }, 24 | "cmd": { 25 | "type": "string" 26 | } 27 | } 28 | }, 29 | "task": { 30 | "$id": "#task", 31 | "type": "object", 32 | "required": [ 33 | "submit", 34 | "program" 35 | ], 36 | "properties": { 37 | "submit": { 38 | "type": "string" 39 | }, 40 | "program": { 41 | "$ref": "#copyfile-list" 42 | }, 43 | "static": { 44 | "$ref": "#copyfile-list" 45 | }, 46 | "cmd": { 47 | "type": "string" 48 | }, 49 | "testdir": { 50 | "type": "string" 51 | } 52 | } 53 | }, 54 | "copyfile-list": { 55 | "$id": "#copyfile-list", 56 | "type": "array", 57 | "items": { 58 | "$ref": "#copyfile" 59 | } 60 | }, 61 | "copyfile": { 62 | "$id": "#copyfile", 63 | "anyOf": [ 64 | { 65 | "type": "string" 66 | }, 67 | { 68 | "type": "array", 69 | "minItems": 2, 70 | "maxItems": 2, 71 | "items": { 72 | "type": "string" 73 | } 74 | } 75 | ] 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/atcoder.ts: -------------------------------------------------------------------------------- 1 | import {SessionInterface} from "./session"; 2 | import querystring from "query-string"; 3 | import {Contest, Task} from "./project"; 4 | 5 | const ATCODER_BASE_URL = "https://atcoder.jp/"; 6 | 7 | export class AtCoder { 8 | static get base_url(): string { 9 | return ATCODER_BASE_URL; 10 | } 11 | 12 | static get login_url(): string { 13 | return `${AtCoder.base_url}login`; 14 | } 15 | 16 | static getContestURL(contest: string) { 17 | return `${AtCoder.base_url}contests/${contest}`; 18 | } 19 | 20 | static getTaskURL(contest: string, task?: string) { 21 | return `${AtCoder.getContestURL(contest)}/tasks${task === undefined ? "" : `/${task}`}`; 22 | } 23 | 24 | private session: SessionInterface; 25 | // null:未検査 true/false: ログインしているかどうか 26 | private _login: boolean | null; 27 | 28 | constructor(session: SessionInterface) { 29 | this.session = session; 30 | this._login = null; 31 | } 32 | 33 | /** 34 | * ログインしているか調べる 35 | * @param force default=false trueならキャッシュを使わずちゃんと調べる 36 | */ 37 | async checkSession(force: boolean = false): Promise { 38 | // 以前取得済みならいちいち接続して確かめない 39 | if (this._login !== null && !force) return this._login; 40 | return this._login = await this.check(); 41 | } 42 | 43 | /** 44 | * アクセスしてログインしている状態かどうかを取得する(結果をキャッシュしない) 45 | */ 46 | private async check(): Promise { 47 | // ログインせず提出ページにアクセスするとコンテストトップに飛ばされることを利用する 48 | const url = `${AtCoder.getContestURL("abc001")}/submit`; 49 | // リダイレクトを無効化・302コードを容認して通信 50 | const response = await this.session.get(url, { 51 | maxRedirects: 0, 52 | validateStatus: (status) => (status >= 200 && status < 300) || status === 302 53 | }); 54 | // リダイレクトされなければログインしている 55 | return response.status !== 302; 56 | } 57 | 58 | /** 59 | * ログイン処理します 60 | * あまりパスワード文字列を引き回したくないので、この中で標準入力からユーザー名とパスワードを尋ねる 61 | */ 62 | async login(): Promise { 63 | if (await this.checkSession()) { 64 | console.error("you logged-in already"); 65 | return true; 66 | } 67 | return this.session.transaction(async ()=> { 68 | const {csrf_token} = await this.getCSRFToken(); 69 | 70 | // ユーザーネームとパスワードを入力させる 71 | const inquirer = await import("inquirer"); 72 | const {username, password} = await inquirer.prompt([{ 73 | type: "input", 74 | message: "username:", 75 | name: "username" 76 | }, { 77 | type: "password", 78 | message: "password:", 79 | name: "password" 80 | }]) as { username: string, password: string }; 81 | 82 | const response = await this.session.post( 83 | AtCoder.login_url, 84 | querystring.stringify({username, password, csrf_token}), 85 | { 86 | maxRedirects: 0, 87 | validateStatus: (status) => (status >= 200 && status < 300) || status === 302, 88 | } 89 | ) 90 | 91 | 92 | // ログインページ以外にリダイレクトされていればログイン成功とみなす 93 | const result = response.headers.location !== "/login"; 94 | if (result) { 95 | // ログインに成功していた場合はセッション情報を保存する 96 | await response.saveSession() 97 | } 98 | return result; 99 | }); 100 | } 101 | 102 | /** 103 | * ログインページにアクセスしてCSRFトークンを取得 104 | */ 105 | private async getCSRFToken(): Promise<{ csrf_token: string }> { 106 | const {JSDOM} = await import("jsdom"); 107 | // cookieなしでログインページにアクセス 108 | const response = await this.session.get(AtCoder.login_url, {headers: {Cookie: ""}}); 109 | 110 | const {document} = new JSDOM(response.data).window; 111 | const input: HTMLInputElement = (document.getElementsByName("csrf_token")[0]) as HTMLInputElement; 112 | 113 | await response.saveSession(); 114 | return {csrf_token: input.value}; 115 | } 116 | 117 | async logout(): Promise { 118 | await this.session.removeSession(); 119 | this._login = null; 120 | } 121 | 122 | /** 123 | * コンテストIDからコンテストの情報を取得 124 | * @param id 125 | * @throws Error 126 | */ 127 | async contest(id: string): Promise { 128 | const url = AtCoder.getContestURL(id); 129 | // コンテストが見つからない場合エラーとなるがハンドルせず外に投げる 130 | const response = await this.session.get(url); 131 | const {JSDOM} = await import("jsdom"); 132 | const {document} = new JSDOM(response.data).window; 133 | const regexp = /^(.*) - AtCoder$/; 134 | const title = regexp.test(document.title) ? regexp.exec(document.title)![1] : document.title; 135 | return {id, title, url}; 136 | } 137 | 138 | /** 139 | * 問題一覧を取得 140 | * @param contest_id 141 | * @throws Error 142 | */ 143 | async tasks(contest_id: string): Promise> { 144 | // コンテストが見つからない場合エラーとなるがハンドルせず外に投げる 145 | const response = await this.session.get(AtCoder.getTaskURL(contest_id)); 146 | 147 | const {JSDOM} = await import("jsdom"); 148 | const {document} = new JSDOM(response.data).window; 149 | // very very ad-hoc and not type-safe section 150 | const tbody = document.querySelector("#main-div .row table>tbody"); 151 | if (tbody === null) return []; 152 | const tasks: Array = []; 153 | for (const tr of tbody.querySelectorAll("tr")) { 154 | // tr>td>a 155 | const id: string = tr.children[0].children[0].getAttribute("href")!.split("/").pop()!; 156 | const label: string = (tr.children[0].children[0] as HTMLAnchorElement).text; 157 | const title: string = (tr.children[1].children[0] as HTMLAnchorElement).text; 158 | const url: string = `${AtCoder.base_url}${tr.children[0].children[0].getAttribute("href")!.substring(1)}`; 159 | tasks.push({id, label, title, url}); 160 | } 161 | return tasks; 162 | } 163 | 164 | /** 165 | * 単一の問題を取得 166 | * @param contest_id 167 | * @param task_id 168 | * @throws Error 169 | */ 170 | async task(contest_id: string, task_id: string): Promise { 171 | const tasks = await this.tasks(contest_id); 172 | for (const task of tasks) { 173 | if (task.id === task_id) return task; 174 | } 175 | throw new Error(`Task ${task_id} not found.`); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import commander from "commander"; 2 | import {name, version} from "../../package.json"; 3 | import * as commands from "../commands"; 4 | import * as help from "../help"; 5 | import updateNotifier from "update-notifier"; 6 | 7 | updateNotifier({pkg: {name, version}}).notify({isGlobal: true}); 8 | 9 | commander 10 | .version(version, "-v, --version"); 11 | 12 | commander 13 | .command("new ") 14 | .alias("n") 15 | .action(commands.setup) 16 | .option('-c, --choice ', "how to choice tasks to add", /^(inquire|all|none|rest|next)$/i) 17 | .option("-f, --force", "ignore existent directories") 18 | .option("-d, --contest-dirname-format ", "specify the format to name contest directory. defaults to \"{ContestID}\"") 19 | .option("-t, --task-dirname-format ", "specify the format to name task directories. defaults to \"{tasklabel}\"") 20 | .option("--no-tests", "skip downloading sample cases by using online-judge-tools") 21 | .option("--template ", "specify the provisioning template") 22 | .option("--no-template", "do not use templates, even if specified by global config") 23 | .description("create new contest project directory") 24 | .on("--help", () => { 25 | console.log(""); 26 | console.log(help.task_choices); 27 | }); 28 | 29 | commander 30 | .command("add") 31 | .alias("a") 32 | .action(commands.add) 33 | .option('-c, --choice ', "how to choice tasks to add", /^(inquire|all|none|rest|next)$/i) 34 | .option("-f, --force", "ignore existent directories") 35 | .option("-t, --task-dirname-format ", "specify the format to name task directories. defaults to \"{tasklabel}\"") 36 | .option("--no-tests", "skip downloading sample cases by using online-judge-tools") 37 | .option("--template ", "specify the provisioning template") 38 | .option("--no-template", "do not use templates, even if specified by global config") 39 | .description("add new directory for the task in the project directory") 40 | .on("--help", () => { 41 | console.log(""); 42 | console.log(help.task_choices); 43 | }); 44 | 45 | commander 46 | .command("submit [filename] [facade-options...]") 47 | .alias("s") 48 | .option("-c, --contest ", "specify contest id to submit") 49 | .option("-t, --task ", "specify task id to submit") 50 | .option("-s, --skip-filename", "specify that filename is not given (the first argument will be parsed as not a filename, but a facade option)") 51 | .action(commands.submit) 52 | .description("submit the program") 53 | .on("--help", () => { 54 | console.log(""); 55 | console.log(help.submit_facade_options); 56 | }); 57 | 58 | commander 59 | .command("login") 60 | .action(commands.login) 61 | .description("login to AtCoder"); 62 | 63 | commander 64 | .command("logout") 65 | .action(commands.logout) 66 | .description("delete login session information"); 67 | 68 | commander 69 | .command("session") 70 | .action(commands.session) 71 | .description("check login or not"); 72 | 73 | commander 74 | .command("contest [contest-id]") 75 | .action(commands.contest) 76 | .option("-t, --title", "show contest title") 77 | .option("-i, --id", "show contest id") 78 | .option("-u, --url", "show contest url") 79 | .description("get contest information"); 80 | 81 | commander 82 | .command("task [contest-id] [task-id]") 83 | .action(commands.task) 84 | .option("-l, --label", "show task label") 85 | .option("-t, --title", "show task title") 86 | .option("-i, --id", "show task id") 87 | .option("-u, --url", "show task url") 88 | .description("get task"); 89 | 90 | commander 91 | .command("tasks [contest-id]") 92 | .action(commands.tasks) 93 | .option("-i, --id", "show task id") 94 | .description("get tasks"); 95 | 96 | commander 97 | .command("url [contest] [task]") 98 | .option("-c, --check", "check the specified contest and/or task id is valid") 99 | .action(commands.url) 100 | .description("get contest or task URL"); 101 | 102 | commander 103 | .command("format [task-id]") 104 | .action(commands.format) 105 | .description("format string with contest and/or task information.") 106 | .on("--help", () => { 107 | console.log(""); 108 | console.log(help.format_strings); 109 | }); 110 | 111 | commander 112 | .command("check-oj") 113 | .action(commands.checkOJAvailable) 114 | .description("check whether online-judge-tools related functions are available or not") 115 | .on("--help", () => { 116 | console.log(""); 117 | console.log(help.online_judge_tools); 118 | }); 119 | 120 | commander 121 | .command("config [key] [value]") 122 | .option("-d", "delete the option value and set back to the default") 123 | .action(commands.config) 124 | .description("get or set values of global options") 125 | .on("--help", () => { 126 | console.log(""); 127 | console.log(help.global_config); 128 | }); 129 | 130 | commander 131 | .command("config-dir") 132 | .action(commands.configDir) 133 | .description("get the path of atcoder-cli config directory"); 134 | 135 | commander 136 | .command("templates") 137 | .action(commands.getTemplateList) 138 | .description("show user templates in the config directory") 139 | .on("--help", () => { 140 | console.log(""); 141 | console.log(help.provisioning_templates); 142 | }); 143 | 144 | 145 | commander.on("--help", () => { 146 | console.log(""); 147 | console.log(help.default_help); 148 | }); 149 | 150 | // error on unknown commands 151 | commander.on("command:*", function () { 152 | console.error('Invalid command: %s\nUse `acc --help` for a list of available commands.', commander.args.join(' ')); 153 | }); 154 | 155 | commander.parse(process.argv); 156 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import {AtCoder} from "./atcoder"; 2 | import {OnlineJudge} from "./facade/oj"; 3 | import {Contest, Task, detectTaskByPath, findProjectJSON, formatTaskDirname, saveProjectJSON, init, installTask, formatContestDirname} from "./project"; 4 | import getConfig, {defaults, getConfigDirectory} from "./config"; 5 | import {getTemplate, getTemplates, Template} from "./template"; 6 | import {getAtCoder} from "./di"; 7 | 8 | export async function login() { 9 | const atcoder = await getAtCoder(); 10 | console.log(await atcoder.login() ? "OK" : "login failed"); 11 | } 12 | 13 | export async function logout() { 14 | const atcoder = await getAtCoder(); 15 | await atcoder.logout(); 16 | console.log("login session aborted."); 17 | } 18 | 19 | export async function session() { 20 | const atcoder = await getAtCoder(); 21 | console.log("check login status..."); 22 | console.log(await atcoder.checkSession() ? "OK" : "not login"); 23 | } 24 | 25 | export async function contest(contest_id: string | undefined, options: {title?: boolean, id?: boolean, url?: boolean}) { 26 | const f_id = options.id === true; 27 | const f_title = options.title === true; 28 | const f_url = options.url === true; 29 | const format = ({ id, title, url }: Contest) => formatAsShellOutput([((f_id || f_title || f_url) ? [f_id ? SGR(id, 37) : null, f_title ? SGR(title, 32, 1) : null, f_url ? url : null].filter(e => e !== null) : [SGR(id, 37), SGR(title, 32, 1), url]) as Array]); 30 | if (contest_id === undefined) { 31 | // idが与えられていない場合、プロジェクトファイルを探してコンテスト情報を表示 32 | try { 33 | const {data: {contest}} = await findProjectJSON(); 34 | console.log(format(contest)); 35 | } catch (e) { 36 | console.error(e.message); 37 | } 38 | } 39 | else { 40 | try { 41 | const atcoder = await getAtCoder(); 42 | if (!await atcoder.checkSession()) await atcoder.login(); 43 | const contest = await atcoder.contest(contest_id); 44 | console.log(format(contest)); 45 | } catch { 46 | console.error(`contest "${contest_id}" not found.`); 47 | } 48 | } 49 | } 50 | 51 | export async function task(contest_id: string | undefined, task_id: string | undefined, options: {label?: boolean, title?: boolean, id?: boolean, url?: boolean}) { 52 | const f_id = options.id === true; 53 | const f_title = options.title === true; 54 | const f_url = options.url === true; 55 | const f_label = options.label === true; 56 | 57 | // in case all flags are false (no print options specified), the whole (id, label, title, url string) is printed 58 | const format = ({id, label, title, url}: Task) => formatAsShellOutput([((f_id || f_label || f_title || f_url) ? [f_id ? SGR(id, 37) : null, f_label ? SGR(label, 32) : null, f_title ? SGR(title, 32, 1) : null, f_url ? url : null].filter(e => e !== null) : [SGR(id, 37), SGR(label, 32), SGR(title, 32, 1), url]) as Array]); 59 | if (contest_id === undefined && task_id === undefined) { 60 | // idが与えられていない場合、プロジェクトファイルを探す 61 | try { 62 | const {task} = await detectTaskByPath(); 63 | if (task === null) { 64 | console.error("failed to find the task."); 65 | return; 66 | } 67 | console.log(format(task)); 68 | } catch (e) { 69 | console.error(e.message); 70 | } 71 | } 72 | else if (contest_id !== undefined && task_id !== undefined) { 73 | try { 74 | const atcoder = await getAtCoder(); 75 | if (!await atcoder.checkSession()) await atcoder.login(); 76 | const task = await atcoder.task(contest_id, task_id); 77 | console.log(format(task)); 78 | } catch { 79 | console.error(`task "${task_id}" of contest "${contest_id}" not found.`); 80 | } 81 | } 82 | else { 83 | console.error("error: specify both the contest id and the task id.") 84 | } 85 | } 86 | 87 | export async function tasks(contest_id: string | undefined, options: {id?: boolean}) { 88 | const f_id = options.id === true; 89 | const format = (tasks: Array) => formatAsShellOutput(tasks.map(({id, label, title, url}) => [f_id ? SGR(id, 37) : null, SGR(label, 32), SGR(title, 32, 1), url].filter(e => e !== null) as Array)); 90 | if (contest_id === undefined) { 91 | // idが与えられていない場合、プロジェクトファイルを探す 92 | try { 93 | const {data: {tasks}} = await findProjectJSON(); 94 | console.log(format(tasks)); 95 | } catch (e) { 96 | console.error(e.message); 97 | } 98 | } 99 | else { 100 | try { 101 | const atcoder = await getAtCoder(); 102 | if (!await atcoder.checkSession()) await atcoder.login(); 103 | const tasks = await atcoder.tasks(contest_id); 104 | console.log(format(tasks)); 105 | } catch { 106 | console.error(`contest "${contest_id}" not found.`); 107 | } 108 | } 109 | } 110 | 111 | export async function url(contest_id: string | undefined, task_id: string | undefined, options: {check?: boolean}) { 112 | const f_check = options.check === true; 113 | if (contest_id !== undefined && task_id !== undefined) { 114 | if (f_check) { 115 | const atcoder = await getAtCoder(); 116 | try { 117 | const tasks = await atcoder.tasks(contest_id); 118 | // Task一覧から一致するURLを探す 119 | for (const {url} of tasks) { 120 | if (url === AtCoder.getTaskURL(contest_id, task_id)) { 121 | console.log(url); 122 | return; 123 | } 124 | } 125 | // なかった 126 | console.error(`task "${task_id}" not found.`); 127 | } catch { 128 | console.error(`contest "${contest_id}" not found.`); 129 | } 130 | } 131 | else { 132 | // URLの妥当性をチェックしない 133 | console.log(AtCoder.getTaskURL(contest_id, task_id)); 134 | } 135 | } 136 | else if (contest_id !== undefined && task_id === undefined) { 137 | if (f_check) { 138 | const atcoder = await getAtCoder(); 139 | try { 140 | // コンテストページの存在確認 141 | const {url} = await atcoder.contest(contest_id); 142 | console.log(url); 143 | } catch { 144 | console.error(`contest "${contest_id}" not found.`); 145 | } 146 | } 147 | else { 148 | // URLの妥当性をチェックしない 149 | console.log(AtCoder.getContestURL(contest_id)); 150 | } 151 | } 152 | else { 153 | console.log(AtCoder.base_url); 154 | } 155 | } 156 | 157 | export async function format(format_string: string, contest_id: string, task_id?: string) { 158 | const atcoder = await getAtCoder(); 159 | if (!await atcoder.checkSession()) await atcoder.login(); 160 | if (task_id === undefined) { 161 | // コンテスト情報のみを使用 162 | try { 163 | const contest = await atcoder.contest(contest_id); 164 | console.log(formatContestDirname(format_string, contest)); 165 | } catch (e) { 166 | console.error(e.toString()); 167 | } 168 | } 169 | else { 170 | // コンテスト・問題情報を使用 171 | try { 172 | const [contest, tasks] = await Promise.all([atcoder.contest(contest_id), atcoder.tasks(contest_id)]); 173 | // 問題番号を調べる 174 | let index = -1; 175 | for (let i = 0; i < tasks.length; i++) { 176 | if (tasks[i].id === task_id) { 177 | index = i; 178 | break; 179 | } 180 | } 181 | if (index < 0) { 182 | console.error(`task ${task_id} not found.`); 183 | return; 184 | } 185 | try { 186 | console.log(formatTaskDirname(format_string, tasks[index], index, contest)); 187 | } catch (e) { 188 | console.error(e.toString()); 189 | } 190 | } catch { 191 | // TODO: もう少し良いエラーハンドリングができないものか 192 | console.error("failed to get contest information."); 193 | } 194 | } 195 | } 196 | 197 | export async function submit(filename: string | undefined, facade_options: Array, options: {task?: string, contest?: string, skipFilename?: boolean}) { 198 | let contest_id = options.contest; 199 | let task_id = options.task; 200 | const f_skip_filename = options.skipFilename === true; 201 | 202 | // treat filename as a first facade option and assume that filename is not given 203 | if (f_skip_filename && filename !== undefined) { 204 | facade_options.unshift(filename); 205 | filename = undefined; 206 | } 207 | if (filename === undefined || contest_id === undefined || task_id === undefined) { 208 | // ファイル名、タスク、コンテストのいずれかが未指定の場合、カレントディレクトリのパスから提出先を調べる 209 | const {contest, task} = await detectTaskByPath(); 210 | if (filename === undefined && task !== null && task.directory !== undefined) filename = task.directory.submit; 211 | if (contest_id === undefined && contest !== null) contest_id = contest.id; 212 | if (task_id === undefined && task !== null) task_id = task.id; 213 | 214 | // 結局特定できなかった 215 | if (filename === undefined) { 216 | console.error(`the program file to submit is not found.`); 217 | return; 218 | } 219 | if (contest_id === undefined || task_id === undefined) { 220 | console.error(`cannot find the ${task_id === undefined ? "task" : "contest"} to submit.`); 221 | console.error(`add ${task_id === undefined ? "-t" : "-c"} flag to specify it.`); 222 | return; 223 | } 224 | } 225 | 226 | if (!await OnlineJudge.checkAvailable()) { 227 | console.error("online-judge-tools is not available."); 228 | return; 229 | } 230 | 231 | // URLの妥当性をチェック 232 | const atcoder = await getAtCoder(); 233 | const url: string | null = (await atcoder.task(contest_id, task_id).catch(() => ({url: null}))).url; 234 | if (url === null) { 235 | console.error(`Task ${AtCoder.getTaskURL(contest_id, task_id)} not found.`); 236 | return; 237 | } 238 | console.log(`submit to: ${url}`); 239 | // 提出 240 | await OnlineJudge.call(["s", url, filename, ...facade_options]); 241 | } 242 | 243 | export async function checkOJAvailable() { 244 | const available = await OnlineJudge.checkAvailable(); 245 | const path = await OnlineJudge.getPath(); 246 | console.log(`online-judge-tools is ${available ? "" : "not "}available. ${available ? "found at:" : ""}`); 247 | if (available) { 248 | console.log(path); 249 | } 250 | } 251 | 252 | export async function configDir() { 253 | console.log(await getConfigDirectory()); 254 | } 255 | 256 | export async function config(key: string | undefined, value: string | undefined, options: {D?: boolean}) { 257 | if (options.D) { 258 | await deleteGlobalConfig(key); 259 | } 260 | else if (key !== undefined && value !== undefined) { 261 | await setGlobalConfig(key, value); 262 | } 263 | else { 264 | await getGlobalConfig(key); 265 | } 266 | } 267 | 268 | async function getGlobalConfig(key?: string) { 269 | const conf = await getConfig(); 270 | if (key === undefined) { 271 | for (const key of Object.keys(defaults)) { 272 | console.log(undef2empty`${key}: ${conf.get(key)}`) 273 | } 274 | return; 275 | } 276 | if (!(key in defaults)) { 277 | console.error(`invalid option "${key}".`); 278 | return; 279 | } 280 | console.log(undef2empty`${conf.get(key)}`); 281 | } 282 | 283 | async function setGlobalConfig(key: string, value: string) { 284 | const conf = await getConfig(); 285 | if (!(key in defaults)) { 286 | console.error(`invalid option "${key}".`); 287 | return; 288 | } 289 | conf.set(key, value); 290 | console.log(undef2empty`${key} = ${conf.get(key)}`); 291 | } 292 | 293 | async function deleteGlobalConfig(key?: string) { 294 | const conf = await getConfig(); 295 | if (key !== undefined) { 296 | if (!(key in defaults)) { 297 | console.error(`invalid option "${key}".`); 298 | return; 299 | } 300 | conf.delete(key); 301 | console.log(`option "${key}" is set back to default.`); 302 | } 303 | else { 304 | console.error("option key is not specified."); 305 | } 306 | } 307 | 308 | /** 309 | * テンプレート文字列に挿入された式がundefinedであった場合に"undefined"のかわりに空文字列に変換する 310 | * @param strings 311 | * @param values 312 | */ 313 | function undef2empty(strings: TemplateStringsArray, ...values: Array): string { 314 | values = values.map(value => value !== undefined ? value : ""); 315 | return String.raw(strings, ...values); 316 | } 317 | 318 | type Choices = "inquire" | "all" | "none" | "rest" | "next" 319 | 320 | /** 321 | * --choiceオプションに渡される値として適切かどうかを判定する 322 | * @param choice 323 | */ 324 | function checkValidChoiceOption(choice: any): choice is Choices { 325 | switch (choice) { 326 | case "inquire": 327 | case "all": 328 | case "none": 329 | case "rest": 330 | case "next": 331 | return true; 332 | } 333 | return false; 334 | } 335 | 336 | async function getTemplateFromOption(template?: string | boolean): Promise