CLI: Add new command to upload a directory (#2659)

This commit is contained in:
Andrey Andreyevich Bienkowski
2026-05-07 01:20:42 +03:00
committed by GitHub
parent 7c6d147ff7
commit 2ab144cff2
7 changed files with 173 additions and 4 deletions
+1 -1
View File
@@ -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]`;
+3 -1
View File
@@ -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 */
+13
View File
@@ -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]",
" ",
+2
View File
@@ -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,
+151
View File
@@ -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;
+2 -2
View File
@@ -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());
}