feat(core): the command line with its local commands after Java, measured against Java
The terminal splits a line as Java's window does and runs ls, cd, pwd, mkdir, touch, cp, mv, rm, cat, test, help, ipconfig and route on the node's files, which are the project's own. Its texts are generated from the bundle Java loads. TerminalCases feeds Java's Terminal without a network (16 cases, de and en); eight cases name Java bugs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
// FILIUS' command line (filius.software.lokal.Terminal, with the input
|
||||
// handling of GUIApplicationTerminalWindow): a line's first word is the
|
||||
// command, the others its parameters; the program tells its window texts,
|
||||
// in the project's language, as Java writes them. The files are the node's
|
||||
// (the snapshot's file system, the same the project keeps).
|
||||
//
|
||||
// Departures from Java (terminal.test.ts names each):
|
||||
// - a text file's size is its length in characters (Java decodes the text as
|
||||
// Base64 and counts the bytes: "Hallo Welt" is 6);
|
||||
// - a folder that is not there is "not there" for ls (Java: "empty"), and
|
||||
// ls / names the root (Java leaves the path out);
|
||||
// - cd to a file is "not there" (Java makes the file the working directory);
|
||||
// - touch and mkdir in a folder that is not there fail (Java says the file
|
||||
// was made, or the folder is there already); cp onto a folder's name
|
||||
// fails (Java throws);
|
||||
// - ".." at the root stays there (Java finds nothing, or throws);
|
||||
// - rm and mv take an absolute path as absolute (Java puts the working
|
||||
// directory in front of the path it removes); the root cannot be removed (Java removes the working
|
||||
// directory and says it removed the file); after the working directory
|
||||
// is removed, the nearest folder above it that is left is the working
|
||||
// directory (Java goes on in the removed one);
|
||||
// - mv onto the file itself, also in another case, renames it (Java copies
|
||||
// the file onto itself and removes it).
|
||||
|
||||
import type { Id } from '../../ids.js';
|
||||
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
|
||||
import { entryName, parentPath } from '../../model/virtualFileSystem.js';
|
||||
import type { NetworkRuntimeEngine } from '../engine.js';
|
||||
import { javaTrim } from '../http.js';
|
||||
import type { Steps } from '../threads.js';
|
||||
import { javaBase64 } from '../../util/javaBase64.js';
|
||||
import { CommandThread, ProgramLog } from './program.js';
|
||||
import type { PageLanguage } from './routerPages.js';
|
||||
import { TERMINAL_TEXTS, type TerminalTextKey } from './terminalTexts.js';
|
||||
|
||||
/** What the command line tells its window: a text, or whether a command runs on (Java's Boolean). */
|
||||
export type TerminalLogEntry = { readonly kind: 'text'; readonly text: string } | { readonly kind: 'busy'; readonly busy: boolean };
|
||||
|
||||
/** What the window does itself with a line (GUIApplicationTerminalWindow). */
|
||||
export type TerminalWindowAction = 'blank' | 'exit' | 'clear';
|
||||
|
||||
/** GUIApplicationTerminalWindow.MENU_LINE. */
|
||||
export const TERMINAL_MENU_LINE = '==========================================================================\n';
|
||||
|
||||
/** The usage texts' keys by command (Terminal.initUsageInfo). */
|
||||
const USAGE: Record<string, string> = {
|
||||
arp: 'arp',
|
||||
arpsend: 'arpsend',
|
||||
cat: 'cat',
|
||||
cd: 'cd',
|
||||
copy: 'copy',
|
||||
cp: 'copy',
|
||||
del: 'del',
|
||||
dir: 'dir',
|
||||
help: 'help',
|
||||
host: 'host',
|
||||
ipconfig: 'ipconfig',
|
||||
ls: 'dir',
|
||||
mkdir: 'mkdir',
|
||||
move: 'move',
|
||||
mv: 'move',
|
||||
netstat: 'netstat',
|
||||
nslookup: 'nslookup',
|
||||
ping: 'ping',
|
||||
pwd: 'pwd',
|
||||
rm: 'del',
|
||||
route: 'route',
|
||||
tcpdump: 'tcpdump',
|
||||
touch: 'touch',
|
||||
traceroute: 'traceroute',
|
||||
type: 'cat',
|
||||
};
|
||||
|
||||
/**
|
||||
* Dateisystem.evaluatePathString for an absolute path: "." and empty parts
|
||||
* left out, ".." one up (at the root it stays there).
|
||||
*/
|
||||
function evaluatedPath(path: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const part of path.split('/')) {
|
||||
if (part === '..') parts.pop();
|
||||
else if (part !== '.' && part !== '') parts.push(part);
|
||||
}
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
/** A path's last part (Dateisystem.getBasename). */
|
||||
const basenameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1);
|
||||
|
||||
export class Terminal {
|
||||
readonly log: ProgramLog<TerminalLogEntry>;
|
||||
private directoryValue = '/';
|
||||
/** The program's thread (Anwendung.ausfuehren): one command after the other. */
|
||||
private readonly commands: CommandThread;
|
||||
|
||||
constructor(
|
||||
private readonly engine: NetworkRuntimeEngine,
|
||||
private readonly nodeId: Id,
|
||||
private readonly files: VirtualFileSystem,
|
||||
) {
|
||||
this.log = new ProgramLog(engine);
|
||||
this.commands = new CommandThread(engine);
|
||||
}
|
||||
|
||||
private get language(): PageLanguage {
|
||||
return this.engine.state.topologySnapshot.language;
|
||||
}
|
||||
|
||||
private text(key: TerminalTextKey): string {
|
||||
return TERMINAL_TEXTS[this.language][key];
|
||||
}
|
||||
|
||||
/** The working directory, as Dateisystem.absoluterPfad writes it. */
|
||||
get directory(): string {
|
||||
return this.directoryValue;
|
||||
}
|
||||
|
||||
/** The window's prompt. */
|
||||
get prompt(): string {
|
||||
return `${this.directoryValue}> `;
|
||||
}
|
||||
|
||||
/** GUIApplicationTerminalWindow.showStartScreen. */
|
||||
get startScreen(): string {
|
||||
return `${this.text('sw_terminal_msg57')}\n${TERMINAL_MENU_LINE}${this.text('sw_terminal_msg26')}\n${TERMINAL_MENU_LINE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A line typed into the window: blank, exit, cls and reset are the
|
||||
* window's; any other line is the program's command, run by its thread.
|
||||
*/
|
||||
enter(line: string): TerminalWindowAction | undefined {
|
||||
const words = line.split(' ').filter((word) => word !== '');
|
||||
if (javaTrim(line) === '' || words.length === 0) return 'blank';
|
||||
const [command, ...parameters] = words as [string, ...string[]];
|
||||
if (command === 'exit') return 'exit';
|
||||
if (command === 'reset' || command === 'cls') return 'clear';
|
||||
this.commands.hand(() => this.run(command, parameters));
|
||||
this.engine.resumeThreads();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private tell(text: string): void {
|
||||
this.log.write({ kind: 'text', text });
|
||||
}
|
||||
|
||||
/** Terminal.terminalEingabeAuswerten: the method of the command's name, if Java has one. */
|
||||
private *run(command: string, parameters: readonly string[]): Steps {
|
||||
const local: Record<string, (parameters: readonly string[]) => void> = {
|
||||
cat: (each) => this.cat(each),
|
||||
cd: (each) => this.cd(each),
|
||||
copy: (each) => this.copy(each, 'cp'),
|
||||
cp: (each) => this.copy(each, 'cp'),
|
||||
del: (each) => this.remove(each),
|
||||
dir: (each) => this.list(each),
|
||||
help: (each) => this.usage(each[0] ?? ''),
|
||||
ipconfig: (each) => this.ipconfig(each),
|
||||
ls: (each) => this.list(each),
|
||||
mkdir: (each) => this.makeDirectory(each),
|
||||
move: (each) => this.move(each),
|
||||
mv: (each) => this.move(each),
|
||||
pwd: (each) => (each.length === 0 ? this.tell(this.directoryValue) : this.usage('pwd')),
|
||||
rm: (each) => this.remove(each),
|
||||
route: (each) => this.route(each),
|
||||
test: () => this.test(),
|
||||
touch: (each) => this.touch(each),
|
||||
type: (each) => this.cat(each),
|
||||
};
|
||||
const handler = Object.hasOwn(local, command) ? local[command] : undefined;
|
||||
if (handler === undefined) {
|
||||
this.tell(`${this.text('terminal_msg2')}\n${this.text('terminal_msg3')}`);
|
||||
return;
|
||||
}
|
||||
handler(parameters);
|
||||
}
|
||||
|
||||
/** Terminal.usage: the command's usage, or the list of commands. */
|
||||
private usage(command: string): void {
|
||||
const key = Object.hasOwn(USAGE, command) ? USAGE[command] : undefined;
|
||||
if (key === undefined) {
|
||||
this.tell(this.text('sw_terminal_msg25'));
|
||||
return;
|
||||
}
|
||||
const texts = TERMINAL_TEXTS[this.language] as Record<string, string>;
|
||||
this.tell(`${texts[`sw_terminal_${key}`]}\n${texts[`sw_terminal_usage_${key}`]}\n`);
|
||||
}
|
||||
|
||||
/** A parameter as an absolute path: from the root, or from the working directory. */
|
||||
private absolute(path: string): string {
|
||||
return evaluatedPath(path.startsWith('/') ? path : `${this.directoryValue}/${path}`);
|
||||
}
|
||||
|
||||
/** The content at a path, found in any case; undefined when there is none. */
|
||||
private content(path: string): { readonly path: string; readonly content: VirtualFileContent } | undefined {
|
||||
try {
|
||||
return this.files.entry(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Datei.holeGroesse, a text in characters (see the departures above). */
|
||||
private static size(content: VirtualFileContent): number {
|
||||
return content.kind === 'text' ? content.text.length : content.kind === 'directory' ? 0 : content.data.length;
|
||||
}
|
||||
|
||||
/** Terminal.dir. */
|
||||
private list(parameters: readonly string[]): void {
|
||||
if (parameters.length > 1) return this.usage('dir');
|
||||
const shown = parameters.length === 0 ? this.directoryValue : this.absolute(parameters[0]!);
|
||||
const found = this.content(shown);
|
||||
if (found === undefined || found.content.kind !== 'directory') return this.tell(this.text('sw_terminal_msg20'));
|
||||
const entries = this.files.entriesInCreationOrder(found.path);
|
||||
if (entries.length === 0) return this.tell(this.text('sw_terminal_msg8'));
|
||||
let text = `${this.text('sw_terminal_msg9')} ${shown}:`;
|
||||
let directories = 0;
|
||||
let files = 0;
|
||||
for (const entry of entries) {
|
||||
const name = entryName(entry.path);
|
||||
if (entry.content.kind === 'directory') {
|
||||
directories += 1;
|
||||
text += `\n[${name}]`;
|
||||
} else {
|
||||
files += 1;
|
||||
text += `\n${name}${'.'.repeat(Math.max(0, 40 - name.length))}${Terminal.size(entry.content)}`;
|
||||
}
|
||||
}
|
||||
this.tell(`${text}\n${this.text('sw_terminal_msg10')}${directories}${this.text('sw_terminal_msg11')}${files}`);
|
||||
}
|
||||
|
||||
/** Terminal.cd. */
|
||||
private cd(parameters: readonly string[]): void {
|
||||
if (parameters.length > 1) return this.usage('cd');
|
||||
if (parameters.length === 0) return this.tell(this.directoryValue);
|
||||
const found = this.content(this.absolute(parameters[0]!));
|
||||
if (found === undefined || found.content.kind !== 'directory') return this.tell(this.text('sw_terminal_msg20'));
|
||||
this.directoryValue = found.path;
|
||||
this.tell('');
|
||||
}
|
||||
|
||||
/** A new item at a path (touch, mkdir): the parent must be a folder, the name new. */
|
||||
private create(parameters: readonly string[], command: 'touch' | 'mkdir', make: (path: string) => void): void {
|
||||
if (parameters.length !== 1) return this.usage(command);
|
||||
const keys =
|
||||
command === 'touch'
|
||||
? ({ noName: 'sw_terminal_msg15', exists: 'sw_terminal_msg14', made: 'sw_terminal_msg13', failed: 'sw_terminal_msg12' } as const)
|
||||
: ({ noName: 'sw_terminal_msg19', exists: 'sw_terminal_msg18', made: 'sw_terminal_msg17', failed: 'sw_terminal_msg16' } as const);
|
||||
const path = this.absolute(parameters[0]!);
|
||||
if (basenameOf(path) === '') return this.tell(this.text(keys.noName));
|
||||
if (this.content(path) !== undefined) return this.tell(this.text(keys.exists));
|
||||
// The file system takes nothing in a folder that is not there (or is a file).
|
||||
try {
|
||||
make(path);
|
||||
this.tell(this.text(keys.made));
|
||||
} catch {
|
||||
this.tell(this.text(keys.failed));
|
||||
}
|
||||
}
|
||||
|
||||
private touch(parameters: readonly string[]): void {
|
||||
this.create(parameters, 'touch', (path) => this.files.writeTextFile(path, '', false));
|
||||
}
|
||||
|
||||
private makeDirectory(parameters: readonly string[]): void {
|
||||
this.create(parameters, 'mkdir', (path) => this.files.createDirectory(path));
|
||||
}
|
||||
|
||||
/** Writes content as a new file or over a file of that name (Dateisystem.speicherDatei). */
|
||||
private write(path: string, content: VirtualFileContent): void {
|
||||
if (content.kind === 'text') this.files.writeTextFile(path, content.text);
|
||||
else if (content.kind === 'image') this.files.writeImageFile(path, content.data, content.mediaType);
|
||||
else if (content.kind === 'binary') this.files.writeBinaryFile(path, content.data, content.mediaType);
|
||||
}
|
||||
|
||||
/** Terminal.pureCopy: a file to a new name or over a file; false where it cannot. */
|
||||
private copied(parameters: readonly string[]): { readonly source: string; readonly destination: string } | undefined {
|
||||
const source = this.content(this.absolute(parameters[0]!));
|
||||
const destination = this.absolute(parameters[1]!);
|
||||
if (source === undefined || source.content.kind === 'directory' || basenameOf(destination) === '') return undefined;
|
||||
// The file system writes over a file only, in a folder that is there; onto the file itself it changes nothing.
|
||||
try {
|
||||
this.write(destination, source.content);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return { source: source.path, destination };
|
||||
}
|
||||
|
||||
private copy(parameters: readonly string[], command: 'cp'): void {
|
||||
if (parameters.length !== 2) return this.usage(command);
|
||||
this.tell(this.text(this.copied(parameters) === undefined ? 'sw_terminal_msg34' : 'sw_terminal_msg33'));
|
||||
}
|
||||
|
||||
/** Terminal.mv: copied, then the source removed; onto the file itself, renamed. */
|
||||
private move(parameters: readonly string[]): void {
|
||||
if (parameters.length !== 2) return this.usage('mv');
|
||||
const done = this.copied(parameters);
|
||||
if (done === undefined) return this.tell(this.text('sw_terminal_msg36'));
|
||||
if (done.source.toLowerCase() === done.destination.toLowerCase()) {
|
||||
const content = this.files.entry(done.source).content;
|
||||
this.files.deleteItem(done.source);
|
||||
this.write(done.destination, content);
|
||||
} else {
|
||||
this.files.deleteItem(done.source, true);
|
||||
}
|
||||
this.tell(this.text('sw_terminal_msg35'));
|
||||
}
|
||||
|
||||
/** Terminal.del: a file or a folder with all in it. */
|
||||
private remove(parameters: readonly string[]): void {
|
||||
if (parameters.length !== 1) return this.usage('del');
|
||||
const found = this.content(this.absolute(parameters[0]!));
|
||||
if (found === undefined || found.path === '/') return this.tell(this.text('sw_terminal_msg38'));
|
||||
this.files.deleteItem(found.path, true);
|
||||
while (this.content(this.directoryValue) === undefined) this.directoryValue = parentPath(this.directoryValue);
|
||||
this.tell(this.text('sw_terminal_msg37'));
|
||||
}
|
||||
|
||||
/** Terminal.cat: a file's content as Java keeps it (an image or binary file in Base64). */
|
||||
private cat(parameters: readonly string[]): void {
|
||||
if (parameters.length === 0) {
|
||||
this.usage('cat');
|
||||
this.tell('');
|
||||
return;
|
||||
}
|
||||
const found = this.content(this.absolute(parameters[0]!));
|
||||
if (found === undefined || found.content.kind === 'directory') return this.tell(this.text('sw_terminal_msg54'));
|
||||
this.tell(found.content.kind === 'text' ? found.content.text : javaBase64(found.content.data));
|
||||
}
|
||||
|
||||
/** Terminal.test: test.txt in the working directory. */
|
||||
private test(): void {
|
||||
try {
|
||||
this.files.writeTextFile(`${this.directoryValue === '/' ? '' : this.directoryValue}/test.txt`, 'blaaa');
|
||||
this.tell(this.text('sw_terminal_msg24'));
|
||||
} catch {
|
||||
this.tell(this.text('sw_terminal_msg23'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal.ipconfig: the node's first address, mask, MAC, gateway and DNS server. */
|
||||
private ipconfig(parameters: readonly string[]): void {
|
||||
if (parameters.length !== 0) return this.usage('ipconfig');
|
||||
const configuration = this.engine.state.topologySnapshot.deviceConfigurations.get(this.nodeId);
|
||||
const card = this.engine.networkInterfaces(this.nodeId)[0];
|
||||
this.tell(
|
||||
`${this.text('sw_terminal_msg4')} ${card?.ipAddress ?? configuration?.ipAddress ?? ''}\n` +
|
||||
`${this.text('sw_terminal_msg5')} ${card?.subnetMask ?? configuration?.subnetMask ?? ''}\n` +
|
||||
`${this.text('sw_terminal_msg6666')} ${card?.macAddress ?? ''}\n` +
|
||||
`${this.text('sw_terminal_msg6')} ${configuration?.defaultGateway ?? ''}\n` +
|
||||
`${this.text('sw_terminal_msg27')} ${configuration?.dnsServer ?? ''}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Terminal.route: the forwarding table, each cell padded to 15. */
|
||||
private route(parameters: readonly string[]): void {
|
||||
if (parameters.length !== 0) return this.usage('route');
|
||||
const rows = this.engine.forwardingTable(this.nodeId);
|
||||
const cell = (text: string) => `${text}${' '.repeat(Math.max(0, 15 - text.length))} | `;
|
||||
const table = rows.map((row) => `| ${[row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress].map(cell).join('')}\n`).join('');
|
||||
this.tell(this.text('sw_terminal_msg7') + table);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user