Scaffold project with logger and config

Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
EugeneTes
2026-09-06 14:19:31 +02:00
parent 794580ee3a
commit 7373c51995
11 changed files with 4303 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Required
TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
TELEGRAM_OWNER_ID=123456789
# Optional
WORKSPACE_DIR=./workspace
OPENAI_API_KEY=
STT_MODEL=gpt-4o-transcribe
PI_MODEL=
SECRETARY_TIMEZONE=
SECRETARY_SHELL=
LOG_LEVEL=info
# PI_CODING_AGENT_DIR= (Pi's own override for ~/.pi/agent, mainly for Docker)

8
.gitignore vendored
View File

@@ -1 +1,9 @@
node_modules/
dist/
.env
.superpowers/ .superpowers/
workspace/inbox/*
!workspace/inbox/.gitkeep
workspace/scripts/*
!workspace/scripts/.gitkeep
workspace/.secretary/

4059
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "volcanic-secretary",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"dev": "node --env-file-if-exists=.env --import tsx src/main.ts",
"start": "node --env-file-if-exists=.env dist/main.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.85.1",
"@grammyjs/auto-retry": "^2.0.2",
"croner": "^10.0.1",
"grammy": "^1.46.0",
"typebox": "^1.3.27"
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.23.13",
"typescript": "^5.9.0",
"vitest": "^5.0.0"
}
}

44
src/config.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ConfigError, loadConfig } from "./config.js";
const base = { TELEGRAM_BOT_TOKEN: "123:abc", TELEGRAM_OWNER_ID: "42" };
describe("loadConfig", () => {
it("applies defaults and resolves the workspace against cwd", () => {
const cfg = loadConfig(base, "/proj");
expect(cfg.telegramBotToken).toBe("123:abc");
expect(cfg.telegramOwnerId).toBe(42);
expect(cfg.workspaceDir).toBe(path.resolve("/proj", "workspace"));
expect(cfg.sttModel).toBe("gpt-4o-transcribe");
expect(cfg.logLevel).toBe("info");
expect(cfg.openaiApiKey).toBeUndefined();
expect(cfg.piModel).toBeUndefined();
expect(typeof cfg.timezone).toBe("string");
});
it("reads optional values and treats blank strings as unset", () => {
const cfg = loadConfig(
{ ...base, OPENAI_API_KEY: "sk", PI_MODEL: " ", SECRETARY_TIMEZONE: "Europe/Berlin", LOG_LEVEL: "debug", WORKSPACE_DIR: "/abs/ws", SECRETARY_SHELL: "/bin/bash" },
"/proj",
);
expect(cfg.openaiApiKey).toBe("sk");
expect(cfg.piModel).toBeUndefined();
expect(cfg.timezone).toBe("Europe/Berlin");
expect(cfg.logLevel).toBe("debug");
expect(cfg.workspaceDir).toBe(path.resolve("/abs/ws"));
expect(cfg.shellPath).toBe("/bin/bash");
});
it("rejects missing required variables by name", () => {
expect(() => loadConfig({ TELEGRAM_OWNER_ID: "42" }, "/p")).toThrow(ConfigError);
expect(() => loadConfig({ TELEGRAM_OWNER_ID: "42" }, "/p")).toThrow(/TELEGRAM_BOT_TOKEN/);
expect(() => loadConfig({ TELEGRAM_BOT_TOKEN: "x" }, "/p")).toThrow(/TELEGRAM_OWNER_ID/);
});
it("rejects a non-numeric owner id, bad log level, and bad timezone", () => {
expect(() => loadConfig({ ...base, TELEGRAM_OWNER_ID: "bob" }, "/p")).toThrow(/positive integer/);
expect(() => loadConfig({ ...base, LOG_LEVEL: "loud" }, "/p")).toThrow(/LOG_LEVEL/);
expect(() => loadConfig({ ...base, SECRETARY_TIMEZONE: "Mars/Olympus" }, "/p")).toThrow(/timezone/);
});
});

62
src/config.ts Normal file
View File

@@ -0,0 +1,62 @@
import path from "node:path";
import type { LogLevel } from "./log.js";
export interface Config {
telegramBotToken: string;
telegramOwnerId: number;
workspaceDir: string;
openaiApiKey?: string;
sttModel: string;
piModel?: string;
timezone: string;
shellPath?: string;
logLevel: LogLevel;
}
export class ConfigError extends Error {}
const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
export function loadConfig(env: NodeJS.ProcessEnv = process.env, cwd: string = process.cwd()): Config {
const telegramBotToken = required(env, "TELEGRAM_BOT_TOKEN");
const ownerRaw = required(env, "TELEGRAM_OWNER_ID");
const telegramOwnerId = Number(ownerRaw);
if (!Number.isInteger(telegramOwnerId) || telegramOwnerId <= 0) {
throw new ConfigError(`TELEGRAM_OWNER_ID must be a positive integer, got "${ownerRaw}"`);
}
const logLevel = (env.LOG_LEVEL?.trim() || "info") as LogLevel;
if (!LOG_LEVELS.includes(logLevel)) {
throw new ConfigError(`LOG_LEVEL must be one of ${LOG_LEVELS.join(", ")}, got "${logLevel}"`);
}
const timezone = env.SECRETARY_TIMEZONE?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone;
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
} catch {
throw new ConfigError(`SECRETARY_TIMEZONE is not a valid IANA timezone: "${timezone}"`);
}
return {
telegramBotToken,
telegramOwnerId,
workspaceDir: path.resolve(cwd, env.WORKSPACE_DIR?.trim() || "workspace"),
openaiApiKey: blankToUndefined(env.OPENAI_API_KEY),
sttModel: env.STT_MODEL?.trim() || "gpt-4o-transcribe",
piModel: blankToUndefined(env.PI_MODEL),
timezone,
shellPath: blankToUndefined(env.SECRETARY_SHELL),
logLevel,
};
}
function required(env: NodeJS.ProcessEnv, key: string): string {
const value = env[key]?.trim();
if (!value) throw new ConfigError(`${key} is required`);
return value;
}
function blankToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

25
src/log.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { createLogger } from "./log.js";
describe("createLogger", () => {
it("drops messages below the configured level", () => {
const lines: string[] = [];
const log = createLogger("warn", (l) => lines.push(l));
log.debug("d");
log.info("i");
log.warn("w");
log.error("e");
expect(lines).toHaveLength(2);
expect(lines[0]).toMatch(/ WARN w$/);
expect(lines[1]).toMatch(/ ERROR e$/);
});
it("appends serialized meta and formats errors by name and message", () => {
const lines: string[] = [];
const log = createLogger("debug", (l) => lines.push(l));
log.info("with meta", { a: 1 });
log.error("boom", new Error("bad"));
expect(lines[0]).toMatch(/with meta \{"a":1\}$/);
expect(lines[1]).toMatch(/boom Error: bad$/);
});
});

36
src/log.ts Normal file
View File

@@ -0,0 +1,36 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface Logger {
debug(message: string, meta?: unknown): void;
info(message: string, meta?: unknown): void;
warn(message: string, meta?: unknown): void;
error(message: string, meta?: unknown): void;
}
const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
export function createLogger(
level: LogLevel,
out: (line: string) => void = (line) => process.stdout.write(line + "\n"),
): Logger {
const emit = (lvl: LogLevel, message: string, meta?: unknown) => {
if (ORDER[lvl] < ORDER[level]) return;
const suffix = meta === undefined ? "" : " " + formatMeta(meta);
out(`${new Date().toISOString()} ${lvl.toUpperCase().padEnd(5)} ${message}${suffix}`);
};
return {
debug: (m, x) => emit("debug", m, x),
info: (m, x) => emit("info", m, x),
warn: (m, x) => emit("warn", m, x),
error: (m, x) => emit("error", m, x),
};
}
function formatMeta(meta: unknown): string {
if (meta instanceof Error) return `${meta.name}: ${meta.message}`;
try {
return JSON.stringify(meta);
} catch {
return String(meta);
}
}

4
tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts", "src/test-support/**"]
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
testTimeout: 20_000,
},
});