Scaffold project with logger and config
Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
8
.gitignore
vendored
@@ -1 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.superpowers/
|
||||
workspace/inbox/*
|
||||
!workspace/inbox/.gitkeep
|
||||
workspace/scripts/*
|
||||
!workspace/scripts/.gitkeep
|
||||
workspace/.secretary/
|
||||
|
||||
4059
package-lock.json
generated
Normal file
4059
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
44
src/config.test.ts
Normal 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
62
src/config.ts
Normal 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
25
src/log.test.ts
Normal 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
36
src/log.ts
Normal 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
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.ts", "src/test-support/**"]
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal 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
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
testTimeout: 20_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user