@the-stations-project/sdk
Version:
SDK for developing Station Services.
439 lines (341 loc) • 11.4 kB
text/typescript
import Child from "child_process";
import Fs from "fs/promises";
import Path from "path";
/* BASIC */
export enum ExitCodes {
Ok = 0,
ErrUnknown = 1,
ErrNoCommand = 2,
ErrMissingParameter = 3,
}
export class Result<C, V> {
code: C;
value: V;
private initial_code: C;
private initial_value: V;
log_message: undefined | (() => string);
panic_message: () => string = () => "Unknown: invalid result.";
constructor(code: C, value: V) {
this.initial_code = this.code = code;
this.initial_value = this.value = value;
}
err(cb: (result: Result<C, V>) => any) {
if (this.has_failed) {
cb(this);
}
return this;
}
ok(cb: (result: Result<C, V>) => any) {
if (!this.has_failed) {
cb(this);
}
return this;
}
or_panic(msg?: string) {
if (this.has_failed) {
console.trace(this);
const message = msg ?? this.panic_message();
log("PANIC", message);
throw message;
}
return this;
}
or_log_error(msg?: string) {
if (this.has_failed) {
log("ERROR", msg ?? this.panic_message());
}
return this;
}
to_string(): string {
return `${this.code}|${this.value}`;
}
finalize(code: C, value: V) {
this.code = code;
this.value = value;
if (this.log_message) log("ACTIVITY", this.log_message());
return this;
}
finalize_with_value(value: V) {
this.value = value;
return this.finalize(this.code, value);
}
finalize_with_code(code: C) {
this.code = code;
return this.finalize(code, this.value);
}
revert() {
return this.finalize(this.initial_code, this.initial_value);
}
get has_failed(): boolean {
return this.code > 0;
}
}
export function contains_undefined_arguments(args: IArguments) {
return [...args].filter(x => x == undefined).length != 0;
}
/* CLI */
export async function start_module(main: (subcommand: string, args: string[]) => Promise<Result<any, any>>, cb: (result: Result<any, any>) => void) {
const args = process.argv;
//remove first two args
args.splice(0, 2);
//get subcommand
const subcommand = args.splice(0, 1)[0];
//run
const result = await main(subcommand, args);
cb(result);
}
/* LOGS */
export type LogType = "ACTIVITY" | "ERROR" | "PANIC" | "OTHER" | "STATUS";
const LOG_DIR = "logs/current";
export async function log(type: LogType, msg: string) {
msg = `TYPE ${type}\nPID ${process.pid}\n${msg}\n\n`;
/* get filename */
const timestamp = new Date().toISOString();
const filename = `log-${timestamp}`;
const path = Path.join(LOG_DIR, filename);
(await Registry.append(path, msg)).or_panic("failed to log");
}
/* REGISTRY */
export enum RegistryExitCodes {
OkUnchanged = -1,
Ok = 0,
ErrUnknown = 1,
ErrRead = 2,
ErrWrite = 3,
ErrDel = 4,
}
class RegistryResult<T> extends Result<RegistryExitCodes, T|undefined> {
constructor() {
super(RegistryExitCodes.ErrUnknown, undefined);
}
}
export const Registry = {
base_path: "registry",
get_full_path: (path: string) => Registry.join_paths(Registry.base_path, path),
get_panic_message: (msg: string) => `Registry: ${msg}.`,
join_paths(...args: string[]): string {
return Path.join(...args
.filter(x => x)
.map(x => x.split("/"))
.flat()
);
},
async mkdir(path: string): Promise<RegistryResult<undefined>> {
const full_path = Registry.get_full_path(path);
const result = new RegistryResult<undefined>();
result.panic_message = () => Registry.get_panic_message(`failed to create directory "${path}"`);
try {
await Fs.mkdir(full_path, { recursive: true });
result.finalize_with_code(RegistryExitCodes.Ok);
} catch {
(await Registry.test(full_path))
.ok(() => result.finalize_with_code(RegistryExitCodes.OkUnchanged))
.err(() => result.finalize_with_code(RegistryExitCodes.ErrUnknown));
}
return result;
},
async write(path: string, content: string): Promise<RegistryResult<undefined>> {
const full_path = Registry.get_full_path(path);
const result = new RegistryResult<undefined>();
result.panic_message = () => Registry.get_panic_message(`failed to write file "${path}"`);
try {
await Fs.writeFile(full_path, content);
result.code = RegistryExitCodes.Ok;
} catch {
result.code = RegistryExitCodes.ErrWrite;
}
return result;
},
async append(path: string, content: string): Promise<RegistryResult<undefined>> {
const full_path = Registry.get_full_path(path);
const result = new RegistryResult<undefined>();
result.panic_message = () => Registry.get_panic_message(`failed to append to file "${path}"`);
try {
await Fs.appendFile(full_path, content);
result.finalize_with_code(RegistryExitCodes.Ok);
} catch {
result.finalize_with_code(RegistryExitCodes.ErrWrite);
}
return result;
},
async read(path: string): Promise<RegistryResult<string|undefined>> {
const full_path = Registry.get_full_path(path);
const result: RegistryResult<string|undefined> = new RegistryResult<undefined>();
result.panic_message = () => Registry.get_panic_message(`failed to read file "${path}"`);
try {
const text = await Fs.readFile(full_path, { encoding: "utf8" });
result.code = RegistryExitCodes.Ok;
result.value = text;
} catch {
result.code = RegistryExitCodes.ErrRead;
}
return result;
},
async ls(path: string): Promise<RegistryResult<string[]|undefined>> {
const full_path = Registry.get_full_path(path);
const result = new RegistryResult<string[]|undefined>();
result.panic_message = () => Registry.get_panic_message(`failed to read directory "${path}"`);
try {
const items = await Fs.readdir(full_path);
result.finalize(RegistryExitCodes.Ok, items);
} catch {
result.finalize_with_code(RegistryExitCodes.ErrRead);
}
return result;
},
async delete(path: string): Promise<RegistryResult<undefined>> {
const full_path = Registry.get_full_path(path);
const result = new RegistryResult<undefined>();
result.panic_message = () => Registry.get_panic_message(`failed to delete item "${path}"`);
try {
await Fs.rm(full_path, { recursive: true });
result.finalize_with_code(RegistryExitCodes.Ok);
} catch {
result.finalize_with_code(RegistryExitCodes.ErrDel);
}
return result;
},
async read_or_create(path: string, default_value: string): Promise<RegistryResult<string|undefined>> {
const read_result = await Registry.read(path);
if (!read_result.has_failed) return read_result;
const write_result = await Registry.write(path, default_value);
return write_result;
},
async test(path: string): Promise<RegistryResult<undefined>> {
const full_path = Registry.get_full_path(path);
const result = new RegistryResult<undefined>();
try {
await Fs.stat(full_path);
result.finalize_with_code(RegistryExitCodes.Ok);
} catch {}
return result;
},
async copy(src: string, dest: string): Promise<RegistryResult<undefined>> {
const full_src = Registry.get_full_path(src);
const full_dest = Registry.get_full_path(dest);
const result = new RegistryResult<undefined>();
try {
await Fs.cp(full_src, full_dest, { recursive: true });
result.finalize_with_code(RegistryExitCodes.Ok);
} catch {}
return result;
},
async move(src: string, dest: string): Promise<RegistryResult<undefined>> {
const full_src = Registry.get_full_path(src);
const full_dest = Registry.get_full_path(dest);
const result = new RegistryResult<undefined>();
try {
await Fs.rename(full_src, full_dest);
result.finalize_with_code(RegistryExitCodes.Ok);
} catch {}
return result;
},
}
/* MEMORY */
export const Memory = {
base_path: "tmp",
get_full_path: (path: string) => Registry.join_paths(Memory.base_path, path),
async init(): Promise<RegistryResult<undefined>> {
await Registry.delete(Memory.base_path);
return await Registry.mkdir(Memory.base_path);
},
mkdir: async (path: string) => await Registry.mkdir(Memory.get_full_path(path)),
ls: async (path: string) => await Registry.read(Memory.get_full_path(path)),
remember: async (path: string, content: string) => await Registry.write(Memory.get_full_path(path), content),
recall: async (path: string) => await Registry.read(Memory.get_full_path(path)),
forget: async (path: string) => await Registry.delete(Memory.get_full_path(path)),
}
/* SHELL */
class ShellResult extends Result<ExitCodes, Child.ChildProcess|undefined> {
cmd: string = "";
panic_message = () => `Shell: an error occured while trying to run "${this.cmd}".`;
constructor(cmd: string) {
super(ExitCodes.ErrUnknown, undefined);
this.cmd = cmd;
}
}
const PROCESS_TRACKING_DIR = "processes"
export const Shell = {
async exec(station_command: string): Promise<ShellResult> {
let result = new ShellResult(station_command);
//remove leading whitespaces
station_command = station_command.replace(/^ /g, "");
const separator_index = station_command.indexOf(" ");
let module, args;
if (separator_index > -1) {
module = station_command.substring(0, separator_index);
args = station_command.substring(separator_index);
} else {
module = station_command;
}
/* get module command */
const cmd_result = await Registry.read(Path.join("modules", module));
if (cmd_result.has_failed) {
log("ERROR", `Shell: failed to get command for "${module}".`);
return result;
}
/* process args */
args = (args ?? "")
.replace(/--/g, "\\ ");
/* get full command */
const module_cmd = cmd_result.value!.split("\n")[0];
const sys_cmd = `${module_cmd}${args}`;
/* spawn process */
const cp = Child.spawn(sys_cmd, {
shell: true,
detached: true,
});
/* track */
Shell.track(sys_cmd, cp);
result.code = ExitCodes.Ok;
result.value = cp;
return result;
},
exec_sync(station_command: string): Promise<Result<ExitCodes, string>> {
return new Promise(async (res) => {
const result = new Result(ExitCodes.Ok, "");
/* execute */
const shell_result = (await Shell.exec(station_command)).or_log_error();
if (shell_result.has_failed) return res(result.finalize_with_code(ExitCodes.ErrUnknown));
let stdout = "";
const cp = shell_result.value!;
cp.stdout?.on("data", (data) => stdout += data.toString());
cp.on("exit", () => res(result.finalize_with_value(stdout)));
});
},
async track(cmd: string, cp: Child.ChildProcess) {
const pid = cp.pid;
/* safety */
if (pid == undefined) {
cp.kill();
return;
}
const path = Path.join(PROCESS_TRACKING_DIR, pid.toString());
const abort = async (type: LogType) => {
if (cp.killed == false) cp.kill();
log(type, `Shell: ${pid} dead`);
(await Memory.forget(path)).or_log_error();
}
/* create tracking directory if needed */
(await Memory.mkdir(PROCESS_TRACKING_DIR))
.err(() => abort("ERROR"));
/* track process */
(await Memory.remember(path, ""))
.err(() => abort("ERROR"))
.ok(() => log("ACTIVITY", `Shell: started tracking process using module "${cmd.split(" ")[1]}" (${pid})`));
/* handle killing */
cp.on("exit", () => abort("STATUS"));
//in case it already died
if (cp.exitCode != null) abort("STATUS");
},
async kill(pid: number) {
//check if process exists
if ((await Memory.recall(Path.join(PROCESS_TRACKING_DIR, pid.toString()))).has_failed) return;
process.kill(pid);
},
parse_output(output: string) {
let [code, value] = output.split("|");
value = value.split("\n")[0];
return [code, value];
}
}