trmnl-cli 0.1.0 → 0.1.2
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/dist/index.js +830 -0
- package/dist/index.js.map +1 -0
- package/package.json +11 -7
- package/bin/trmnl.mjs +0 -3
- package/src/commands/config.ts +0 -210
- package/src/commands/history.ts +0 -160
- package/src/commands/send.ts +0 -115
- package/src/commands/validate.ts +0 -88
- package/src/index.ts +0 -38
- package/src/lib/config.ts +0 -257
- package/src/lib/index.ts +0 -8
- package/src/lib/logger.ts +0 -141
- package/src/lib/validator.ts +0 -116
- package/src/lib/webhook.ts +0 -167
- package/src/types.ts +0 -80
package/dist/index.js
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import cac from "cac";
|
|
4
|
+
|
|
5
|
+
// src/lib/config.ts
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
|
|
10
|
+
// src/types.ts
|
|
11
|
+
var TIER_LIMITS = {
|
|
12
|
+
free: 2048,
|
|
13
|
+
// 2 KB
|
|
14
|
+
plus: 5120
|
|
15
|
+
// 5 KB
|
|
16
|
+
};
|
|
17
|
+
var DEFAULT_CONFIG = {
|
|
18
|
+
plugins: {},
|
|
19
|
+
defaultPlugin: void 0,
|
|
20
|
+
tier: "free",
|
|
21
|
+
history: {
|
|
22
|
+
path: "~/.trmnl/history.jsonl",
|
|
23
|
+
maxSizeMb: 100
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/lib/config.ts
|
|
28
|
+
var CONFIG_DIR = join(homedir(), ".trmnl");
|
|
29
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
30
|
+
var LEGACY_CONFIG_PATH = join(CONFIG_DIR, "config.toml");
|
|
31
|
+
var HISTORY_PATH = join(CONFIG_DIR, "history.jsonl");
|
|
32
|
+
function ensureConfigDir() {
|
|
33
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
34
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function migrateLegacyConfig() {
|
|
38
|
+
if (!existsSync(LEGACY_CONFIG_PATH)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const content = readFileSync(LEGACY_CONFIG_PATH, "utf-8");
|
|
43
|
+
const config = { plugins: {}, tier: "free" };
|
|
44
|
+
let webhookUrl;
|
|
45
|
+
for (const line of content.split("\n")) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
const urlMatch = trimmed.match(/^url\s*=\s*"([^"]+)"/);
|
|
48
|
+
if (urlMatch) webhookUrl = urlMatch[1];
|
|
49
|
+
}
|
|
50
|
+
if (webhookUrl) {
|
|
51
|
+
config.plugins["default"] = { url: webhookUrl };
|
|
52
|
+
config.defaultPlugin = "default";
|
|
53
|
+
}
|
|
54
|
+
unlinkSync(LEGACY_CONFIG_PATH);
|
|
55
|
+
console.log("Migrated legacy config.toml to config.json");
|
|
56
|
+
return config;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function loadConfig() {
|
|
62
|
+
ensureConfigDir();
|
|
63
|
+
const migrated = migrateLegacyConfig();
|
|
64
|
+
if (migrated) {
|
|
65
|
+
saveConfig(migrated);
|
|
66
|
+
return migrated;
|
|
67
|
+
}
|
|
68
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
69
|
+
return { ...DEFAULT_CONFIG };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(CONFIG_PATH, "utf-8");
|
|
73
|
+
const parsed = JSON.parse(content);
|
|
74
|
+
return {
|
|
75
|
+
plugins: parsed.plugins || {},
|
|
76
|
+
defaultPlugin: parsed.defaultPlugin,
|
|
77
|
+
tier: parsed.tier || "free",
|
|
78
|
+
history: parsed.history || DEFAULT_CONFIG.history
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return { ...DEFAULT_CONFIG };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function saveConfig(config) {
|
|
85
|
+
ensureConfigDir();
|
|
86
|
+
const content = JSON.stringify(config, null, 2);
|
|
87
|
+
writeFileSync(CONFIG_PATH, content, "utf-8");
|
|
88
|
+
}
|
|
89
|
+
function getPlugin(name) {
|
|
90
|
+
const config = loadConfig();
|
|
91
|
+
if (name) {
|
|
92
|
+
const plugin = config.plugins[name];
|
|
93
|
+
if (plugin) {
|
|
94
|
+
return { name, plugin };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const defaultName = config.defaultPlugin;
|
|
99
|
+
if (defaultName && config.plugins[defaultName]) {
|
|
100
|
+
return { name: defaultName, plugin: config.plugins[defaultName] };
|
|
101
|
+
}
|
|
102
|
+
const pluginNames = Object.keys(config.plugins);
|
|
103
|
+
if (pluginNames.length === 1) {
|
|
104
|
+
const name2 = pluginNames[0];
|
|
105
|
+
return { name: name2, plugin: config.plugins[name2] };
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function setPlugin(name, url, description) {
|
|
110
|
+
const config = loadConfig();
|
|
111
|
+
config.plugins[name] = { url, description };
|
|
112
|
+
if (!config.defaultPlugin || Object.keys(config.plugins).length === 1) {
|
|
113
|
+
config.defaultPlugin = name;
|
|
114
|
+
}
|
|
115
|
+
saveConfig(config);
|
|
116
|
+
}
|
|
117
|
+
function removePlugin(name) {
|
|
118
|
+
const config = loadConfig();
|
|
119
|
+
if (!config.plugins[name]) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
delete config.plugins[name];
|
|
123
|
+
if (config.defaultPlugin === name) {
|
|
124
|
+
const remaining = Object.keys(config.plugins);
|
|
125
|
+
config.defaultPlugin = remaining.length > 0 ? remaining[0] : void 0;
|
|
126
|
+
}
|
|
127
|
+
saveConfig(config);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
function setDefaultPlugin(name) {
|
|
131
|
+
const config = loadConfig();
|
|
132
|
+
if (!config.plugins[name]) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
config.defaultPlugin = name;
|
|
136
|
+
saveConfig(config);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
function listPlugins() {
|
|
140
|
+
const config = loadConfig();
|
|
141
|
+
return Object.entries(config.plugins).map(([name, plugin]) => ({
|
|
142
|
+
name,
|
|
143
|
+
plugin,
|
|
144
|
+
isDefault: name === config.defaultPlugin
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
function getTier() {
|
|
148
|
+
const config = loadConfig();
|
|
149
|
+
return config.tier || "free";
|
|
150
|
+
}
|
|
151
|
+
function setTier(tier) {
|
|
152
|
+
const config = loadConfig();
|
|
153
|
+
config.tier = tier;
|
|
154
|
+
saveConfig(config);
|
|
155
|
+
}
|
|
156
|
+
function getWebhookUrl(pluginName) {
|
|
157
|
+
const envUrl = process.env.TRMNL_WEBHOOK;
|
|
158
|
+
if (envUrl) {
|
|
159
|
+
return { url: envUrl, name: "$TRMNL_WEBHOOK" };
|
|
160
|
+
}
|
|
161
|
+
const result = getPlugin(pluginName);
|
|
162
|
+
if (result) {
|
|
163
|
+
return {
|
|
164
|
+
url: result.plugin.url,
|
|
165
|
+
name: result.name
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
function getHistoryConfig() {
|
|
171
|
+
const config = loadConfig();
|
|
172
|
+
const historyPath = config.history?.path || DEFAULT_CONFIG.history.path;
|
|
173
|
+
const expandedPath = historyPath.startsWith("~") ? historyPath.replace("~", homedir()) : historyPath;
|
|
174
|
+
return {
|
|
175
|
+
path: expandedPath,
|
|
176
|
+
maxSizeMb: config.history?.maxSizeMb || DEFAULT_CONFIG.history.maxSizeMb
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/lib/logger.ts
|
|
181
|
+
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
182
|
+
function logEntry(entry) {
|
|
183
|
+
ensureConfigDir();
|
|
184
|
+
const { path } = getHistoryConfig();
|
|
185
|
+
const line = JSON.stringify(entry) + "\n";
|
|
186
|
+
appendFileSync(path, line, "utf-8");
|
|
187
|
+
}
|
|
188
|
+
function readHistory() {
|
|
189
|
+
const { path } = getHistoryConfig();
|
|
190
|
+
if (!existsSync2(path)) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const content = readFileSync2(path, "utf-8");
|
|
195
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
196
|
+
return lines.map((line) => JSON.parse(line));
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function getHistory(filter = {}) {
|
|
202
|
+
let entries = readHistory();
|
|
203
|
+
if (filter.plugin) {
|
|
204
|
+
entries = entries.filter((e) => e.plugin === filter.plugin);
|
|
205
|
+
}
|
|
206
|
+
if (filter.failed) {
|
|
207
|
+
entries = entries.filter((e) => !e.success);
|
|
208
|
+
}
|
|
209
|
+
if (filter.success) {
|
|
210
|
+
entries = entries.filter((e) => e.success);
|
|
211
|
+
}
|
|
212
|
+
if (filter.today) {
|
|
213
|
+
const today = /* @__PURE__ */ new Date();
|
|
214
|
+
today.setHours(0, 0, 0, 0);
|
|
215
|
+
entries = entries.filter((e) => new Date(e.timestamp) >= today);
|
|
216
|
+
}
|
|
217
|
+
if (filter.since) {
|
|
218
|
+
entries = entries.filter((e) => new Date(e.timestamp) >= filter.since);
|
|
219
|
+
}
|
|
220
|
+
if (filter.until) {
|
|
221
|
+
entries = entries.filter((e) => new Date(e.timestamp) <= filter.until);
|
|
222
|
+
}
|
|
223
|
+
entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
224
|
+
if (filter.last) {
|
|
225
|
+
entries = entries.slice(0, filter.last);
|
|
226
|
+
}
|
|
227
|
+
return entries;
|
|
228
|
+
}
|
|
229
|
+
function formatEntry(entry, verbose = false) {
|
|
230
|
+
const time = new Date(entry.timestamp).toLocaleString();
|
|
231
|
+
const status = entry.success ? "\u2713" : "\u2717";
|
|
232
|
+
const sizeKb = (entry.size_bytes / 1024).toFixed(2);
|
|
233
|
+
let line = `${status} ${time} | ${entry.plugin} | ${sizeKb} KB | ${entry.duration_ms}ms`;
|
|
234
|
+
if (!entry.success && entry.error) {
|
|
235
|
+
line += ` | ${entry.error}`;
|
|
236
|
+
}
|
|
237
|
+
if (verbose && entry.payload?.merge_variables?.content) {
|
|
238
|
+
const preview = entry.payload.merge_variables.content.substring(0, 80);
|
|
239
|
+
line += `
|
|
240
|
+
${preview}${entry.payload.merge_variables.content.length > 80 ? "..." : ""}`;
|
|
241
|
+
}
|
|
242
|
+
return line;
|
|
243
|
+
}
|
|
244
|
+
function getHistoryStats() {
|
|
245
|
+
const { path } = getHistoryConfig();
|
|
246
|
+
if (!existsSync2(path)) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const stats = statSync(path);
|
|
251
|
+
const entries = readHistory().length;
|
|
252
|
+
return {
|
|
253
|
+
entries,
|
|
254
|
+
sizeBytes: stats.size,
|
|
255
|
+
sizeMb: Math.round(stats.size / 1024 / 1024 * 100) / 100
|
|
256
|
+
};
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function getHistoryPath() {
|
|
262
|
+
return getHistoryConfig().path;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/commands/config.ts
|
|
266
|
+
function registerConfigCommand(cli2) {
|
|
267
|
+
cli2.command("config", "Show configuration").action(() => {
|
|
268
|
+
const config = loadConfig();
|
|
269
|
+
const plugins = listPlugins();
|
|
270
|
+
console.log(`Config file: ${CONFIG_PATH}`);
|
|
271
|
+
console.log("");
|
|
272
|
+
console.log("Plugins:");
|
|
273
|
+
if (plugins.length === 0) {
|
|
274
|
+
console.log(" (none configured)");
|
|
275
|
+
console.log("");
|
|
276
|
+
console.log(" Add a plugin:");
|
|
277
|
+
console.log(" trmnl plugin add <name> <url>");
|
|
278
|
+
} else {
|
|
279
|
+
for (const { name, plugin, isDefault } of plugins) {
|
|
280
|
+
const defaultMark = isDefault ? " (default)" : "";
|
|
281
|
+
console.log(` ${name}${defaultMark}`);
|
|
282
|
+
console.log(` url: ${plugin.url}`);
|
|
283
|
+
if (plugin.description) {
|
|
284
|
+
console.log(` desc: ${plugin.description}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
console.log("");
|
|
289
|
+
console.log(`Tier: ${config.tier || "free"}`);
|
|
290
|
+
console.log(` Limit: ${config.tier === "plus" ? "5 KB" : "2 KB"}`);
|
|
291
|
+
console.log("");
|
|
292
|
+
console.log("History:");
|
|
293
|
+
console.log(` path: ${getHistoryPath()}`);
|
|
294
|
+
console.log("");
|
|
295
|
+
console.log("Environment:");
|
|
296
|
+
console.log(` TRMNL_WEBHOOK: ${process.env.TRMNL_WEBHOOK || "(not set)"}`);
|
|
297
|
+
});
|
|
298
|
+
cli2.command("tier [value]", "Get or set tier (free or plus)").example("trmnl tier # Show current tier").example("trmnl tier plus # Set to plus").example("trmnl tier free # Set to free").action((value) => {
|
|
299
|
+
if (!value) {
|
|
300
|
+
const tier = getTier();
|
|
301
|
+
console.log(`Tier: ${tier}`);
|
|
302
|
+
console.log(`Limit: ${tier === "plus" ? "5 KB (5,120 bytes)" : "2 KB (2,048 bytes)"}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (value !== "free" && value !== "plus") {
|
|
306
|
+
console.error('Invalid tier. Use "free" or "plus".');
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
setTier(value);
|
|
310
|
+
console.log(`\u2713 Tier set to: ${value}`);
|
|
311
|
+
});
|
|
312
|
+
cli2.command("plugin [action] [name] [url]", "Manage webhook plugins").option("-d, --desc <description>", "Plugin description").option("-u, --url <url>", "Webhook URL (for set action)").option("--default", "Set as default plugin").example("trmnl plugin # List plugins").example("trmnl plugin add home <url> # Add plugin").example("trmnl plugin rm home # Remove plugin").example("trmnl plugin default home # Set default").example("trmnl plugin set home --url ... # Update plugin").action((action, name, url, options) => {
|
|
313
|
+
if (!action) {
|
|
314
|
+
showPluginList();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
switch (action) {
|
|
318
|
+
case "add":
|
|
319
|
+
if (!name || !url) {
|
|
320
|
+
console.error("Usage: trmnl plugin add <name> <url>");
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
setPlugin(name, url, options?.desc);
|
|
324
|
+
console.log(`\u2713 Added plugin: ${name}`);
|
|
325
|
+
if (options?.default) {
|
|
326
|
+
setDefaultPlugin(name);
|
|
327
|
+
console.log(`\u2713 Set as default`);
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
case "rm":
|
|
331
|
+
case "remove":
|
|
332
|
+
if (!name) {
|
|
333
|
+
console.error("Usage: trmnl plugin rm <name>");
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
if (removePlugin(name)) {
|
|
337
|
+
console.log(`\u2713 Removed plugin: ${name}`);
|
|
338
|
+
} else {
|
|
339
|
+
console.error(`Plugin not found: ${name}`);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
case "default":
|
|
344
|
+
if (!name) {
|
|
345
|
+
console.error("Usage: trmnl plugin default <name>");
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
if (setDefaultPlugin(name)) {
|
|
349
|
+
console.log(`\u2713 Default plugin: ${name}`);
|
|
350
|
+
} else {
|
|
351
|
+
console.error(`Plugin not found: ${name}`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
case "set":
|
|
356
|
+
case "update":
|
|
357
|
+
if (!name) {
|
|
358
|
+
console.error("Usage: trmnl plugin set <name> [options]");
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
const plugins = listPlugins();
|
|
362
|
+
const existing = plugins.find((p) => p.name === name);
|
|
363
|
+
if (!existing) {
|
|
364
|
+
console.error(`Plugin not found: ${name}`);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
const newUrl = options?.url || existing.plugin.url;
|
|
368
|
+
const newDesc = options?.desc !== void 0 ? options.desc : existing.plugin.description;
|
|
369
|
+
setPlugin(name, newUrl, newDesc);
|
|
370
|
+
console.log(`\u2713 Updated plugin: ${name}`);
|
|
371
|
+
break;
|
|
372
|
+
case "list":
|
|
373
|
+
showPluginList();
|
|
374
|
+
break;
|
|
375
|
+
default:
|
|
376
|
+
console.error(`Unknown action: ${action}`);
|
|
377
|
+
console.log("");
|
|
378
|
+
console.log("Available actions:");
|
|
379
|
+
console.log(" add <name> <url> - Add a plugin");
|
|
380
|
+
console.log(" rm <name> - Remove a plugin");
|
|
381
|
+
console.log(" default <name> - Set default plugin");
|
|
382
|
+
console.log(" set <name> - Update a plugin");
|
|
383
|
+
console.log(" list - List all plugins");
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
cli2.command("plugins", "List all plugins").action(() => {
|
|
388
|
+
showPluginList();
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
function showPluginList() {
|
|
392
|
+
const plugins = listPlugins();
|
|
393
|
+
if (plugins.length === 0) {
|
|
394
|
+
console.log("No plugins configured.");
|
|
395
|
+
console.log("");
|
|
396
|
+
console.log("Add a plugin:");
|
|
397
|
+
console.log(" trmnl plugin add <name> <url>");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
console.log("Plugins:");
|
|
401
|
+
for (const { name, plugin, isDefault } of plugins) {
|
|
402
|
+
const defaultMark = isDefault ? " \u2605" : "";
|
|
403
|
+
console.log(` ${name}${defaultMark}`);
|
|
404
|
+
console.log(` ${plugin.url}`);
|
|
405
|
+
if (plugin.description) {
|
|
406
|
+
console.log(` ${plugin.description}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
console.log("");
|
|
410
|
+
console.log("\u2605 = default plugin");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/commands/history.ts
|
|
414
|
+
import { unlinkSync as unlinkSync2 } from "fs";
|
|
415
|
+
function registerHistoryCommand(cli2) {
|
|
416
|
+
cli2.command("history", "View send history").option("-n, --last <n>", "Show last N entries", { default: 10 }).option("--today", "Show only today's entries").option("--failed", "Show only failed sends").option("--success", "Show only successful sends").option("-p, --plugin <name>", "Filter by plugin name").option("--json", "Output as JSON").option("-v, --verbose", "Show content preview").example("trmnl history").example("trmnl history --last 20").example("trmnl history --today --failed").example("trmnl history --plugin office").action((options) => {
|
|
417
|
+
const filter = {
|
|
418
|
+
last: options.last,
|
|
419
|
+
today: options.today,
|
|
420
|
+
failed: options.failed,
|
|
421
|
+
success: options.success,
|
|
422
|
+
plugin: options.plugin
|
|
423
|
+
};
|
|
424
|
+
const entries = getHistory(filter);
|
|
425
|
+
if (options.json) {
|
|
426
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (entries.length === 0) {
|
|
430
|
+
console.log("No history entries found.");
|
|
431
|
+
console.log(`History file: ${getHistoryPath()}`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const stats = getHistoryStats();
|
|
435
|
+
if (stats) {
|
|
436
|
+
console.log(`History: ${stats.entries} total entries (${stats.sizeMb} MB)`);
|
|
437
|
+
console.log("");
|
|
438
|
+
}
|
|
439
|
+
const filterParts = [];
|
|
440
|
+
if (options.today) filterParts.push("today");
|
|
441
|
+
if (options.failed) filterParts.push("failed");
|
|
442
|
+
if (options.success) filterParts.push("success");
|
|
443
|
+
if (options.plugin) filterParts.push(`plugin: ${options.plugin}`);
|
|
444
|
+
if (filterParts.length > 0) {
|
|
445
|
+
console.log(`Filter: ${filterParts.join(", ")}`);
|
|
446
|
+
console.log("");
|
|
447
|
+
}
|
|
448
|
+
console.log(`Showing ${entries.length} entries (most recent first):`);
|
|
449
|
+
console.log("");
|
|
450
|
+
for (const entry of entries) {
|
|
451
|
+
console.log(formatEntry(entry, options.verbose));
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
cli2.command("history clear", "Clear send history").option("--confirm", "Confirm deletion").action((options) => {
|
|
455
|
+
const historyPath = getHistoryPath();
|
|
456
|
+
if (!options.confirm) {
|
|
457
|
+
console.log("This will delete all history. Use --confirm to proceed.");
|
|
458
|
+
console.log(`History file: ${historyPath}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
unlinkSync2(historyPath);
|
|
463
|
+
console.log("\u2713 History cleared");
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (err.code === "ENOENT") {
|
|
466
|
+
console.log("History file does not exist.");
|
|
467
|
+
} else {
|
|
468
|
+
console.error("Error clearing history:", err);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
cli2.command("history stats", "Show history statistics").action(() => {
|
|
473
|
+
const stats = getHistoryStats();
|
|
474
|
+
if (!stats) {
|
|
475
|
+
console.log("No history file found.");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const entries = getHistory({});
|
|
479
|
+
const successCount = entries.filter((e) => e.success).length;
|
|
480
|
+
const failedCount = entries.filter((e) => !e.success).length;
|
|
481
|
+
const totalBytes = entries.reduce((sum, e) => sum + e.size_bytes, 0);
|
|
482
|
+
const avgBytes = entries.length > 0 ? Math.round(totalBytes / entries.length) : 0;
|
|
483
|
+
const avgDuration = entries.length > 0 ? Math.round(entries.reduce((sum, e) => sum + e.duration_ms, 0) / entries.length) : 0;
|
|
484
|
+
const byPlugin = /* @__PURE__ */ new Map();
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
byPlugin.set(entry.plugin, (byPlugin.get(entry.plugin) || 0) + 1);
|
|
487
|
+
}
|
|
488
|
+
console.log("History Statistics");
|
|
489
|
+
console.log("");
|
|
490
|
+
console.log(`File: ${getHistoryPath()}`);
|
|
491
|
+
console.log(`Size: ${stats.sizeMb} MB`);
|
|
492
|
+
console.log("");
|
|
493
|
+
console.log(`Total: ${entries.length} sends`);
|
|
494
|
+
console.log(`Success: ${successCount} (${entries.length > 0 ? Math.round(successCount / entries.length * 100) : 0}%)`);
|
|
495
|
+
console.log(`Failed: ${failedCount} (${entries.length > 0 ? Math.round(failedCount / entries.length * 100) : 0}%)`);
|
|
496
|
+
console.log("");
|
|
497
|
+
console.log(`Avg size: ${avgBytes} bytes`);
|
|
498
|
+
console.log(`Avg duration: ${avgDuration}ms`);
|
|
499
|
+
if (byPlugin.size > 1) {
|
|
500
|
+
console.log("");
|
|
501
|
+
console.log("By plugin:");
|
|
502
|
+
for (const [plugin, count] of byPlugin.entries()) {
|
|
503
|
+
console.log(` ${plugin}: ${count} sends`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const today = getHistory({ today: true });
|
|
507
|
+
const thisWeek = getHistory({ since: new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3) });
|
|
508
|
+
console.log("");
|
|
509
|
+
console.log(`Today: ${today.length} sends`);
|
|
510
|
+
console.log(`This week: ${thisWeek.length} sends`);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/commands/send.ts
|
|
515
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
516
|
+
|
|
517
|
+
// src/lib/validator.ts
|
|
518
|
+
function validatePayload(payload, tier = "free") {
|
|
519
|
+
const jsonString = JSON.stringify(payload);
|
|
520
|
+
const sizeBytes = new TextEncoder().encode(jsonString).length;
|
|
521
|
+
const limitBytes = TIER_LIMITS[tier];
|
|
522
|
+
const remainingBytes = limitBytes - sizeBytes;
|
|
523
|
+
const percentUsed = Math.round(sizeBytes / limitBytes * 1e3) / 10;
|
|
524
|
+
const warnings = [];
|
|
525
|
+
const errors = [];
|
|
526
|
+
if (sizeBytes > limitBytes) {
|
|
527
|
+
errors.push(`Payload exceeds ${tier} tier limit: ${sizeBytes} bytes > ${limitBytes} bytes`);
|
|
528
|
+
} else if (percentUsed > 90) {
|
|
529
|
+
warnings.push(`Payload is at ${percentUsed}% of ${tier} tier limit`);
|
|
530
|
+
}
|
|
531
|
+
if (!payload.merge_variables) {
|
|
532
|
+
errors.push("Missing merge_variables object");
|
|
533
|
+
} else if (!payload.merge_variables.content && !payload.merge_variables.text) {
|
|
534
|
+
warnings.push("No content or text field in merge_variables");
|
|
535
|
+
}
|
|
536
|
+
const content = payload.merge_variables?.content || "";
|
|
537
|
+
if (content) {
|
|
538
|
+
const openDivs = (content.match(/<div/g) || []).length;
|
|
539
|
+
const closeDivs = (content.match(/<\/div>/g) || []).length;
|
|
540
|
+
if (openDivs !== closeDivs) {
|
|
541
|
+
warnings.push(`Potential unclosed divs: ${openDivs} open, ${closeDivs} close`);
|
|
542
|
+
}
|
|
543
|
+
const hasLayoutClass = /class=["'][^"']*\blayout\b[^"']*["']/.test(content);
|
|
544
|
+
if (!hasLayoutClass) {
|
|
545
|
+
warnings.push("Missing .layout class - TRMNL requires a root layout element");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
valid: errors.length === 0,
|
|
550
|
+
size_bytes: sizeBytes,
|
|
551
|
+
tier,
|
|
552
|
+
limit_bytes: limitBytes,
|
|
553
|
+
remaining_bytes: remainingBytes,
|
|
554
|
+
percent_used: percentUsed,
|
|
555
|
+
warnings,
|
|
556
|
+
errors
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function createPayload(content) {
|
|
560
|
+
try {
|
|
561
|
+
const parsed = JSON.parse(content);
|
|
562
|
+
if (parsed.merge_variables) {
|
|
563
|
+
return parsed;
|
|
564
|
+
}
|
|
565
|
+
return { merge_variables: parsed };
|
|
566
|
+
} catch {
|
|
567
|
+
return {
|
|
568
|
+
merge_variables: {
|
|
569
|
+
content
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function formatValidation(result) {
|
|
575
|
+
const lines = [];
|
|
576
|
+
const status = result.valid ? "\u2713" : "\u2717";
|
|
577
|
+
const sizeKb = (result.size_bytes / 1024).toFixed(2);
|
|
578
|
+
const limitKb = (result.limit_bytes / 1024).toFixed(2);
|
|
579
|
+
lines.push(`${status} Payload: ${result.size_bytes} bytes (${sizeKb} KB)`);
|
|
580
|
+
lines.push(` Tier: ${result.tier} (limit: ${limitKb} KB)`);
|
|
581
|
+
lines.push(` Used: ${result.percent_used}% (${result.remaining_bytes} bytes remaining)`);
|
|
582
|
+
if (result.errors.length > 0) {
|
|
583
|
+
lines.push("");
|
|
584
|
+
lines.push("Errors:");
|
|
585
|
+
for (const error of result.errors) {
|
|
586
|
+
lines.push(` \u2717 ${error}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (result.warnings.length > 0) {
|
|
590
|
+
lines.push("");
|
|
591
|
+
lines.push("Warnings:");
|
|
592
|
+
for (const warning of result.warnings) {
|
|
593
|
+
lines.push(` \u26A0 ${warning}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return lines.join("\n");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/lib/webhook.ts
|
|
600
|
+
async function sendToWebhook(payload, options = {}) {
|
|
601
|
+
const startTime = Date.now();
|
|
602
|
+
const tier = getTier();
|
|
603
|
+
let webhookUrl;
|
|
604
|
+
let pluginName;
|
|
605
|
+
if (options.webhookUrl) {
|
|
606
|
+
webhookUrl = options.webhookUrl;
|
|
607
|
+
pluginName = "custom";
|
|
608
|
+
} else {
|
|
609
|
+
const resolved = getWebhookUrl(options.plugin);
|
|
610
|
+
if (!resolved) {
|
|
611
|
+
const durationMs = Date.now() - startTime;
|
|
612
|
+
const validation2 = validatePayload(payload, tier);
|
|
613
|
+
let error = "No webhook URL configured.";
|
|
614
|
+
if (options.plugin) {
|
|
615
|
+
error = `Plugin "${options.plugin}" not found.`;
|
|
616
|
+
} else {
|
|
617
|
+
error += " Add a plugin with: trmnl plugin add <name> <url>";
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
success: false,
|
|
621
|
+
pluginName: options.plugin || "unknown",
|
|
622
|
+
error,
|
|
623
|
+
durationMs,
|
|
624
|
+
validation: validation2
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
webhookUrl = resolved.url;
|
|
628
|
+
pluginName = resolved.name;
|
|
629
|
+
}
|
|
630
|
+
const validation = validatePayload(payload, tier);
|
|
631
|
+
if (!options.skipValidation && !validation.valid) {
|
|
632
|
+
const durationMs = Date.now() - startTime;
|
|
633
|
+
const result = {
|
|
634
|
+
success: false,
|
|
635
|
+
pluginName,
|
|
636
|
+
error: validation.errors.join("; "),
|
|
637
|
+
durationMs,
|
|
638
|
+
validation
|
|
639
|
+
};
|
|
640
|
+
if (!options.skipLog) {
|
|
641
|
+
logEntry(createHistoryEntry(payload, result, tier, pluginName));
|
|
642
|
+
}
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const response = await fetch(webhookUrl, {
|
|
647
|
+
method: "POST",
|
|
648
|
+
headers: {
|
|
649
|
+
"Content-Type": "application/json"
|
|
650
|
+
},
|
|
651
|
+
body: JSON.stringify(payload)
|
|
652
|
+
});
|
|
653
|
+
const durationMs = Date.now() - startTime;
|
|
654
|
+
const responseText = await response.text();
|
|
655
|
+
const result = {
|
|
656
|
+
success: response.ok,
|
|
657
|
+
pluginName,
|
|
658
|
+
statusCode: response.status,
|
|
659
|
+
response: responseText,
|
|
660
|
+
durationMs,
|
|
661
|
+
validation
|
|
662
|
+
};
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
result.error = `HTTP ${response.status}: ${responseText}`;
|
|
665
|
+
}
|
|
666
|
+
if (!options.skipLog) {
|
|
667
|
+
logEntry(createHistoryEntry(payload, result, tier, pluginName));
|
|
668
|
+
}
|
|
669
|
+
return result;
|
|
670
|
+
} catch (err) {
|
|
671
|
+
const durationMs = Date.now() - startTime;
|
|
672
|
+
const error = err instanceof Error ? err.message : "Unknown error";
|
|
673
|
+
const result = {
|
|
674
|
+
success: false,
|
|
675
|
+
pluginName,
|
|
676
|
+
error,
|
|
677
|
+
durationMs,
|
|
678
|
+
validation
|
|
679
|
+
};
|
|
680
|
+
if (!options.skipLog) {
|
|
681
|
+
logEntry(createHistoryEntry(payload, result, tier, pluginName));
|
|
682
|
+
}
|
|
683
|
+
return result;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function createHistoryEntry(payload, result, tier, pluginName) {
|
|
687
|
+
return {
|
|
688
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
689
|
+
plugin: pluginName,
|
|
690
|
+
size_bytes: result.validation.size_bytes,
|
|
691
|
+
tier,
|
|
692
|
+
payload,
|
|
693
|
+
success: result.success,
|
|
694
|
+
status_code: result.statusCode,
|
|
695
|
+
response: result.response,
|
|
696
|
+
error: result.error,
|
|
697
|
+
duration_ms: result.durationMs
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/commands/send.ts
|
|
702
|
+
function registerSendCommand(cli2) {
|
|
703
|
+
cli2.command("send", "Send content to TRMNL display").option("-c, --content <html>", "HTML content to send").option("-f, --file <path>", "Read content from file").option("-p, --plugin <name>", "Plugin to use (default: default plugin)").option("-w, --webhook <url>", "Override webhook URL directly").option("--skip-validation", "Skip payload validation").option("--skip-log", "Skip history logging").option("--json", "Output result as JSON").example('trmnl send --content "<div class=\\"layout\\">Hello</div>"').example("trmnl send --file ./output.html").example("trmnl send --file ./output.html --plugin office").example(`echo '{"merge_variables":{"content":"..."}}' | trmnl send`).action(async (options) => {
|
|
704
|
+
let content;
|
|
705
|
+
if (options.content) {
|
|
706
|
+
content = options.content;
|
|
707
|
+
} else if (options.file) {
|
|
708
|
+
try {
|
|
709
|
+
content = readFileSync3(options.file, "utf-8");
|
|
710
|
+
} catch (err) {
|
|
711
|
+
console.error(`Error reading file: ${options.file}`);
|
|
712
|
+
process.exit(1);
|
|
713
|
+
}
|
|
714
|
+
} else {
|
|
715
|
+
content = await readStdin();
|
|
716
|
+
if (!content) {
|
|
717
|
+
console.error("No content provided. Use --content, --file, or pipe content via stdin.");
|
|
718
|
+
process.exit(1);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const payload = createPayload(content);
|
|
722
|
+
const result = await sendToWebhook(payload, {
|
|
723
|
+
plugin: options.plugin,
|
|
724
|
+
webhookUrl: options.webhook,
|
|
725
|
+
skipValidation: options.skipValidation,
|
|
726
|
+
skipLog: options.skipLog
|
|
727
|
+
});
|
|
728
|
+
if (options.json) {
|
|
729
|
+
console.log(JSON.stringify(result, null, 2));
|
|
730
|
+
} else {
|
|
731
|
+
if (result.success) {
|
|
732
|
+
console.log(`\u2713 Sent to TRMNL (${result.pluginName})`);
|
|
733
|
+
console.log(` Status: ${result.statusCode}`);
|
|
734
|
+
console.log(` Time: ${result.durationMs}ms`);
|
|
735
|
+
console.log(` Size: ${result.validation.size_bytes} bytes (${result.validation.percent_used}% of limit)`);
|
|
736
|
+
} else {
|
|
737
|
+
console.error("\u2717 Failed to send");
|
|
738
|
+
console.error(` Error: ${result.error}`);
|
|
739
|
+
if (result.pluginName) {
|
|
740
|
+
console.error(` Plugin: ${result.pluginName}`);
|
|
741
|
+
}
|
|
742
|
+
console.log("");
|
|
743
|
+
console.log("Validation:");
|
|
744
|
+
console.log(formatValidation(result.validation));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
process.exit(result.success ? 0 : 1);
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
async function readStdin() {
|
|
751
|
+
if (process.stdin.isTTY) {
|
|
752
|
+
return "";
|
|
753
|
+
}
|
|
754
|
+
const chunks = [];
|
|
755
|
+
return new Promise((resolve) => {
|
|
756
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
757
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8").trim()));
|
|
758
|
+
process.stdin.on("error", () => resolve(""));
|
|
759
|
+
setTimeout(() => {
|
|
760
|
+
if (chunks.length === 0) {
|
|
761
|
+
resolve("");
|
|
762
|
+
}
|
|
763
|
+
}, 100);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/commands/validate.ts
|
|
768
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
769
|
+
function registerValidateCommand(cli2) {
|
|
770
|
+
cli2.command("validate", "Validate payload without sending").option("-c, --content <html>", "HTML content to validate").option("-f, --file <path>", "Read content from file").option("-t, --tier <tier>", "Override tier (free or plus)").option("--json", "Output result as JSON").example("trmnl validate --file ./output.html").example('trmnl validate --content "<div>...</div>" --tier plus').action(async (options) => {
|
|
771
|
+
let content;
|
|
772
|
+
if (options.content) {
|
|
773
|
+
content = options.content;
|
|
774
|
+
} else if (options.file) {
|
|
775
|
+
try {
|
|
776
|
+
content = readFileSync4(options.file, "utf-8");
|
|
777
|
+
} catch (err) {
|
|
778
|
+
console.error(`Error reading file: ${options.file}`);
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
} else {
|
|
782
|
+
content = await readStdin2();
|
|
783
|
+
if (!content) {
|
|
784
|
+
console.error("No content provided. Use --content, --file, or pipe content via stdin.");
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const tier = options.tier || getTier();
|
|
789
|
+
const payload = createPayload(content);
|
|
790
|
+
const result = validatePayload(payload, tier);
|
|
791
|
+
if (options.json) {
|
|
792
|
+
console.log(JSON.stringify(result, null, 2));
|
|
793
|
+
} else {
|
|
794
|
+
console.log(formatValidation(result));
|
|
795
|
+
}
|
|
796
|
+
process.exit(result.valid ? 0 : 1);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
async function readStdin2() {
|
|
800
|
+
if (process.stdin.isTTY) {
|
|
801
|
+
return "";
|
|
802
|
+
}
|
|
803
|
+
const chunks = [];
|
|
804
|
+
return new Promise((resolve) => {
|
|
805
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
806
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8").trim()));
|
|
807
|
+
process.stdin.on("error", () => resolve(""));
|
|
808
|
+
setTimeout(() => {
|
|
809
|
+
if (chunks.length === 0) {
|
|
810
|
+
resolve("");
|
|
811
|
+
}
|
|
812
|
+
}, 100);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/index.ts
|
|
817
|
+
var require2 = createRequire(import.meta.url);
|
|
818
|
+
var { version } = require2("../package.json");
|
|
819
|
+
var cli = cac("trmnl");
|
|
820
|
+
cli.version(version);
|
|
821
|
+
registerSendCommand(cli);
|
|
822
|
+
registerValidateCommand(cli);
|
|
823
|
+
registerConfigCommand(cli);
|
|
824
|
+
registerHistoryCommand(cli);
|
|
825
|
+
cli.help();
|
|
826
|
+
cli.command("").action(() => {
|
|
827
|
+
cli.outputHelp();
|
|
828
|
+
});
|
|
829
|
+
cli.parse();
|
|
830
|
+
//# sourceMappingURL=index.js.map
|