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/
|
.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