@rubar/lavish-publish-cf 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,592 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * publish-cf — CLI client for the publish-cloudflare worker.
4
+ * Pure Node, zero deps. Mirrors htmlship's command surface.
5
+ */
6
+
7
+ const fs = require("node:fs");
8
+ const path = require("node:path");
9
+ const os = require("node:os");
10
+ const { spawnSync } = require("node:child_process");
11
+
12
+ const HOME = os.homedir();
13
+ const DIR = path.join(HOME, ".publish-cloudflare");
14
+ const KEYS_PATH = path.join(DIR, "keys.json");
15
+ const CONFIG_PATH = path.join(DIR, "config.json");
16
+ const DEFAULT_API = "http://localhost:8787";
17
+
18
+ // ---------- config / keys ----------
19
+
20
+ function ensureDir() {
21
+ if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
22
+ }
23
+
24
+ function loadJson(p, fallback) {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(p, "utf8"));
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ function saveJson(p, obj) {
33
+ ensureDir();
34
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2), { mode: 0o600 });
35
+ }
36
+
37
+ function getApiBase() {
38
+ if (process.env.PUBLISH_CF_API) return process.env.PUBLISH_CF_API.replace(/\/$/, "");
39
+ const cfg = loadJson(CONFIG_PATH, {});
40
+ if (cfg.api_base) return String(cfg.api_base).replace(/\/$/, "");
41
+ return DEFAULT_API;
42
+ }
43
+
44
+ function loadKeys() {
45
+ return loadJson(KEYS_PATH, {});
46
+ }
47
+
48
+ function saveKey(slug, entry) {
49
+ const keys = loadKeys();
50
+ keys[slug] = entry;
51
+ saveJson(KEYS_PATH, keys);
52
+ }
53
+
54
+ function removeKey(slug) {
55
+ const keys = loadKeys();
56
+ delete keys[slug];
57
+ saveJson(KEYS_PATH, keys);
58
+ }
59
+
60
+ function getOwnerKey(slug) {
61
+ const keys = loadKeys();
62
+ return keys[slug]?.owner_key || null;
63
+ }
64
+
65
+ function getSourcePath(slug) {
66
+ const keys = loadKeys();
67
+ return keys[slug]?.source_path || null;
68
+ }
69
+
70
+ function updateKey(slug, patch) {
71
+ const keys = loadKeys();
72
+ if (!keys[slug]) return;
73
+ keys[slug] = { ...keys[slug], ...patch };
74
+ saveJson(KEYS_PATH, keys);
75
+ }
76
+
77
+ // ---------- http ----------
78
+
79
+ async function api(method, pathStr, { body, ownerKey, extraHeaders } = {}) {
80
+ const base = getApiBase();
81
+ const url = `${base}${pathStr}`;
82
+ const headers = { "content-type": "application/json", accept: "application/json" };
83
+ if (ownerKey) headers["x-owner-key"] = ownerKey;
84
+ if (extraHeaders) Object.assign(headers, extraHeaders);
85
+ let res;
86
+ try {
87
+ res = await fetch(url, {
88
+ method,
89
+ headers,
90
+ body: body !== undefined ? JSON.stringify(body) : undefined,
91
+ });
92
+ } catch (e) {
93
+ fail(`Network error talking to ${base}: ${e.message}`);
94
+ }
95
+ const text = await res.text();
96
+ let data = null;
97
+ try {
98
+ data = text ? JSON.parse(text) : null;
99
+ } catch {
100
+ /* non-json */
101
+ }
102
+ if (!res.ok) {
103
+ const msg = data?.error?.message || text || `HTTP ${res.status}`;
104
+ fail(`${method} ${pathStr} → ${res.status}: ${msg}`);
105
+ }
106
+ return data;
107
+ }
108
+
109
+ // ---------- io ----------
110
+
111
+ function readInput(file) {
112
+ if (file === "-" || file === "/dev/stdin") {
113
+ return fs.readFileSync(0, "utf8");
114
+ }
115
+ if (!fs.existsSync(file)) fail(`File not found: ${file}`);
116
+ return fs.readFileSync(file, "utf8");
117
+ }
118
+
119
+ function copyToClipboard(text) {
120
+ if (process.platform !== "darwin") return false;
121
+ try {
122
+ const r = spawnSync("pbcopy", { input: text });
123
+ return r.status === 0;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function fail(msg) {
130
+ process.stderr.write(`error: ${msg}\n`);
131
+ process.exit(1);
132
+ }
133
+
134
+ function decodeEntities(s) {
135
+ return String(s)
136
+ .replace(/&/g, "&")
137
+ .replace(/&lt;/g, "<")
138
+ .replace(/&gt;/g, ">")
139
+ .replace(/&quot;/g, '"')
140
+ .replace(/&#39;/g, "'");
141
+ }
142
+
143
+ function extractTitleFromHtml(html) {
144
+ const sample = ["— sample", "untitled", "the quiet architecture"];
145
+ const isSample = (s) => {
146
+ const lc = s.toLowerCase();
147
+ return sample.some((p) => lc.includes(p));
148
+ };
149
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
150
+ if (titleMatch) {
151
+ const t = decodeEntities(titleMatch[1]).trim();
152
+ if (t && !isSample(t)) return t;
153
+ }
154
+ const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
155
+ if (h1Match) {
156
+ const t = decodeEntities(h1Match[1].replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, ""))
157
+ .replace(/\s+/g, " ")
158
+ .trim();
159
+ if (t && !isSample(t)) return t;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ // ---------- arg parsing ----------
165
+
166
+ function parseArgs(argv) {
167
+ const positional = [];
168
+ const flags = {};
169
+ for (let i = 0; i < argv.length; i++) {
170
+ const a = argv[i];
171
+ if (a.startsWith("--")) {
172
+ const key = a.slice(2);
173
+ const next = argv[i + 1];
174
+ if (next === undefined || next.startsWith("--")) {
175
+ flags[key] = true;
176
+ } else {
177
+ flags[key] = next;
178
+ i++;
179
+ }
180
+ } else {
181
+ positional.push(a);
182
+ }
183
+ }
184
+ return { positional, flags };
185
+ }
186
+
187
+ // ---------- commands ----------
188
+
189
+ async function cmdPublish(args) {
190
+ const { positional, flags } = parseArgs(args);
191
+ const file = positional[0];
192
+ if (!file) fail("usage: publish-cf publish <file> [--password X] [--title X] [--expires-in MINUTES] [--no-comments]");
193
+ const html = readInput(file);
194
+ const body = { html };
195
+ if (flags.title) {
196
+ body.title = String(flags.title);
197
+ } else {
198
+ const extracted = extractTitleFromHtml(html);
199
+ if (extracted) body.title = extracted;
200
+ }
201
+ if (flags.password) body.password = String(flags.password);
202
+ if (flags["expires-in"]) {
203
+ const n = Number(flags["expires-in"]);
204
+ if (!Number.isFinite(n) || n <= 0) fail("--expires-in must be a positive number of minutes");
205
+ body.expires_in = n;
206
+ }
207
+ if (flags["no-comments"]) body.comments_enabled = false;
208
+ const res = await api("POST", "/api/v1/pages", { body });
209
+ // Resolve to an absolute path for later /address-comments lookups.
210
+ // Stdin publishes are ephemeral — no source path to remember.
211
+ const sourcePath = file === "-" || file === "/dev/stdin" ? null : path.resolve(file);
212
+ saveKey(res.slug, {
213
+ owner_key: res.owner_key,
214
+ url: res.url,
215
+ title: body.title || null,
216
+ created_at: Date.now(),
217
+ expires_at: res.expires_at || null,
218
+ api_base: getApiBase(),
219
+ comments_enabled: res.comments_enabled !== false,
220
+ source_path: sourcePath,
221
+ });
222
+ const copied = copyToClipboard(res.url);
223
+ process.stdout.write(`${res.url}\n`);
224
+ process.stderr.write(`slug: ${res.slug}\n`);
225
+ process.stderr.write(`owner_key: ${res.owner_key} (saved to ${KEYS_PATH})\n`);
226
+ if (sourcePath) process.stderr.write(`source: ${sourcePath}\n`);
227
+ if (res.comments_enabled === false) process.stderr.write(`comments: off\n`);
228
+ if (res.expires_at) {
229
+ process.stderr.write(`expires: ${new Date(res.expires_at).toISOString()}\n`);
230
+ }
231
+ if (copied) process.stderr.write(`(URL copied to clipboard)\n`);
232
+ }
233
+
234
+ async function cmdGet(args) {
235
+ const slug = args[0];
236
+ if (!slug) fail("usage: publish-cf get <slug>");
237
+ const meta = await api("GET", `/api/v1/pages/${slug}`);
238
+ process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
239
+ }
240
+
241
+ async function cmdUpdate(args) {
242
+ const { positional, flags } = parseArgs(args);
243
+ const slug = positional[0];
244
+ const file = positional[1];
245
+ if (!slug || !file) fail("usage: publish-cf update <slug> <file> [--title X]");
246
+ const ownerKey = getOwnerKey(slug);
247
+ if (!ownerKey) fail(`No owner_key on file for ${slug}. Did you publish from this machine?`);
248
+ const html = readInput(file);
249
+ const body = { html };
250
+ if (flags.title) body.title = String(flags.title);
251
+ const res = await api("PATCH", `/api/v1/pages/${slug}`, { body, ownerKey });
252
+ // Refresh the remembered source so /address-comments finds the right file.
253
+ if (file !== "-" && file !== "/dev/stdin") {
254
+ updateKey(slug, { source_path: path.resolve(file) });
255
+ }
256
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
257
+ }
258
+
259
+ async function cmdDelete(args) {
260
+ const slug = args[0];
261
+ if (!slug) fail("usage: publish-cf delete <slug>");
262
+ const ownerKey = getOwnerKey(slug);
263
+ if (!ownerKey) fail(`No owner_key on file for ${slug}.`);
264
+ await api("DELETE", `/api/v1/pages/${slug}`, { ownerKey });
265
+ removeKey(slug);
266
+ process.stdout.write(`deleted ${slug}\n`);
267
+ }
268
+
269
+ // ---------- comments ----------
270
+
271
+ function formatRelativeTime(ts) {
272
+ if (!ts || !Number.isFinite(Number(ts))) return "?";
273
+ const now = Date.now();
274
+ const diff = Math.max(0, now - Number(ts));
275
+ const sec = Math.floor(diff / 1000);
276
+ if (sec < 60) return `${sec}s ago`;
277
+ const min = Math.floor(sec / 60);
278
+ if (min < 60) return `${min}m ago`;
279
+ const hr = Math.floor(min / 60);
280
+ if (hr < 24) return `${hr}h ago`;
281
+ const day = Math.floor(hr / 24);
282
+ if (day < 30) return `${day}d ago`;
283
+ const mon = Math.floor(day / 30);
284
+ if (mon < 12) return `${mon}mo ago`;
285
+ const yr = Math.floor(day / 365);
286
+ return `${yr}y ago`;
287
+ }
288
+
289
+ function printCommentsPretty(comments) {
290
+ if (!Array.isArray(comments) || comments.length === 0) {
291
+ process.stdout.write("no comments\n");
292
+ return;
293
+ }
294
+ const lines = [];
295
+ for (let i = 0; i < comments.length; i++) {
296
+ const c = comments[i];
297
+ const badge = c.status === "resolved" ? "[RESOLVED]" : "[OPEN]";
298
+ const author = c.author || "anonymous";
299
+ const when = formatRelativeTime(c.created_at);
300
+ lines.push(`${badge} ${c.id} ${author} · ${when}`);
301
+ if (c.anchor && c.anchor.quote) {
302
+ const q = String(c.anchor.quote).replace(/\s+/g, " ").trim();
303
+ lines.push(` > "${q}"`);
304
+ } else {
305
+ lines.push(` > (no anchor — free-floating note)`);
306
+ }
307
+ const bodyLines = String(c.body || "").split("\n");
308
+ for (const bl of bodyLines) lines.push(` ${bl}`);
309
+ if (c.status === "resolved") {
310
+ const rWhen = c.resolved_at ? formatRelativeTime(c.resolved_at) : "?";
311
+ const rBy = c.resolved_by || "?";
312
+ lines.push(` resolved by ${rBy} · ${rWhen}`);
313
+ if (c.resolution_note) lines.push(` note: ${c.resolution_note}`);
314
+ }
315
+ if (i < comments.length - 1) lines.push("---");
316
+ }
317
+ process.stdout.write(lines.join("\n") + "\n");
318
+ }
319
+
320
+ async function cmdCommentsList(slug, flags) {
321
+ const status = flags.status ? String(flags.status) : "open";
322
+ if (status !== "open" && status !== "all") {
323
+ fail("--status must be 'open' or 'all'");
324
+ }
325
+ const format = flags.format ? String(flags.format) : "pretty";
326
+ if (format !== "pretty" && format !== "json") {
327
+ fail("--format must be 'pretty' or 'json'");
328
+ }
329
+ const ownerKey = getOwnerKey(slug);
330
+ const qs = `?status=${encodeURIComponent(status)}`;
331
+ const res = await api("GET", `/v/${encodeURIComponent(slug)}/comments${qs}`, {
332
+ ownerKey: ownerKey || undefined,
333
+ });
334
+ const comments = res && Array.isArray(res.comments) ? res.comments : [];
335
+ if (format === "json") {
336
+ process.stdout.write(JSON.stringify(comments, null, 2) + "\n");
337
+ } else {
338
+ printCommentsPretty(comments);
339
+ }
340
+ }
341
+
342
+ async function cmdCommentsResolve(slug, id, flags) {
343
+ if (!slug || !id) {
344
+ fail('usage: publish-cf comments resolve <slug> <id> [--note "..."]');
345
+ }
346
+ const ownerKey = getOwnerKey(slug);
347
+ if (!ownerKey) fail(`No owner_key on file for ${slug}. Did you publish from this machine?`);
348
+ const body = { status: "resolved" };
349
+ if (flags.note !== undefined && flags.note !== true) {
350
+ body.resolution_note = String(flags.note);
351
+ }
352
+ await api("PATCH", `/v/${encodeURIComponent(slug)}/comments/${encodeURIComponent(id)}`, {
353
+ body,
354
+ ownerKey,
355
+ extraHeaders: { "x-resolved-by": "agent" },
356
+ });
357
+ process.stdout.write(`Resolved ${id}\n`);
358
+ }
359
+
360
+ function promptYesNo(question) {
361
+ process.stderr.write(`${question} [y/N] `);
362
+ let input = "";
363
+ const buf = Buffer.alloc(1);
364
+ // Read from stdin synchronously until newline; tolerate non-TTY by returning false.
365
+ try {
366
+ while (true) {
367
+ const n = fs.readSync(0, buf, 0, 1, null);
368
+ if (n === 0) break;
369
+ const ch = buf.toString("utf8");
370
+ if (ch === "\n" || ch === "\r") break;
371
+ input += ch;
372
+ }
373
+ } catch {
374
+ return false;
375
+ }
376
+ return /^y(es)?$/i.test(input.trim());
377
+ }
378
+
379
+ async function cmdCommentsToggle(slug, flags) {
380
+ if (!slug) fail("usage: publish-cf comments toggle <slug> [--on | --off]");
381
+ if (!flags.on && !flags.off) fail("specify --on or --off");
382
+ if (flags.on && flags.off) fail("specify only one of --on / --off");
383
+ const ownerKey = getOwnerKey(slug);
384
+ if (!ownerKey) fail(`No owner_key on file for ${slug}.`);
385
+ const enabled = !!flags.on;
386
+ const res = await api("PATCH", `/api/v1/pages/${slug}`, {
387
+ body: { comments_enabled: enabled },
388
+ ownerKey,
389
+ });
390
+ updateKey(slug, { comments_enabled: enabled });
391
+ process.stdout.write(`comments ${enabled ? "on" : "off"} for ${slug}\n`);
392
+ process.stderr.write(JSON.stringify(res, null, 2) + "\n");
393
+ }
394
+
395
+ async function cmdCommentsDelete(slug, id, flags) {
396
+ if (!slug || !id) {
397
+ fail("usage: publish-cf comments delete <slug> <id> [--yes]");
398
+ }
399
+ const ownerKey = getOwnerKey(slug);
400
+ if (!ownerKey) fail(`No owner_key on file for ${slug}.`);
401
+ if (!flags.yes) {
402
+ const ok = promptYesNo(`Delete comment ${id} on ${slug}?`);
403
+ if (!ok) {
404
+ process.stderr.write("aborted\n");
405
+ return;
406
+ }
407
+ }
408
+ await api("DELETE", `/v/${encodeURIComponent(slug)}/comments/${encodeURIComponent(id)}`, {
409
+ ownerKey,
410
+ });
411
+ process.stdout.write(`deleted ${id}\n`);
412
+ }
413
+
414
+ function commentsHelp() {
415
+ process.stdout.write(`publish-cf comments — read and manage inline comments on a published page
416
+
417
+ usage:
418
+ publish-cf comments <slug> [--status open|all] [--format json|pretty]
419
+ publish-cf comments resolve <slug> <id> [--note "..."]
420
+ publish-cf comments delete <slug> <id> [--yes]
421
+ publish-cf comments toggle <slug> --on | --off
422
+
423
+ flags:
424
+ --status open|all filter comments (default: open)
425
+ --format json|pretty output format (default: pretty)
426
+ --note "..." resolution note to attach when resolving
427
+ --yes skip the confirmation prompt on delete
428
+ --on | --off enable or disable the comment UI for a page
429
+
430
+ examples:
431
+ publish-cf comments abc12345
432
+ publish-cf comments abc12345 --status all --format json
433
+ publish-cf comments resolve abc12345 c_a1b2c3d4e5 --note "fixed in latest update"
434
+ publish-cf comments delete abc12345 c_a1b2c3d4e5 --yes
435
+ publish-cf comments toggle abc12345 --off
436
+ `);
437
+ }
438
+
439
+ async function cmdComments(args) {
440
+ const { positional, flags } = parseArgs(args);
441
+ if (flags.help || flags.h || positional[0] === "help") {
442
+ commentsHelp();
443
+ return;
444
+ }
445
+ const first = positional[0];
446
+ if (!first) {
447
+ commentsHelp();
448
+ fail("missing <slug>");
449
+ }
450
+ if (first === "resolve") {
451
+ await cmdCommentsResolve(positional[1], positional[2], flags);
452
+ return;
453
+ }
454
+ if (first === "delete") {
455
+ await cmdCommentsDelete(positional[1], positional[2], flags);
456
+ return;
457
+ }
458
+ if (first === "toggle") {
459
+ await cmdCommentsToggle(positional[1], flags);
460
+ return;
461
+ }
462
+ // default: list comments for slug
463
+ await cmdCommentsList(first, flags);
464
+ }
465
+
466
+ function cmdListMine() {
467
+ const keys = loadKeys();
468
+ const slugs = Object.keys(keys).sort();
469
+ if (slugs.length === 0) {
470
+ process.stdout.write("no pages saved on this machine\n");
471
+ return;
472
+ }
473
+ for (const slug of slugs) {
474
+ const e = keys[slug];
475
+ const expires = e.expires_at ? new Date(e.expires_at).toISOString() : "—";
476
+ const src = e.source_path || "—";
477
+ const comments = e.comments_enabled === false ? "off" : "on";
478
+ process.stdout.write(
479
+ `${slug}\t${e.url}\t(title: ${e.title || "—"}, expires: ${expires}, comments: ${comments}, source: ${src})\n`,
480
+ );
481
+ }
482
+ }
483
+
484
+ function cmdConfig(args) {
485
+ const { positional, flags } = parseArgs(args);
486
+ const sub = positional[0];
487
+ const cfg = loadJson(CONFIG_PATH, {});
488
+ if (!sub || sub === "show") {
489
+ process.stdout.write(JSON.stringify({ ...cfg, _resolved_api_base: getApiBase() }, null, 2) + "\n");
490
+ return;
491
+ }
492
+ if (sub === "set") {
493
+ if (flags["api-base"]) cfg.api_base = String(flags["api-base"]).replace(/\/$/, "");
494
+ saveJson(CONFIG_PATH, cfg);
495
+ process.stdout.write(`saved ${CONFIG_PATH}\n`);
496
+ return;
497
+ }
498
+ fail("usage: publish-cf config show | publish-cf config set --api-base <url>");
499
+ }
500
+
501
+ async function cmdPassword(args) {
502
+ const { positional, flags } = parseArgs(args);
503
+ const slug = positional[0];
504
+ if (!slug) fail("usage: publish-cf password <slug> --set <pw> | --clear");
505
+ const ownerKey = getOwnerKey(slug);
506
+ if (!ownerKey) fail(`No owner_key on file for ${slug}.`);
507
+
508
+ let payload;
509
+ if (flags.clear) {
510
+ payload = { password: null };
511
+ } else if (typeof flags.set === "string" && flags.set) {
512
+ payload = { password: flags.set };
513
+ } else {
514
+ fail("specify --set <pw> or --clear");
515
+ }
516
+
517
+ const res = await api("PATCH", `/api/v1/pages/${slug}`, { body: payload, ownerKey });
518
+ process.stdout.write(`password ${flags.clear ? "cleared" : "set"} for ${slug}\n`);
519
+ process.stderr.write(JSON.stringify(res, null, 2) + "\n");
520
+ }
521
+
522
+ function help() {
523
+ process.stdout.write(`publish-cf — CLI for self-hosted publish-cloudflare
524
+
525
+ usage:
526
+ publish-cf publish <file> [--password X] [--title X] [--expires-in MINUTES] [--no-comments]
527
+ publish-cf get <slug>
528
+ publish-cf update <slug> <file> [--title X]
529
+ publish-cf delete <slug>
530
+ publish-cf password <slug> --set <pw> | --clear
531
+ publish-cf comments <slug> [--status open|all] [--format json|pretty]
532
+ publish-cf comments resolve <slug> <id> [--note "..."]
533
+ publish-cf comments delete <slug> <id> [--yes]
534
+ publish-cf comments toggle <slug> --on | --off
535
+ publish-cf list-mine
536
+ publish-cf config show
537
+ publish-cf config set --api-base <url>
538
+
539
+ config:
540
+ ~/.publish-cloudflare/config.json { "api_base": "https://..." }
541
+ ~/.publish-cloudflare/keys.json owner_keys keyed by slug
542
+ PUBLISH_CF_API env var overrides config
543
+
544
+ current api_base: ${getApiBase()}
545
+ `);
546
+ }
547
+
548
+ // ---------- entry ----------
549
+
550
+ (async () => {
551
+ const [, , cmd, ...rest] = process.argv;
552
+ try {
553
+ switch (cmd) {
554
+ case "publish":
555
+ await cmdPublish(rest);
556
+ break;
557
+ case "get":
558
+ await cmdGet(rest);
559
+ break;
560
+ case "update":
561
+ await cmdUpdate(rest);
562
+ break;
563
+ case "delete":
564
+ await cmdDelete(rest);
565
+ break;
566
+ case "password":
567
+ await cmdPassword(rest);
568
+ break;
569
+ case "comments":
570
+ await cmdComments(rest);
571
+ break;
572
+ case "list-mine":
573
+ cmdListMine();
574
+ break;
575
+ case "config":
576
+ cmdConfig(rest);
577
+ break;
578
+ case "--help":
579
+ case "-h":
580
+ case "help":
581
+ case undefined:
582
+ help();
583
+ break;
584
+ default:
585
+ process.stderr.write(`unknown command: ${cmd}\n\n`);
586
+ help();
587
+ process.exit(1);
588
+ }
589
+ } catch (e) {
590
+ fail(e?.message || String(e));
591
+ }
592
+ })();