mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-05-07 16:17:49 +02:00
CLI: Add new command to upload a directory (#2659)
This commit is contained in:
committed by
GitHub
parent
7c6d147ff7
commit
2ab144cff2
@@ -25,7 +25,7 @@ export const root = "" as Directory;
|
||||
* #: Invalid because it might have a use in the terminal in the future.
|
||||
* (quote marks): Invalid to avoid conflict with quote marks used in the terminal.
|
||||
* (whitespace): Invalid to avoid confusion with terminal command separator */
|
||||
const invalidCharacters = ["/", "*", "?", "[", "]", "!", "\\", "~", "|", "#", '"', "'"];
|
||||
export const invalidCharacters = ["/", "*", "?", "[", "]", "!", "\\", "~", "|", "#", '"', "'"] as const;
|
||||
|
||||
/** A valid character is any character that is not one of the invalid characters */
|
||||
export const oneValidCharacter = `[^${escapeRegExp(invalidCharacters.join(""))}\\s]`;
|
||||
|
||||
@@ -5,9 +5,11 @@ import { FilePath, resolveFilePath } from "./FilePath";
|
||||
type WithTextExtension = string & { __fileType: "Text" };
|
||||
export type TextFilePath = FilePath & WithTextExtension;
|
||||
|
||||
export const validTextExtensions = [".txt", ".json", ".css"];
|
||||
|
||||
/** Check extension only */
|
||||
export function hasTextExtension(path: string): path is WithTextExtension {
|
||||
return path.endsWith(".txt") || path.endsWith(".json") || path.endsWith(".css");
|
||||
return validTextExtensions.some((extension) => path.endsWith(extension));
|
||||
}
|
||||
|
||||
/** Sanitize a player input, resolve any relative paths, and for imports add the correct extension if missing */
|
||||
|
||||
@@ -14,6 +14,7 @@ export const TerminalHelpText: string[] = [
|
||||
" connect [hostname] Connects to a remote server",
|
||||
" cp [src] [dest] Copy a file",
|
||||
" download [script/text file] Downloads scripts or text files to your computer",
|
||||
" upload [dir] Upload scripts or text files from your computer",
|
||||
" expr [math expression] Evaluate a mathematical expression",
|
||||
" free Check the machine's memory (RAM) usage",
|
||||
" grep [opts]... pattern [file]... Search for PATTERN (string/regular expression) in each FILE and print results to terminal",
|
||||
@@ -216,6 +217,18 @@ export const HelpTexts: Record<string, string[]> = {
|
||||
"Download all text files: download *.txt",
|
||||
" ",
|
||||
],
|
||||
upload: [
|
||||
"Usage: upload [dir]",
|
||||
" ",
|
||||
"Uploads a directory from your computer into the game.",
|
||||
" ",
|
||||
"Examples:",
|
||||
" ",
|
||||
" upload path/to/dir",
|
||||
" ",
|
||||
" upload .",
|
||||
" ",
|
||||
],
|
||||
expr: [
|
||||
"Usage: expr [mathematical expression]",
|
||||
" ",
|
||||
|
||||
@@ -44,6 +44,7 @@ import { check } from "./commands/check";
|
||||
import { connect } from "./commands/connect";
|
||||
import { cp } from "./commands/cp";
|
||||
import { download } from "./commands/download";
|
||||
import { upload } from "./commands/upload";
|
||||
import { expr } from "./commands/expr";
|
||||
import { free } from "./commands/free";
|
||||
import { grep } from "./commands/grep";
|
||||
@@ -105,6 +106,7 @@ export const TerminalCommands: Record<string, (args: (string | number | boolean)
|
||||
connect: connect,
|
||||
cp: cp,
|
||||
download: download,
|
||||
upload: upload,
|
||||
expr: expr,
|
||||
free: free,
|
||||
grep: grep,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Terminal } from "../../Terminal";
|
||||
import type { BaseServer } from "../../Server/BaseServer";
|
||||
import { combinePath, isFilePath } from "../../Paths/FilePath";
|
||||
import { hasTextExtension, validTextExtensions } from "../../Paths/TextFilePath";
|
||||
import { hasScriptExtension, validScriptExtensions } from "../../Paths/ScriptFilePath";
|
||||
import { PromptEvent } from "../../ui/React/PromptManager";
|
||||
import type { ContentFilePath } from "../../Paths/ContentFile";
|
||||
import { invalidCharacters } from "../../Paths/Directory";
|
||||
import { pluralize } from "../../utils/I18nUtils";
|
||||
|
||||
function pickDirectory(): Promise<FileList | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.webkitdirectory = true;
|
||||
input.onchange = () => {
|
||||
resolve(input.files);
|
||||
};
|
||||
input.oncancel = () => {
|
||||
resolve(null);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirm(txt: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
PromptEvent.emit({
|
||||
txt,
|
||||
resolve: (value: string | boolean) => {
|
||||
if (typeof value === "string") {
|
||||
reject(new Error("PromptEvent got a string, expected boolean"));
|
||||
} else {
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAsync(args: (string | number | boolean)[], server: BaseServer) {
|
||||
if (args.length !== 1) {
|
||||
return Terminal.error("Incorrect usage of upload command. Usage: upload [dir]");
|
||||
}
|
||||
const destinationInput = String(args[0]);
|
||||
const destination = Terminal.getDirectory(destinationInput);
|
||||
if (destination === null) {
|
||||
return Terminal.error(`Could not resolve ${destinationInput} as a Directory`);
|
||||
}
|
||||
const files = await pickDirectory();
|
||||
if (files === null || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const withPath: (
|
||||
| { badPath: string }
|
||||
| { overwrite: ContentFilePath; file: File }
|
||||
| { create: ContentFilePath; file: File }
|
||||
)[] = [...files].map((f) => {
|
||||
const { webkitRelativePath } = f;
|
||||
/*
|
||||
If the player has a directory /home/alice/foo/bar on her computer
|
||||
and wants to upload the contents of the directory
|
||||
and if the directory hierarchy looks like this:
|
||||
/home/alice/foo/bar
|
||||
├── hello
|
||||
│ └── world.js
|
||||
└── more
|
||||
└── files.txt
|
||||
`webkitRelativePath` for world.js will be "bar/hello/world.js"
|
||||
`path` will be "hello/world.js"
|
||||
`webkitRelativePath` for files.txt will "bar/more/files.txt"
|
||||
`path` will be "more/files.txt"
|
||||
*/
|
||||
const path = webkitRelativePath.substring(1 + webkitRelativePath.indexOf("/"));
|
||||
if (!isFilePath(path)) {
|
||||
return { badPath: path };
|
||||
}
|
||||
const destFilePath = combinePath(destination, path);
|
||||
let fileExists: boolean;
|
||||
if (hasTextExtension(destFilePath)) {
|
||||
fileExists = server.textFiles.has(destFilePath);
|
||||
} else if (hasScriptExtension(destFilePath)) {
|
||||
fileExists = server.scripts.has(destFilePath);
|
||||
} else {
|
||||
return { badPath: path };
|
||||
}
|
||||
if (fileExists) {
|
||||
return {
|
||||
overwrite: destFilePath,
|
||||
file: f,
|
||||
};
|
||||
}
|
||||
return {
|
||||
create: destFilePath,
|
||||
file: f,
|
||||
};
|
||||
});
|
||||
const overwrite = withPath.filter((item) => "overwrite" in item);
|
||||
const skipped = withPath.filter((item) => "badPath" in item);
|
||||
const create = withPath.filter((item) => "create" in item);
|
||||
const destForPrint = destination === "" ? "/" : destination;
|
||||
const lines = [`Upload files to ${destForPrint}?`];
|
||||
if (overwrite.length !== 0) {
|
||||
lines.push(
|
||||
"",
|
||||
`${pluralize(overwrite.length, "file")} will be overwritten:`,
|
||||
...overwrite.map(({ overwrite }) => overwrite),
|
||||
);
|
||||
}
|
||||
if (skipped.length !== 0) {
|
||||
const extensions = [...validScriptExtensions, ...validTextExtensions];
|
||||
lines.push(
|
||||
"",
|
||||
`Characters ${invalidCharacters
|
||||
.filter((v) => v !== "/")
|
||||
.join(" ")} and whitespace are not allowed in file paths.`,
|
||||
`Only file extensions ${extensions.join(", ")} are allowed.`,
|
||||
"A file name must have at least one character before the extension.",
|
||||
"",
|
||||
`${pluralize(skipped.length, "file")} will be skipped due to prohibited file paths:`,
|
||||
...skipped.map(({ badPath }) => badPath),
|
||||
);
|
||||
}
|
||||
if (create.length !== 0) {
|
||||
lines.push("", `${pluralize(create.length, "new file")} will be created:`, ...create.map(({ create }) => create));
|
||||
}
|
||||
if (!(await askConfirm(lines.join("\n")))) {
|
||||
return;
|
||||
}
|
||||
Terminal.print(`Starting to upload files to ${destForPrint}`);
|
||||
for (const item of [...overwrite, ...create]) {
|
||||
const destFilePath = "create" in item ? item.create : item.overwrite;
|
||||
let text: string | undefined = undefined;
|
||||
try {
|
||||
text = await item.file.text();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Terminal.error(`Failed to upload ${destFilePath}. Error: ${error}`);
|
||||
continue;
|
||||
}
|
||||
server.writeToContentFile(destFilePath, text);
|
||||
}
|
||||
Terminal.print(`Successfully uploaded files to ${destForPrint}`);
|
||||
}
|
||||
|
||||
export function upload(args: (string | number | boolean)[], server: BaseServer): void {
|
||||
uploadAsync(args, server).catch((error) => {
|
||||
console.error(error);
|
||||
Terminal.error(`Error while uploading files. Error: ${error}`);
|
||||
});
|
||||
}
|
||||
@@ -185,6 +185,7 @@ export async function getTabCompletionPossibilities(terminalText: string, baseDi
|
||||
|
||||
case "cd":
|
||||
case "ls":
|
||||
case "upload":
|
||||
if (onFirstCommandArg && !relativeDir) addDirectories();
|
||||
return possibilities;
|
||||
|
||||
|
||||
@@ -178,9 +178,9 @@ describe("getTabCompletionPossibilities", function () {
|
||||
}
|
||||
});
|
||||
|
||||
it("completes the ls and cd commands", async () => {
|
||||
it("completes the ls, cd and upload commands", async () => {
|
||||
writeFiles();
|
||||
for (const command of ["ls", "cd"]) {
|
||||
for (const command of ["ls", "cd", "upload"]) {
|
||||
const options = await getTabCompletionPossibilities(`${command} `, root);
|
||||
expect(options.sort()).toEqual(["folder1/", "anotherFolder/"].sort());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user