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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload validation for TRMNL webhooks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TIER_LIMITS, type ValidationResult, type WebhookPayload, type WebhookTier } from '../types.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate a webhook payload against size limits
|
|
9
|
+
*/
|
|
10
|
+
export function validatePayload(payload: WebhookPayload, tier: WebhookTier = 'free'): ValidationResult {
|
|
11
|
+
const jsonString = JSON.stringify(payload);
|
|
12
|
+
const sizeBytes = new TextEncoder().encode(jsonString).length;
|
|
13
|
+
const limitBytes = TIER_LIMITS[tier];
|
|
14
|
+
const remainingBytes = limitBytes - sizeBytes;
|
|
15
|
+
const percentUsed = Math.round((sizeBytes / limitBytes) * 1000) / 10;
|
|
16
|
+
|
|
17
|
+
const warnings: string[] = [];
|
|
18
|
+
const errors: string[] = [];
|
|
19
|
+
|
|
20
|
+
// Size check
|
|
21
|
+
if (sizeBytes > limitBytes) {
|
|
22
|
+
errors.push(`Payload exceeds ${tier} tier limit: ${sizeBytes} bytes > ${limitBytes} bytes`);
|
|
23
|
+
} else if (percentUsed > 90) {
|
|
24
|
+
warnings.push(`Payload is at ${percentUsed}% of ${tier} tier limit`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Content check
|
|
28
|
+
if (!payload.merge_variables) {
|
|
29
|
+
errors.push('Missing merge_variables object');
|
|
30
|
+
} else if (!payload.merge_variables.content && !payload.merge_variables.text) {
|
|
31
|
+
warnings.push('No content or text field in merge_variables');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// HTML sanity checks
|
|
35
|
+
const content = payload.merge_variables?.content || '';
|
|
36
|
+
if (content) {
|
|
37
|
+
// Check for unclosed tags (basic check)
|
|
38
|
+
const openDivs = (content.match(/<div/g) || []).length;
|
|
39
|
+
const closeDivs = (content.match(/<\/div>/g) || []).length;
|
|
40
|
+
if (openDivs !== closeDivs) {
|
|
41
|
+
warnings.push(`Potential unclosed divs: ${openDivs} open, ${closeDivs} close`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for common TRMNL patterns - look for 'layout' as a class (may have other classes too)
|
|
45
|
+
const hasLayoutClass = /class=["'][^"']*\blayout\b[^"']*["']/.test(content);
|
|
46
|
+
if (!hasLayoutClass) {
|
|
47
|
+
warnings.push('Missing .layout class - TRMNL requires a root layout element');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
valid: errors.length === 0,
|
|
53
|
+
size_bytes: sizeBytes,
|
|
54
|
+
tier,
|
|
55
|
+
limit_bytes: limitBytes,
|
|
56
|
+
remaining_bytes: remainingBytes,
|
|
57
|
+
percent_used: percentUsed,
|
|
58
|
+
warnings,
|
|
59
|
+
errors,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse content into a webhook payload
|
|
65
|
+
*/
|
|
66
|
+
export function createPayload(content: string): WebhookPayload {
|
|
67
|
+
// Try to parse as JSON first
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(content);
|
|
70
|
+
if (parsed.merge_variables) {
|
|
71
|
+
return parsed as WebhookPayload;
|
|
72
|
+
}
|
|
73
|
+
// If it's just merge_variables content
|
|
74
|
+
return { merge_variables: parsed };
|
|
75
|
+
} catch {
|
|
76
|
+
// Treat as raw HTML content
|
|
77
|
+
return {
|
|
78
|
+
merge_variables: {
|
|
79
|
+
content: content,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format validation result for display
|
|
87
|
+
*/
|
|
88
|
+
export function formatValidation(result: ValidationResult): string {
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
|
|
91
|
+
const status = result.valid ? '✓' : '✗';
|
|
92
|
+
const sizeKb = (result.size_bytes / 1024).toFixed(2);
|
|
93
|
+
const limitKb = (result.limit_bytes / 1024).toFixed(2);
|
|
94
|
+
|
|
95
|
+
lines.push(`${status} Payload: ${result.size_bytes} bytes (${sizeKb} KB)`);
|
|
96
|
+
lines.push(` Tier: ${result.tier} (limit: ${limitKb} KB)`);
|
|
97
|
+
lines.push(` Used: ${result.percent_used}% (${result.remaining_bytes} bytes remaining)`);
|
|
98
|
+
|
|
99
|
+
if (result.errors.length > 0) {
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push('Errors:');
|
|
102
|
+
for (const error of result.errors) {
|
|
103
|
+
lines.push(` ✗ ${error}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (result.warnings.length > 0) {
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push('Warnings:');
|
|
110
|
+
for (const warning of result.warnings) {
|
|
111
|
+
lines.push(` ⚠ ${warning}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook sending logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getTier, getWebhookUrl } from './config.ts';
|
|
6
|
+
import { logEntry } from './logger.ts';
|
|
7
|
+
import { validatePayload } from './validator.ts';
|
|
8
|
+
import type { HistoryEntry, WebhookPayload, WebhookTier } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
export interface SendResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
pluginName: string;
|
|
13
|
+
statusCode?: number;
|
|
14
|
+
response?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
validation: ReturnType<typeof validatePayload>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SendOptions {
|
|
21
|
+
plugin?: string; // Plugin name to use
|
|
22
|
+
webhookUrl?: string; // Direct URL override
|
|
23
|
+
skipValidation?: boolean;
|
|
24
|
+
skipLog?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Send payload to TRMNL webhook
|
|
29
|
+
*/
|
|
30
|
+
export async function sendToWebhook(
|
|
31
|
+
payload: WebhookPayload,
|
|
32
|
+
options: SendOptions = {}
|
|
33
|
+
): Promise<SendResult> {
|
|
34
|
+
const startTime = Date.now();
|
|
35
|
+
const tier = getTier();
|
|
36
|
+
|
|
37
|
+
// Resolve webhook URL
|
|
38
|
+
let webhookUrl: string;
|
|
39
|
+
let pluginName: string;
|
|
40
|
+
|
|
41
|
+
if (options.webhookUrl) {
|
|
42
|
+
// Direct URL override
|
|
43
|
+
webhookUrl = options.webhookUrl;
|
|
44
|
+
pluginName = 'custom';
|
|
45
|
+
} else {
|
|
46
|
+
// Get from config/env
|
|
47
|
+
const resolved = getWebhookUrl(options.plugin);
|
|
48
|
+
if (!resolved) {
|
|
49
|
+
const durationMs = Date.now() - startTime;
|
|
50
|
+
const validation = validatePayload(payload, tier);
|
|
51
|
+
|
|
52
|
+
let error = 'No webhook URL configured.';
|
|
53
|
+
if (options.plugin) {
|
|
54
|
+
error = `Plugin "${options.plugin}" not found.`;
|
|
55
|
+
} else {
|
|
56
|
+
error += ' Add a plugin with: trmnl plugin add <name> <url>';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
pluginName: options.plugin || 'unknown',
|
|
62
|
+
error,
|
|
63
|
+
durationMs,
|
|
64
|
+
validation,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
webhookUrl = resolved.url;
|
|
68
|
+
pluginName = resolved.name;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate payload
|
|
72
|
+
const validation = validatePayload(payload, tier);
|
|
73
|
+
|
|
74
|
+
if (!options.skipValidation && !validation.valid) {
|
|
75
|
+
const durationMs = Date.now() - startTime;
|
|
76
|
+
const result: SendResult = {
|
|
77
|
+
success: false,
|
|
78
|
+
pluginName,
|
|
79
|
+
error: validation.errors.join('; '),
|
|
80
|
+
durationMs,
|
|
81
|
+
validation,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Log failed validation
|
|
85
|
+
if (!options.skipLog) {
|
|
86
|
+
logEntry(createHistoryEntry(payload, result, tier, pluginName));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Send request
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(webhookUrl, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify(payload),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const durationMs = Date.now() - startTime;
|
|
103
|
+
const responseText = await response.text();
|
|
104
|
+
|
|
105
|
+
const result: SendResult = {
|
|
106
|
+
success: response.ok,
|
|
107
|
+
pluginName,
|
|
108
|
+
statusCode: response.status,
|
|
109
|
+
response: responseText,
|
|
110
|
+
durationMs,
|
|
111
|
+
validation,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
result.error = `HTTP ${response.status}: ${responseText}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Log the send
|
|
119
|
+
if (!options.skipLog) {
|
|
120
|
+
logEntry(createHistoryEntry(payload, result, tier, pluginName));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const durationMs = Date.now() - startTime;
|
|
127
|
+
const error = err instanceof Error ? err.message : 'Unknown error';
|
|
128
|
+
|
|
129
|
+
const result: SendResult = {
|
|
130
|
+
success: false,
|
|
131
|
+
pluginName,
|
|
132
|
+
error,
|
|
133
|
+
durationMs,
|
|
134
|
+
validation,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Log the error
|
|
138
|
+
if (!options.skipLog) {
|
|
139
|
+
logEntry(createHistoryEntry(payload, result, tier, pluginName));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a history entry from send result
|
|
148
|
+
*/
|
|
149
|
+
function createHistoryEntry(
|
|
150
|
+
payload: WebhookPayload,
|
|
151
|
+
result: SendResult,
|
|
152
|
+
tier: WebhookTier,
|
|
153
|
+
pluginName: string
|
|
154
|
+
): HistoryEntry {
|
|
155
|
+
return {
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
plugin: pluginName,
|
|
158
|
+
size_bytes: result.validation.size_bytes,
|
|
159
|
+
tier,
|
|
160
|
+
payload,
|
|
161
|
+
success: result.success,
|
|
162
|
+
status_code: result.statusCode,
|
|
163
|
+
response: result.response,
|
|
164
|
+
error: result.error,
|
|
165
|
+
duration_ms: result.durationMs,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRMNL CLI Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Webhook tier determines payload size limits */
|
|
6
|
+
export type WebhookTier = 'free' | 'plus';
|
|
7
|
+
|
|
8
|
+
/** Size limits per tier in bytes */
|
|
9
|
+
export const TIER_LIMITS: Record<WebhookTier, number> = {
|
|
10
|
+
free: 2048, // 2 KB
|
|
11
|
+
plus: 5120, // 5 KB
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Plugin configuration */
|
|
15
|
+
export interface Plugin {
|
|
16
|
+
url: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** CLI configuration stored in ~/.trmnl/config.json */
|
|
21
|
+
export interface Config {
|
|
22
|
+
plugins: Record<string, Plugin>;
|
|
23
|
+
defaultPlugin?: string;
|
|
24
|
+
tier?: WebhookTier; // Global tier setting
|
|
25
|
+
history?: {
|
|
26
|
+
path?: string;
|
|
27
|
+
maxSizeMb?: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Default config values */
|
|
32
|
+
export const DEFAULT_CONFIG: Config = {
|
|
33
|
+
plugins: {},
|
|
34
|
+
defaultPlugin: undefined,
|
|
35
|
+
tier: 'free',
|
|
36
|
+
history: {
|
|
37
|
+
path: '~/.trmnl/history.jsonl',
|
|
38
|
+
maxSizeMb: 100,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Merge variables payload structure */
|
|
43
|
+
export interface MergeVariables {
|
|
44
|
+
content?: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
text?: string;
|
|
47
|
+
image?: string;
|
|
48
|
+
[key: string]: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Webhook request payload */
|
|
52
|
+
export interface WebhookPayload {
|
|
53
|
+
merge_variables: MergeVariables;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** History entry stored in JSONL */
|
|
57
|
+
export interface HistoryEntry {
|
|
58
|
+
timestamp: string;
|
|
59
|
+
plugin: string;
|
|
60
|
+
size_bytes: number;
|
|
61
|
+
tier: WebhookTier;
|
|
62
|
+
payload: WebhookPayload;
|
|
63
|
+
success: boolean;
|
|
64
|
+
status_code?: number;
|
|
65
|
+
response?: string;
|
|
66
|
+
error?: string;
|
|
67
|
+
duration_ms: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Validation result */
|
|
71
|
+
export interface ValidationResult {
|
|
72
|
+
valid: boolean;
|
|
73
|
+
size_bytes: number;
|
|
74
|
+
tier: WebhookTier;
|
|
75
|
+
limit_bytes: number;
|
|
76
|
+
remaining_bytes: number;
|
|
77
|
+
percent_used: number;
|
|
78
|
+
warnings: string[];
|
|
79
|
+
errors: string[];
|
|
80
|
+
}
|