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,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
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Library exports for programmatic use
3
+ */
4
+
5
+ export * from './config.ts';
6
+ export * from './logger.ts';
7
+ export * from './validator.ts';
8
+ export * from './webhook.ts';
@@ -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
+ }