├── .deploy └── tag.sh ├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md ├── api ├── README.md ├── package.json ├── src │ ├── const.ts │ ├── cookie.ts │ ├── fetch.ts │ ├── index.ts │ ├── methods │ │ ├── account.ts │ │ ├── authenticity.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ └── redeem.ts │ └── types.ts ├── test │ └── redeem.spec.ts └── tsconfig.json ├── cli ├── README.md ├── docs │ └── shift-code-redeem.gif ├── package.json ├── src │ ├── bin.ts │ ├── cache │ │ ├── data │ │ │ ├── account.ts │ │ │ └── meta.ts │ │ ├── index.ts │ │ ├── migrate.ts │ │ └── store.ts │ ├── commands │ │ ├── account.ts │ │ ├── cache.ts │ │ ├── index.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ └── redeem.ts │ ├── names.ts │ ├── shared.ts │ └── types.ts └── tsconfig.json ├── get ├── README.md ├── package.json ├── src │ ├── codes.ts │ ├── index.ts │ └── types.ts └── tsconfig.json ├── package.json ├── tsconfig.json └── yarn.lock /.deploy/tag.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | PKG="$1" 6 | 7 | if [ ! -d "$PKG" ]; then 8 | echo "Invalid package name" 9 | exit 10 | elif [ ! -f "$PKG/package.json" ]; then 11 | echo "Invalid package name" 12 | exit 13 | fi 14 | 15 | NAME="$(node -e 'process.stdout.write(require("./'"$PKG"'/package.json").name)')" 16 | 17 | yarn workspace "$NAME" version --no-git-tag-version 18 | 19 | VERSION="$(node -e 'process.stdout.write(require("./'"$PKG"'/package.json").version)')" 20 | 21 | git add "./$PKG/package.json" 22 | git commit -m "chore: bump $NAME to $VERSION" 23 | 24 | if [[ "$PKG" == "cli" ]]; then 25 | TAG="v$VERSION" 26 | else 27 | TAG="$NAME@$VERSION" 28 | fi 29 | 30 | echo "Creating tag: $TAG" 31 | 32 | git tag -a "$TAG" -m "$VERSION" 33 | git push --tags 34 | git push 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: '16' 14 | - name: Get Tag 15 | id: tag 16 | run: | 17 | echo "::set-output name=value::${GITHUB_REF#refs/tags/}" 18 | - name: Get Package 19 | id: package 20 | run: | 21 | TAG="${{ steps.tag.outputs.value }}" 22 | if [[ "$TAG" =~ @ ]]; then 23 | PACKAGE="$(echo "$TAG" | sed -e 's#@[0-9].[0-9].[0-9]$##')" 24 | else 25 | PACKAGE="@shift-code/cli" 26 | fi 27 | echo "::set-output name=value::$PACKAGE" 28 | - name: Get Version 29 | id: version 30 | run: | 31 | TAG="${{ steps.tag.outputs.value }}" 32 | VERSION="$(echo "$TAG" | sed -e 's#.*\([0-9].[0-9].[0-9]$\)#\1#')" 33 | echo "::set-output name=value::$VERSION" 34 | - name: Get Changes 35 | id: changes 36 | run: | 37 | CHANGES=$(git log --graph --pretty=format:'%h - %s' "$(git describe --abbrev=0 --tags $(git describe --tags --abbrev=0)^)"..HEAD | cat) 38 | echo "::set-output name=value::$CHANGES" 39 | - name: Install 40 | run: yarn install --pure-lockfile 41 | - name: Build 42 | run: yarn workspace ${{ steps.package.outputs.value }} run build 43 | - name: Package 44 | if: ${{ steps.package.outputs.value == '@shift-code/cli' }} 45 | run: | 46 | yarn workspace ${{ steps.package.outputs.value }} run pkg 47 | for file in cli/pkg/cli-*; do 48 | mv "$file" "$(echo "$file" | sed -e s#cli-#shift-code-#)" 49 | done 50 | - name: Release 51 | if: ${{ steps.package.outputs.value == '@shift-code/cli' }} 52 | uses: softprops/action-gh-release@v1 53 | with: 54 | body: ${{ steps.changes.outputs.value }} 55 | files: cli/pkg/* 56 | - name: Publish 57 | run: | 58 | git config user.email "git@tylerstewart.ca" 59 | git config user.name "Tyler Stewart" 60 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 61 | yarn workspace ${{ steps.package.outputs.value }} publish --new-version ${{ steps.version.outputs.value }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | pkg/ 4 | 5 | .env 6 | tsconfig.tsbuildinfo 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gearbox Shift Code Repo 2 | 3 | ### Monorepo for Gearbox Shift code automatic redemption 4 | 5 | ## Usage 6 | 7 | You're probably looking for the [command line tool](https://github.com/trs/shift-code/tree/master/cli), which will automatically redeem shift codes for you. 8 | 9 | If you're a developer, some of the other packages might interest you in creating your own redemption tool. 10 | 11 | ## Packages 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
PackageDescription
@shift-code/cliCommand-line tool for redeeming codes
@shift-code/apiAPI for interacting with shift website
@shift-code/getLibrary for retrieving shift codes
31 | 32 | ## Deployment 33 | 34 | - Run `yarn run tag ` 35 | - eg: `yarn run tag cli` 36 | - Enter new version for package 37 | - Version will be changed and a tag will be created 38 | - Tag and commit are then pushed 39 | - Workflow triggers on tag, publishing to NPM and creating a Github release 40 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # `@shift-code/api` 2 | 3 | > Gearbox SHiFT code redemption library 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install @shift-code/api 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import {login, redeem, account, logout} from '@shift-code/api'; 15 | 16 | (async () => { 17 | const session = await login('email', 'password'); 18 | 19 | const user = await account(session); 20 | console.log('Redeeming code for %s', user.email); 21 | 22 | const results = redeem(session, 'XXXXX-XXXXX-XXXXX-XXXXX-XXXXX'); 23 | for await (const result of results) { 24 | console.log(result); 25 | } 26 | 27 | await logout(session); 28 | })(); 29 | ``` 30 | 31 | ## API 32 | 33 | ### `login(email: string, password: string) => Promise` 34 | 35 | Create a login session to use for additional methods. 36 | 37 | ### `logout(session: Session) => Promise` 38 | 39 | Logout and invalidate the session. 40 | 41 | ### `redeem(session, code) => AsyncGenerator` 42 | 43 | Redeem a SHiFT code on the account associated to the session. 44 | 45 | A code can be associated to multiple platforms, so one or many RedemptionResults will be yielded. 46 | 47 | ### `account(session) => Promise` 48 | 49 | Get account details, such as email and ID. 50 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shift-code/api", 3 | "version": "0.5.0", 4 | "description": "Gearbox SHiFT code redemption library", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist/" 9 | ], 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "scripts": { 14 | "test": "jest", 15 | "build": "tsc" 16 | }, 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/cheerio": "^0.22.30", 20 | "@types/debug": "^4.1.7", 21 | "@types/node": "^16.10.3", 22 | "@types/node-fetch": "ts4.4", 23 | "@types/tough-cookie": "^4.0.0", 24 | "jest": "^27.2.5", 25 | "typescript": "^4.4.3" 26 | }, 27 | "dependencies": { 28 | "cheerio": "^1.0.0-rc.10", 29 | "debug": "^4.3.2", 30 | "node-fetch": "^2.6.7", 31 | "tough-cookie": "^4.0.0" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/trs/shift-code.git" 36 | }, 37 | "homepage": "https://github.com/trs/shift-code/tree/master/api", 38 | "author": { 39 | "email": "git@tylerstewart.ca", 40 | "name": "Tyler Stewart", 41 | "url": "https://tylerstewart.ca" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/src/const.ts: -------------------------------------------------------------------------------- 1 | export const SHIFT_URL = 'https://shift.gearboxsoftware.com'; 2 | 3 | export const SHIFT_SERVICE = [ 4 | 'steam', 5 | 'xboxlive', 6 | 'psn', 7 | 'epic', 8 | 'stadia' 9 | ] as const; 10 | 11 | export const SERVICE_CODE = [ 12 | 'steam', 13 | 'xbox', 14 | 'psn', 15 | 'epic', 16 | 'stadia' 17 | ] as const; 18 | 19 | export const SHIFT_TITLE = [ 20 | 'mopane', // Borderlands 1 21 | 'willow2', // Borderlands 2 22 | 'cork', // Borderlands: The Pre-Sequel 23 | 'oak', // Borderlands 3 24 | 'swan', // Godfall 25 | 'daffodil' // Tiny Tina's Wonderlands 26 | ] as const; 27 | 28 | export const GAME_CODE = [ 29 | 'bl1', 30 | 'bl2', 31 | 'tps', 32 | 'bl3', 33 | 'gf', 34 | 'ttw' 35 | ] as const; 36 | -------------------------------------------------------------------------------- /api/src/cookie.ts: -------------------------------------------------------------------------------- 1 | 2 | import { CookieJar, Cookie } from 'tough-cookie'; 3 | import { SHIFT_URL } from './const'; 4 | import { Session } from './types'; 5 | 6 | export function extractSetCookie(setCookieString: string | string[], key: string) { 7 | if (!Array.isArray(setCookieString)) setCookieString = [setCookieString]; 8 | 9 | for (const cookieString of setCookieString) { 10 | const cookie = Cookie.parse(cookieString); 11 | if (cookie?.key === key) return cookie; 12 | } 13 | return null; 14 | } 15 | 16 | export function createSessionCookieJar(session: Omit) { 17 | const jar = new CookieJar(); 18 | jar.setCookieSync(`si=${session.si}`, SHIFT_URL); 19 | jar.setCookieSync(`_session_id=${session.sessionID}`, SHIFT_URL); 20 | return jar; 21 | } 22 | -------------------------------------------------------------------------------- /api/src/fetch.ts: -------------------------------------------------------------------------------- 1 | import fetch, { RequestInit, Response } from 'node-fetch'; 2 | import { CookieJar } from 'tough-cookie'; 3 | 4 | import createDebugger from 'debug'; 5 | const debug = createDebugger('fetch'); 6 | 7 | const DEFAULT_RETRY_INTERVAL = 30 * 1000; 8 | 9 | function sleep(time: number): Promise { 10 | return new Promise((resolve) => setTimeout(resolve, time)); 11 | } 12 | 13 | export async function request(jar: CookieJar | null, url: string, init: RequestInit = {}): Promise { 14 | if (jar) { 15 | init.headers = { 16 | ...(init.headers ?? {}), 17 | cookie: await jar.getCookieString(url) 18 | }; 19 | } 20 | 21 | const response = await fetch(url, init); 22 | 23 | if (jar) { 24 | const setCookies = response.headers.raw()['set-cookie'] ?? []; 25 | for (const cookieString of setCookies) { 26 | jar.setCookie(cookieString, url); 27 | } 28 | } 29 | 30 | if (response.status === 429) { // Too Many Requests 31 | const retryAfterHeader = response.headers.get('retry-after'); 32 | const delay = retryAfterHeader ? parseInt(retryAfterHeader) : DEFAULT_RETRY_INTERVAL; 33 | 34 | debug(`Too Many Requests: ${url}`); 35 | debug(`Delay: ${delay}ms`) 36 | 37 | await sleep(delay); 38 | return await request(jar, url, init); 39 | } 40 | 41 | return response; 42 | } 43 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | export { authenticity } from './methods/authenticity'; 2 | export { login } from './methods/login'; 3 | export { logout } from './methods/logout'; 4 | export { redeem } from './methods/redeem'; 5 | export { account } from './methods/account'; 6 | 7 | export * from './types'; 8 | -------------------------------------------------------------------------------- /api/src/methods/account.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import * as cheerio from 'cheerio'; 3 | 4 | import * as fetch from '../fetch'; 5 | import { SHIFT_URL } from '../const'; 6 | import { Session, Account } from '../types'; 7 | 8 | import createDebugger from 'debug'; 9 | import { createSessionCookieJar } from '../cookie'; 10 | const debug = createDebugger('account'); 11 | 12 | export async function account(session: Session) { 13 | debug('Requesting account'); 14 | 15 | const jar = createSessionCookieJar(session); 16 | 17 | const url = new URL('/account', SHIFT_URL); 18 | const response = await fetch.request(jar, url.href, { 19 | redirect: 'manual', 20 | method: 'GET' 21 | }); 22 | 23 | debug('Account response', response.status, response.statusText); 24 | 25 | if (!response.ok) { 26 | throw new Error(response.statusText); 27 | } 28 | 29 | const text = await response.text(); 30 | const $ = cheerio.load(text); 31 | 32 | const email = $('#current_email').text(); 33 | const name = $('#current_display_name').text(); 34 | const id = $('#current_shift_service_id').text(); 35 | 36 | const account: Account = { 37 | email, 38 | name, 39 | id 40 | }; 41 | 42 | debug('Account', account); 43 | return account; 44 | } 45 | -------------------------------------------------------------------------------- /api/src/methods/authenticity.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import * as cheerio from 'cheerio'; 3 | 4 | import * as fetch from '../fetch'; 5 | import { extractSetCookie } from '../cookie'; 6 | import { SHIFT_URL } from '../const'; 7 | import { Authenticity } from '../types'; 8 | 9 | import createDebugger from 'debug'; 10 | const debug = createDebugger('session'); 11 | 12 | export async function authenticity(): Promise { 13 | debug('Requesting session'); 14 | 15 | const url = new URL('/', SHIFT_URL); 16 | const response = await fetch.request(null, url.href); 17 | if (!response.ok) { 18 | throw new Error(response.statusText); 19 | } 20 | 21 | const text = await response.text(); 22 | const $ = cheerio.load(text); 23 | 24 | // Get authenticity token from head 25 | const token = $('meta[name=csrf-token]').attr('content'); 26 | if (!token) throw new Error('Token content not found'); 27 | 28 | debug(`Session token: ${token}`); 29 | 30 | const sessionCookie = extractSetCookie(response.headers.raw()['set-cookie'], '_session_id'); 31 | if (!sessionCookie) throw new Error('No session ID cookie set'); 32 | 33 | debug(`Session ID: ${sessionCookie.value}`); 34 | 35 | return { 36 | token, 37 | sessionID: sessionCookie.value 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /api/src/methods/login.ts: -------------------------------------------------------------------------------- 1 | import { URL, URLSearchParams } from 'url'; 2 | 3 | import { authenticity } from './authenticity'; 4 | import { extractSetCookie } from '../cookie'; 5 | import * as fetch from '../fetch'; 6 | import { SHIFT_URL } from '../const'; 7 | import { Session, Authenticity } from '../types'; 8 | 9 | import createDebugger from 'debug'; 10 | const debug = createDebugger('login'); 11 | 12 | export interface LoginParameters { 13 | email: string; 14 | password: string; 15 | } 16 | 17 | export async function login({email, password}: LoginParameters, authentic?: Authenticity): Promise { 18 | const {token, sessionID} = authentic ? authentic : await authenticity(); 19 | 20 | debug('Authenticating', email); 21 | 22 | const url = new URL('/sessions', SHIFT_URL); 23 | 24 | const params = new URLSearchParams(); 25 | params.set('authenticity_token', token); 26 | params.set('user[email]', email); 27 | params.set('user[password]', password); 28 | 29 | const response = await fetch.request(null, url.href, { 30 | headers: { 31 | 'cookie': `_session_id=${sessionID}` 32 | }, 33 | redirect: 'manual', 34 | method: 'POST', 35 | body: params 36 | }); 37 | 38 | debug('Authentication response', response.statusText, response.statusText); 39 | 40 | if (response.status !== 302) { 41 | throw new Error(response.statusText); 42 | } 43 | 44 | const location = response.headers.get('location'); 45 | if (!location || !location.endsWith('/account')) { 46 | throw new Error('Authentication failed'); 47 | } 48 | 49 | const siCookie = extractSetCookie(response.headers.raw()['set-cookie'], 'si'); 50 | if (!siCookie) { 51 | throw new Error('No si token found'); 52 | } 53 | 54 | debug('Authentication successful', siCookie.value); 55 | 56 | return { 57 | token, 58 | sessionID, 59 | si: siCookie.value 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /api/src/methods/logout.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | import * as fetch from '../fetch'; 4 | import { createSessionCookieJar } from '../cookie'; 5 | import { SHIFT_URL } from '../const'; 6 | import { Session } from '../types'; 7 | 8 | import createDebugger from 'debug'; 9 | const debug = createDebugger('logout'); 10 | 11 | export async function logout(session: Session) { 12 | debug('Attempting logout'); 13 | 14 | const jar = createSessionCookieJar(session); 15 | 16 | const url = new URL('/logout', SHIFT_URL); 17 | const response = await fetch.request(jar, url.href, { 18 | redirect: "manual", 19 | // headers: { 20 | // 'x-csrt-token': session.token, 21 | // 'x-requested-with': 'XMLHttpRequest' 22 | // } 23 | }); 24 | 25 | debug('Logout response', response.status, response.statusText); 26 | 27 | if (response.status === 302) { 28 | const location = response.headers.get('location') || ''; 29 | 30 | if (location.endsWith('/home')) { 31 | return; 32 | } 33 | } 34 | 35 | throw new Error(response.statusText); 36 | } 37 | -------------------------------------------------------------------------------- /api/src/methods/redeem.ts: -------------------------------------------------------------------------------- 1 | import { URL, URLSearchParams } from "url"; 2 | import * as cheerio from 'cheerio'; 3 | import { CookieJar } from "tough-cookie"; 4 | 5 | import * as fetch from '../fetch'; 6 | import { createSessionCookieJar } from "../cookie"; 7 | import { SHIFT_URL, GAME_CODE, SHIFT_TITLE, SERVICE_CODE, SHIFT_SERVICE } from "../const"; 8 | import { Session, RedemptionOption, RedemptionResult, ErrorCodes, RedeemFilter, ShiftService, ShiftTitle } from "../types"; 9 | 10 | import createDebugger from 'debug'; 11 | const debug = createDebugger('redeem'); 12 | 13 | export async function getRedemptionOptions(token: string, jar: CookieJar, code: string): Promise<[ErrorCodes, string | RedemptionOption[]]> { 14 | debug('Fetching redemption options'); 15 | 16 | const url = new URL('/entitlement_offer_codes', SHIFT_URL); 17 | url.searchParams.set('code', code); 18 | 19 | const response = await fetch.request(jar, url.href, { 20 | redirect: "manual", 21 | headers: { 22 | 'x-csrt-token': token, 23 | 'x-requested-with': 'XMLHttpRequest' 24 | } 25 | }); 26 | 27 | debug('Redemption options response', response.status, response.statusText); 28 | 29 | if (!response.ok) { 30 | if (response.status === 302) { 31 | return [ErrorCodes.LoginRequired, 'Login required']; 32 | } 33 | return [ErrorCodes.Unknown, response.statusText]; 34 | } 35 | 36 | const text = await response.text(); 37 | const $ = cheerio.load(text); 38 | 39 | const redeemOptions = $('.new_archway_code_redemption'); 40 | if (redeemOptions.length === 0) { 41 | const error = text.trim(); 42 | return [ErrorCodes.NoRedemptionOptions, error]; 43 | } 44 | 45 | const options: RedemptionOption[] = []; 46 | 47 | redeemOptions.each((i, element) => { 48 | const token = $(element).find('input[name=authenticity_token]').val(); 49 | const code = $(element).find('#archway_code_redemption_code').val(); 50 | const check = $(element).find('#archway_code_redemption_check').val(); 51 | const service = $(element).find('#archway_code_redemption_service').val() as ShiftService; 52 | const title = $(element).find('#archway_code_redemption_title').val() as ShiftTitle; 53 | 54 | options.push({ 55 | token, 56 | code, 57 | check, 58 | service, 59 | title 60 | }); 61 | }); 62 | 63 | debug('Redemption options', options); 64 | 65 | return [ErrorCodes.Success, options]; 66 | } 67 | 68 | export async function submitRedemption(jar: CookieJar, option: RedemptionOption): Promise { 69 | debug('Submitting redemption', option); 70 | 71 | const url = new URL('/code_redemptions', SHIFT_URL); 72 | 73 | const params = new URLSearchParams(); 74 | params.set('authenticity_token', option.token); 75 | params.set('archway_code_redemption[code]', option.code); 76 | params.set('archway_code_redemption[check]', option.check); 77 | params.set('archway_code_redemption[service]', option.service); 78 | params.set('archway_code_redemption[title]', option.title); 79 | 80 | const response = await fetch.request(jar, url.href, { 81 | method: 'POST', 82 | body: params, 83 | redirect: "manual", 84 | }); 85 | 86 | debug('Redemption submission response', response.status, response.statusText); 87 | 88 | if (response.status !== 302) { 89 | throw new Error(response.statusText); 90 | } 91 | 92 | const statusUrl = new URL(response.headers.get('location') as string); 93 | 94 | // Invalid redirect, continue with redirect and get error message 95 | if (!statusUrl.pathname.startsWith('/code_redemptions')) { 96 | debug('Invalid redemption submission redirect', statusUrl.pathname); 97 | 98 | const errorResponse = await fetch.request(jar, statusUrl.href, { 99 | method: 'GET', 100 | headers: { 101 | 'cookie': await jar.getCookieString(SHIFT_URL) 102 | } 103 | }); 104 | 105 | const text = await errorResponse.text(); 106 | const $ = cheerio.load(text); 107 | const notice = $('.alert.notice'); 108 | const status = notice.text().trim() || 'Invalid redemption option result'; 109 | throw new Error(status); 110 | } 111 | 112 | return statusUrl.href; 113 | } 114 | 115 | export async function waitForRedemption(jar: CookieJar, url: string): Promise { 116 | debug(`Waiting for redemption: ${url}`); 117 | 118 | const response = await fetch.request(jar, url, { 119 | redirect: 'manual', 120 | headers: { 121 | 'cookie': await jar.getCookieString(SHIFT_URL) 122 | } 123 | }); 124 | 125 | if (response.status === 302) { 126 | const checkUrl = response.headers.get('location') as string; 127 | return checkUrl; 128 | } 129 | 130 | if (!response.ok) { 131 | throw new Error(response.statusText); 132 | } 133 | 134 | const text = await response.text(); 135 | const $ = cheerio.load(text); 136 | 137 | const status = $('#check_redemption_status'); 138 | const statusPath = status.attr('data-url'); 139 | if (!statusPath) { 140 | throw new Error('Invalid redemption status'); 141 | } 142 | 143 | const statusUrl = statusPath ? new URL(statusPath, SHIFT_URL).href : url; 144 | 145 | return await waitForRedemption(jar, statusUrl); 146 | } 147 | 148 | export async function checkRedemptionStatus(jar: CookieJar, url: string) { 149 | debug('Getting redemption status'); 150 | 151 | const response = await fetch.request(jar, url, { 152 | headers: { 153 | 'cookie': await jar.getCookieString(SHIFT_URL) 154 | } 155 | }); 156 | if (!response.ok) { 157 | throw new Error(response.statusText); 158 | } 159 | 160 | const text = await response.text(); 161 | const $ = cheerio.load(text); 162 | 163 | const notice = $('.notice:not(#cookie-banner)'); 164 | const status = notice.text().trim(); 165 | 166 | debug('Redemption status:', status); 167 | 168 | return status; 169 | } 170 | 171 | export async function redeemOption(jar: CookieJar, option: RedemptionOption) { 172 | try { 173 | const statusUrl = await submitRedemption(jar, option); 174 | const checkUrl = await waitForRedemption(jar, statusUrl); 175 | const status = await checkRedemptionStatus(jar, checkUrl); 176 | 177 | const error = (() => { 178 | if (/Your code was successfully redeemed/i.test(status)) return ErrorCodes.Success; 179 | else if (/Failed to redeem your SHiFT code/i.test(status)) return ErrorCodes.AlreadyRedeemed; 180 | else return ErrorCodes.Unknown; 181 | })(); 182 | 183 | const result: RedemptionResult = { 184 | code: option.code, 185 | title: GAME_CODE[SHIFT_TITLE.indexOf(option.title)], 186 | service: SERVICE_CODE[SHIFT_SERVICE.indexOf(option.service)], 187 | status, 188 | error 189 | }; 190 | 191 | debug('Redemption result', result); 192 | 193 | return result; 194 | } catch (err) { 195 | const message = err instanceof Error ? err.message : 'Unknown error'; 196 | if (message.includes("please launch a SHiFT-enabled title first")) { 197 | return { 198 | code: option.code, 199 | error: ErrorCodes.LaunchGame, 200 | status: message 201 | } 202 | } else if (message.includes("Invalid redemption option result")) { 203 | return { 204 | code: option.code, 205 | error: ErrorCodes.LoginRequired, 206 | status: message 207 | } 208 | } else { 209 | return { 210 | code: option.code, 211 | error: ErrorCodes.Unknown, 212 | status: message 213 | } 214 | } 215 | } 216 | } 217 | 218 | export async function* redeem(session: Session, code: string, filter?: RedeemFilter): AsyncGenerator { 219 | const jar = createSessionCookieJar(session); 220 | 221 | const [error, status] = await getRedemptionOptions(session.token, jar, code); 222 | if (error !== ErrorCodes.Success) { 223 | yield { 224 | code, 225 | error, 226 | status: status as string 227 | }; 228 | return; 229 | } 230 | 231 | const options = status as RedemptionOption[]; 232 | for await (const option of options) { 233 | const platform = SERVICE_CODE[SHIFT_SERVICE.indexOf(option.service)]; 234 | const game = GAME_CODE[SHIFT_TITLE.indexOf(option.title)]; 235 | 236 | if (filter?.platform?.length && !filter.platform.includes(platform)) { 237 | yield { 238 | code, 239 | status: 'Filtered out by platform', 240 | error: ErrorCodes.SkippedDueToFilter, 241 | service: platform, 242 | title: game 243 | } 244 | } 245 | 246 | if (filter?.game?.length && !filter.game.includes(game)) { 247 | yield { 248 | code, 249 | status: 'Filtered out by game', 250 | error: ErrorCodes.SkippedDueToFilter, 251 | service: platform, 252 | title: game 253 | } 254 | } 255 | 256 | const result = await redeemOption(jar, option); 257 | yield result; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /api/src/types.ts: -------------------------------------------------------------------------------- 1 | import { GAME_CODE, SERVICE_CODE, SHIFT_SERVICE, SHIFT_TITLE } from "./const"; 2 | 3 | export interface Authenticity { 4 | token: string; 5 | sessionID: string; 6 | } 7 | 8 | export interface Session extends Authenticity { 9 | si: string; 10 | } 11 | 12 | export interface Account { 13 | email: string; 14 | name: string; 15 | id: string; 16 | } 17 | 18 | export type ShiftService = typeof SHIFT_SERVICE[number]; 19 | 20 | export type ShiftTitle = typeof SHIFT_TITLE[number]; 21 | 22 | export type ServiceCode = typeof SERVICE_CODE[number]; 23 | 24 | export type GameCode = typeof GAME_CODE[number]; 25 | 26 | export interface RedemptionOption { 27 | token: string; 28 | code: string; 29 | check: string; 30 | service: ShiftService; 31 | title: ShiftTitle; 32 | } 33 | 34 | export interface RedemptionResult { 35 | code: string; 36 | status: string; 37 | error: ErrorCodes; 38 | title?: GameCode; 39 | service?: ServiceCode; 40 | } 41 | 42 | export interface RedeemFilter { 43 | platform?: Array; 44 | game?: Array; 45 | } 46 | 47 | export enum ErrorCodes { 48 | Success = 'Success', 49 | LoginRequired = 'LoginRequired', 50 | NoRedemptionOptions = 'NoRedemptionOptions', 51 | CodeNotAvailable = 'CodeNotAvailable', 52 | LaunchGame = 'LaunchGame', 53 | AlreadyRedeemed = 'AlreadyRedeemed', 54 | SkippedDueToFilter = 'SkippedDueToFilter', 55 | Unknown = 'Unknown' 56 | } 57 | -------------------------------------------------------------------------------- /api/test/redeem.spec.ts: -------------------------------------------------------------------------------- 1 | import {login, logout, redeem} from '../src'; 2 | import {Session} from '../src/types'; 3 | 4 | describe('redeem', () => { 5 | let session: Session; 6 | beforeAll(async () => { 7 | session = await login({ 8 | email: process.env.SHIFT_USERNAME as string, 9 | password: process.env.SHIFT_PASSWORD as string 10 | }); 11 | }); 12 | 13 | afterAll(async () => { 14 | await logout(session); 15 | }); 16 | 17 | it('test', async () => { 18 | const results = await redeem(session, 'KKWJB-JKKBK-66XBR-56JTJ-5WBCC'); 19 | for await (const result of results) { 20 | expect(result); 21 | } 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "composite": true 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ], 11 | "exclude": [ 12 | "**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # `@shift-code/cli` 2 | 3 | > Automatically redeem Gearbox Shift codes 4 | 5 | Example of a terminal executing the shift-code command 6 | 7 | ### Supported Games: 8 | 9 | - Tiny Tina's Wonderlands 10 | - Borderlands GOTY 11 | - Borderlands 2 12 | - Borderlands: The Pre-Sequel 13 | - Borderlands 3 14 | - Godfall 15 | 16 | ### Supported Platforms: 17 | 18 | - Steam 19 | - Epic 20 | - PSN 21 | - Xbox 22 | - Stadia 23 | 24 | ## Install 25 | 26 | Download the [latest binary release](https://github.com/trs/shift-code/releases/latest) for your platform: 27 | 28 | - [Windows](https://github.com/trs/shift-code/releases/latest/download/shift-code-win.exe) 29 | - [MacOS](https://github.com/trs/shift-code/releases/latest/download/shift-code-macos) 30 | - [Linux](https://github.com/trs/shift-code/releases/latest/download/shift-code-linux) 31 | 32 | Or, run via NodeJS: 33 | 34 | ```sh 35 | npm install @shift-code/cli --global 36 | ``` 37 | 38 | ## Usage 39 | 40 | 1. Login to SHiFT: `shift-code login` 41 | 1. Enter your shift credentials 42 | 1. Redeem available codes: `shift-code redeem` 43 | 1. Codes will be automatically redeemed. Just let it do it's thing. 44 | 45 | ## Commands 46 | 47 | ### `login` 48 | 49 | ```sh 50 | shift-code login [--email ] [--password ] 51 | ``` 52 | 53 | Login to a SHiFT account. Stores the session in the config location. 54 | 55 | If the email is already associated to an account, it will switch that account to the current active account. 56 | 57 | ### `redeem` 58 | 59 | ```sh 60 | shift-code redeem [codes...] [--game ] [--platform ] 61 | ``` 62 | 63 | Redeem the given codes or all available codes on the current active account. 64 | 65 | You can optionally provide one or more `--game` flags to only redeem codes for those games. Same with `--platform`. 66 | 67 | ```sh 68 | # Will only redeem codes that are for Borderlands 2 and 3 for Xbox 69 | shift-code redeem --game bl2 --game bl3 --platform xbox 70 | ``` 71 | 72 | ### `logout` 73 | 74 | ```sh 75 | shift-code logout 76 | ``` 77 | 78 | Logout from SHiFT and remove the stored session. 79 | 80 | ### `accounts` 81 | 82 | ```sh 83 | shift-code accounts 84 | ``` 85 | 86 | List all saved accounts, show current active account 87 | 88 | ### `cache clear` 89 | 90 | ```sh 91 | shift-code cache clear 92 | ``` 93 | 94 | Remove all codes from the redemption cache for the current active account. 95 | 96 | ## FAQ 97 | 98 | 1. What does `"You need to launch a Shift-enabled game to continue redeeming"` mean? 99 | - You can only redeem a certain number of SHiFT codes before you'll see this. It means you need to open a SHiFT enabled title and play past the main menu. Once you're loaded in, you can exit the game and continue redeeming. 100 | - Alternatively, this error will go away after a certain amount of time. 101 | -------------------------------------------------------------------------------- /cli/docs/shift-code-redeem.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trs/shift-code/10c9e5650bf54089c1fa3edad25330b558b7a63d/cli/docs/shift-code-redeem.gif -------------------------------------------------------------------------------- /cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shift-code/cli", 3 | "version": "1.0.0", 4 | "description": "Redeem Gearbox SHiFT codes automatically", 5 | "main": "dist/bin.js", 6 | "bin": { 7 | "shift-code": "./dist/bin.js" 8 | }, 9 | "files": [ 10 | "dist/" 11 | ], 12 | "publishConfig": { 13 | "access": "public" 14 | }, 15 | "scripts": { 16 | "dev": "ts-node-dev src/bin.ts", 17 | "build": "tsc --build", 18 | "pkg": "pkg ./package.json --out-path pkg" 19 | }, 20 | "engines": { 21 | "node": ">= 16" 22 | }, 23 | "keywords": [ 24 | "borderlands", 25 | "shift", 26 | "code", 27 | "redeem" 28 | ], 29 | "pkg": { 30 | "targets": [ 31 | "node16-macos", 32 | "node16-linux", 33 | "node16-win" 34 | ] 35 | }, 36 | "license": "MIT", 37 | "devDependencies": { 38 | "@types/mkdirp": "^1.0.2", 39 | "@types/node": "^16.10.3", 40 | "@types/prompts": "^2.0.14", 41 | "@types/signale": "^1.4.2", 42 | "@types/yargs": "^15.0.4", 43 | "pkg": "^5.3.3", 44 | "ts-node-dev": "^1.1.8", 45 | "typescript": "^4.4.3" 46 | }, 47 | "dependencies": { 48 | "@shift-code/api": "^0.5.0", 49 | "@shift-code/get": "^0.5.0", 50 | "chalk": "^4.1.2", 51 | "mkdirp": "^1.0.4", 52 | "prompts": "^2.4.2", 53 | "table": "^6.7.3", 54 | "yargs": "^15.3.1" 55 | }, 56 | "repository": { 57 | "type": "git", 58 | "url": "https://github.com/trs/shift-code.git" 59 | }, 60 | "homepage": "https://github.com/trs/shift-code/tree/master/cli", 61 | "author": { 62 | "email": "git@tylerstewart.ca", 63 | "name": "Tyler Stewart", 64 | "url": "https://tylerstewart.ca" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cli/src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from 'yargs'; 4 | import { Arguments } from 'yargs'; 5 | import { migrateOldCache } from './cache/migrate'; 6 | 7 | import { loginCommand, logoutCommand, redeemCommand, cacheCommand, accountCommand } from './commands'; 8 | import { GameName, PlatformName } from './names'; 9 | 10 | const runCommand = (fn: (args: Arguments) => Promise) => (args: Arguments) => { 11 | fn(args) 12 | .catch((err) => { 13 | console.log(err); 14 | }); 15 | }; 16 | 17 | void async function () { 18 | await migrateOldCache(); 19 | 20 | yargs 21 | .command({ 22 | command: 'login', 23 | describe: 'Login to a new account', 24 | handler: runCommand(loginCommand), 25 | builder: { 26 | email: { 27 | alias: 'e' 28 | }, 29 | password: { 30 | alias: 'p' 31 | } 32 | } 33 | }) 34 | .command({ 35 | command: 'logout', 36 | describe: 'Logout of current account, or specified account', 37 | handler: runCommand(logoutCommand), 38 | builder: { 39 | email: { 40 | alias: 'e' 41 | } 42 | } 43 | }) 44 | .command({ 45 | command: 'accounts', 46 | describe: 'Show all accounts, marking the currently active account', 47 | handler: runCommand(accountCommand) 48 | }) 49 | .command({ 50 | command: 'cache ', 51 | describe: 'Manage cached Shift codes', 52 | handler: () => {}, 53 | builder: (yargs) => yargs 54 | .command({ 55 | command: 'clear', 56 | describe: 'Clear code cache for current account, or specified account', 57 | handler: runCommand(cacheCommand), 58 | builder: { 59 | email: { 60 | alias: 'e' 61 | } 62 | } 63 | }) 64 | }) 65 | .command({ 66 | command: 'redeem [codes...]', 67 | describe: 'Redeem all available codes or the given codes if provided', 68 | handler: runCommand(redeemCommand), 69 | builder: { 70 | game: { 71 | alias: 'g', 72 | array: true, 73 | choices: Object.keys(GameName) 74 | }, 75 | platform: { 76 | alias: 'p', 77 | array: true, 78 | choices: Object.keys(PlatformName) 79 | } 80 | } 81 | }) 82 | .help() 83 | .version() 84 | .demandCommand() 85 | .strict() 86 | .argv; 87 | }(); 88 | -------------------------------------------------------------------------------- /cli/src/cache/data/account.ts: -------------------------------------------------------------------------------- 1 | import { Account, Session } from "@shift-code/api"; 2 | import { loadContents, ACCOUNT_CACHE, storeContents, storeExists } from "../store"; 3 | 4 | export type AccountCache = { 5 | account?: Account; 6 | session?: Session; 7 | codes?: string[] 8 | } 9 | 10 | export async function accountCacheExists(accountID: string) { 11 | const exists = await storeExists(ACCOUNT_CACHE, accountID); 12 | return exists; 13 | } 14 | 15 | export async function loadAccountCache(accountID: string) { 16 | const cache = await loadContents(ACCOUNT_CACHE, accountID, {}); 17 | return cache; 18 | } 19 | 20 | export async function saveAccountCache(accountID: string, data: Partial) { 21 | await storeContents(ACCOUNT_CACHE, accountID, data); 22 | } 23 | 24 | export async function saveAccountSession(accountID: string, session: Session) { 25 | const cache = await loadAccountCache(accountID); 26 | 27 | await saveAccountCache(accountID, { 28 | ...cache, 29 | session 30 | }); 31 | } 32 | 33 | export async function saveAccount(accountID: string, account: Account) { 34 | const cache = await loadAccountCache(accountID); 35 | 36 | await saveAccountCache(accountID, { 37 | ...cache, 38 | account 39 | }); 40 | } 41 | 42 | export async function clearAccountSession(accountID: string) { 43 | const cache = await loadAccountCache(accountID); 44 | 45 | delete cache?.session; 46 | 47 | await saveAccountCache(accountID, cache); 48 | } 49 | 50 | export async function saveCodeCache(accountID: string, codes: string[]) { 51 | const cache = await loadAccountCache(accountID); 52 | 53 | await saveAccountCache(accountID, { 54 | ...cache, 55 | codes 56 | }); 57 | } 58 | 59 | export async function appendCodeCache(accountID: string, code: string) { 60 | const cache = await loadAccountCache(accountID); 61 | if (!cache.codes) cache.codes = []; 62 | cache.codes.push(code); 63 | 64 | await saveAccountCache(accountID, cache); 65 | } 66 | 67 | export async function clearCodeCache(accountID: string) { 68 | const cache = await loadAccountCache(accountID); 69 | delete cache?.codes; 70 | 71 | await saveAccountCache(accountID, cache); 72 | } 73 | -------------------------------------------------------------------------------- /cli/src/cache/data/meta.ts: -------------------------------------------------------------------------------- 1 | import { loadContents, META_CACHE, META_FILE, storeContents, storeExists } from "../store"; 2 | 3 | export type MetaCache = { 4 | activeAccountID?: string; 5 | accounts?: string[]; 6 | } 7 | 8 | export async function metaCacheExists() { 9 | const exists = await storeExists(META_CACHE, META_FILE); 10 | return exists; 11 | } 12 | 13 | export async function loadMetaCache() { 14 | const cache = await loadContents(META_CACHE, META_FILE, {}); 15 | return cache; 16 | } 17 | 18 | export async function saveMetaCache(data: Partial) { 19 | await storeContents(META_CACHE, META_FILE, data); 20 | } 21 | 22 | export async function saveMetaActiveAccount(accountID: string) { 23 | const cache = await loadMetaCache(); 24 | cache.activeAccountID = accountID; 25 | 26 | await saveMetaCache(cache); 27 | } 28 | 29 | export async function saveMetaAccount(accountID: string) { 30 | const cache = await loadMetaCache(); 31 | if (!cache.accounts) cache.accounts = []; 32 | cache.accounts.push(accountID); 33 | 34 | await saveMetaCache(cache); 35 | } 36 | 37 | export async function clearMetaActiveAccount() { 38 | const cache = await loadMetaCache(); 39 | delete cache.activeAccountID; 40 | 41 | await saveMetaCache(cache); 42 | } 43 | -------------------------------------------------------------------------------- /cli/src/cache/index.ts: -------------------------------------------------------------------------------- 1 | export * from './data/account'; 2 | export * from './data/meta'; 3 | -------------------------------------------------------------------------------- /cli/src/cache/migrate.ts: -------------------------------------------------------------------------------- 1 | import { Session, account } from '@shift-code/api'; 2 | import { metaCacheExists, saveAccount, saveAccountSession, saveMetaActiveAccount, saveCodeCache, saveMetaAccount } from '.'; 3 | import { SESSION_FILE, CACHE_FILE, loadContents, deleteStoreFile } from './store'; 4 | 5 | export async function migrateOldCache() { 6 | if (await metaCacheExists()) { 7 | return; 8 | } 9 | 10 | const codes = await loadContents('', CACHE_FILE, []); 11 | const session = await loadContents('', SESSION_FILE, {} as any); 12 | const user = await account(session) 13 | .catch(() => null); 14 | 15 | if (user) { 16 | await saveMetaAccount(user.id); 17 | await saveMetaActiveAccount(user.id); 18 | await saveAccountSession(user.id, session); 19 | await saveAccount(user.id, user); 20 | 21 | await saveCodeCache(user.id, codes); 22 | } 23 | 24 | await Promise.all([ 25 | deleteStoreFile('', SESSION_FILE), 26 | deleteStoreFile('', CACHE_FILE) 27 | ]); 28 | } 29 | -------------------------------------------------------------------------------- /cli/src/cache/store.ts: -------------------------------------------------------------------------------- 1 | import {writeFile, readFile, readFileSync, stat, rm} from 'fs'; 2 | import {promisify} from 'util'; 3 | import path from 'path'; 4 | import os from 'os'; 5 | import mkdirp from 'mkdirp'; 6 | 7 | const {name} = JSON.parse(readFileSync(path.join(__dirname, '../../package.json')).toString('utf-8')); 8 | 9 | const writeFileAsync = promisify(writeFile); 10 | const readFileAsync = promisify(readFile); 11 | const statAsync = promisify(stat); 12 | const rmAsync = promisify(rm); 13 | 14 | const homedir = os.homedir(); 15 | const {env} = process; 16 | 17 | export const ACCOUNT_CACHE = 'account'; 18 | export const META_CACHE = ''; 19 | export const META_FILE = 'meta'; 20 | export const SESSION_FILE = 'session'; 21 | export const CACHE_FILE = 'cache'; 22 | 23 | export function getStorePath() { 24 | // macos 25 | if (process.platform === 'darwin') { 26 | const library = path.join(homedir, 'Library'); 27 | 28 | return path.join(library, 'Application Support', name); 29 | } 30 | 31 | // windows 32 | if (process.platform === 'win32') { 33 | const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local'); 34 | 35 | return path.join(localAppData, name, 'Data'); 36 | } 37 | 38 | // linux 39 | return path.join(env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'), name); 40 | } 41 | 42 | const getStoreFilePath = (cache: string, name: string) => path.join(getStorePath(), `${[cache, name].filter(Boolean).join('-')}.json`); 43 | 44 | export async function storeExists(cache: string, id: string) { 45 | const filePath = getStoreFilePath(cache, id); 46 | try { 47 | const stat = await statAsync(filePath); 48 | return stat.isFile(); 49 | } catch { 50 | return false; 51 | } 52 | } 53 | 54 | export async function deleteStoreFile(cache: string, id: string) { 55 | const filePath = getStoreFilePath(cache, id); 56 | try { 57 | await rmAsync(filePath); 58 | } finally { 59 | return true; 60 | } 61 | } 62 | 63 | export async function storeContents(cache: string, id: string, contents: T): Promise { 64 | const filePath = getStoreFilePath(cache, id); 65 | const storePath = getStorePath(); 66 | 67 | await mkdirp(storePath); 68 | await writeFileAsync(filePath, JSON.stringify(contents), {encoding: 'utf-8'}); 69 | 70 | return filePath; 71 | } 72 | 73 | export async function loadContents(cache: string, id: string, defaultContent: T): Promise { 74 | const filePath = getStoreFilePath(cache, id); 75 | 76 | try { 77 | const data = await readFileAsync(filePath, {encoding: 'utf-8'}); 78 | const json = JSON.parse(data) as T; 79 | return json; 80 | } catch (err) { 81 | await storeContents(cache, id, defaultContent); 82 | return defaultContent; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /cli/src/commands/account.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { table, getBorderCharacters } from 'table'; 3 | 4 | import { loadMetaCache, loadAccountCache } from '../cache'; 5 | 6 | export async function accountCommand() { 7 | const cache = await loadMetaCache(); 8 | if (!cache.accounts?.length) { 9 | console.error('No saved accounts, login to add one.'); 10 | console.info('$ shift-code login'); 11 | return; 12 | } 13 | 14 | const list: string[][] = [ 15 | [ 16 | chalk.bold('Name'), 17 | chalk.bold('Email'), 18 | chalk.bold('Active') 19 | ] 20 | ]; 21 | for (const accountID of cache.accounts) { 22 | try { 23 | const user = await loadAccountCache(accountID); 24 | if (!user.account) continue; 25 | 26 | const isActiveAccount = typeof cache.activeAccountID === 'string' 27 | && cache.activeAccountID === user.account?.id; 28 | 29 | list.push([ 30 | user.account?.name, 31 | user.account?.email, 32 | isActiveAccount ? chalk.green('Y') : '' 33 | ]); 34 | } catch {} 35 | } 36 | 37 | console.log(table(list, { 38 | header: { 39 | alignment: 'left', 40 | content: 'Saved accounts' 41 | }, 42 | border: getBorderCharacters('norc') 43 | })); 44 | } 45 | -------------------------------------------------------------------------------- /cli/src/commands/cache.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import { clearCodeCache, loadAccountCache, loadMetaCache } from '../cache'; 4 | 5 | export async function cacheCommand() { 6 | const {activeAccountID} = await loadMetaCache(); 7 | if (!activeAccountID) { 8 | console.error('No active user, please login.'); 9 | console.info('$ shift-code login'); 10 | return; 11 | } 12 | 13 | const user = await loadAccountCache(activeAccountID); 14 | if (!user.account) { 15 | console.error('No active user, please login.'); 16 | console.info('$ shift-code login'); 17 | return; 18 | } 19 | 20 | await clearCodeCache(user.account.id); 21 | 22 | console.info(`Cache cleared for ${chalk.bold(user.account.email)}`); 23 | } 24 | -------------------------------------------------------------------------------- /cli/src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login'; 2 | export * from './logout'; 3 | export * from './account'; 4 | export * from './redeem'; 5 | export * from './cache'; 6 | -------------------------------------------------------------------------------- /cli/src/commands/login.ts: -------------------------------------------------------------------------------- 1 | import { Arguments } from 'yargs'; 2 | import { account, login } from '@shift-code/api'; 3 | import chalk from 'chalk'; 4 | 5 | import { tryPromptArgs } from '../shared'; 6 | import { saveAccountSession, saveMetaActiveAccount, saveAccount, loadAccountCache, loadMetaCache, saveMetaAccount } from '../cache'; 7 | 8 | export interface LoginParameters { 9 | email?: string; 10 | password?: string; 11 | } 12 | 13 | export async function loginCommand(args: Arguments) { 14 | const tryPrompt = tryPromptArgs(args); 15 | 16 | const cache = await loadMetaCache(); 17 | 18 | const email = await tryPrompt({ 19 | name: 'email', 20 | type: 'text', 21 | message: 'SHiFT email', 22 | validate: (value) => !!value 23 | }); 24 | 25 | // If user is already saved, switch to it without requiring another login 26 | for (const accountID of cache.accounts ?? []) { 27 | const existingAccount = await loadAccountCache(accountID); 28 | if (existingAccount.account?.email === email && existingAccount.session) { 29 | const user = await account(existingAccount.session) 30 | .catch(() => null); 31 | 32 | if (user) { 33 | await saveMetaActiveAccount(user.id); 34 | await saveAccount(user.id, user); 35 | 36 | console.log('Changed active account'); 37 | console.info(` Name: ${user.name}`); 38 | console.info(` Email: ${user.email}`); 39 | 40 | return; 41 | } 42 | } 43 | } 44 | 45 | const password = await tryPrompt({ 46 | name: 'password', 47 | type: 'password', 48 | message: 'SHiFT password', 49 | validate: (value) => !!value 50 | }); 51 | 52 | const session = await login({email, password}); 53 | const user = await account(session); 54 | 55 | await saveMetaActiveAccount(user.id); 56 | await saveMetaAccount(user.id); 57 | await saveAccountSession(user.id, session); 58 | await saveAccount(user.id, user); 59 | 60 | console.log(chalk.green('Login successful')); 61 | } 62 | -------------------------------------------------------------------------------- /cli/src/commands/logout.ts: -------------------------------------------------------------------------------- 1 | import { logout } from '@shift-code/api'; 2 | import chalk from 'chalk'; 3 | 4 | import { loadAccountCache, clearAccountSession, loadMetaCache, clearMetaActiveAccount } from '../cache'; 5 | 6 | export async function logoutCommand() { 7 | const cache = await loadMetaCache(); 8 | if (cache.activeAccountID) { 9 | const {session} = await loadAccountCache(cache.activeAccountID); 10 | if (session) { 11 | await logout(session).catch(() => void 0); 12 | await clearAccountSession(cache.activeAccountID); 13 | } 14 | await clearMetaActiveAccount(); 15 | } 16 | 17 | console.log(chalk.green('Logout successful')); 18 | } 19 | -------------------------------------------------------------------------------- /cli/src/commands/redeem.ts: -------------------------------------------------------------------------------- 1 | import { Arguments } from 'yargs'; 2 | import { Session, ErrorCodes, redeem, account } from '@shift-code/api'; 3 | import { getShiftCodes } from '@shift-code/get'; 4 | import chalk from 'chalk'; 5 | 6 | import { saveAccount, appendCodeCache, loadMetaCache, loadAccountCache } from '../cache'; 7 | import { GameName, isGameName, PlatformName, isPlatformName, IPlatformName, IGameName } from '../names'; 8 | 9 | export interface RedeemFilter { 10 | platform?: IPlatformName[]; 11 | game?: IGameName[]; 12 | } 13 | 14 | export interface RedeemParameters extends RedeemFilter { 15 | codes?: string[]; 16 | } 17 | 18 | async function redeemCode(session: Session, code: string, filter: RedeemFilter): Promise<[cont: boolean, cache: boolean]> { 19 | try { 20 | let game: IGameName | string | undefined; 21 | 22 | process.stdout.write(`[${chalk.yellow(code)}] Redeeming...`); 23 | for await (const result of redeem(session, code, filter)) { 24 | const platform = isPlatformName(result.service) ? PlatformName[result.service] : result.service; 25 | 26 | if (!game) { 27 | process.stdout.write("\r\x1b[K"); 28 | 29 | game = isGameName(result.title) ? GameName[result.title] : result.title; 30 | 31 | const gameName = game ? ` ${game}` : ''; 32 | console.log(`[${chalk.yellow(code)}]${gameName}`); 33 | } 34 | 35 | const scope = platform ? `[${platform}] ` : ''; 36 | const message = `${scope}${result.status}`.trim(); 37 | 38 | switch (result.error) { 39 | case ErrorCodes.Success: 40 | console.info(` > ${message}`); 41 | break; 42 | case ErrorCodes.LoginRequired: 43 | console.error(` > ${chalk.red('Failed to redeem due to invalid session. Please login again!')}`); 44 | return [false, false]; 45 | case ErrorCodes.LaunchGame: 46 | console.error(` > ${chalk.redBright('You need to launch a Shift-enabled game to continue redeeming.')}`); 47 | return [false, false]; 48 | case ErrorCodes.SkippedDueToFilter: 49 | console.error(` > ${message}`); 50 | return [true, false]; 51 | default: 52 | console.error(` > ${message}`); 53 | break; 54 | } 55 | } 56 | 57 | } catch (err) { 58 | const message = err instanceof Error ? err.message : 'Unknown error'; 59 | console.error(chalk.bgRed.white(message)); 60 | } 61 | 62 | return [true, true]; 63 | } 64 | 65 | export async function redeemCommand(args: Arguments) { 66 | const {activeAccountID} = await loadMetaCache(); 67 | if (!activeAccountID) { 68 | console.error('No active user, please login.'); 69 | console.info('$ shift-code login'); 70 | return; 71 | } 72 | 73 | const {session, codes} = await loadAccountCache(activeAccountID); 74 | if (!session) { 75 | console.error('No active user, please login.'); 76 | console.info('$ shift-code login'); 77 | return; 78 | } 79 | 80 | const user = await account(session); 81 | await saveAccount(user.id, user); 82 | 83 | console.info(`Starting code redemption for: ${chalk.bold(user.email)}`); 84 | 85 | const codeCache = codes ?? []; 86 | 87 | const source = args.codes ? args.codes.map((code) => ({code})) : getShiftCodes(); 88 | 89 | for await (const {code} of source) { 90 | if (codeCache.includes(code)) { 91 | console.info(`[${chalk.yellow(code)}] Code found in cache, skipping.`); 92 | continue; 93 | } 94 | 95 | const [cont, cache] = await redeemCode(session, code, {game: args.game, platform: args.platform}); 96 | if (!cont) return; 97 | 98 | if (cache) { 99 | codeCache.push(code); 100 | await appendCodeCache(user.id, code); 101 | } 102 | } 103 | 104 | console.log(chalk.green('Complete')); 105 | } 106 | -------------------------------------------------------------------------------- /cli/src/names.ts: -------------------------------------------------------------------------------- 1 | export const GameName = { 2 | 'bl1': 'Borderlands 1', 3 | 'bl2': 'Borderlands 2', 4 | 'bl3': 'Borderlands 3', 5 | 'tps': 'Borderlands: The Pre-Sequel', 6 | 'gf': 'Godfall', 7 | 'ttw': 'Tiny Tina\'s Wonderlands' 8 | } 9 | 10 | export const PlatformName = { 11 | 'steam': 'Steam', 12 | 'xbox': 'Xbox', 13 | 'psn': 'Playstation', 14 | 'epic': 'Epic Games', 15 | 'stadia': 'Stadia' 16 | } 17 | 18 | export type IGameName = keyof typeof GameName; 19 | 20 | export type IPlatformName = keyof typeof PlatformName; 21 | 22 | export function isGameName(name: string | undefined): name is keyof typeof GameName { 23 | return typeof name === "string" && name in GameName; 24 | } 25 | 26 | export function isPlatformName(name: string | undefined): name is keyof typeof PlatformName { 27 | return typeof name === "string" && name in PlatformName; 28 | } 29 | -------------------------------------------------------------------------------- /cli/src/shared.ts: -------------------------------------------------------------------------------- 1 | import { Arguments } from 'yargs'; 2 | import prompt from 'prompts'; 3 | import { PromptObject } from 'prompts'; 4 | 5 | export const tryPromptArgs = (args: Arguments) => async (question: PromptObject) => { 6 | const key = question.name as string; 7 | if (args[key]) return args[key] as string; 8 | const result = await prompt(question); 9 | return (result as any)[key] as string; 10 | }; 11 | -------------------------------------------------------------------------------- /cli/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Account, Session } from '@shift-code/api'; 2 | 3 | export type JSONSession = { 4 | token: string; 5 | cookie: string; 6 | } 7 | 8 | export type MetaStore = { 9 | activeAccountID: string; 10 | } 11 | 12 | export type AccountStore = { 13 | account?: Account; 14 | session?: Session; 15 | codes?: string[] 16 | } 17 | -------------------------------------------------------------------------------- /cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "composite": true 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ], 11 | "exclude": [ 12 | "**/*.spec.ts" 13 | ], 14 | "references": [ 15 | { "path": "../api" }, 16 | { "path": "../get" } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /get/README.md: -------------------------------------------------------------------------------- 1 | # `@shift-code/get` 2 | 3 | > Get active Gearbox SHiFT codes 4 | 5 | ## Install 6 | 7 | ``` 8 | npm install @shift-code/get 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### `getShiftCodes(): AsyncGenerator` 14 | 15 | Create an `AsyncGenerator` of active SHiFT codes. 16 | 17 | ```ts 18 | 19 | import {getShiftCodes} from '@shift-code/get'; 20 | 21 | for await (const shift of getShiftCodes()) { 22 | console.log(shift.code); 23 | } 24 | 25 | ``` 26 | 27 | ## Source Attribution 28 | 29 | - https://shift.orcicorn.com/shift/ 30 | -------------------------------------------------------------------------------- /get/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shift-code/get", 3 | "version": "0.5.0", 4 | "description": "Get active Gearbox SHiFT codes", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist/" 9 | ], 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "scripts": { 14 | "build": "tsc" 15 | }, 16 | "license": "MIT", 17 | "release": { 18 | "branch": "master" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^16.10.3", 22 | "@types/node-fetch": "ts4.4", 23 | "typescript": "^4.4.3" 24 | }, 25 | "dependencies": { 26 | "node-fetch": "^2.6.7", 27 | "stream-json": "^1.7.3" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/trs/shift-code.git" 32 | }, 33 | "homepage": "https://github.com/trs/shift-code/tree/master/get", 34 | "author": { 35 | "email": "git@tylerstewart.ca", 36 | "name": "Tyler Stewart", 37 | "url": "https://tylerstewart.ca" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /get/src/codes.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const Pick = require('stream-json/filters/Pick'); 3 | const {streamValues} = require('stream-json/streamers/StreamValues'); 4 | 5 | import { ShiftCode } from './types'; 6 | 7 | const SHIFT_CODES_URL = 'https://shift.orcicorn.com/shift-code/index.json'; 8 | 9 | function parseDate(str: string) { 10 | const date = new Date(str); 11 | if (isNaN(date.valueOf())) return undefined; 12 | return date; 13 | } 14 | 15 | export async function * getShiftCodes() { 16 | const response = await fetch(SHIFT_CODES_URL); 17 | 18 | const stream = response.body! 19 | .pipe(Pick.withParser({filter: /^0.codes.\d+/})) 20 | .pipe(streamValues()); 21 | 22 | for await (const {value} of stream) { 23 | const created = parseDate(value.archived); 24 | const expired = parseDate(value.expires); 25 | 26 | const code: ShiftCode = { 27 | code: value.code, 28 | game: value.game, 29 | platform: value.platform, 30 | reward: value.reward, 31 | created, 32 | expired 33 | } 34 | 35 | yield code; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /get/src/index.ts: -------------------------------------------------------------------------------- 1 | export { getShiftCodes } from './codes'; 2 | export { ShiftCode } from './types'; 3 | -------------------------------------------------------------------------------- /get/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ShiftCode { 2 | code: string; 3 | game: string; 4 | platform: string; 5 | reward: string; 6 | created?: Date; 7 | expired?: Date; 8 | } 9 | -------------------------------------------------------------------------------- /get/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "composite": true 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ], 11 | "exclude": [ 12 | "**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shift-code/redeem", 3 | "scripts": { 4 | "tag": ".deploy/tag.sh" 5 | }, 6 | "private": true, 7 | "workspaces": [ 8 | "./api", 9 | "./cli", 10 | "./get" 11 | ], 12 | "volta": { 13 | "node": "16.11.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "./api" }, 4 | { "path": "./cli" }, 5 | { "path": "./get" } 6 | ], 7 | "compilerOptions": { 8 | "module": "commonjs", 9 | "target": "ES2020", 10 | "lib": ["ES2020"], 11 | "sourceMap": true, 12 | "strict": true, 13 | "moduleResolution": "node", 14 | "declaration": true, 15 | "esModuleInterop": true 16 | }, 17 | "exclude": [ 18 | "node_modules" 19 | ] 20 | } 21 | --------------------------------------------------------------------------------