├── .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 | Package |
16 | Description |
17 |
18 |
19 | @shift-code/cli |
20 | Command-line tool for redeeming codes |
21 |
22 |
23 | @shift-code/api |
24 | API for interacting with shift website |
25 |
26 |
27 | @shift-code/get |
28 | Library for retrieving shift codes |
29 |
30 |
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 |
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 |
--------------------------------------------------------------------------------