Compare commits

..

7 Commits

25 changed files with 4709 additions and 4479 deletions

View File

@@ -5,6 +5,7 @@ A collection of advanced utilities and libraries for Minecraft ComputerCraft, wr
## Features
### 1. Access Control System
A comprehensive system for managing player access to a specific area. It uses a `playerDetector` to monitor for players in range and a `chatBox` to interact with them and administrators.
- **Player Detection:** Monitors a configurable range for players.
@@ -15,6 +16,7 @@ A comprehensive system for managing player access to a specific area. It uses a
- **Logging:** Detailed logging of events, viewable with the included `logviewer` program.
### 2. AutoCraft System
An automated crafting solution designed to work with the Create mod's packaged recipes.
- **Automated Crafting:** Detects cardboard packages in a chest and automatically crafts the recipes they contain.
@@ -22,6 +24,7 @@ An automated crafting solution designed to work with the Create mod's packaged r
- **Inventory Management:** Manages pulling ingredients from a source inventory and pushing crafted items to a destination.
### 3. ccTUI Framework
A declarative, reactive TUI (Terminal User Interface) framework inspired by [SolidJS](https://www.solidjs.com/) for building complex and interactive interfaces in ComputerCraft.
- **Declarative Syntax:** Build UIs with simple, composable functions like `div`, `label`, `button`, and `input`.
@@ -31,6 +34,7 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
- **Component-Based:** Structure your UI into reusable components. See `src/tuiExample/main.ts` for a demo.
### 4. ccCLI Framework
A lightweight, functional-style framework for building command-line interfaces (CLIs) within CC:Tweaked. It supports nested commands, arguments, options, and automatic help generation. See the [ccCLI Documentation](./docs/ccCLI.md) for more details.
- **Declarative API:** Define commands, arguments, and options using a simple, object-based structure.
@@ -40,6 +44,7 @@ A lightweight, functional-style framework for building command-line interfaces (
- **Type-Safe:** Built with TypeScript for robust development.
### 5. Core Libraries
- **`ChatManager`:** A powerful manager for `chatBox` peripherals that handles message queuing, cooldowns, and asynchronous sending/receiving. See the [ChatManager Documentation](./docs/ChatManager.md) for more details.
- **`ccStructLog`:** A modern, structured logging library inspired by Python's `structlog`. It provides a flexible, extensible framework based on processors, renderers, and streams, designed for CC:Tweaked. See the [ccStructLog Documentation](./docs/ccStructLog.md) for more details.
- **`PeripheralManager`:** A utility for easily finding and requiring peripherals by name or type.
@@ -54,6 +59,7 @@ A lightweight, functional-style framework for building command-line interfaces (
## Setup & Installation
1. **Clone the repository:**
```sh
git clone <repository-url>
cd cc-utils
@@ -69,6 +75,7 @@ A lightweight, functional-style framework for building command-line interfaces (
This project uses `just` to manage build tasks. The compiled Lua files will be placed in the `build/` directory.
- **Build all modules:**
```sh
just build
```
@@ -105,44 +112,30 @@ To deploy the built programs to your in-game computer, you need to configure the
### Access Control
- **Start the system:**
```sh
accesscontrol start
```
- **Open the configuration TUI:**
```sh
accesscontrol config
```
Alternatively, press `c` while the main program is running.
- **View logs:**
```sh
logviewer accesscontrol.log
```
Alternatively, press `c` while the main program is running.
- **Admin Commands (in-game chat):**
```
@AC /help
@AC /add user Notch
@AC /list
@AC help
@AC add user Notch
@AC list
```
### AutoCraft
The autocraft program runs in the background. Simply run it on a turtle with the correct peripheral setup (see `src/autocraft/main.ts`). It will automatically process packages placed in the designated chest.
```sh
autocraft
```
### TUI Example
Run the example program to see a demonstration of the `ccTUI` framework.
```sh
tuiExample
```
## Development
- **Lint and format the code:**

View File

@@ -11,16 +11,37 @@ A modern, structured logging library for CC:Tweaked, inspired by Python's struct
## Quick Start
```typescript
import { createDevLogger } from "@/lib/ccStructLog";
The easiest way to get started is to create a `Logger` instance and configure it with processors, a renderer, and streams.
// Create a development logger
const logger = createDevLogger();
Here's a simple example of a logger that prints colored, human-readable messages to the console:
```typescript
import {
Logger,
LogLevel,
processor,
textRenderer,
ConsoleStream,
} from "@/lib/ccStructLog";
// Create a logger
const logger = new Logger({
processors: [
processor.addTimestamp({ format: "%T" }), // Add HH:MM:SS timestamp
processor.filterByLevel(LogLevel.Info), // Log Info and higher
processor.addSource("MyApp"),
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
// Log messages with context
logger.info("Server started", { port: 8080, version: "1.0.0" });
logger.warn("Low disk space", { available: 1024, threshold: 2048 });
logger.error("Connection failed", { host: "example.com", retries: 3 });
// This debug message will be filtered out by `filterByLevel`
logger.debug("This is a debug message.");
```
## Core Concepts
@@ -46,48 +67,69 @@ export enum LogLevel {
4. **Render**: The final event is converted to a string by a renderer (e.g., `textRenderer`, `jsonRenderer`).
5. **Output**: The string is sent to one or more streams (e.g., console, file).
## Pre-configured Loggers
## Common Configurations
### Development Logger
Optimized for development and debugging with human-readable console output.
A typical development logger is configured for human-readable console output with timestamps and colors.
```typescript
import { createDevLogger, LogLevel } from "@/lib/ccStructLog";
import {
Logger,
processor,
textRenderer,
ConsoleStream,
} from "@/lib/ccStructLog";
const logger = createDevLogger({
source: "MyApp",
includeComputerId: true,
const devLogger = new Logger({
processors: [
processor.addTimestamp({ format: "%F %T" }), // YYYY-MM-DD HH:MM:SS
processor.addSource("DevApp"),
processor.addComputerId(),
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
logger.debug("This is a debug message.");
devLogger.debug("This is a debug message.", { user: "dev" });
```
### Production Logger
Optimized for production with JSON-formatted file output and daily rotation.
```typescript
import { createProdLogger, DAY } from "@/lib/ccStructLog";
const logger = createProdLogger("app.log", {
source: "MyApp",
rotationInterval: DAY, // Rotate daily
includeConsole: false, // Don't log to console
});
logger.info("Application is running in production.");
```
## Custom Configuration
You can create a logger with a completely custom setup.
A production logger is often configured to write machine-readable JSON logs to a file with daily rotation.
```typescript
import {
Logger,
LogLevel,
addFullTimestamp,
addComputerId,
addSource,
processor,
jsonRenderer,
FileStream,
DAY,
} from "@/lib/ccStructLog";
const prodLogger = new Logger({
processors: [
processor.addTimestamp(), // Default format is %F %T
processor.filterByLevel(LogLevel.Info),
processor.addSource("ProdApp"),
processor.addComputerId(),
],
renderer: jsonRenderer,
streams: [
new FileStream("app.log", DAY), // Rotate daily
],
});
prodLogger.info("Application is running in production.");
```
## Custom Configuration
You can create a logger with any combination of processors, renderers, and streams.
```typescript
import {
Logger,
processor,
jsonRenderer,
FileStream,
ConsoleStream,
@@ -96,9 +138,9 @@ import {
const logger = new Logger({
processors: [
addFullTimestamp(),
addComputerId(),
addSource("MyApplication"),
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("MyApplication"),
],
renderer: jsonRenderer,
streams: [
@@ -112,31 +154,40 @@ logger.info("Custom logger reporting for duty.", { user: "admin" });
## Processors
Processors are functions that modify, enrich, or filter log events before they are rendered.
Processors are functions that modify, enrich, or filter log events before they are rendered. They are all available under the `processor` namespace.
### Built-in Processors
```typescript
import {
addTimestamp, // Add structured timestamp
addFormattedTimestamp, // Add HH:MM:SS string
addFullTimestamp, // Add YYYY-MM-DD HH:MM:SS string
filterByLevel, // Filter by minimum level
filterBy, // Filter based on a custom predicate
addSource, // Add source/logger name
addComputerId, // Add computer ID
addComputerLabel, // Add computer label
addStaticFields, // Add static fields to all events
transformField, // Transform a specific field's value
removeFields, // Remove sensitive fields
} from "@/lib/ccStructLog";
import { Logger, LogLevel, processor } from "@/lib/ccStructLog";
// Usage example
const logger = new Logger({
processors: [
addTimestamp(),
addSource("MyApp"),
filterByLevel(LogLevel.Warn), // Only allow Warn, Error, Fatal
removeFields(["password", "token"]),
// Adds a timestamp. Format is compatible with os.date().
// Default: "%F %T" (e.g., "2023-10-27 15:30:00")
processor.addTimestamp({ format: "%T" }), // e.g., "15:30:00"
// Filter by minimum level
processor.filterByLevel(LogLevel.Warn), // Only allow Warn, Error, Fatal
// Filter based on a custom predicate
processor.filterBy((event) => event.get("user") === "admin"),
// Add source/logger name
processor.addSource("MyApp"),
// Add computer ID or label
processor.addComputerId(),
processor.addComputerLabel(),
// Add static fields to all events
processor.addStaticFields({ env: "production", version: "1.2.3" }),
// Transform a specific field's value
processor.transformField("user_id", (id) => `user_${id}`),
// Remove sensitive fields
processor.removeFields(["password", "token"]),
],
// ... other config
});
@@ -172,10 +223,10 @@ Renderers convert the final `LogEvent` object into a string.
import { textRenderer, jsonRenderer } from "@/lib/ccStructLog";
// textRenderer: Human-readable, colored output for the console.
// Example: 15:30:45 [INFO] Message key=value
// Example: [15:30:45] [INFO] Message key=value
// jsonRenderer: Machine-readable JSON output.
// Example: {"level":"info","message":"Message","key":"value","timestamp":"..."}
// Example: {"level":2,"message":"Message","key":"value","timestamp":"15:30:45"}
```
## Streams
@@ -185,12 +236,14 @@ Streams handle the final output destination. You can use multiple streams to sen
### Built-in Streams
```typescript
import {
ConsoleStream, // Output to CC:Tweaked terminal with colors
FileStream, // Output to file with rotation support
BufferStream, // Store in an in-memory buffer
NullStream, // Discard all output
ConsoleStream,
FileStream,
BufferStream,
NullStream,
ConditionalStream,
LogLevel,
DAY,
} from "@/lib/ccStructLog";
import { ConditionalStream } from "@/lib/ccStructLog/streams"; // Note direct import
// File stream with daily rotation
const fileStream = new FileStream("app.log", DAY);
@@ -207,7 +260,7 @@ const errorStream = new ConditionalStream(
## File Rotation
`FileStream` supports automatic file rotation based on time intervals.
`FileStream` supports automatic file rotation based on time intervals. The rotation interval is specified in seconds as the second argument to the constructor.
```typescript
import { FileStream, HOUR, DAY, WEEK } from "@/lib/ccStructLog";
@@ -221,7 +274,7 @@ const dailyLog = new FileStream("app_daily.log", DAY);
// Rotate weekly
const weeklyLog = new FileStream("app_weekly.log", WEEK);
// No rotation
// No rotation (pass 0 or undefined)
const permanentLog = new FileStream("permanent.log", 0);
```
@@ -243,15 +296,22 @@ const permanentLog = new FileStream("permanent.log", 0);
- `error`: Errors that affect a single operation but not the whole app.
- `fatal`: Critical errors that require the application to shut down.
3. **Use a `source`**: Identify which component generated the log.
3. **Use a `source`**: Identify which component generated the log using `processor.addSource`.
```typescript
const logger = createDevLogger({ source: "UserService" });
import { Logger, processor } from "@/lib/ccStructLog";
const logger = new Logger({
processors: [processor.addSource("UserService")],
// ...
});
```
4. **Sanitize Sensitive Data**: Use a processor to remove passwords, API keys, etc.
```typescript
import { Logger, processor } from "@/lib/ccStructLog";
const secureLogger = new Logger({
processors: [ removeFields(["password", "token"]) ],
processors: [ processor.removeFields(["password", "token"]) ],
//...
});
```

View File

@@ -1,6 +1,5 @@
import { Command, createCli } from "@/lib/ccCLI";
import { Ok } from "@/lib/thirdparty/ts-result-es";
import { CCLog } from "@/lib/ccLog";
import {
AccessConfig,
UserGroupConfig,
@@ -8,12 +7,13 @@ import {
saveConfig,
} from "./config";
import { parseBoolean } from "@/lib/common";
import { Logger } from "@/lib/ccStructLog";
// 1. Define AppContext
export interface AppContext {
configFilepath: string;
reloadConfig: () => void;
logger: CCLog;
logger: Logger;
print: (
message: string | MinecraftTextComponent | MinecraftTextComponent[],
) => void;
@@ -48,7 +48,9 @@ const addCommand: Command<AppContext> = {
config.adminGroupConfig.groupUsers.push(playerName);
}
} else {
const group = config.usersGroups.find((g) => g.groupName === groupName);
const group = config.usersGroups.find(
(g) => g.groupName === groupName,
);
if (!group) {
const groupNames = getGroupNames(config);
context.print({
@@ -107,7 +109,9 @@ const delCommand: Command<AppContext> = {
}
if (group.groupUsers !== undefined) {
group.groupUsers = group.groupUsers.filter((user) => user !== playerName);
group.groupUsers = group.groupUsers.filter(
(user) => user !== playerName,
);
}
saveConfig(config, context.configFilepath);
@@ -238,7 +242,10 @@ const configCommand: Command<AppContext> = {
{ name: "value", description: "要设置的值", required: true },
],
action: ({ args, context }) => {
const [option, valueStr] = [args.option as string, args.value as string];
const [option, valueStr] = [
args.option as string,
args.value as string,
];
const config = loadConfig(context.configFilepath)!;
// Check if it's a group property (contains a dot)
@@ -251,7 +258,9 @@ const configCommand: Command<AppContext> = {
if (groupName === "admin") {
groupConfig = config.adminGroupConfig;
} else {
groupConfig = config.usersGroups.find((g) => g.groupName === groupName);
groupConfig = config.usersGroups.find(
(g) => g.groupName === groupName,
);
}
if (!groupConfig) {
@@ -321,7 +330,9 @@ const configCommand: Command<AppContext> = {
const value = parseInt(valueStr);
if (isNaN(value)) {
context.print({ text: `无效的值: ${valueStr}. 必须是一个数字。` });
context.print({
text: `无效的值: ${valueStr}. 必须是一个数字。`,
});
return Ok.EMPTY;
}

View File

@@ -1,4 +1,3 @@
import { CCLog, DAY, LogLevel } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
import { createAccessControlCli } from "./cli";
import { launchAccessControlTUI } from "./tui";
@@ -7,15 +6,45 @@ import { ReadWriteLock } from "@/lib/mutex/ReadWriteLock";
import { ChatManager } from "@/lib/ChatManager";
import { gTimerManager } from "@/lib/TimerManager";
import { KeyEvent, pullEventAs } from "@/lib/event";
import {
ConditionalStream,
ConsoleStream,
DAY,
FileStream,
getStructLogger,
LoggerOptions,
LogLevel,
MB,
processor,
setStructLoggerConfig,
textRenderer,
} from "@/lib/ccStructLog";
const args = [...$vararg];
// Init Log
const logger = new CCLog("accesscontrol.log", {
printTerminal: true,
logInterval: DAY,
outputMinLevel: LogLevel.Info,
});
let isOnConsoleStream = true;
const loggerConfig: LoggerOptions = {
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream),
new FileStream({
filePath: "accesscontrol.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 7,
maxSizeBytes: MB,
},
}),
],
};
setStructLoggerConfig(loggerConfig);
const logger = getStructLogger();
// Load Config
const configFilepath = `${shell.dir()}/access.config.json`;
@@ -54,6 +83,11 @@ function reloadConfig() {
gWatchPlayersInfo = [];
releaser.release();
logger.info("Reload config successfully!");
const tutorial: string[] = [];
tutorial.push("Access Control System started.");
tutorial.push("\tPress 'c' to open configuration TUI.");
tutorial.push("\tPress 'r' to reload configuration.");
print(tutorial.join("\n"));
}
function safeParseTextComponent(
@@ -203,7 +237,9 @@ function watchLoop() {
continue;
}
const watchPlayerNames = gWatchPlayersInfo.flatMap((value) => value.name);
const watchPlayerNames = gWatchPlayersInfo.flatMap(
(value) => value.name,
);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
for (const player of gWatchPlayersInfo) {
const playerInfo = playerDetector.getPlayerPos(player.name);
@@ -329,13 +365,15 @@ function keyboardLoop() {
if (event.key === keys.c) {
logger.info("Launching Access Control TUI...");
try {
logger.setInTerminal(false);
isOnConsoleStream = false;
launchAccessControlTUI();
logger.info("TUI closed, resuming normal operation");
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
} finally {
logger.setInTerminal(true);
isOnConsoleStream = true;
reloadConfig();
}
} else if (event.key === keys.r) {
@@ -379,7 +417,9 @@ function cliLoop() {
releaser = configLock.tryAcquireRead();
}
const isAdmin = config.adminGroupConfig.groupUsers.includes(ev.username);
const isAdmin = config.adminGroupConfig.groupUsers.includes(
ev.username,
);
releaser.release();
if (!isAdmin) continue;
@@ -422,11 +462,14 @@ function main(args: string[]) {
return;
} else if (args[0] == "config") {
logger.info("Launching Access Control TUI...");
logger.setInTerminal(false);
isOnConsoleStream = false;
try {
launchAccessControlTUI();
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
}
return;
}

View File

@@ -99,25 +99,31 @@ const AccessControlTUI = () => {
// Validate numbers
if (
validateNumber(currentConfig.detectInterval?.toString() ?? "") === null
validateNumber(
currentConfig.detectInterval?.toString() ?? "",
) === null
) {
showError("Invalid Detect Interval: must be a number");
return;
}
if (
validateNumber(currentConfig.watchInterval?.toString() ?? "") === null
validateNumber(
currentConfig.watchInterval?.toString() ?? "",
) === null
) {
showError("Invalid Watch Interval: must be a number");
return;
}
if (
validateNumber(currentConfig.noticeTimes?.toString() ?? "") === null
validateNumber(currentConfig.noticeTimes?.toString() ?? "") ===
null
) {
showError("Invalid Notice Times: must be a number");
return;
}
if (
validateNumber(currentConfig.detectRange?.toString() ?? "") === null
validateNumber(currentConfig.detectRange?.toString() ?? "") ===
null
) {
showError("Invalid Detect Range: must be a number");
return;
@@ -153,7 +159,9 @@ const AccessControlTUI = () => {
for (const toastConfig of toastConfigs) {
if (toastConfig.value != undefined) {
const serialized = textutils.serialiseJSON(toastConfig.value);
const serialized = textutils.serialiseJSON(
toastConfig.value,
);
if (!validateTextComponent(serialized)) {
showError(
`Invalid ${toastConfig.name}: must be valid MinecraftTextComponent JSON`,
@@ -210,7 +218,9 @@ const AccessControlTUI = () => {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
groupUsers: currentAdmin.groupUsers.filter((user) => user !== userName),
groupUsers: currentAdmin.groupUsers.filter(
(user) => user !== userName,
),
});
} else {
// Regular group
@@ -268,7 +278,10 @@ const AccessControlTUI = () => {
onFocusChanged: () => {
const num = validateNumber(getDetectInterval());
if (num !== null) setConfig("detectInterval", num);
else setDetectInterval(config().detectInterval.toString());
else
setDetectInterval(
config().detectInterval.toString(),
);
},
}),
),
@@ -282,7 +295,8 @@ const AccessControlTUI = () => {
onFocusChanged: () => {
const num = validateNumber(getWatchInterval());
if (num !== null) setConfig("watchInterval", num);
else setWatchInterval(config().watchInterval.toString());
else
setWatchInterval(config().watchInterval.toString());
},
}),
),
@@ -347,11 +361,15 @@ const AccessControlTUI = () => {
div(
{ class: "flex flex-col" },
label({}, "Groups:"),
For({ each: () => groups, class: "flex flex-col" }, (group, index) =>
For(
{ each: () => groups, class: "flex flex-col" },
(group, index) =>
button(
{
class:
selectedGroupIndex() === index() ? "bg-blue text-white" : "",
selectedGroupIndex() === index()
? "bg-blue text-white"
: "",
onClick: () => setSelectedGroupIndex(index()),
},
group.groupName,
@@ -489,7 +507,10 @@ const AccessControlTUI = () => {
* Toast Configuration Tab Factory
*/
const createToastTab = (
toastType: "welcomeToastConfig" | "warnToastConfig" | "noticeToastConfig",
toastType:
| "welcomeToastConfig"
| "warnToastConfig"
| "noticeToastConfig",
) => {
return () => {
const toastConfig = config()[toastType];
@@ -533,7 +554,9 @@ const AccessControlTUI = () => {
} catch {
setTempToastConfig({
...getTempToastConfig(),
title: textutils.serialiseJSON(currentToastConfig.title),
title: textutils.serialiseJSON(
currentToastConfig.title,
),
});
}
},
@@ -545,7 +568,10 @@ const AccessControlTUI = () => {
type: "text",
value: () => getTempToastConfig().msg,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), msg: value }),
setTempToastConfig({
...getTempToastConfig(),
msg: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
@@ -566,7 +592,9 @@ const AccessControlTUI = () => {
} catch {
setTempToastConfig({
...getTempToastConfig(),
msg: textutils.serialiseJSON(currentToastConfig.msg),
msg: textutils.serialiseJSON(
currentToastConfig.msg,
),
});
// Invalid JSON, ignore
}
@@ -579,13 +607,19 @@ const AccessControlTUI = () => {
input({
type: "text",
value: () => {
const str = textutils.serialiseJSON(getTempToastConfig().prefix, {
const str = textutils.serialiseJSON(
getTempToastConfig().prefix,
{
unicode_strings: true,
});
},
);
return str.substring(1, str.length - 1);
},
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), prefix: value }),
setTempToastConfig({
...getTempToastConfig(),
prefix: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
@@ -603,7 +637,10 @@ const AccessControlTUI = () => {
type: "text",
value: () => getTempToastConfig().brackets,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), brackets: value }),
setTempToastConfig({
...getTempToastConfig(),
brackets: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
@@ -678,7 +715,10 @@ const AccessControlTUI = () => {
{ when: () => currentTab() === TABS.WELCOME_TOAST },
WelcomeToastTab(),
),
Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()),
Match(
{ when: () => currentTab() === TABS.WARN_TOAST },
WarnToastTab(),
),
Match(
{ when: () => currentTab() === TABS.NOTICE_TOAST },
NoticeToastTab(),
@@ -703,7 +743,10 @@ const AccessControlTUI = () => {
For({ each: () => tabNames }, (tabName, index) =>
button(
{
class: currentTab() === index() ? "bg-blue text-white" : "",
class:
currentTab() === index()
? "bg-blue text-white"
: "",
onClick: () => setCurrentTab(index() as TabIndex),
},
tabName,

View File

@@ -3,10 +3,35 @@ import {
CraftRecipe,
CreatePackageTag,
} from "@/lib/CraftManager";
import { CCLog, LogLevel } from "@/lib/ccLog";
import { Queue } from "@/lib/datatype/Queue";
import {
ConsoleStream,
DAY,
FileStream,
Logger,
LogLevel,
processor,
textRenderer,
} from "@/lib/ccStructLog";
const logger = new CCLog("autocraft.log", { outputMinLevel: LogLevel.Info });
const logger = new Logger({
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [
new ConsoleStream(),
new FileStream({
filePath: "autocraft.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 3,
},
}),
],
});
const peripheralsNames = {
// packsInventory: "minecraft:chest_14",
@@ -20,22 +45,12 @@ const peripheralsNames = {
packageExtractor: "create:packager_0",
};
const packsInventory = peripheral.wrap(
peripheralsNames.packsInventory,
) as InventoryPeripheral;
const itemsInventory = peripheral.wrap(
peripheralsNames.itemsInventory,
) as InventoryPeripheral;
const packageExtractor = peripheral.wrap(
peripheralsNames.packageExtractor,
) as InventoryPeripheral;
const blockReader = peripheral.wrap(
peripheralsNames.blockReader,
) as BlockReaderPeripheral;
const wiredModem = peripheral.wrap(
peripheralsNames.wiredModem,
) as WiredModemPeripheral;
const turtleLocalName = wiredModem.getNameLocal();
let packsInventory: InventoryPeripheral;
let itemsInventory: InventoryPeripheral;
let packageExtractor: InventoryPeripheral;
let blockReader: BlockReaderPeripheral;
let wiredModem: WiredModemPeripheral;
let turtleLocalName: string;
enum State {
IDLE,
@@ -44,9 +59,40 @@ enum State {
}
function main() {
let isFinishedInitPeripheral = false;
while (!isFinishedInitPeripheral) {
try {
packsInventory = peripheral.wrap(
peripheralsNames.packsInventory,
) as InventoryPeripheral;
itemsInventory = peripheral.wrap(
peripheralsNames.itemsInventory,
) as InventoryPeripheral;
packageExtractor = peripheral.wrap(
peripheralsNames.packageExtractor,
) as InventoryPeripheral;
blockReader = peripheral.wrap(
peripheralsNames.blockReader,
) as BlockReaderPeripheral;
wiredModem = peripheral.wrap(
peripheralsNames.wiredModem,
) as WiredModemPeripheral;
turtleLocalName = wiredModem.getNameLocal();
logger.info("Peripheral initialization complete...");
isFinishedInitPeripheral = true;
} catch (error) {
logger.warn(
`Peripheral initialization failed for ${String(error)}, try again...`,
);
sleep(1);
}
}
const craftManager = new CraftManager(turtleLocalName, itemsInventory);
const recipesQueue = new Queue<CraftRecipe>();
const recipesWaitingMap = new Map<number, CraftRecipe[] | CraftRecipe>();
let currentState = State.IDLE;
let nextState = State.IDLE;
let hasPackage = redstone.getInput(peripheralsNames.redstone);
@@ -103,17 +149,20 @@ function main() {
logger.debug(
`Turtle:\n${textutils.serialise(blockReader.getBlockData()!, { allow_repetitions: true })}`,
);
const packageDetailInfo = blockReader.getBlockData()?.Items[1];
const packageDetailInfo =
blockReader.getBlockData()?.Items[1];
if (packageDetailInfo === undefined) {
logger.error(`Package detail info not found`);
continue;
}
// Get OrderId and isFinal
const packageOrderId = (packageDetailInfo.tag as CreatePackageTag)
.Fragment.OrderId;
const packageOrderId = (
packageDetailInfo.tag as CreatePackageTag
).Fragment.OrderId;
const packageIsFinal =
(packageDetailInfo.tag as CreatePackageTag).Fragment.IsFinal > 0
(packageDetailInfo.tag as CreatePackageTag).Fragment
.IsFinal > 0
? true
: false;
@@ -121,11 +170,21 @@ function main() {
const packageRecipes =
CraftManager.getPackageRecipe(packageDetailInfo);
if (packageRecipes.isSome()) {
if (packageIsFinal) recipesQueue.enqueue(packageRecipes.value);
else recipesWaitingMap.set(packageOrderId, packageRecipes.value);
if (packageIsFinal)
recipesQueue.enqueue(packageRecipes.value);
else
recipesWaitingMap.set(
packageOrderId,
packageRecipes.value,
);
} else {
if (packageIsFinal && recipesWaitingMap.has(packageOrderId)) {
recipesQueue.enqueue(recipesWaitingMap.get(packageOrderId)!);
if (
packageIsFinal &&
recipesWaitingMap.has(packageOrderId)
) {
recipesQueue.enqueue(
recipesWaitingMap.get(packageOrderId)!,
);
recipesWaitingMap.delete(packageOrderId);
} else {
logger.debug(`No recipe, just pass`);
@@ -166,7 +225,8 @@ function main() {
});
if (craftCnt == 0) break;
if (craftCnt < maxSignleCraftCnt) maxSignleCraftCnt = craftCnt;
if (craftCnt < maxSignleCraftCnt)
maxSignleCraftCnt = craftCnt;
const craftRet = craftManager.craft(maxSignleCraftCnt);
craftItemDetail ??= craftRet;
logger.info(`Craft ${craftCnt} times`);

View File

@@ -4,6 +4,9 @@
*/
import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
import { processor } from "./processors";
import { ConsoleStream } from "./streams";
import { textRenderer } from "./renderers";
/**
* The main Logger class that orchestrates the logging pipeline.
@@ -13,26 +16,19 @@ import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
*/
export class Logger implements ILogger {
private options: LoggerOptions;
private loggerName?: string;
/**
* Create a new Logger instance.
*
* @param options - Configuration options for the logger
* @param name - The name of the logger
*/
constructor(options: Partial<LoggerOptions>) {
this.options = {
processors: options.processors ?? [],
renderer: options.renderer ?? this.defaultRenderer,
streams: options.streams ?? [],
};
constructor(options: LoggerOptions, name?: string) {
this.options = options;
this.loggerName = name;
}
/**
* Default renderer that returns an empty string.
* Used as fallback when no renderer is provided.
*/
private defaultRenderer = (): string => "";
/**
* Main logging method that handles the complete logging pipeline.
*
@@ -51,6 +47,8 @@ export class Logger implements ILogger {
["message", message],
...Object.entries(context),
]);
if (this.loggerName !== undefined)
event.set("loggerName", this.loggerName);
// 2. Process through the processor chain
for (const processor of this.options.processors) {
@@ -62,12 +60,11 @@ export class Logger implements ILogger {
// 3. Render and output if event wasn't dropped
if (event !== undefined) {
const finalEvent = event;
const output = this.options.renderer(finalEvent);
const output = this.options.renderer(event);
// Send to all configured streams
for (const stream of this.options.streams) {
stream.write(output, finalEvent);
stream.write(output, event);
}
}
}
@@ -163,3 +160,17 @@ export class Logger implements ILogger {
}
}
}
let globalLoggerConfig: LoggerOptions = {
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [new ConsoleStream()],
};
export function getStructLogger(name?: string): Logger {
return new Logger(globalLoggerConfig, name);
}
export function setStructLoggerConfig(config: LoggerOptions): void {
globalLoggerConfig = config;
}

View File

@@ -7,150 +7,14 @@
*/
// Re-export all core types and classes
export {
LogLevel,
LogEvent,
Processor,
Renderer,
Stream,
LoggerOptions,
ILogger,
} from "./types";
export { Logger } from "./Logger";
export * from "./types";
export * from "./Logger";
// Re-export all processors
export {
addTimestamp,
addFormattedTimestamp,
addFullTimestamp,
filterByLevel,
addSource,
addComputerId,
addComputerLabel,
filterBy,
transformField,
removeFields,
addStaticFields,
} from "./processors";
export * from "./processors";
// Re-export all renderers
export { jsonRenderer, textRenderer } from "./renderers";
export * from "./renderers";
// Re-export all streams
export {
ConsoleStream,
FileStream,
BufferStream,
NullStream,
SECOND,
MINUTE,
HOUR,
DAY,
WEEK,
} from "./streams";
import { Logger } from "./Logger";
import { LogLevel, LogEvent } from "./types";
import {
addFormattedTimestamp,
addFullTimestamp,
addComputerId,
} from "./processors";
import { textRenderer, jsonRenderer } from "./renderers";
import { ConsoleStream, FileStream, DAY } from "./streams";
/**
* Create a development logger with console output and colored formatting.
*
* This logger is optimized for development and debugging, with:
* - Debug level and above
* - Formatted timestamps
* - Computer ID tracking
* - Human-readable console output with colors
*
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for development
*/
export function createDevLogger(
options: {
level?: LogLevel;
source?: string;
includeComputerId?: boolean;
} = {},
): Logger {
const processors = [addFormattedTimestamp()];
if (options.includeComputerId !== false) {
processors.push(addComputerId());
}
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
return new Logger({
processors,
renderer: textRenderer,
streams: [new ConsoleStream()],
});
}
/**
* Create a production logger with file output and JSON formatting.
*
* This logger is optimized for production environments, with:
* - Info level and above
* - Full timestamps
* - Computer ID and label tracking
* - JSON output for machine processing
* - Daily file rotation
*
* @param filename - Base filename for log files
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for production
*/
export function createProdLogger(
filename: string,
options: {
level?: LogLevel;
source?: string;
rotationInterval?: number;
includeConsole?: boolean;
} = {},
): Logger {
const processors = [
addFullTimestamp(),
addComputerId(),
(event: LogEvent) => {
const label = os.getComputerLabel();
if (label) {
event.set("computer_label", label);
}
return event;
},
];
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
const streams: Array<ConsoleStream | FileStream> = [
new FileStream(filename, options.rotationInterval ?? DAY),
];
if (options.includeConsole) {
streams.push(new ConsoleStream());
}
return new Logger({
processors,
renderer: jsonRenderer,
streams,
});
}
export * from "./streams";

View File

@@ -8,64 +8,42 @@
import { LogEvent, Processor, LogLevel } from "./types";
/**
* Adds a timestamp to the log event.
*
* This processor adds the current time as a structured timestamp object
* using CC:Tweaked's os.date() function. The timestamp includes year,
* month, day, hour, minute, and second components.
*
* Performance note: os.date() is relatively expensive, so this should
* typically be placed early in the processor chain and used only once.
*
* @param event - The log event to process
* @returns The event with timestamp added
export namespace processor {
/**
* Configuration options for the timestamp processor.
*/
export function addTimestamp(): Processor {
interface TimestampConfig {
/**
* The format string takes the same formats as C's strftime function.
*/
format?: string;
}
/**
* Adds a timestamp to each log event.
*
* This processor adds a "time" field to each log event with the current
* timestamp. The timestamp format can be customized using the `format`
* option.
*
* @param config - Configuration options for the timestamp processor.
* @returns A processor function that adds a timestamp to each log event.
*/
export function addTimestamp(config: TimestampConfig = {}): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
event.set("timestamp", timestamp);
let time: string;
if (config.format === undefined) {
time = os.date("%F %T") as string;
} else {
time = os.date(config.format) as string;
}
event.set("timestamp", time);
return event;
};
}
}
/**
* Adds a human-readable timestamp string to the log event.
*
* This processor adds a formatted timestamp string that's easier to read
* in log output. The format is "HH:MM:SS" in UTC time.
*
* @param event - The log event to process
* @returns The event with formatted timestamp added
*/
export function addFormattedTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const timeStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("time", timeStr);
return event;
};
}
/**
* Adds a full ISO-like timestamp string to the log event.
*
* This processor adds a complete timestamp in YYYY-MM-DD HH:MM:SS format
* which is useful for file logging and structured output.
*
* @param event - The log event to process
* @returns The event with full timestamp added
*/
export function addFullTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const fullTimeStr = `${timestamp.year}-${string.format("%02d", timestamp.month)}-${string.format("%02d", timestamp.day)} ${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("datetime", fullTimeStr);
return event;
};
}
/**
/**
* Filters log events by minimum level.
*
* This processor drops log events that are below the specified minimum level.
@@ -76,38 +54,22 @@ export function addFullTimestamp(): Processor {
* @param minLevel - The minimum log level to allow through
* @returns A processor function that filters by level
*/
export function filterByLevel(minLevel: LogLevel): Processor {
export function filterByLevel(minLevel: LogLevel): Processor {
return (event) => {
const eventLevel = event.get("level") as LogLevel | undefined;
if (eventLevel === undefined) {
return event; // Pass through if no level is set
}
if (eventLevel !== undefined && eventLevel < minLevel) {
if (eventLevel < minLevel) {
return undefined; // Drop the log event
}
return event;
};
}
}
/**
* Adds a logger name/source to the log event.
*
* This processor is useful when you have multiple loggers in your application
* and want to identify which component generated each log entry.
*
* @param name - The name/source to add to log events
* @returns A processor function that adds the source name
*/
export function addSource(name: string): Processor {
return (event) => {
event.set("source", name);
return event;
};
}
/**
/**
* Adds the current computer ID to the log event.
*
* In CC:Tweaked environments, this can help identify which computer
@@ -116,14 +78,14 @@ export function addSource(name: string): Processor {
* @param event - The log event to process
* @returns The event with computer ID added
*/
export function addComputerId(): Processor {
export function addComputerId(): Processor {
return (event) => {
event.set("computer_id", os.getComputerID());
return event;
};
}
}
/**
/**
* Adds the current computer label to the log event.
*
* If the computer has a label set, this adds it to the log event.
@@ -132,7 +94,7 @@ export function addComputerId(): Processor {
* @param event - The log event to process
* @returns The event with computer label added (if available)
*/
export function addComputerLabel(): Processor {
export function addComputerLabel(): Processor {
return (event) => {
const label = os.getComputerLabel();
if (label !== undefined && label !== null) {
@@ -140,9 +102,9 @@ export function addComputerLabel(): Processor {
}
return event;
};
}
}
/**
/**
* Filters out events that match a specific condition.
*
* This is a generic processor that allows you to filter events based on
@@ -152,13 +114,15 @@ export function addComputerLabel(): Processor {
* @param predicate - Function that returns true to keep the event
* @returns A processor function that filters based on the predicate
*/
export function filterBy(predicate: (event: LogEvent) => boolean): Processor {
export function filterBy(
predicate: (event: LogEvent) => boolean,
): Processor {
return (event) => {
return predicate(event) ? event : undefined;
};
}
}
/**
/**
* Transforms a specific field in the log event.
*
* This processor allows you to modify the value of a specific field
@@ -168,10 +132,10 @@ export function filterBy(predicate: (event: LogEvent) => boolean): Processor {
* @param transformer - Function to transform the field value
* @returns A processor function that transforms the specified field
*/
export function transformField(
export function transformField(
fieldName: string,
transformer: (value: unknown) => unknown,
): Processor {
): Processor {
return (event) => {
if (event.has(fieldName)) {
const currentValue = event.get(fieldName);
@@ -180,9 +144,9 @@ export function transformField(
}
return event;
};
}
}
/**
/**
* Removes specified fields from the log event.
*
* This processor can be used to strip sensitive or unnecessary information
@@ -191,16 +155,16 @@ export function transformField(
* @param fieldNames - Array of field names to remove
* @returns A processor function that removes the specified fields
*/
export function removeFields(fieldNames: string[]): Processor {
export function removeFields(fieldNames: string[]): Processor {
return (event) => {
for (const fieldName of fieldNames) {
event.delete(fieldName);
}
return event;
};
}
}
/**
/**
* Adds static fields to every log event.
*
* This processor adds the same set of fields to every log event that
@@ -210,11 +174,14 @@ export function removeFields(fieldNames: string[]): Processor {
* @param fields - Object containing the static fields to add
* @returns A processor function that adds the static fields
*/
export function addStaticFields(fields: Record<string, unknown>): Processor {
export function addStaticFields(
fields: Record<string, unknown>,
): Processor {
return (event) => {
for (const [key, value] of Object.entries(fields)) {
event.set(key, value);
}
return event;
};
}
}

View File

@@ -6,7 +6,7 @@
* different output formats (JSON, console-friendly, etc.).
*/
import { Renderer } from "./types";
import { LogLevel, Renderer } from "./types";
/**
* Renders log events as JSON strings.
@@ -43,44 +43,36 @@ export const jsonRenderer: Renderer = (event) => {
* timestamp, level, message, and additional context fields formatted
* in a readable way.
*
* Format: [HH:MM:SS] [LEVEL] message { key=value, key2=value2 }
* Format: [YYYY-MM-DD HH:MM:SS] [LEVEL] message key=value, key2=value2
*
* @param event - The log event to render
* @returns Human-readable string representation
*/
export const textRenderer: Renderer = (event) => {
// Extract core components
const timestamp = event.get("timestamp") as LuaDate | undefined;
const timeStr = event.get("time") as string | undefined;
const level = (event.get("level") as string)?.toUpperCase() ?? "UNKNOWN";
const timeStr = event.get("timestamp") as string | undefined;
const level: string | undefined = LogLevel[event.get("level") as LogLevel];
const message = (event.get("message") as string) ?? "";
// Format timestamp
let timestampStr = "";
if (timeStr) {
timestampStr = timeStr;
} else if (timestamp) {
timestampStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
}
const loggerName = event.get("loggerName") as string | undefined;
// Start building the output
let output = `[${timestampStr}] [${level}] ${message}`;
let output = `${timeStr} [${level}] ${message} \t ${loggerName !== undefined ? "[" + loggerName + "]" : ""}`;
// Add context fields (excluding the core fields we already used)
const contextFields: string[] = [];
for (const [key, value] of event.entries()) {
if (
key !== "timestamp" &&
key !== "time" &&
key !== "level" &&
key !== "message"
key !== "message" &&
key !== "loggerName"
) {
contextFields.push(`${key}=${tostring(value)}`);
}
}
if (contextFields.length > 0) {
output += ` { ${contextFields.join(", ")} }`;
output += contextFields.join(", ");
}
return output;

View File

@@ -6,7 +6,34 @@
* implements the Stream interface and handles its own output logic.
*/
import { Stream, LogEvent } from "./types";
import { LogLevel, Stream, LogEvent } from "./types";
/**
* Configuration interface for FileStream with auto-cleanup options.
*/
interface FileStreamConfig {
/** Path to the log file */
filePath: string;
/**
* Time in seconds between file rotations (0 = no rotation)
* Time must larger than one DAY
* @default 0
*/
rotationInterval?: number;
/** Auto-cleanup configuration */
autoCleanup?: {
/** Whether to enable auto-cleanup */
enabled: boolean;
/** Maximum number of log files to keep */
maxFiles?: number;
/** Maximum total size in bytes for all log files */
maxSizeBytes?: number;
/** Directory to search for log files (defaults to log file directory) */
logDir?: string;
/** File pattern to match (defaults to base filename pattern) */
pattern?: string;
};
}
/**
* Console stream that outputs to the CC:Tweaked terminal.
@@ -16,14 +43,14 @@ import { Stream, LogEvent } from "./types";
* color after writing each message.
*/
export class ConsoleStream implements Stream {
private levelColors: Map<string, number> = new Map([
["trace", colors.lightGray],
["debug", colors.gray],
["info", colors.white],
["warn", colors.orange],
["error", colors.red],
["fatal", colors.red],
]);
private levelColors: { [key: string]: number } = {
Trace: colors.lightGray,
Debug: colors.gray,
Info: colors.green,
Warn: colors.orange,
Error: colors.red,
Fatal: colors.red,
};
/**
* Write a formatted log message to the terminal.
@@ -32,8 +59,9 @@ export class ConsoleStream implements Stream {
* @param event - The original log event for context (used for level-based coloring)
*/
public write(message: string, event: LogEvent): void {
const level = event.get("level") as string | undefined;
const color = level ? this.levelColors.get(level) : undefined;
const level: string | undefined =
LogLevel[event.get("level") as LogLevel];
const color = level !== undefined ? this.levelColors[level] : undefined;
if (color !== undefined) {
const originalColor = term.getTextColor();
@@ -58,20 +86,20 @@ export class FileStream implements Stream {
private filePath: string;
private rotationInterval: number;
private lastRotationTime: number;
private baseFilename: string;
private autoCleanupConfig?: FileStreamConfig["autoCleanup"];
/**
* Create a new file stream.
* Create a new file stream with configuration object.
*
* @param filePath - Path to the log file
* @param rotationInterval - Time in seconds between file rotations (0 = no rotation)
* @param config - FileStream configuration object
*/
constructor(filePath: string, rotationInterval: number = 0) {
this.filePath = filePath;
this.rotationInterval = rotationInterval;
constructor(config: FileStreamConfig) {
this.filePath = config.filePath;
this.rotationInterval = config.rotationInterval || 0;
if (this.rotationInterval !== 0 && this.rotationInterval < DAY)
throw Error("Rotation interval must be at least one day");
this.autoCleanupConfig = config.autoCleanup;
this.lastRotationTime = os.time();
this.baseFilename = filePath;
this.openFile();
}
@@ -93,24 +121,27 @@ export class FileStream implements Stream {
return;
}
this.fileHandle = handle;
// Perform auto-cleanup when opening file
this.performAutoCleanup();
}
/**
* Generate a filename with timestamp for file rotation.
*/
private getRotatedFilename(): string {
const currentTime = os.time();
const currentTime = os.time(os.date("*t"));
const rotationPeriod =
Math.floor(currentTime / this.rotationInterval) *
this.rotationInterval;
const date = os.date("*t", rotationPeriod) as LuaDate;
const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}_${string.format("%02d", date.hour)}-${string.format("%02d", date.min)}`;
const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}`;
// Split filename and extension
const splitStrs = this.baseFilename.split(".");
const splitStrs = this.filePath.split(".");
if (splitStrs.length === 1) {
return `${this.baseFilename}_${timestamp}.log`;
return `${this.filePath}_${timestamp}.log`;
}
const name = splitStrs[0];
@@ -125,19 +156,50 @@ export class FileStream implements Stream {
if (this.rotationInterval <= 0) return;
const currentTime = os.time();
const currentPeriod = Math.floor(currentTime / this.rotationInterval);
const lastPeriod = Math.floor(
this.lastRotationTime / this.rotationInterval,
);
if (currentPeriod > lastPeriod) {
if (
Math.floor(
(currentTime - this.lastRotationTime) / this.rotationInterval,
) > 0
) {
// Time to rotate
this.close();
this.lastRotationTime = currentTime;
this.openFile();
// Auto-cleanup is performed in openFile()
}
}
/**
* Perform auto-cleanup based on configuration.
* This method is called automatically when opening files or rotating.
*/
private performAutoCleanup(): void {
if (!this.autoCleanupConfig || !this.autoCleanupConfig.enabled) {
return;
}
const config = this.autoCleanupConfig;
// Cleanup by file count if configured
if (config.maxFiles !== undefined && config.maxFiles > 0) {
this.cleanupOldLogFiles(config.maxFiles, config.logDir);
}
// Cleanup by total size if configured
if (config.maxSizeBytes !== undefined && config.maxSizeBytes > 0) {
this.cleanupLogFilesBySize(config.maxSizeBytes, config.logDir);
}
}
/**
* Enable or update auto-cleanup configuration at runtime.
*
* @param config - Auto-cleanup configuration
*/
public setAutoCleanup(config: FileStreamConfig["autoCleanup"]): void {
this.autoCleanupConfig = config;
}
/**
* Write a formatted log message to the file.
*
@@ -162,6 +224,123 @@ export class FileStream implements Stream {
this.fileHandle = undefined;
}
}
/**
* Search for log files matching the specified pattern in a directory.
*
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @returns Array of log file information including path, size, and modification time
*/
private searchLogFiles(
logDir?: string,
): Array<{ path: string; size: number; modified: number }> {
const directory = logDir || fs.getDir(this.filePath);
const splitStrs = this.filePath.split(".");
const name = splitStrs[0] + "_";
const ext = splitStrs.length > 1 ? splitStrs[1] : "log";
if (!fs.exists(directory) || !fs.isDir(directory)) {
return [];
}
const logFiles: Array<{
path: string;
size: number;
modified: number;
}> = [];
const files = fs.list(directory);
for (const file of files) {
const fullPath = fs.combine(directory, file);
if (
fs.isDir(fullPath) ||
!file.startsWith(name) ||
!file.endsWith(ext)
)
continue;
const attributes = fs.attributes(fullPath);
if (attributes !== undefined) {
logFiles.push({
path: fullPath,
size: attributes.size,
modified: attributes.modified,
});
}
}
return logFiles;
}
/**
* Clean up old log files by keeping only the specified number of most recent files.
*
* @param maxFiles - Maximum number of log files to keep
* @param logDir - Directory containing log files (defaults to directory of current log file)
*/
public cleanupOldLogFiles(maxFiles: number, logDir?: string): void {
if (maxFiles <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length <= maxFiles) return;
// Sort by modification time (newest first)
logFiles.sort((a, b) => b.modified - a.modified);
// Delete files beyond the limit
for (let i = maxFiles; i < logFiles.length; i++) {
try {
fs.delete(logFiles[i].path);
} catch (err) {
printError(
`Failed to delete old log file ${logFiles[i].path}: ${err}`,
);
}
}
}
/**
* Clean up log files by total size, deleting oldest files until total size is under limit.
*
* @param maxSizeBytes - Maximum total size in bytes for all log files
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @param fileName - Base File Name
*/
public cleanupLogFilesBySize(maxSizeBytes: number, logDir?: string): void {
if (maxSizeBytes <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length === 0) return;
// Calculate total size
let totalSize = 0;
for (const logFile of logFiles) {
totalSize += logFile.size;
}
// If total size is within limit, no cleanup needed
if (totalSize <= maxSizeBytes) {
return;
}
// Sort by modification time (oldest first for deletion)
logFiles.sort((a, b) => a.modified - b.modified);
// Delete oldest files until we're under the size limit
for (const logFile of logFiles) {
if (totalSize <= maxSizeBytes) {
break;
}
try {
fs.delete(logFile.path);
totalSize -= logFile.size;
} catch (err) {
printError(`Failed to delete log file ${logFile.path}: ${err}`);
}
}
}
}
/**
@@ -306,3 +485,7 @@ export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;
// Byte constants for file rotation
export const MB = 1024 * 1024;
export const KB = 1024;

View File

@@ -398,11 +398,17 @@ export class UIObject {
const newScrollX = Math.max(
0,
Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX),
Math.min(
this.scrollProps.maxScrollX,
this.scrollProps.scrollX + deltaX,
),
);
const newScrollY = Math.max(
0,
Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY),
Math.min(
this.scrollProps.maxScrollY,
this.scrollProps.scrollY + deltaY,
),
);
this.scrollProps.scrollX = newScrollX;

View File

@@ -5,10 +5,10 @@
import { UIObject } from "./UIObject";
import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer";
import { CCLog, DAY, LogLevel } from "../ccLog";
import { setLogger } from "./context";
import { InputProps } from "./components";
import { Setter } from "./reactivity";
import { getStructLogger, Logger } from "@/lib/ccStructLog";
import { setLogger } from "./context";
/**
* Main application class
@@ -21,7 +21,7 @@ export class Application {
private focusedNode?: UIObject;
private termWidth: number;
private termHeight: number;
private logger: CCLog;
private logger: Logger;
private cursorBlinkState = false;
private lastBlinkTime = 0;
private readonly BLINK_INTERVAL = 0.5; // seconds
@@ -30,11 +30,7 @@ export class Application {
const [width, height] = term.getSize();
this.termWidth = width;
this.termHeight = height;
this.logger = new CCLog("tui_debug.log", {
printTerminal: false,
logInterval: DAY,
outputMinLevel: LogLevel.Info,
});
this.logger = getStructLogger("ccTUI");
setLogger(this.logger);
this.logger.debug("Application constructed.");
}
@@ -99,7 +95,6 @@ export class Application {
this.root.unmount();
}
this.logger.close();
clearScreen();
}
@@ -224,8 +219,10 @@ export class Application {
| undefined;
if (type === "checkbox") {
// Toggle checkbox
const onChangeProp = (this.focusedNode.props as InputProps).onChange;
const checkedProp = (this.focusedNode.props as InputProps).checked;
const onChangeProp = (this.focusedNode.props as InputProps)
.onChange;
const checkedProp = (this.focusedNode.props as InputProps)
.checked;
if (
typeof onChangeProp === "function" &&
@@ -260,7 +257,10 @@ export class Application {
const valueProp = (this.focusedNode.props as InputProps).value;
const onInputProp = (this.focusedNode.props as InputProps).onInput;
if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
if (
typeof valueProp !== "function" ||
typeof onInputProp !== "function"
) {
return;
}
@@ -273,7 +273,10 @@ export class Application {
this.needsRender = true;
} else if (key === keys.right) {
// Move cursor right
this.focusedNode.cursorPos = math.min(currentValue.length, cursorPos + 1);
this.focusedNode.cursorPos = math.min(
currentValue.length,
cursorPos + 1,
);
this.needsRender = true;
} else if (key === keys.backspace) {
// Delete character before cursor
@@ -301,11 +304,15 @@ export class Application {
* Handle character input events
*/
private handleCharEvent(char: string): void {
if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input"
) {
const type = (this.focusedNode.props as InputProps).type;
if (type !== "checkbox") {
// Insert character at cursor position
const onInputProp = (this.focusedNode.props as InputProps).onInput;
const onInputProp = (this.focusedNode.props as InputProps)
.onInput;
const valueProp = (this.focusedNode.props as InputProps).value;
if (
@@ -338,7 +345,10 @@ export class Application {
if (clicked !== undefined) {
this.logger.debug(
string.format("handleMouseClick: Found node of type %s.", clicked.type),
string.format(
"handleMouseClick: Found node of type %s.",
clicked.type,
),
);
// Set focus
if (
@@ -351,7 +361,8 @@ export class Application {
}
this.focusedNode = clicked;
if (typeof clicked.props.onFocusChanged === "function") {
const onFocusChanged = clicked.props.onFocusChanged as Setter<boolean>;
const onFocusChanged = clicked.props
.onFocusChanged as Setter<boolean>;
onFocusChanged(true);
}
@@ -375,11 +386,15 @@ export class Application {
"handleMouseClick: onClick handler found, executing.",
);
(onClick as () => void)();
this.logger.debug("handleMouseClick: onClick handler finished.");
this.logger.debug(
"handleMouseClick: onClick handler finished.",
);
this.needsRender = true;
}
} else if (clicked.type === "input") {
const type = (clicked.props as InputProps).type as string | undefined;
const type = (clicked.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") {
const onChangeProp = (clicked.props as InputProps).onChange;
const checkedProp = (clicked.props as InputProps).checked;
@@ -397,7 +412,9 @@ export class Application {
this.needsRender = true;
} else {
this.logger.debug("handleMouseClick: No node found at click position.");
this.logger.debug(
"handleMouseClick: No node found at click position.",
);
}
}
@@ -432,7 +449,9 @@ export class Application {
);
// Only return interactive elements
if (node.type === "button" || node.type === "input") {
this.logger.debug("findNodeAt: Node is interactive, returning.");
this.logger.debug(
"findNodeAt: Node is interactive, returning.",
);
return node;
}
}
@@ -502,7 +521,9 @@ export class Application {
);
// Only return scrollable elements
if (node.type === "scroll-container") {
this.logger.debug("findNodeAt: Node is scrollable, returning.");
this.logger.debug(
"findNodeAt: Node is scrollable, returning.",
);
return node;
}
}

View File

@@ -167,12 +167,15 @@ export function label(
const sentences = createMemo(() => {
const words = splitByWhitespace(text());
const ret = concatSentence(words, 40);
context.logger?.debug(`label words changed : [ ${ret.join(",")} ]`);
context.logger?.debug(
`label words changed : [ ${ret.join(",")} ]`,
);
return ret;
});
const forNode = For({ class: `flex flex-col`, each: sentences }, (word) =>
label({ class: p.class }, word),
const forNode = For(
{ class: `flex flex-col`, each: sentences },
(word) => label({ class: p.class }, word),
);
return forNode;

View File

@@ -4,13 +4,13 @@
* to all components without prop drilling.
*/
import type { CCLog } from "../ccLog";
import { Logger } from "@/lib/ccStructLog";
/**
* The global context object for the TUI application.
* This will be set by the Application instance on creation.
*/
export const context: { logger: CCLog | undefined } = {
export const context: { logger: Logger | undefined } = {
logger: undefined,
};
@@ -18,6 +18,6 @@ export const context: { logger: CCLog | undefined } = {
* Sets the global logger instance.
* @param l The logger instance.
*/
export function setLogger(l: CCLog): void {
export function setLogger(l: Logger): void {
context.logger = l;
}

View File

@@ -62,7 +62,10 @@ function measureNode(
if (node.styleProps.width === "screen") {
const termSize = getTerminalSize();
measuredWidth = termSize.width;
} else if (node.styleProps.width === "full" && parentWidth !== undefined) {
} else if (
node.styleProps.width === "full" &&
parentWidth !== undefined
) {
measuredWidth = parentWidth;
} else if (typeof node.styleProps.width === "number") {
measuredWidth = node.styleProps.width;
@@ -297,7 +300,13 @@ export function calculateLayout(
const childY = startY + scrollOffsetY;
// Recursively calculate layout for child with its natural size
calculateLayout(child, childSize.width, childSize.height, childX, childY);
calculateLayout(
child,
childSize.width,
childSize.height,
childX,
childY,
);
}
return;
}
@@ -368,7 +377,8 @@ export function calculateLayout(
// Cross axis (vertical) alignment
if (align === "center") {
childY = startY + math.floor((availableHeight - measure.height) / 2);
childY =
startY + math.floor((availableHeight - measure.height) / 2);
} else if (align === "end") {
childY = startY + (availableHeight - measure.height);
} else {
@@ -385,7 +395,8 @@ export function calculateLayout(
// Cross axis (horizontal) alignment
if (align === "center") {
childX = startX + math.floor((availableWidth - measure.width) / 2);
childX =
startX + math.floor((availableWidth - measure.width) / 2);
} else if (align === "end") {
childX = startX + (availableWidth - measure.width);
} else {

View File

@@ -75,10 +75,10 @@ export function createSignal<T>(initialValue: T): Signal<T> {
// Notify all subscribed listeners
if (batchDepth > 0) {
// In batch mode, collect effects to run later
listeners.forEach(listener => pendingEffects.add(listener));
listeners.forEach((listener) => pendingEffects.add(listener));
} else {
// Run effects immediately
listeners.forEach(listener => {
listeners.forEach((listener) => {
try {
listener();
} catch (e) {
@@ -152,7 +152,7 @@ export function batch(fn: () => void): void {
const effects = Array.from(pendingEffects);
pendingEffects.clear();
effects.forEach(effect => {
effects.forEach((effect) => {
try {
effect();
} catch (e) {

View File

@@ -18,7 +18,10 @@ function getTextContent(node: UIObject): string {
}
// For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) {
if (
node.children.length > 0 &&
node.children[0].textContent !== undefined
) {
const child = node.children[0];
if (typeof child.textContent === "function") {
return child.textContent();
@@ -39,7 +42,11 @@ function isPositionVisible(
): boolean {
let current = node.parent;
while (current) {
if (isScrollContainer(current) && current.layout && current.scrollProps) {
if (
isScrollContainer(current) &&
current.layout &&
current.scrollProps
) {
const { x: containerX, y: containerY } = current.layout;
const { viewportWidth, viewportHeight } = current.scrollProps;
@@ -189,7 +196,9 @@ function drawNode(
}
case "input": {
const type = (node.props as InputProps).type as string | undefined;
const type = (node.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") {
// Draw checkbox
@@ -224,7 +233,11 @@ function drawNode(
const focusedBgColor = bgColor ?? colors.white;
const unfocusedBgColor = bgColor ?? colors.black;
if (displayText === "" && placeholder !== undefined && !focused) {
if (
displayText === "" &&
placeholder !== undefined &&
!focused
) {
displayText = placeholder;
showPlaceholder = true;
currentTextColor = currentTextColor ?? colors.gray;
@@ -235,7 +248,9 @@ function drawNode(
}
// Set background and clear the input area, creating a 1-character padding on the left
term.setBackgroundColor(focused ? focusedBgColor : unfocusedBgColor);
term.setBackgroundColor(
focused ? focusedBgColor : unfocusedBgColor,
);
term.setCursorPos(x, y);
term.write(" ".repeat(width));
@@ -247,7 +262,9 @@ function drawNode(
// Move text if it's too long for the padded area
const startDisPos =
cursorPos >= renderWidth ? cursorPos - renderWidth + 1 : 0;
cursorPos >= renderWidth
? cursorPos - renderWidth + 1
: 0;
const stopDisPos = startDisPos + renderWidth;
if (focused && !showPlaceholder && cursorBlinkState) {
@@ -271,7 +288,10 @@ function drawNode(
}
}
// Draw cursor at the end of the text if applicable
if (cursorPos === textToRender.length && cursorPos < renderWidth) {
if (
cursorPos === textToRender.length &&
cursorPos < renderWidth
) {
term.setBackgroundColor(currentTextColor);
term.setTextColor(focusedBgColor);
term.write(" ");
@@ -281,7 +301,9 @@ function drawNode(
}
} else {
// Not focused or no cursor, just write the text
term.write(textToRender.substring(startDisPos, stopDisPos));
term.write(
textToRender.substring(startDisPos, stopDisPos),
);
}
}
break;

View File

@@ -45,7 +45,9 @@ export interface SetStoreFunction<T> {
* setTodos([{ title: "First", done: false }]);
* ```
*/
export function createStore<T extends object>(initialValue: T): [Accessor<T>, SetStoreFunction<T>] {
export function createStore<T extends object>(
initialValue: T,
): [Accessor<T>, SetStoreFunction<T>] {
// Use a signal to track the entire state
const [get, set] = createSignal(initialValue);
@@ -88,8 +90,11 @@ export function createStore<T extends object>(initialValue: T): [Accessor<T>, Se
if (Array.isArray(current)) {
const newArray = [...current] as unknown[];
if (typeof newArray[index] === "object" && newArray[index] !== undefined) {
newArray[index] = { ...(newArray[index]!), [key]: value };
if (
typeof newArray[index] === "object" &&
newArray[index] !== undefined
) {
newArray[index] = { ...newArray[index]!, [key]: value };
}
set(newArray as T);
}

View File

View File

@@ -7,56 +7,16 @@
import {
Logger,
createDevLogger,
createProdLogger,
// Processors
addTimestamp,
addFormattedTimestamp,
addFullTimestamp,
addSource,
addComputerId,
addStaticFields,
transformField,
// Renderers
textRenderer,
jsonRenderer,
// Streams
processor,
ConsoleStream,
FileStream,
BufferStream,
DAY,
ConditionalStream,
HOUR,
jsonRenderer,
LogLevel,
textRenderer,
} from "../lib/ccStructLog";
import { ConditionalStream } from "@/lib/ccStructLog/streams";
// =============================================================================
// Basic Usage Examples
// =============================================================================
print("=== Basic Usage Examples ===");
// 1. Quick start with pre-configured loggers
const devLog = createDevLogger();
devLog.info("Application started", { version: "1.0.0", port: 8080 });
devLog.debug("Debug information", { userId: 123, action: "login" });
devLog.error("Something went wrong", {
error: "Connection failed",
retries: 3,
});
// 2. Production logging to file
const prodLog = createProdLogger("app.log", {
source: "MyApplication",
rotationInterval: DAY,
includeConsole: true,
});
prodLog.info("User action", { userId: 456, action: "purchase", amount: 29.99 });
prodLog.warn("Low disk space", { available: 1024, threshold: 2048 });
// =============================================================================
// Custom Logger Configurations
@@ -67,16 +27,22 @@ print("\n=== Custom Logger Configurations ===");
// 4. Custom logger with specific processors and renderer
const customLogger = new Logger({
processors: [
addFullTimestamp(),
addComputerId(),
addSource("CustomApp"),
addStaticFields({
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("CustomApp"),
processor.addStaticFields({
environment: "development",
version: "2.1.0",
}),
],
renderer: jsonRenderer,
streams: [new ConsoleStream(), new FileStream("custom.log", HOUR)],
streams: [
new ConsoleStream(),
new FileStream({
filePath: "custom.log",
rotationInterval: HOUR,
}),
],
});
customLogger.info("Custom logger example", {
@@ -109,10 +75,10 @@ const sanitizePasswords = (event: Map<string, unknown>) => {
const secureLogger = new Logger({
processors: [
addTimestamp(),
processor.addTimestamp(),
addRequestId,
sanitizePasswords,
transformField("message", (msg) => `[SECURE] ${msg}`),
processor.transformField("message", (msg) => `[SECURE] ${msg}`),
],
renderer: jsonRenderer,
streams: [new ConsoleStream()],
@@ -133,7 +99,7 @@ print("\n=== Stream Examples ===");
// 11. Buffer stream for batch processing
const bufferStream = new BufferStream(100); // Keep last 100 messages
const bufferLogger = new Logger({
processors: [addFormattedTimestamp()],
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), (msg, event) => {
@@ -162,7 +128,7 @@ for (const msg of bufferedMessages) {
// 12. Multi-stream with different formats
const multiFormatLogger = new Logger({
processors: [addFullTimestamp(), addComputerId()],
processors: [processor.addTimestamp(), processor.addComputerId()],
renderer: (event) => "default", // This won't be used
streams: [
// Console with human-readable format
@@ -176,7 +142,10 @@ const multiFormatLogger = new Logger({
{
write: (_, event) => {
const formatted = jsonRenderer(event);
new FileStream("structured.log").write(formatted, event);
new FileStream({ filePath: "structured.log" }).write(
formatted,
event,
);
},
},
],
@@ -196,7 +165,7 @@ print("\n=== Error Handling Examples ===");
// 13. Robust error handling
const robustLogger = new Logger({
processors: [
addTimestamp(),
processor.addTimestamp(),
// Processor that might fail
(event) => {
try {
@@ -231,9 +200,9 @@ print("\n=== Cleanup Examples ===");
// 14. Proper cleanup
const fileLogger = new Logger({
processors: [addTimestamp()],
processors: [processor.addTimestamp()],
renderer: jsonRenderer,
streams: [new FileStream("temp.log")],
streams: [new FileStream({ filePath: "structured.log" })],
});
fileLogger.info("Temporary log entry");
@@ -249,37 +218,3 @@ print("- all.log (complete log)");
print("- debug.log (detailed debug info)");
print("- structured.log (JSON format)");
print("- temp.log (temporary file, now closed)");
// =============================================================================
// Performance Comparison (commented out to avoid noise)
// =============================================================================
/*
print("\n=== Performance Comparison ===");
const iterations = 1000;
// Test simple console logging
const startTime1 = os.clock();
const simpleLogger = createMinimalLogger();
for (let i = 0; i < iterations; i++) {
simpleLogger.info(`Simple message ${i}`);
}
const endTime1 = os.clock();
print(`Simple Console Logger: ${endTime1 - startTime1} seconds`);
// Test complex processor chain
const startTime2 = os.clock();
const complexLogger = createDetailedLogger("perf_test.log", {
source: "PerfTest"
});
for (let i = 0; i < iterations; i++) {
complexLogger.info(`Complex message ${i}`, {
iteration: i,
data: { nested: { value: i * 2 } }
});
}
complexLogger.close();
const endTime2 = os.clock();
print(`Complex Processor Chain: ${endTime2 - startTime2} seconds`);
*/