From 2ab144cff2d967fa2c10fdfdd2a0b02910f03aa5 Mon Sep 17 00:00:00 2001 From: Andrey Andreyevich Bienkowski Date: Thu, 7 May 2026 01:20:42 +0300 Subject: [PATCH] CLI: Add new command to upload a directory (#2659) --- src/Paths/Directory.ts | 2 +- src/Paths/TextFilePath.ts | 4 +- src/Terminal/HelpText.ts | 13 ++ src/Terminal/Terminal.ts | 2 + src/Terminal/commands/upload.ts | 151 ++++++++++++++++++ src/Terminal/getTabCompletionPossibilities.ts | 1 + test/jest/Terminal/tabCompletion.test.ts | 4 +- 7 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 src/Terminal/commands/upload.ts diff --git a/src/Paths/Directory.ts b/src/Paths/Directory.ts index 510f56956..692fe4f38 100644 --- a/src/Paths/Directory.ts +++ b/src/Paths/Directory.ts @@ -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]`; diff --git a/src/Paths/TextFilePath.ts b/src/Paths/TextFilePath.ts index 4127838aa..dbf76a13b 100644 --- a/src/Paths/TextFilePath.ts +++ b/src/Paths/TextFilePath.ts @@ -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 */ diff --git a/src/Terminal/HelpText.ts b/src/Terminal/HelpText.ts index 3ef23c24e..f8fa4f249 100644 --- a/src/Terminal/HelpText.ts +++ b/src/Terminal/HelpText.ts @@ -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 = { "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]", " ", diff --git a/src/Terminal/Terminal.ts b/src/Terminal/Terminal.ts index a3225a9dd..ce5a11a33 100644 --- a/src/Terminal/Terminal.ts +++ b/src/Terminal/Terminal.ts @@ -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 { + 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 { + 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}`); + }); +} diff --git a/src/Terminal/getTabCompletionPossibilities.ts b/src/Terminal/getTabCompletionPossibilities.ts index b699599cd..781c1e247 100644 --- a/src/Terminal/getTabCompletionPossibilities.ts +++ b/src/Terminal/getTabCompletionPossibilities.ts @@ -185,6 +185,7 @@ export async function getTabCompletionPossibilities(terminalText: string, baseDi case "cd": case "ls": + case "upload": if (onFirstCommandArg && !relativeDir) addDirectories(); return possibilities; diff --git a/test/jest/Terminal/tabCompletion.test.ts b/test/jest/Terminal/tabCompletion.test.ts index 8498ef4e3..e2e6677a7 100644 --- a/test/jest/Terminal/tabCompletion.test.ts +++ b/test/jest/Terminal/tabCompletion.test.ts @@ -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()); }