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.
@@ -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
+ }