trmnl-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -0
- package/bin/trmnl.mjs +3 -0
- package/package.json +49 -0
- package/src/commands/config.ts +210 -0
- package/src/commands/history.ts +160 -0
- package/src/commands/send.ts +115 -0
- package/src/commands/validate.ts +88 -0
- package/src/index.ts +38 -0
- package/src/lib/config.ts +257 -0
- package/src/lib/index.ts +8 -0
- package/src/lib/logger.ts +141 -0
- package/src/lib/validator.ts +116 -0
- package/src/lib/webhook.ts +167 -0
- package/src/types.ts +80 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trmnl send - Send content to TRMNL display
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import type { CAC } from 'cac';
|
|
7
|
+
import { createPayload, formatValidation } from '../lib/validator.ts';
|
|
8
|
+
import { sendToWebhook } from '../lib/webhook.ts';
|
|
9
|
+
|
|
10
|
+
interface SendOptions {
|
|
11
|
+
content?: string;
|
|
12
|
+
file?: string;
|
|
13
|
+
plugin?: string;
|
|
14
|
+
webhook?: string;
|
|
15
|
+
skipValidation?: boolean;
|
|
16
|
+
skipLog?: boolean;
|
|
17
|
+
json?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerSendCommand(cli: CAC): void {
|
|
21
|
+
cli
|
|
22
|
+
.command('send', 'Send content to TRMNL display')
|
|
23
|
+
.option('-c, --content <html>', 'HTML content to send')
|
|
24
|
+
.option('-f, --file <path>', 'Read content from file')
|
|
25
|
+
.option('-p, --plugin <name>', 'Plugin to use (default: default plugin)')
|
|
26
|
+
.option('-w, --webhook <url>', 'Override webhook URL directly')
|
|
27
|
+
.option('--skip-validation', 'Skip payload validation')
|
|
28
|
+
.option('--skip-log', 'Skip history logging')
|
|
29
|
+
.option('--json', 'Output result as JSON')
|
|
30
|
+
.example('trmnl send --content "<div class=\\"layout\\">Hello</div>"')
|
|
31
|
+
.example('trmnl send --file ./output.html')
|
|
32
|
+
.example('trmnl send --file ./output.html --plugin office')
|
|
33
|
+
.example('echo \'{"merge_variables":{"content":"..."}}\' | trmnl send')
|
|
34
|
+
.action(async (options: SendOptions) => {
|
|
35
|
+
let content: string;
|
|
36
|
+
|
|
37
|
+
// Get content from options, file, or stdin
|
|
38
|
+
if (options.content) {
|
|
39
|
+
content = options.content;
|
|
40
|
+
} else if (options.file) {
|
|
41
|
+
try {
|
|
42
|
+
content = readFileSync(options.file, 'utf-8');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`Error reading file: ${options.file}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
// Try reading from stdin
|
|
49
|
+
content = await readStdin();
|
|
50
|
+
if (!content) {
|
|
51
|
+
console.error('No content provided. Use --content, --file, or pipe content via stdin.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create payload
|
|
57
|
+
const payload = createPayload(content);
|
|
58
|
+
|
|
59
|
+
// Send
|
|
60
|
+
const result = await sendToWebhook(payload, {
|
|
61
|
+
plugin: options.plugin,
|
|
62
|
+
webhookUrl: options.webhook,
|
|
63
|
+
skipValidation: options.skipValidation,
|
|
64
|
+
skipLog: options.skipLog,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Output
|
|
68
|
+
if (options.json) {
|
|
69
|
+
console.log(JSON.stringify(result, null, 2));
|
|
70
|
+
} else {
|
|
71
|
+
if (result.success) {
|
|
72
|
+
console.log(`✓ Sent to TRMNL (${result.pluginName})`);
|
|
73
|
+
console.log(` Status: ${result.statusCode}`);
|
|
74
|
+
console.log(` Time: ${result.durationMs}ms`);
|
|
75
|
+
console.log(` Size: ${result.validation.size_bytes} bytes (${result.validation.percent_used}% of limit)`);
|
|
76
|
+
} else {
|
|
77
|
+
console.error('✗ Failed to send');
|
|
78
|
+
console.error(` Error: ${result.error}`);
|
|
79
|
+
if (result.pluginName) {
|
|
80
|
+
console.error(` Plugin: ${result.pluginName}`);
|
|
81
|
+
}
|
|
82
|
+
console.log('');
|
|
83
|
+
console.log('Validation:');
|
|
84
|
+
console.log(formatValidation(result.validation));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
process.exit(result.success ? 0 : 1);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read content from stdin (non-blocking check)
|
|
94
|
+
*/
|
|
95
|
+
async function readStdin(): Promise<string> {
|
|
96
|
+
// Check if stdin has data (not a TTY)
|
|
97
|
+
if (process.stdin.isTTY) {
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const chunks: Buffer[] = [];
|
|
102
|
+
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
105
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').trim()));
|
|
106
|
+
process.stdin.on('error', () => resolve(''));
|
|
107
|
+
|
|
108
|
+
// Timeout to avoid hanging
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
if (chunks.length === 0) {
|
|
111
|
+
resolve('');
|
|
112
|
+
}
|
|
113
|
+
}, 100);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trmnl validate - Validate payload without sending
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import type { CAC } from 'cac';
|
|
7
|
+
import { getTier } from '../lib/config.ts';
|
|
8
|
+
import { createPayload, formatValidation, validatePayload } from '../lib/validator.ts';
|
|
9
|
+
import type { WebhookTier } from '../types.ts';
|
|
10
|
+
|
|
11
|
+
interface ValidateOptions {
|
|
12
|
+
content?: string;
|
|
13
|
+
file?: string;
|
|
14
|
+
tier?: WebhookTier;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerValidateCommand(cli: CAC): void {
|
|
19
|
+
cli
|
|
20
|
+
.command('validate', 'Validate payload without sending')
|
|
21
|
+
.option('-c, --content <html>', 'HTML content to validate')
|
|
22
|
+
.option('-f, --file <path>', 'Read content from file')
|
|
23
|
+
.option('-t, --tier <tier>', 'Override tier (free or plus)')
|
|
24
|
+
.option('--json', 'Output result as JSON')
|
|
25
|
+
.example('trmnl validate --file ./output.html')
|
|
26
|
+
.example('trmnl validate --content "<div>...</div>" --tier plus')
|
|
27
|
+
.action(async (options: ValidateOptions) => {
|
|
28
|
+
let content: string;
|
|
29
|
+
|
|
30
|
+
// Get content from options, file, or stdin
|
|
31
|
+
if (options.content) {
|
|
32
|
+
content = options.content;
|
|
33
|
+
} else if (options.file) {
|
|
34
|
+
try {
|
|
35
|
+
content = readFileSync(options.file, 'utf-8');
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(`Error reading file: ${options.file}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
// Try reading from stdin
|
|
42
|
+
content = await readStdin();
|
|
43
|
+
if (!content) {
|
|
44
|
+
console.error('No content provided. Use --content, --file, or pipe content via stdin.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Use explicit tier or global config
|
|
50
|
+
const tier = options.tier || getTier();
|
|
51
|
+
|
|
52
|
+
// Create payload and validate
|
|
53
|
+
const payload = createPayload(content);
|
|
54
|
+
const result = validatePayload(payload, tier);
|
|
55
|
+
|
|
56
|
+
// Output
|
|
57
|
+
if (options.json) {
|
|
58
|
+
console.log(JSON.stringify(result, null, 2));
|
|
59
|
+
} else {
|
|
60
|
+
console.log(formatValidation(result));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
process.exit(result.valid ? 0 : 1);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read content from stdin (non-blocking check)
|
|
69
|
+
*/
|
|
70
|
+
async function readStdin(): Promise<string> {
|
|
71
|
+
if (process.stdin.isTTY) {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const chunks: Buffer[] = [];
|
|
76
|
+
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
79
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').trim()));
|
|
80
|
+
process.stdin.on('error', () => resolve(''));
|
|
81
|
+
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
if (chunks.length === 0) {
|
|
84
|
+
resolve('');
|
|
85
|
+
}
|
|
86
|
+
}, 100);
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* trmnl-cli - CLI tool for TRMNL e-ink displays
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* trmnl send - Send content to TRMNL display
|
|
7
|
+
* trmnl validate - Validate payload without sending
|
|
8
|
+
* trmnl config - Manage CLI configuration
|
|
9
|
+
* trmnl history - View send history
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import cac from 'cac';
|
|
13
|
+
import { registerConfigCommand } from './commands/config.ts';
|
|
14
|
+
import { registerHistoryCommand } from './commands/history.ts';
|
|
15
|
+
import { registerSendCommand } from './commands/send.ts';
|
|
16
|
+
import { registerValidateCommand } from './commands/validate.ts';
|
|
17
|
+
|
|
18
|
+
const cli = cac('trmnl');
|
|
19
|
+
|
|
20
|
+
// Version from package.json
|
|
21
|
+
cli.version('0.1.0');
|
|
22
|
+
|
|
23
|
+
// Register commands
|
|
24
|
+
registerSendCommand(cli);
|
|
25
|
+
registerValidateCommand(cli);
|
|
26
|
+
registerConfigCommand(cli);
|
|
27
|
+
registerHistoryCommand(cli);
|
|
28
|
+
|
|
29
|
+
// Help text
|
|
30
|
+
cli.help();
|
|
31
|
+
|
|
32
|
+
// Default action (no command)
|
|
33
|
+
cli.command('').action(() => {
|
|
34
|
+
cli.outputHelp();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Parse and run
|
|
38
|
+
cli.parse();
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config file management for ~/.trmnl/config.json
|
|
3
|
+
* Supports multiple plugins with a default
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import type { Config, Plugin, WebhookTier } from '../types.ts';
|
|
10
|
+
import { DEFAULT_CONFIG } from '../types.ts';
|
|
11
|
+
|
|
12
|
+
/** Config directory path */
|
|
13
|
+
export const CONFIG_DIR = join(homedir(), '.trmnl');
|
|
14
|
+
|
|
15
|
+
/** Config file path */
|
|
16
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
17
|
+
|
|
18
|
+
/** Legacy TOML config path (for migration) */
|
|
19
|
+
const LEGACY_CONFIG_PATH = join(CONFIG_DIR, 'config.toml');
|
|
20
|
+
|
|
21
|
+
/** History file path */
|
|
22
|
+
export const HISTORY_PATH = join(CONFIG_DIR, 'history.jsonl');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure ~/.trmnl directory exists
|
|
26
|
+
*/
|
|
27
|
+
export function ensureConfigDir(): void {
|
|
28
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
29
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Migrate legacy TOML config to JSON (one-time)
|
|
35
|
+
*/
|
|
36
|
+
function migrateLegacyConfig(): Config | null {
|
|
37
|
+
if (!existsSync(LEGACY_CONFIG_PATH)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const content = readFileSync(LEGACY_CONFIG_PATH, 'utf-8');
|
|
43
|
+
const config: Config = { plugins: {}, tier: 'free' };
|
|
44
|
+
|
|
45
|
+
// Parse legacy TOML (simple parser for our format)
|
|
46
|
+
let webhookUrl: string | undefined;
|
|
47
|
+
|
|
48
|
+
for (const line of content.split('\n')) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
const urlMatch = trimmed.match(/^url\s*=\s*"([^"]+)"/);
|
|
51
|
+
if (urlMatch) webhookUrl = urlMatch[1];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (webhookUrl) {
|
|
55
|
+
config.plugins['default'] = { url: webhookUrl };
|
|
56
|
+
config.defaultPlugin = 'default';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove legacy file after migration
|
|
60
|
+
unlinkSync(LEGACY_CONFIG_PATH);
|
|
61
|
+
console.log('Migrated legacy config.toml to config.json');
|
|
62
|
+
|
|
63
|
+
return config;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load config from file
|
|
71
|
+
*/
|
|
72
|
+
export function loadConfig(): Config {
|
|
73
|
+
ensureConfigDir();
|
|
74
|
+
|
|
75
|
+
// Try to migrate legacy config first
|
|
76
|
+
const migrated = migrateLegacyConfig();
|
|
77
|
+
if (migrated) {
|
|
78
|
+
saveConfig(migrated);
|
|
79
|
+
return migrated;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
83
|
+
return { ...DEFAULT_CONFIG };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const content = readFileSync(CONFIG_PATH, 'utf-8');
|
|
88
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
89
|
+
return {
|
|
90
|
+
plugins: parsed.plugins || {},
|
|
91
|
+
defaultPlugin: parsed.defaultPlugin,
|
|
92
|
+
tier: parsed.tier || 'free',
|
|
93
|
+
history: parsed.history || DEFAULT_CONFIG.history,
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
return { ...DEFAULT_CONFIG };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Save config to file
|
|
102
|
+
*/
|
|
103
|
+
export function saveConfig(config: Config): void {
|
|
104
|
+
ensureConfigDir();
|
|
105
|
+
const content = JSON.stringify(config, null, 2);
|
|
106
|
+
writeFileSync(CONFIG_PATH, content, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get a plugin by name (or default if not specified)
|
|
111
|
+
*/
|
|
112
|
+
export function getPlugin(name?: string): { name: string; plugin: Plugin } | null {
|
|
113
|
+
const config = loadConfig();
|
|
114
|
+
|
|
115
|
+
// If name specified, use that
|
|
116
|
+
if (name) {
|
|
117
|
+
const plugin = config.plugins[name];
|
|
118
|
+
if (plugin) {
|
|
119
|
+
return { name, plugin };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Use default plugin
|
|
125
|
+
const defaultName = config.defaultPlugin;
|
|
126
|
+
if (defaultName && config.plugins[defaultName]) {
|
|
127
|
+
return { name: defaultName, plugin: config.plugins[defaultName] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// If only one plugin, use it
|
|
131
|
+
const pluginNames = Object.keys(config.plugins);
|
|
132
|
+
if (pluginNames.length === 1) {
|
|
133
|
+
const name = pluginNames[0];
|
|
134
|
+
return { name, plugin: config.plugins[name] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Add or update a plugin
|
|
142
|
+
*/
|
|
143
|
+
export function setPlugin(name: string, url: string, description?: string): void {
|
|
144
|
+
const config = loadConfig();
|
|
145
|
+
config.plugins[name] = { url, description };
|
|
146
|
+
|
|
147
|
+
// If no default and this is the first plugin, make it default
|
|
148
|
+
if (!config.defaultPlugin || Object.keys(config.plugins).length === 1) {
|
|
149
|
+
config.defaultPlugin = name;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
saveConfig(config);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Remove a plugin
|
|
157
|
+
*/
|
|
158
|
+
export function removePlugin(name: string): boolean {
|
|
159
|
+
const config = loadConfig();
|
|
160
|
+
|
|
161
|
+
if (!config.plugins[name]) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
delete config.plugins[name];
|
|
166
|
+
|
|
167
|
+
// If we removed the default, pick a new one
|
|
168
|
+
if (config.defaultPlugin === name) {
|
|
169
|
+
const remaining = Object.keys(config.plugins);
|
|
170
|
+
config.defaultPlugin = remaining.length > 0 ? remaining[0] : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
saveConfig(config);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Set the default plugin
|
|
179
|
+
*/
|
|
180
|
+
export function setDefaultPlugin(name: string): boolean {
|
|
181
|
+
const config = loadConfig();
|
|
182
|
+
|
|
183
|
+
if (!config.plugins[name]) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
config.defaultPlugin = name;
|
|
188
|
+
saveConfig(config);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* List all plugins
|
|
194
|
+
*/
|
|
195
|
+
export function listPlugins(): Array<{ name: string; plugin: Plugin; isDefault: boolean }> {
|
|
196
|
+
const config = loadConfig();
|
|
197
|
+
return Object.entries(config.plugins).map(([name, plugin]) => ({
|
|
198
|
+
name,
|
|
199
|
+
plugin,
|
|
200
|
+
isDefault: name === config.defaultPlugin,
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get global tier setting
|
|
206
|
+
*/
|
|
207
|
+
export function getTier(): WebhookTier {
|
|
208
|
+
const config = loadConfig();
|
|
209
|
+
return config.tier || 'free';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Set global tier setting
|
|
214
|
+
*/
|
|
215
|
+
export function setTier(tier: WebhookTier): void {
|
|
216
|
+
const config = loadConfig();
|
|
217
|
+
config.tier = tier;
|
|
218
|
+
saveConfig(config);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Get webhook URL from environment, plugin name, or default
|
|
223
|
+
*/
|
|
224
|
+
export function getWebhookUrl(pluginName?: string): { url: string; name: string } | null {
|
|
225
|
+
// Environment variable takes highest precedence
|
|
226
|
+
const envUrl = process.env.TRMNL_WEBHOOK;
|
|
227
|
+
if (envUrl) {
|
|
228
|
+
return { url: envUrl, name: '$TRMNL_WEBHOOK' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Try to get plugin
|
|
232
|
+
const result = getPlugin(pluginName);
|
|
233
|
+
if (result) {
|
|
234
|
+
return {
|
|
235
|
+
url: result.plugin.url,
|
|
236
|
+
name: result.name,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get history config
|
|
245
|
+
*/
|
|
246
|
+
export function getHistoryConfig(): { path: string; maxSizeMb: number } {
|
|
247
|
+
const config = loadConfig();
|
|
248
|
+
const historyPath = config.history?.path || DEFAULT_CONFIG.history!.path!;
|
|
249
|
+
const expandedPath = historyPath.startsWith('~')
|
|
250
|
+
? historyPath.replace('~', homedir())
|
|
251
|
+
: historyPath;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
path: expandedPath,
|
|
255
|
+
maxSizeMb: config.history?.maxSizeMb || DEFAULT_CONFIG.history!.maxSizeMb!,
|
|
256
|
+
};
|
|
257
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL history logger for tracking sent payloads
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { ensureConfigDir, getHistoryConfig } from './config.ts';
|
|
7
|
+
import type { HistoryEntry } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Append a history entry to the JSONL file
|
|
11
|
+
*/
|
|
12
|
+
export function logEntry(entry: HistoryEntry): void {
|
|
13
|
+
ensureConfigDir();
|
|
14
|
+
const { path } = getHistoryConfig();
|
|
15
|
+
const line = JSON.stringify(entry) + '\n';
|
|
16
|
+
appendFileSync(path, line, 'utf-8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read all history entries
|
|
21
|
+
*/
|
|
22
|
+
export function readHistory(): HistoryEntry[] {
|
|
23
|
+
const { path } = getHistoryConfig();
|
|
24
|
+
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const content = readFileSync(path, 'utf-8');
|
|
31
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
32
|
+
return lines.map(line => JSON.parse(line) as HistoryEntry);
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get history entries with filters
|
|
40
|
+
*/
|
|
41
|
+
export interface HistoryFilter {
|
|
42
|
+
last?: number;
|
|
43
|
+
today?: boolean;
|
|
44
|
+
failed?: boolean;
|
|
45
|
+
success?: boolean;
|
|
46
|
+
plugin?: string;
|
|
47
|
+
since?: Date;
|
|
48
|
+
until?: Date;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getHistory(filter: HistoryFilter = {}): HistoryEntry[] {
|
|
52
|
+
let entries = readHistory();
|
|
53
|
+
|
|
54
|
+
// Filter by plugin
|
|
55
|
+
if (filter.plugin) {
|
|
56
|
+
entries = entries.filter(e => e.plugin === filter.plugin);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Filter by success/failed
|
|
60
|
+
if (filter.failed) {
|
|
61
|
+
entries = entries.filter(e => !e.success);
|
|
62
|
+
}
|
|
63
|
+
if (filter.success) {
|
|
64
|
+
entries = entries.filter(e => e.success);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Filter by date
|
|
68
|
+
if (filter.today) {
|
|
69
|
+
const today = new Date();
|
|
70
|
+
today.setHours(0, 0, 0, 0);
|
|
71
|
+
entries = entries.filter(e => new Date(e.timestamp) >= today);
|
|
72
|
+
}
|
|
73
|
+
if (filter.since) {
|
|
74
|
+
entries = entries.filter(e => new Date(e.timestamp) >= filter.since!);
|
|
75
|
+
}
|
|
76
|
+
if (filter.until) {
|
|
77
|
+
entries = entries.filter(e => new Date(e.timestamp) <= filter.until!);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Sort by timestamp descending (most recent first)
|
|
81
|
+
entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
82
|
+
|
|
83
|
+
// Limit results
|
|
84
|
+
if (filter.last) {
|
|
85
|
+
entries = entries.slice(0, filter.last);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format a history entry for display
|
|
93
|
+
*/
|
|
94
|
+
export function formatEntry(entry: HistoryEntry, verbose = false): string {
|
|
95
|
+
const time = new Date(entry.timestamp).toLocaleString();
|
|
96
|
+
const status = entry.success ? '✓' : '✗';
|
|
97
|
+
const sizeKb = (entry.size_bytes / 1024).toFixed(2);
|
|
98
|
+
|
|
99
|
+
let line = `${status} ${time} | ${entry.plugin} | ${sizeKb} KB | ${entry.duration_ms}ms`;
|
|
100
|
+
|
|
101
|
+
if (!entry.success && entry.error) {
|
|
102
|
+
line += ` | ${entry.error}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (verbose && entry.payload?.merge_variables?.content) {
|
|
106
|
+
const preview = entry.payload.merge_variables.content.substring(0, 80);
|
|
107
|
+
line += `\n ${preview}${entry.payload.merge_variables.content.length > 80 ? '...' : ''}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return line;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get history file stats
|
|
115
|
+
*/
|
|
116
|
+
export function getHistoryStats(): { entries: number; sizeBytes: number; sizeMb: number } | null {
|
|
117
|
+
const { path } = getHistoryConfig();
|
|
118
|
+
|
|
119
|
+
if (!existsSync(path)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const stats = statSync(path);
|
|
125
|
+
const entries = readHistory().length;
|
|
126
|
+
return {
|
|
127
|
+
entries,
|
|
128
|
+
sizeBytes: stats.size,
|
|
129
|
+
sizeMb: Math.round((stats.size / 1024 / 1024) * 100) / 100,
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get history file path
|
|
138
|
+
*/
|
|
139
|
+
export function getHistoryPath(): string {
|
|
140
|
+
return getHistoryConfig().path;
|
|
141
|
+
}
|