Skip to content

Commit

Permalink
Support get and delete, starting from the next bitburner version
Browse files Browse the repository at this point in the history
  • Loading branch information
Nezrahm committed Mar 1, 2022
1 parent 7abc713 commit 5cb157c
Show file tree
Hide file tree
Showing 11 changed files with 406 additions and 112 deletions.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ It can also be installed as a global tool via `npm install bitburner-sync -g`.

Can be used from the terminal, then always prefix the calls with `npx`. Use `npx bitburner-sync --help` for full information.

| Option | Description |
|------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| authToken | The only required option, it can also be read from package.json. See the [VSCode extension](https://github.com/bitburner-official/bitburner-vscode) for information about how to retrieve it. |
| scriptRoot | The folder that you want to sync. Defaults to the current folder. The directory node_modules is ignored but any other valid game files are synced. It's highly recommended to do a dryRun first to list all the files that would be synced. |
| dryRun | Doesn't sync the files, simply lists them in the terminal. |
| watch | Continuously monitor the scriptRoot for changes. |
| help | Displays the full help. |
| Option | Description |
|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| authToken | The only required option, it can also be read from package.json. See the [VSCode extension](https://github.com/bitburner-official/bitburner-vscode) for information about how to retrieve it. |
| scriptRoot | The folder that you want to sync. Defaults to the current folder. The directory node_modules is ignored but any other valid game files are synced. It's highly recommended to do a dryRun first to list all the files that would be synced. |
| allowDelete [^footnote] | If the sync (upload and retrieve) should be allowed to delete files at the target |
| get [^footnote] | Retrieve files from bitburner and store at scriptRoot |
| dryRun | Doesn't sync the files, simply lists them in the terminal. |
| watch | Continuously monitor the scriptRoot for changes. |
| help | Displays the full help. |

[^footnote]: This does not work in Bitburner v1.4.0, only for later versions. This API change also enables reporting of scripts RAM usage (in the terminal)

### package.json

Expand All @@ -42,7 +46,8 @@ Optional config
{
"config": {
"bitburnerAuthToken": "abc",
"bitburnerScriptRoot": "./dist"
"bitburnerScriptRoot": "./dist",
"bitburnerAllowDelete": false
}
}
```
Expand All @@ -56,7 +61,8 @@ NB: The config inside package.json will override this config if both are specifi
```json
{
"authToken": "abc",
"scriptRoot": "./dist"
"scriptRoot": "./dist",
"allowDelete": false
}
```

Expand Down
18 changes: 11 additions & 7 deletions bin/cli.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
#!/usr/bin/env node

import { getArgs } from '../src/config.js';
import { sync, watch } from '../src/index.js';
import { get, sync, watch } from '../src/index.js';
import { log } from '../src/log.js';

const main = () => {
const { doWatch, isDryRun, opts } = getArgs();
const main = async () => {
const { doWatch, doGet, isDryRun, config } = getArgs();

if (doWatch)
watch(opts);
watch(config);
else if (doGet)
await get(config, isDryRun);
else
sync(opts, isDryRun);
await sync(config, isDryRun);
};

try {
main();
await main();
} catch (e) {
const msg = e && e.message ? e.message : e;
log.error(msg);

if (msg !== undefined)
log.error(msg);
}
3 changes: 2 additions & 1 deletion bitburner-sync.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"scriptRoot": "",
"authToken": ""
"authToken": "",
"allowDelete": false
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bitburner-sync",
"version": "1.1.1",
"version": "1.2.0",
"description": "A tool used to upload your code to the game Bitburner",
"type": "module",
"bin": "./bin/cli.js",
Expand Down
242 changes: 201 additions & 41 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const bitburnerConfig = {
port: 9990,
schema: 'http',
url: 'localhost',
filePostURI: '/',
fileURI: '/',
validFileExtensions: [
'.js',
'.script',
Expand All @@ -22,68 +22,224 @@ export const getGameFileGlobs = () => bitburnerConfig
.validFileExtensions
.map(ext => `**/*${ext}`);

/***
* Upload all files
* @param {Map<string, string>} fileToContentMap
/**
* Make a POST request to the expected port of the game
* @param {UploadPayload} payload The payload to send to the game client
*/
export const uploadFileToBitburner = payload => {
const file = prepareUploadFile(payload);

sendRequestToBitburner(
'POST',
file.blob,
payload.authToken,
(res, body) => {
switch (res.statusCode) {
case 200:
logMessage(false, file.filename, 'Uploaded', body);
break;
case 401:
logMessage(true, file.filename, 'Unauthorized', body);
break;
default:
logMessage(true, file.filename, 'Failed to push', body);
break;
}
});
};

/**
* Get all files from home
* @param {string} authToken
* @returns {Promise<BitburnerFiles[]>}
*/
export const getFilesFromBitburner = authToken => {
const deferred = getDeferred();

sendRequestToBitburner(
'GET',
'{}',
authToken,
(res, body) => {
if (body === 'not a script file') {
logMessage(true, undefined, 'The bitburner client is too old for retrieval', undefined);
deferred.reject();
return;
}

switch (res.statusCode) {
case 200: {
let json;

try {
json = JSON.parse(body);
} catch {
logMessage(true, undefined, 'Failed to parse files', body);
break;
}

if (json.success) {
deferred.resolve(json.data.files);
return;
}

logMessage(true, undefined, 'Failed while retrieving files', body);
break;
}
default:
logMessage(true, undefined, 'Failed to retrieve files', body);
break;
}

deferred.reject();
});

return deferred.promise;
};

/**
* Delete a file at bitburner
* @param {DeletePayload} payload
*/
export const postFilesToBitburner = (fileToContentMap, authToken) => {
for (const [filename, code] of fileToContentMap.entries()) {
postRequestToBitburner({
action: 'UPSERT',
filename,
code,
authToken,
export const deleteFileAtBitburner = payload => {
const file = prepareDeleteFile(payload);

sendRequestToBitburner(
'DELETE',
file.blob,
payload.authToken,
(res, body) => {
switch (res.statusCode) {
case 200:
logMessage(false, file.filename, 'Deleted', body);
break;
default:
logMessage(true, file.filename, 'Failed to delete', body);
break;
}
});
};

/**
* Log a message, parsing the body for additional information.
* @param {boolean} isError
* @param {string?} filename
* @param {string} message
* @param {string} body
*/
const logMessage = (isError, filename, message, body) => {
let json;

try {
json = JSON.parse(body);
} catch {
// NOOP
}

if (json === undefined) {
if (body && body !== 'written')
message += ` - ${body}`;
} else {
isError = json.success === undefined ? isError : !json.success;

if (json.msg !== undefined)
message += ` - ${json.msg}`;

if (json.data !== undefined) {
if (json.data.overwritten)
message += ', overwritten';

if (json.data.ramUsage !== undefined)
message += `, RAM usage: ${json.data.ramUsage}GB`;
}
}

if (filename) {
if (isError) log.fileError(filename, message);
else log.fileInfo(filename, message);
} else {
if (isError) log.error(message);
else log.info(message);
}
};

/**
* Make a POST request to the expected port of the game
* @param {UploadPayload} payload The payload to send to the game client
* Used to handle responses
* @callback sendRequestToBitburner-callback
* @param {IncomingMessage} res The response object
* @param {string} body The response body
*/
export const postRequestToBitburner = payload => {
const file = prepareFile(payload);

/**
* Craft a http request and send it
* @param {'POST' | 'GET' | 'DELETE' } method
* @param {string} blob
* @param {string} authToken
* @param {sendRequestToBitburner-callback} responseHandler
*/
const sendRequestToBitburner = (method, blob, authToken, responseHandler) => {
const options = {
hostname: bitburnerConfig.url,
port: bitburnerConfig.port,
path: bitburnerConfig.filePostURI,
method: 'POST',
path: bitburnerConfig.fileURI,
method,
headers: {
'Content-Type': 'application/json',
'Content-Length': file.blob.length,
Authorization: `Bearer ${payload.authToken}`,
'Content-Length': blob.length,
Authorization: `Bearer ${authToken}`,
},
};

const req = http.request(options, res => {
res.on('data', chunk => {
const responseBody = Buffer.from(chunk).toString();
switch (res.statusCode) {
case 200:
// log.info(`${file.filename} has been uploaded!`);
break;
case 401:
log.error(`Failed to push ${file.filename} to the game!\n${responseBody}`);
break;
default:
log.error(`File failed to push, statusCode: ${res.statusCode} | message: ${responseBody}`);
break;
}
});
let body = '';
res.on('data', chunk => body += chunk.toString());
res.on('end', () => responseHandler(res, body));
});

req.write(file.blob);
req.write(blob);
req.end();
};

const prepareFile = payload => {
/**
* Get prepared data for the request
* @param {UploadPayload} payload
* @returns {{filename: string, blob: string}}
*/
const prepareUploadFile = payload => {
const filename = cleanUpFilename(payload.filename);
const code = Buffer.from(payload.code).toString('base64');

return {
filename,
blob: JSON.stringify({ filename, code }),
};
};

/**
*
* @param {DeletePayload} payload
* @returns {{filename: string, blob: string}}
*/
const prepareDeleteFile = payload => {
const filename = cleanUpFilename(payload.filename);

return {
filename,
blob: JSON.stringify({ filename }),
};
};

/**
*
* @param {string} filename
* @returns {string}
*/
export const cleanUpFilename = (filename) => {
// If the file is going to be in a directory, it NEEDS the leading `/`, i.e. `/my-dir/file.js`
// If the file is standalone, it CAN NOT HAVE a leading slash, i.e. `file.js`
// The game will not accept the file and/or have undefined behaviour otherwise...

let filename = `${payload.filename}`.replace(/[\\|/]+/g, '/');
filename = `${filename}`.replace(/[\\|/]+/g, '/');

const haveFolder = /^.+\//.test(filename);
const hasInitialSlash = filename.startsWith('/');
Expand All @@ -93,10 +249,14 @@ const prepareFile = payload => {
else if (!haveFolder && hasInitialSlash)
filename = filename.substring(1);

const code = Buffer.from(payload.code).toString('base64');
return filename;
};

return {
filename,
blob: JSON.stringify({ filename, code }),
};
const getDeferred = () => {
const deferred = {};
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
return deferred;
};
Loading

0 comments on commit 5cb157c

Please sign in to comment.