droppage 0.1.1 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +91 -11
  2. package/dist/cli.js +802 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,14 +38,21 @@ Open the printed link in any browser. That's it.
38
38
  |---|---|
39
39
  | `droppage login` | Authenticate with a magic link (session stored in `~/.droppage/config.json`). |
40
40
  | `droppage logout` | Sign out and clear local credentials. |
41
- | `droppage upload <file>` | Publish an `.html`/`.htm` file and print its URL. Options: `--password`, `--email <addr>`, `--private`. |
42
- | `droppage update <slug> <file>` | Replace the HTML behind an existing URL. Keeps the URL, access mode, and stats. |
43
- | `droppage list` | List your pages. `--json` for machines, `--preview` for no-count preview URLs. |
44
- | `droppage delete <slug>` | Delete a page. `--yes` skips the confirmation prompt. |
41
+ | `droppage upload <file>` | Publish an `.html`/`.htm` file (and the local images it references) and print its URL. Options: `--password`, `--email <addr>`, `--private`, `--expires <when>`. |
42
+ | `droppage update <slug> <file>` | Replace the HTML (and images) behind an existing URL. Keeps the URL, access mode, and stats. `--expires <when\|never>` changes the expiry. |
43
+ | `droppage expire <slug> <when\|never>` | Set or clear when a page expires without re-uploading. |
44
+ | `droppage list` | List your pages, including an EXPIRES column. `--json` for machines (rows have `expires_at`), `--preview` for no-count preview URLs. |
45
+ | `droppage delete <slug>` | Delete a page and its images. `--yes` skips the confirmation prompt. |
45
46
  | `droppage recipients list <slug>` | List recipients of an email-gated page (`--json`, `--page <n>`). |
46
47
  | `droppage recipients add <slug> <emails...>` | Add recipients and queue their invitation emails. |
47
48
  | `droppage recipients remove <slug> <email>` | Revoke a recipient; their invitation link stops working (`--yes`). |
48
49
  | `droppage recipients resend <slug> <email>` | Queue a fresh invitation for an existing recipient. |
50
+ | `droppage image upload <files...> --page <slug>` | Advanced: add images (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each) to an existing page and print their URLs. `--json` prints `[{file,id,url,bytes,content_type,existing}]`. |
51
+ | `droppage image list [--page <slug>]` | List your pages' images; with `--page`, one page and its image budget. `--json` for machines. |
52
+ | `droppage image delete <id-or-url...>` | Delete images. Cached copies may still show for up to a day. |
53
+ | `droppage quota` | Show the current plan, page usage, monthly meter, reset date, and enabled features. `--json` returns the account API body. |
54
+ | `droppage feedback <message>` | Send feedback without requiring login. Options: `--kind idea|bug|review|other`, `--agent [model]`, `--contact <email>`, review-only `--rating <1-5>` and `--allow-quote`. Quote permission never publishes automatically. |
55
+ | `droppage upgrade` | Install the latest CLI globally with npm and report the installed version. |
49
56
 
50
57
  Run `droppage <command> --help` for full option details.
51
58
 
@@ -59,22 +66,76 @@ Run `droppage <command> --help` for full option details.
59
66
  | `--private` | private | Only you (your account email). Useful for drafts. |
60
67
 
61
68
  `--password`, `--email`, and `--private` are mutually exclusive. `update` never changes the access
62
- mode — it only swaps the HTML.
69
+ mode — it only swaps the HTML. When you are signed in to droppage.dev, your own protected pages
70
+ open directly, without the password or email link.
63
71
 
64
72
  ## Rules for uploads
65
73
 
66
74
  - One `.html` or `.htm` file per upload; the file is validated before any network call.
67
- - Inline your assets (CSS, JS, images as data URIs). Relative asset paths will not resolve.
68
- - Views shown by `list` are raw successful renders (including refreshes and crawlers), not unique visitors.
75
+ - Inline your CSS, JS, and data. Relative asset paths other than images will not resolve. Local
76
+ images referenced in `<img src>` are uploaded with the page (see below); sensitive images belong
77
+ inline as data: URIs.
78
+ - Views shown by `list` count one unique visitor per page. Refreshes, reopening in the same browser,
79
+ link previews from chat apps, and your own opens do not add views.
69
80
  - Preview URLs (`list --preview`) never increment the view count.
70
81
 
82
+ ## Images
83
+
84
+ Images belong to a page. Reference local image files in your HTML (inside the HTML file's folder; `../` and absolute paths are ignored), with paths relative to the HTML
85
+ file, and upload it:
86
+
87
+ ```html
88
+ <img src="chart.png">
89
+ ```
90
+
91
+ ```bash
92
+ $ droppage upload report.html
93
+ droppage: image chart.png → https://droppage.dev/i/AbCdEfGhIjKl.png
94
+ https://droppage.dev/p/k3x9qd2m
95
+ ```
96
+
97
+ `upload` finds the images, uploads them to that page, rewrites the links to
98
+ `https://droppage.dev/i/<id>.<ext>`, then publishes. The image lines go to stderr; stdout is still
99
+ only the page URL.
100
+
101
+ - `update` uploads any new local images, keeps the same URL for an unchanged image, and deletes
102
+ the page's images the new HTML no longer uses.
103
+ - Deleting a page, or the page expiring, removes its images too. Images never outlive their page.
104
+ - Image budget per page by plan: Free 5 MB, Starter 20 MB, Builder 50 MB. Each image up to 4 MB.
105
+ PNG, JPEG, GIF, WebP, AVIF, SVG. Image traffic counts toward your monthly bandwidth.
106
+ - Prefer small formats: WebP or AVIF for photos and screenshots, SVG for charts and logos (often
107
+ several times smaller than PNG or JPEG). The CLI prints a tip on stderr when a PNG, JPEG, or GIF
108
+ is over 300 KB.
109
+ - **Images are public** to anyone with the URL. A page's password, email, or private gate does not
110
+ protect them. Keep sensitive images inline in the HTML as data: URIs instead.
111
+ - Advanced: `droppage image upload <files...> --page <slug>` adds images to an existing page and
112
+ prints their URLs; `droppage image list [--page <slug>] [--json]` shows them (`--json` prints
113
+ `{images:[{id,url,ext,content_type,bytes,name,created_at,page}], page?:{slug,used_bytes,limit_bytes}}`);
114
+ `droppage image delete <id-or-url...>` removes them.
115
+
116
+ ## Expiry
117
+
118
+ Pages can expire. Nothing expires unless you set it, on any plan.
119
+
120
+ ```bash
121
+ droppage upload report.html --expires 7d # also 30m, 12h, 2w, or a date like 2026-12-31
122
+ droppage update <slug> report.html --expires never
123
+ droppage expire <slug> 2026-12-31 # change it without re-uploading; `never` clears it
124
+ ```
125
+
126
+ After expiry the page shows "This page has expired." (HTTP 410) to everyone, including you, and
127
+ its images stop working. A daily cleanup then deletes the page and its images for good.
128
+ `droppage list` shows an EXPIRES column (`never` or a date); `list --json` rows have `expires_at`
129
+ (`null` means never).
130
+
71
131
  ## For agents and scripts
72
132
 
73
133
  droppage keeps a strict stream contract so it is safe to call from automation:
74
134
 
75
- - `upload` and `update` print **only the URL** on stdout.
76
- - `list --json` and `recipients list --json` print **only JSON** on stdout.
77
- - Progress, warnings, prompts, and errors go to **stderr**.
135
+ - `upload` and `update` print **only the URL** on stdout, even when they upload images;
136
+ `image upload` prints **only the image URL(s)**; `expire` prints **nothing** on stdout.
137
+ - `list --json`, `image list --json`, `recipients list --json`, and `quota --json` print **only JSON** on stdout.
138
+ - Progress, warnings, prompts, errors, and the per-image `droppage: image … → …` lines go to **stderr**.
78
139
  - Non-zero exit code on any failure.
79
140
  - Use `--yes` on `delete` / `recipients remove` for non-interactive runs.
80
141
 
@@ -84,10 +145,28 @@ Ready-made instruction packs for Codex, Cursor, and Claude-style agents live in
84
145
  ## Updating
85
146
 
86
147
  ```bash
87
- npm update -g droppage # upgrade to the latest release
148
+ droppage upgrade # upgrade to the latest release
88
149
  droppage --version # confirm the installed version
89
150
  ```
90
151
 
152
+ After a command finishes, interactive terminals check npm for a new release at most once every 24
153
+ hours and put any update notice on stderr only. The check sends no identifier or usage data, and
154
+ all errors are ignored. Set `DROPPAGE_NO_UPDATE_CHECK=1` to opt out. This registry lookup is the
155
+ CLI's only non-backend network request; droppage still collects no telemetry.
156
+
157
+ Before bulk publishing, use `droppage quota --json`. A `warn` level means the monthly meter is at
158
+ least 80% used; `limit` means new uploads, updates, or previews may pause. Existing links remain
159
+ live. Delete unneeded pages or review plan options at [droppage.dev/account](https://droppage.dev/account).
160
+
161
+ Feedback is public and login is optional:
162
+
163
+ ```bash
164
+ droppage feedback "The preview command should…" --kind idea
165
+ droppage feedback "Upload failed after…" --kind bug --agent "my-model"
166
+ ```
167
+
168
+ Agent-authored feedback should always use `--agent` and include the model name.
169
+
91
170
  Releases are published from GitHub Actions with
92
171
  [npm provenance](https://docs.npmjs.com/generating-provenance-statements), so every version on npm
93
172
  is verifiably built from a tagged commit in the public repository.
@@ -102,6 +181,7 @@ setups, these environment variables are honoured (in order of precedence):
102
181
  | `DROPPAGE_SUPABASE_URL` / `DROPPAGE_SUPABASE_ANON_KEY` | Point the CLI at another backend. |
103
182
  | `SUPABASE_URL` / `SUPABASE_ANON_KEY` | Fallback names for the same. |
104
183
  | `DROPPAGE_SHARE_BASE_URL` | Origin used when printing share URLs (default `https://droppage.dev`). |
184
+ | `DROPPAGE_NO_UPDATE_CHECK` | Set to `1` to disable the optional 24-hour npm release check. |
105
185
 
106
186
  ## Security
107
187
 
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ var init_package = __esm({
15
15
  "package.json"() {
16
16
  package_default = {
17
17
  name: "droppage",
18
- version: "0.1.1",
18
+ version: "0.2.0",
19
19
  description: "Publish HTML files as shareable, access-controlled live pages",
20
20
  type: "module",
21
21
  license: "Apache-2.0",
@@ -83,12 +83,12 @@ var init_package = __esm({
83
83
  // src/lib/callback-server.ts
84
84
  import { createServer } from "http";
85
85
  function closeServer(server) {
86
- return new Promise((resolve) => {
87
- server.close(() => resolve());
86
+ return new Promise((resolve2) => {
87
+ server.close(() => resolve2());
88
88
  });
89
89
  }
90
90
  function tryListen(port) {
91
- return new Promise((resolve, reject) => {
91
+ return new Promise((resolve2, reject) => {
92
92
  const server = createServer();
93
93
  const onError = (err) => {
94
94
  server.off("listening", onListening);
@@ -96,7 +96,7 @@ function tryListen(port) {
96
96
  };
97
97
  const onListening = () => {
98
98
  server.off("error", onError);
99
- resolve(server);
99
+ resolve2(server);
100
100
  };
101
101
  server.once("error", onError);
102
102
  server.once("listening", onListening);
@@ -137,7 +137,7 @@ function normalizeArgs(portOrOptions, pathArg) {
137
137
  }
138
138
  function waitForAuthCode(portOrOptions, pathArg) {
139
139
  const { port, path, timeoutMs } = normalizeArgs(portOrOptions, pathArg);
140
- return new Promise((resolve, reject) => {
140
+ return new Promise((resolve2, reject) => {
141
141
  let settled = false;
142
142
  let timeout;
143
143
  const settle = (fn) => {
@@ -160,7 +160,7 @@ function waitForAuthCode(portOrOptions, pathArg) {
160
160
  res.end(SUCCESS_BODY);
161
161
  void closeServer(server).then(() => {
162
162
  if (code) {
163
- settle(() => resolve(code));
163
+ settle(() => resolve2(code));
164
164
  return;
165
165
  }
166
166
  const message = errorDescription || error || "No auth code in callback";
@@ -215,7 +215,7 @@ function rejectSecrets(label, value) {
215
215
  }
216
216
  }
217
217
  function resolveSupabaseEnv() {
218
- loadDotenv({ path: join(process.cwd(), ".env") });
218
+ loadDotenv({ path: join(process.cwd(), ".env"), quiet: true });
219
219
  const url = process.env.DROPPAGE_SUPABASE_URL || process.env.SUPABASE_URL || DEFAULT_SUPABASE_URL;
220
220
  const anonKey = process.env.DROPPAGE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;
221
221
  if (!url.trim() || !anonKey.trim()) {
@@ -245,6 +245,18 @@ var init_env = __esm({
245
245
  import Conf from "conf";
246
246
  import { homedir } from "os";
247
247
  import { join as join2 } from "path";
248
+ function confAt(slot, configName) {
249
+ const home = homedir();
250
+ if (!slot.instance || slot.home !== home) {
251
+ slot.home = home;
252
+ slot.instance = new Conf({
253
+ cwd: join2(home, ".droppage"),
254
+ configName,
255
+ configFileMode: 384
256
+ });
257
+ }
258
+ return slot.instance;
259
+ }
248
260
  function validateConfig(data) {
249
261
  for (const [key, value] of Object.entries(data)) {
250
262
  if (FORBIDDEN.test(key)) {
@@ -260,16 +272,10 @@ function validateConfig(data) {
260
272
  }
261
273
  }
262
274
  function getConf() {
263
- const home = homedir();
264
- if (!confInstance || confHome !== home) {
265
- confHome = home;
266
- confInstance = new Conf({
267
- cwd: join2(home, ".droppage"),
268
- configName: "config",
269
- configFileMode: 384
270
- });
271
- }
272
- return confInstance;
275
+ return confAt(sessionConf, "config");
276
+ }
277
+ function getUpdateCheckConf() {
278
+ return confAt(updateCheckConf, "update-check");
273
279
  }
274
280
  function createConfStorage() {
275
281
  const conf = getConf();
@@ -287,11 +293,13 @@ function createConfStorage() {
287
293
  }
288
294
  };
289
295
  }
290
- var FORBIDDEN, confInstance, confHome;
296
+ var FORBIDDEN, sessionConf, updateCheckConf;
291
297
  var init_config = __esm({
292
298
  "src/lib/config.ts"() {
293
299
  "use strict";
294
300
  FORBIDDEN = /service_role|sb_secret/i;
301
+ sessionConf = {};
302
+ updateCheckConf = {};
295
303
  }
296
304
  });
297
305
 
@@ -375,7 +383,8 @@ async function login() {
375
383
  `(debug) After you click the link, Auth redirects to: ${emailRedirectTo}`
376
384
  );
377
385
  const { url } = resolveSupabaseEnv();
378
- if (isLocalSupabaseUrl(url)) {
386
+ const noBrowser = Boolean(process.env.DROPPAGE_NO_BROWSER || process.env.CI);
387
+ if (isLocalSupabaseUrl(url) && !noBrowser) {
379
388
  try {
380
389
  await open(INBUCKET_URL);
381
390
  } catch {
@@ -483,6 +492,86 @@ var init_password = __esm({
483
492
  }
484
493
  });
485
494
 
495
+ // src/lib/duration.ts
496
+ function parseExpiry(value, now = Date.now()) {
497
+ const raw = value.trim().toLowerCase();
498
+ if (raw === "never" || raw === "none") return null;
499
+ const match = /^(\d+)\s*([mhdw])$/.exec(raw);
500
+ if (match) {
501
+ const ms = Number(match[1]) * UNITS[match[2]];
502
+ if (ms <= 0) throw new Error(`--expires must be in the future: ${value}`);
503
+ return new Date(now + ms);
504
+ }
505
+ if (/^\d{4}-\d{2}-\d{2}/.test(raw)) {
506
+ const date = new Date(value.trim());
507
+ if (Number.isNaN(date.getTime())) throw new Error(`--expires: not a date: ${value}`);
508
+ if (date.getTime() <= now) throw new Error(`--expires must be in the future: ${value}`);
509
+ return date;
510
+ }
511
+ throw new Error(`--expires: use a duration like 30m, 12h, 7d or 2w, a date like 2026-12-31, or never`);
512
+ }
513
+ var UNITS;
514
+ var init_duration = __esm({
515
+ "src/lib/duration.ts"() {
516
+ "use strict";
517
+ UNITS = { m: 6e4, h: 36e5, d: 864e5, w: 7 * 864e5 };
518
+ }
519
+ });
520
+
521
+ // src/lib/html-images.ts
522
+ import { existsSync, realpathSync, statSync } from "fs";
523
+ import { dirname, extname, relative, resolve, sep } from "path";
524
+ function inside(base, path) {
525
+ const rel = relative(base, path);
526
+ return !!rel && !rel.startsWith("..") && !rel.startsWith(sep) && !/^[a-z]:/i.test(rel);
527
+ }
528
+ function findLocalImages(html, htmlFile) {
529
+ const base = dirname(resolve(htmlFile));
530
+ let realBase;
531
+ try {
532
+ realBase = realpathSync(base);
533
+ } catch {
534
+ return [];
535
+ }
536
+ const seen = /* @__PURE__ */ new Map();
537
+ for (const match of html.matchAll(SRC_RE)) {
538
+ const ref = match[3].trim();
539
+ if (seen.has(ref) || /^(?:[a-z][a-z0-9+.-]*:|\/|\\|#)/i.test(ref)) continue;
540
+ let decoded;
541
+ try {
542
+ decoded = decodeURIComponent(ref.split(/[?#]/)[0]);
543
+ } catch {
544
+ continue;
545
+ }
546
+ if (!IMAGE_EXTS.has(extname(decoded).toLowerCase())) continue;
547
+ const path = resolve(base, decoded);
548
+ if (!inside(base, path) || !existsSync(path)) continue;
549
+ let real;
550
+ try {
551
+ real = realpathSync(path);
552
+ } catch {
553
+ continue;
554
+ }
555
+ if (!inside(realBase, real) || !statSync(real).isFile()) continue;
556
+ seen.set(ref, { ref, path: real });
557
+ }
558
+ return [...seen.values()];
559
+ }
560
+ function rewriteImageRefs(html, urls) {
561
+ return html.replace(SRC_RE, (whole, before, quote, ref) => {
562
+ const url = urls.get(ref.trim());
563
+ return url ? `${before}${quote}${url}${quote}` : whole;
564
+ });
565
+ }
566
+ var IMAGE_EXTS, SRC_RE;
567
+ var init_html_images = __esm({
568
+ "src/lib/html-images.ts"() {
569
+ "use strict";
570
+ IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
571
+ SRC_RE = /(<(?:img|source)\b[^>]*?\ssrc\s*=\s*)(["'])([^"']+)\2/gi;
572
+ }
573
+ });
574
+
486
575
  // src/lib/share-url.ts
487
576
  function resolveShareBaseUrl() {
488
577
  const raw = process.env.DROPPAGE_SHARE_BASE_URL?.trim();
@@ -499,6 +588,143 @@ var init_share_url = __esm({
499
588
  }
500
589
  });
501
590
 
591
+ // src/services/images.ts
592
+ import { readFile, stat } from "fs/promises";
593
+ import { basename } from "path";
594
+ async function accessToken() {
595
+ const { data } = await createSupabaseClient().auth.getSession();
596
+ const token = data.session?.access_token;
597
+ if (!token) throw new ImageRequestError("not signed in; run `droppage login`", 401);
598
+ return token;
599
+ }
600
+ async function call(path, init) {
601
+ const base = resolveShareBaseUrl();
602
+ let response;
603
+ try {
604
+ response = await fetch(`${base}${path}`, init);
605
+ } catch (err) {
606
+ throw new ImageRequestError(`could not reach ${base}: ${err instanceof Error ? err.message : String(err)}`, 0);
607
+ }
608
+ const text = await response.text();
609
+ let body = null;
610
+ try {
611
+ body = text ? JSON.parse(text) : null;
612
+ } catch {
613
+ }
614
+ if (!response.ok) {
615
+ const message = body?.error ?? `request failed (HTTP ${response.status})`;
616
+ throw new ImageRequestError(message, response.status);
617
+ }
618
+ return body;
619
+ }
620
+ async function uploadImage(file, slug, { update = false } = {}) {
621
+ const info = await stat(file).catch(() => null);
622
+ if (!info?.isFile()) throw new ImageRequestError(`file not found: ${file}`, 0);
623
+ if (info.size === 0) throw new ImageRequestError(`file is empty: ${file}`, 0);
624
+ if (info.size > MAX_IMAGE_BYTES) {
625
+ throw new ImageRequestError(`image is larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB (${info.size} bytes): ${file}`, 413);
626
+ }
627
+ const bytes = await readFile(file);
628
+ const body = await call("/api/images", {
629
+ method: "POST",
630
+ headers: {
631
+ Authorization: `Bearer ${await accessToken()}`,
632
+ "Content-Type": "application/octet-stream",
633
+ "X-Droppage-Filename": encodeURIComponent(basename(file)),
634
+ "X-Droppage-Page": slug,
635
+ ...update ? { "X-Droppage-Update": "1" } : {}
636
+ },
637
+ body: bytes
638
+ });
639
+ if (info.size > HEAVY_IMAGE_BYTES && /\.(png|jpe?g|gif)$/i.test(file)) {
640
+ console.error(`droppage: tip: ${basename(file)} is ${Math.round(info.size / 1024)} KB; WebP or AVIF is usually several times smaller (SVG for charts).`);
641
+ }
642
+ const image = body;
643
+ if (!image || typeof image.url !== "string" || typeof image.id !== "string") {
644
+ throw new ImageRequestError("unexpected response from the image API", 0);
645
+ }
646
+ return image;
647
+ }
648
+ async function listImages(slug) {
649
+ const query = slug ? `?page=${encodeURIComponent(slug)}` : "";
650
+ const body = await call(`/api/images${query}`, {
651
+ method: "GET",
652
+ headers: { Authorization: `Bearer ${await accessToken()}` }
653
+ });
654
+ const list = body;
655
+ if (!list || !Array.isArray(list.images)) throw new ImageRequestError("unexpected response from the image API", 0);
656
+ return list;
657
+ }
658
+ function imageIdFrom(value) {
659
+ const match = /(?:^|\/i\/)([A-Za-z0-9]{12})(?:\.[a-z]+)?$/.exec(value.trim());
660
+ return match ? match[1] : null;
661
+ }
662
+ async function deleteImage(idOrUrl) {
663
+ const id = imageIdFrom(idOrUrl);
664
+ if (!id) throw new ImageRequestError(`not an image id or URL: ${idOrUrl}`, 0);
665
+ await call(`/api/images/${id}`, {
666
+ method: "DELETE",
667
+ headers: { Authorization: `Bearer ${await accessToken()}` }
668
+ });
669
+ return id;
670
+ }
671
+ async function pruneImages(slug) {
672
+ const body = await call(`/api/pages/${encodeURIComponent(slug)}/images/prune`, {
673
+ method: "POST",
674
+ headers: { Authorization: `Bearer ${await accessToken()}` }
675
+ });
676
+ return Number(body?.removed ?? 0);
677
+ }
678
+ var MAX_IMAGE_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
679
+ var init_images = __esm({
680
+ "src/services/images.ts"() {
681
+ "use strict";
682
+ init_share_url();
683
+ init_supabase();
684
+ MAX_IMAGE_BYTES = 4 * 1024 * 1024;
685
+ HEAVY_IMAGE_BYTES = 300 * 1024;
686
+ ImageRequestError = class extends Error {
687
+ constructor(message, status) {
688
+ super(message);
689
+ this.status = status;
690
+ this.name = "ImageRequestError";
691
+ }
692
+ };
693
+ }
694
+ });
695
+
696
+ // src/services/page-images.ts
697
+ import { readFile as readFile2 } from "fs/promises";
698
+ import { basename as basename2 } from "path";
699
+ async function attachLocalImages(file, slug, { html, update = false } = {}) {
700
+ const source = html ?? await readFile2(file);
701
+ const text = source.toString("utf8");
702
+ const local = findLocalImages(text, file);
703
+ if (local.length === 0) return { html: source, uploaded: [] };
704
+ const urls = /* @__PURE__ */ new Map();
705
+ const uploaded = [];
706
+ for (const image of local) {
707
+ const result = await uploadImage(image.path, slug, { update });
708
+ urls.set(image.ref, result.url);
709
+ uploaded.push(result);
710
+ console.error(`droppage: image ${basename2(image.path)} \u2192 ${result.url}`);
711
+ }
712
+ return { html: Buffer.from(rewriteImageRefs(text, urls), "utf8"), uploaded };
713
+ }
714
+ async function hasLocalImages(file) {
715
+ return findLocalImages((await readFile2(file)).toString("utf8"), file).length > 0;
716
+ }
717
+ async function pruneUnusedImages(slug) {
718
+ return pruneImages(slug);
719
+ }
720
+ var init_page_images = __esm({
721
+ "src/services/page-images.ts"() {
722
+ "use strict";
723
+ init_html_images();
724
+ init_images();
725
+ }
726
+ });
727
+
502
728
  // src/lib/slug.ts
503
729
  import { customAlphabet } from "nanoid";
504
730
  function createSlug() {
@@ -514,14 +740,34 @@ var init_slug = __esm({
514
740
  }
515
741
  });
516
742
 
743
+ // src/lib/supabase-error.ts
744
+ function asError(err) {
745
+ if (err instanceof Error) return err;
746
+ if (err !== null && typeof err === "object" && typeof err.message === "string") {
747
+ const error = new Error(err.message);
748
+ const code = err.code;
749
+ if (typeof code === "string") Object.assign(error, { code });
750
+ return error;
751
+ }
752
+ return new Error(String(err));
753
+ }
754
+ var init_supabase_error = __esm({
755
+ "src/lib/supabase-error.ts"() {
756
+ "use strict";
757
+ }
758
+ });
759
+
517
760
  // src/lib/validate-html.ts
518
- import { extname } from "path";
761
+ import { extname as extname2 } from "path";
762
+ function isHostedImage(url) {
763
+ return url.startsWith(`${resolveShareBaseUrl()}/i/`);
764
+ }
519
765
  function formatBytes(bytes) {
520
766
  const mb = bytes / (1024 * 1024);
521
767
  return `${mb.toFixed(1)} MB`;
522
768
  }
523
769
  function validateHtmlUpload(path, bytes) {
524
- const ext = extname(path).toLowerCase();
770
+ const ext = extname2(path).toLowerCase();
525
771
  if (!ALLOWED_EXTENSIONS.has(ext)) {
526
772
  throw new Error(
527
773
  `Unsupported file type: ${path} (expected .html or .htm)`
@@ -553,7 +799,8 @@ function validateHtmlUpload(path, bytes) {
553
799
  "HTML references relative assets (src/href beginning with ./ or /); they will not resolve in a single-file share. Inline them for a self-contained page."
554
800
  );
555
801
  }
556
- if (EXTERNAL_ASSET_RE.test(body)) {
802
+ const external = [...body.matchAll(EXTERNAL_ASSET_RE)].map((m) => m[1]).filter((url) => !isHostedImage(url));
803
+ if (external.length > 0) {
557
804
  warnings.push(
558
805
  "HTML references external assets (//host or http(s)://); they must be publicly reachable to render in the shared page."
559
806
  );
@@ -564,6 +811,7 @@ var MAX_UPLOAD_BYTES, ALLOWED_EXTENSIONS, SNIFF_BYTES, HTML_MARKERS, RELATIVE_AS
564
811
  var init_validate_html = __esm({
565
812
  "src/lib/validate-html.ts"() {
566
813
  "use strict";
814
+ init_share_url();
567
815
  MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
568
816
  ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
569
817
  SNIFF_BYTES = 4096;
@@ -574,33 +822,51 @@ var init_validate_html = __esm({
574
822
  /<body[\s>]/i,
575
823
  /<div[\s>]/i
576
824
  ];
577
- RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/))/i;
578
- EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["'](\/\/|https?:\/\/)/i;
825
+ RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/)(?!i\/[A-Za-z0-9]{12}\.[a-z]+["']))/i;
826
+ EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["']((?:\/\/|https?:\/\/)[^"']*)/gi;
579
827
  }
580
828
  });
581
829
 
582
830
  // src/services/upload.ts
583
- import { readFile } from "fs/promises";
831
+ import { readFile as readFile3 } from "fs/promises";
584
832
  async function upload({
585
833
  file,
586
834
  user,
587
835
  passwordHash,
588
836
  allowedEmails,
589
- accessMode
837
+ accessMode,
838
+ prepare,
839
+ expiresAt
590
840
  }) {
591
- const bytes = await readFile(file);
841
+ let bytes = await readFile3(file);
592
842
  const { warnings } = validateHtmlUpload(file, bytes);
593
- for (const w of warnings) console.error(`droppage: warning: ${w}`);
843
+ if (!prepare) for (const w of warnings) console.error(`droppage: warning: ${w}`);
594
844
  const supabase = createSupabaseClient();
845
+ const resolvedMode = accessMode ?? (allowedEmails ? "email" : passwordHash ? "password" : "public");
846
+ const preflight = await supabase.rpc("assert_page_create_allowed", {
847
+ p_access_mode: resolvedMode,
848
+ p_recipient_count: allowedEmails?.length ?? 0
849
+ });
850
+ if (preflight.error) throw asError(preflight.error);
595
851
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
596
852
  const slug = createSlug();
597
853
  const storagePath = `${user.id}/${slug}.html`;
854
+ if (prepare) {
855
+ bytes = await prepare(slug);
856
+ if (attempt === 0) for (const w of validateHtmlUpload(file, bytes).warnings) console.error(`droppage: warning: ${w}`);
857
+ }
598
858
  const up = await supabase.storage.from(BUCKET).upload(storagePath, bytes, {
599
859
  contentType: "text/html; charset=utf-8",
600
860
  upsert: false
601
861
  });
602
- if (up.error) throw up.error;
603
- const resolvedMode = accessMode ?? (allowedEmails ? "email" : passwordHash ? "password" : "public");
862
+ if (up.error) {
863
+ if (/row-level security/i.test(up.error.message)) {
864
+ throw new Error(
865
+ `the server rejected this upload: ${up.error.message}. This can happen when an account limit is reached while an upload is in flight \u2014 run \`droppage quota\` to check your limits.`
866
+ );
867
+ }
868
+ throw asError(up.error);
869
+ }
604
870
  const ins = await supabase.rpc("create_page_with_entitlements", {
605
871
  p_slug: slug,
606
872
  p_storage_path: storagePath,
@@ -608,7 +874,17 @@ async function upload({
608
874
  p_password_hash: resolvedMode === "password" ? passwordHash : null,
609
875
  p_allowed_emails: allowedEmails ?? null
610
876
  });
611
- if (!ins.error) return buildShareUrl(slug);
877
+ if (!ins.error) {
878
+ if (expiresAt) {
879
+ const expiry = await supabase.rpc("set_page_expiry", { p_slug: slug, p_expires_at: expiresAt.toISOString() });
880
+ if (expiry.error) {
881
+ throw new Error(
882
+ `published ${buildShareUrl(slug)} but could not set its expiry (${expiry.error.message}); set it with \`droppage expire ${slug} <when>\` or delete it`
883
+ );
884
+ }
885
+ }
886
+ return buildShareUrl(slug);
887
+ }
612
888
  const rm = await supabase.storage.from(BUCKET).remove([storagePath]);
613
889
  if (rm.error) {
614
890
  console.error(
@@ -616,7 +892,7 @@ async function upload({
616
892
  );
617
893
  }
618
894
  if (ins.error.code === "23505") continue;
619
- throw ins.error;
895
+ throw asError(ins.error);
620
896
  }
621
897
  throw new Error(
622
898
  `Could not allocate a unique slug after ${MAX_RETRIES} attempts`
@@ -628,6 +904,7 @@ var init_upload = __esm({
628
904
  "use strict";
629
905
  init_share_url();
630
906
  init_slug();
907
+ init_supabase_error();
631
908
  init_supabase();
632
909
  init_validate_html();
633
910
  BUCKET = "pages";
@@ -642,7 +919,7 @@ function registerUpload(program2) {
642
919
  "--email <address>",
643
920
  "restrict access to an email allowlist (repeatable; comma-separated)",
644
921
  (val, prev = []) => prev.concat(val.split(","))
645
- ).option("--private", "restrict access to only you (owner-only draft)").action(
922
+ ).option("--private", "restrict access to only you (owner-only draft)").option("--expires <when>", "delete the page (and its images) after 30m, 12h, 7d, 2w or on a date").action(
646
923
  async (file, options) => {
647
924
  let user;
648
925
  try {
@@ -707,21 +984,28 @@ function registerUpload(program2) {
707
984
  allowedEmails = [ownerEmail];
708
985
  accessMode = "private";
709
986
  }
987
+ const expiresAt = options.expires ? parseExpiry(options.expires) ?? void 0 : void 0;
710
988
  console.error(`droppage: uploading ${file}...`);
989
+ const prepare = await hasLocalImages(file) ? async (slug) => (await attachLocalImages(file, slug)).html : void 0;
711
990
  const url = await upload({
712
991
  file,
713
992
  user,
714
993
  passwordHash,
715
994
  allowedEmails,
716
- accessMode
995
+ accessMode,
996
+ prepare,
997
+ expiresAt
717
998
  });
999
+ if (expiresAt) console.error(`droppage: expires ${expiresAt.toISOString()}`);
718
1000
  console.error("droppage: done");
719
1001
  process.stdout.write(url + "\n");
720
1002
  } catch (err) {
721
1003
  process.exitCode = 1;
722
- console.error(
723
- `droppage: upload failed: ${err instanceof Error ? err.message : String(err)}`
724
- );
1004
+ const message = err instanceof Error ? err.message : String(err);
1005
+ console.error(`droppage: upload failed: ${message}`);
1006
+ if (/quota reached|require (Starter|Builder)/i.test(message)) {
1007
+ console.error("run `droppage quota` to see your limits");
1008
+ }
725
1009
  }
726
1010
  }
727
1011
  );
@@ -730,17 +1014,19 @@ var init_upload2 = __esm({
730
1014
  "src/commands/upload.ts"() {
731
1015
  "use strict";
732
1016
  init_password();
1017
+ init_duration();
1018
+ init_page_images();
733
1019
  init_auth();
734
1020
  init_upload();
735
1021
  }
736
1022
  });
737
1023
 
738
1024
  // src/services/pages.ts
739
- import { readFile as readFile2 } from "fs/promises";
1025
+ import { readFile as readFile4 } from "fs/promises";
740
1026
  async function listPages(user) {
741
1027
  const supabase = createSupabaseClient();
742
1028
  const { data, error } = await supabase.from("pages").select(
743
- "slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at"
1029
+ "slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at, expires_at"
744
1030
  ).eq("owner_id", user.id).order("created_at", { ascending: false });
745
1031
  if (error) throw error;
746
1032
  return data ?? [];
@@ -782,9 +1068,10 @@ async function deletePage(slug, user) {
782
1068
  async function updatePageHtml({
783
1069
  slug,
784
1070
  file,
785
- user
1071
+ user,
1072
+ html
786
1073
  }) {
787
- const bytes = await readFile2(file);
1074
+ const bytes = html ?? await readFile4(file);
788
1075
  const { warnings } = validateHtmlUpload(file, bytes);
789
1076
  for (const warning of warnings) {
790
1077
  console.error(`droppage: warning: ${warning}`);
@@ -793,7 +1080,7 @@ async function updatePageHtml({
793
1080
  const preflight = await supabase.rpc("assert_page_update_allowed", {
794
1081
  p_slug: slug
795
1082
  });
796
- if (preflight.error) throw preflight.error;
1083
+ if (preflight.error) throw asError(preflight.error);
797
1084
  const { data: row, error: selectError } = await supabase.from("pages").select("slug, storage_path").eq("slug", slug).eq("owner_id", user.id).maybeSingle();
798
1085
  if (selectError) throw selectError;
799
1086
  if (!row) throw new Error("page not found or not owned");
@@ -815,11 +1102,24 @@ async function updatePageHtml({
815
1102
  url: buildShareUrl(slug)
816
1103
  };
817
1104
  }
1105
+ async function setPageExpiry(slug, expiresAt) {
1106
+ const supabase = createSupabaseClient();
1107
+ const { error } = await supabase.rpc("set_page_expiry", {
1108
+ p_slug: slug,
1109
+ p_expires_at: expiresAt ? expiresAt.toISOString() : null
1110
+ });
1111
+ if (error) throw asError(error);
1112
+ }
1113
+ async function assertUpdateAllowed(slug) {
1114
+ const { error } = await createSupabaseClient().rpc("assert_page_update_allowed", { p_slug: slug });
1115
+ if (error) throw asError(error);
1116
+ }
818
1117
  var BUCKET2, NOT_FOUND_MESSAGE;
819
1118
  var init_pages = __esm({
820
1119
  "src/services/pages.ts"() {
821
1120
  "use strict";
822
1121
  init_share_url();
1122
+ init_supabase_error();
823
1123
  init_supabase();
824
1124
  init_validate_html();
825
1125
  BUCKET2 = "pages";
@@ -828,8 +1128,9 @@ var init_pages = __esm({
828
1128
  });
829
1129
 
830
1130
  // src/commands/update.ts
1131
+ import { readFile as readFile5 } from "fs/promises";
831
1132
  function registerUpdate(program2) {
832
- program2.command("update").description("Replace the HTML behind an existing page URL").argument("<slug>", "slug of the page to update").argument("<file>", "path to replacement .html file").action(async (slug, file) => {
1133
+ program2.command("update").description("Replace the HTML behind an existing page URL (its local images are uploaded too)").argument("<slug>", "slug of the page to update").argument("<file>", "path to replacement .html file").option("--expires <when>", "set when the page expires: 30m, 12h, 7d, 2w, a date, or never").action(async (slug, file, options) => {
833
1134
  let user;
834
1135
  try {
835
1136
  user = await requireAuth();
@@ -838,22 +1139,53 @@ function registerUpdate(program2) {
838
1139
  throw err;
839
1140
  }
840
1141
  try {
1142
+ const expiresAt = options.expires === void 0 ? void 0 : parseExpiry(options.expires);
841
1143
  console.error(`droppage: updating ${slug} from ${file}...`);
842
- const result = await updatePageHtml({ slug, file, user });
1144
+ await assertUpdateAllowed(slug);
1145
+ validateHtmlUpload(file, await readFile5(file));
1146
+ const { html } = await attachLocalImages(file, slug, { update: true });
1147
+ const result = await updatePageHtml({ slug, file, user, html });
1148
+ const removed = await pruneUnusedImages(slug);
1149
+ if (removed) console.error(`droppage: removed ${removed} image(s) the page no longer uses`);
1150
+ if (expiresAt !== void 0) {
1151
+ await setPageExpiry(slug, expiresAt);
1152
+ console.error(expiresAt ? `droppage: expires ${expiresAt.toISOString()}` : "droppage: never expires");
1153
+ }
843
1154
  console.error(`droppage: updated ${slug}`);
844
1155
  process.stdout.write(result.url + "\n");
845
1156
  } catch (err) {
846
1157
  process.exitCode = 1;
847
- console.error(
848
- `droppage: update failed: ${err instanceof Error ? err.message : String(err)}`
849
- );
1158
+ const message = err instanceof Error ? err.message : String(err);
1159
+ console.error(`droppage: update failed: ${message}`);
1160
+ if (/quota reached|limit reached|require (Starter|Builder)/i.test(message)) {
1161
+ console.error("run `droppage quota` to see your limits");
1162
+ }
1163
+ }
1164
+ });
1165
+ program2.command("expire").description("Set when a page (and its images) is deleted: 30m, 12h, 7d, 2w, a date, or never").argument("<slug>", "slug of the page").argument("<when>", "e.g. 7d, 2026-12-31, or never").action(async (slug, when) => {
1166
+ try {
1167
+ await requireAuth();
1168
+ } catch (err) {
1169
+ if (err instanceof AuthRequiredError) return;
1170
+ throw err;
1171
+ }
1172
+ try {
1173
+ const expiresAt = parseExpiry(when);
1174
+ await setPageExpiry(slug, expiresAt);
1175
+ console.error(expiresAt ? `droppage: ${slug} expires ${expiresAt.toISOString()}` : `droppage: ${slug} never expires`);
1176
+ } catch (err) {
1177
+ process.exitCode = 1;
1178
+ console.error(`droppage: expire failed: ${err instanceof Error ? err.message : String(err)}`);
850
1179
  }
851
1180
  });
852
1181
  }
853
1182
  var init_update = __esm({
854
1183
  "src/commands/update.ts"() {
855
1184
  "use strict";
1185
+ init_validate_html();
1186
+ init_duration();
856
1187
  init_auth();
1188
+ init_page_images();
857
1189
  init_pages();
858
1190
  }
859
1191
  });
@@ -883,7 +1215,8 @@ function pageTableCells(row) {
883
1215
  formatModeCell(row.access_mode, row.allowed_emails),
884
1216
  formatDateCell(row.created_at),
885
1217
  String(row.view_count),
886
- formatDateCell(row.last_viewed_at)
1218
+ formatDateCell(row.last_viewed_at),
1219
+ row.expires_at ? row.expires_at.slice(0, 16).replace("T", " ") : "never"
887
1220
  ];
888
1221
  }
889
1222
  var PAGE_TABLE_HEADERS, PAGE_TABLE_ALIGN;
@@ -895,13 +1228,15 @@ var init_table = __esm({
895
1228
  "MODE",
896
1229
  "CREATED",
897
1230
  "VIEWS",
898
- "LAST VIEWED"
1231
+ "LAST VIEWED",
1232
+ "EXPIRES"
899
1233
  ];
900
1234
  PAGE_TABLE_ALIGN = [
901
1235
  "left",
902
1236
  "left",
903
1237
  "left",
904
1238
  "right",
1239
+ "left",
905
1240
  "left"
906
1241
  ];
907
1242
  }
@@ -1243,6 +1578,353 @@ var init_recipients2 = __esm({
1243
1578
  }
1244
1579
  });
1245
1580
 
1581
+ // src/services/quota.ts
1582
+ async function fetchQuota() {
1583
+ const supabase = createSupabaseClient();
1584
+ const { data } = await supabase.auth.getSession();
1585
+ const token = data.session?.access_token;
1586
+ if (!token) throw new QuotaRequestError("not signed in; run `droppage login`", 401);
1587
+ let response;
1588
+ try {
1589
+ response = await fetch(`${resolveShareBaseUrl()}/api/account`, {
1590
+ method: "GET",
1591
+ headers: { Authorization: `Bearer ${token}` }
1592
+ });
1593
+ } catch (err) {
1594
+ throw new QuotaRequestError(
1595
+ `could not reach ${resolveShareBaseUrl()}: ${err instanceof Error ? err.message : String(err)}`,
1596
+ 0
1597
+ );
1598
+ }
1599
+ const text = await response.text();
1600
+ let body = null;
1601
+ try {
1602
+ body = text ? JSON.parse(text) : null;
1603
+ } catch {
1604
+ }
1605
+ if (!response.ok) {
1606
+ const message = body?.error ?? `request failed (HTTP ${response.status})`;
1607
+ throw new QuotaRequestError(message, response.status);
1608
+ }
1609
+ if (body === null || typeof body !== "object" || !("usage" in body) || !("entitlements" in body) || typeof body.usage !== "object" || body.usage === null || typeof body.entitlements !== "object" || body.entitlements === null) {
1610
+ throw new QuotaRequestError(`request failed (HTTP ${response.status})`, response.status);
1611
+ }
1612
+ return body;
1613
+ }
1614
+ var QuotaRequestError;
1615
+ var init_quota = __esm({
1616
+ "src/services/quota.ts"() {
1617
+ "use strict";
1618
+ init_share_url();
1619
+ init_supabase();
1620
+ QuotaRequestError = class extends Error {
1621
+ constructor(message, status) {
1622
+ super(message);
1623
+ this.status = status;
1624
+ this.name = "QuotaRequestError";
1625
+ }
1626
+ };
1627
+ }
1628
+ });
1629
+
1630
+ // src/commands/quota.ts
1631
+ function formatBytes2(value) {
1632
+ if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
1633
+ if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
1634
+ return `${Number((value / 1e9).toFixed(2))} GB`;
1635
+ }
1636
+ function usedAndLimit(usage) {
1637
+ const used = usage.meter === "views" ? `${usage.used} views` : formatBytes2(usage.used);
1638
+ if (usage.limit === null) return `${used} / unlimited`;
1639
+ const limit = usage.meter === "views" ? `${usage.limit} views` : formatBytes2(usage.limit);
1640
+ return `${used} / ${limit} (${usage.percent}%)`;
1641
+ }
1642
+ function registerQuota(program2) {
1643
+ program2.command("quota").description("Show your plan, current usage, and publishing limits").option("--json", "print the account API response as JSON").action(async (options) => {
1644
+ try {
1645
+ await requireAuth();
1646
+ } catch (err) {
1647
+ if (err instanceof AuthRequiredError) return;
1648
+ throw err;
1649
+ }
1650
+ try {
1651
+ const result = await fetchQuota();
1652
+ if (options.json) {
1653
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1654
+ return;
1655
+ }
1656
+ const enabled = [
1657
+ result.entitlements.can_update && "update",
1658
+ result.entitlements.can_preview && "preview",
1659
+ result.entitlements.can_password && "password",
1660
+ result.entitlements.can_email_allowlist && "email"
1661
+ ].filter((feature) => Boolean(feature));
1662
+ const pageLimit = result.usage.pages.limit === null ? "unlimited" : String(result.usage.pages.limit);
1663
+ process.stdout.write(renderTable(
1664
+ ["ITEM", "VALUE"],
1665
+ [
1666
+ ["Plan", result.entitlements.plan_name],
1667
+ ["Pages", `${result.usage.pages.used} / ${pageLimit}`],
1668
+ ...result.usage.images ? [["Images per page", result.usage.images.per_page_limit_bytes === null ? "unlimited" : `up to ${formatBytes2(result.usage.images.per_page_limit_bytes)}`]] : [],
1669
+ ["Monthly meter", usedAndLimit(result.usage)],
1670
+ ["Period ends", result.usage.period_end],
1671
+ ["Features", enabled.length ? enabled.join(", ") : "basic publishing"]
1672
+ ]
1673
+ ) + "\n");
1674
+ console.error(`droppage: quota level ${result.usage.level}.`);
1675
+ } catch (err) {
1676
+ const message = err instanceof Error ? err.message : String(err);
1677
+ console.error(`droppage: quota failed: ${message}`);
1678
+ process.exitCode = 1;
1679
+ }
1680
+ });
1681
+ }
1682
+ var init_quota2 = __esm({
1683
+ "src/commands/quota.ts"() {
1684
+ "use strict";
1685
+ init_table();
1686
+ init_auth();
1687
+ init_quota();
1688
+ }
1689
+ });
1690
+
1691
+ // src/services/feedback.ts
1692
+ async function submitFeedback(input2) {
1693
+ const { data } = await createSupabaseClient().auth.getSession();
1694
+ const token = data.session?.access_token;
1695
+ const base = resolveShareBaseUrl();
1696
+ let response;
1697
+ try {
1698
+ response = await fetch(`${base}/api/feedback`, {
1699
+ method: "POST",
1700
+ headers: {
1701
+ "Content-Type": "application/json",
1702
+ ...token ? { Authorization: `Bearer ${token}` } : {}
1703
+ },
1704
+ body: JSON.stringify(input2)
1705
+ });
1706
+ } catch (error) {
1707
+ throw new FeedbackRequestError(
1708
+ `could not reach ${base}: ${error instanceof Error ? error.message : String(error)}`,
1709
+ 0
1710
+ );
1711
+ }
1712
+ const text = await response.text();
1713
+ let body = null;
1714
+ try {
1715
+ body = text ? JSON.parse(text) : null;
1716
+ } catch {
1717
+ }
1718
+ if (!response.ok || !body?.ok || !body.id) {
1719
+ throw new FeedbackRequestError(
1720
+ body?.error ?? `request failed (HTTP ${response.status})`,
1721
+ response.status
1722
+ );
1723
+ }
1724
+ return { ok: true, id: body.id };
1725
+ }
1726
+ var FeedbackRequestError;
1727
+ var init_feedback = __esm({
1728
+ "src/services/feedback.ts"() {
1729
+ "use strict";
1730
+ init_share_url();
1731
+ init_supabase();
1732
+ FeedbackRequestError = class extends Error {
1733
+ constructor(message, status) {
1734
+ super(message);
1735
+ this.status = status;
1736
+ this.name = "FeedbackRequestError";
1737
+ }
1738
+ };
1739
+ }
1740
+ });
1741
+
1742
+ // src/commands/feedback.ts
1743
+ function isKind(value) {
1744
+ return value === "idea" || value === "bug" || value === "review" || value === "other";
1745
+ }
1746
+ function registerFeedback(program2) {
1747
+ program2.command("feedback").description("Send an idea, bug report, review, or other feedback").argument("<message>", "feedback message (3\u20134000 characters)").option("--kind <kind>", "idea, bug, review, or other", "other").option("--agent [model]", "identify this feedback as coming from an agent").option("--contact <email>", "optional reply-to email address").option("--rating <rating>", "optional 1\u20135 rating for a review").option("--allow-quote", "allow this review to be quoted publicly (never publishes automatically)").action(async (message, options) => {
1748
+ const cleanedMessage = message.trim();
1749
+ const model = typeof options.agent === "string" ? options.agent.trim() : void 0;
1750
+ const contact = options.contact?.trim().toLowerCase();
1751
+ if (cleanedMessage.length < 3 || cleanedMessage.length > 4e3) {
1752
+ console.error("droppage: feedback must be between 3 and 4000 characters");
1753
+ process.exitCode = 1;
1754
+ return;
1755
+ }
1756
+ if (!isKind(options.kind)) {
1757
+ console.error("droppage: --kind must be idea, bug, review, or other");
1758
+ process.exitCode = 1;
1759
+ return;
1760
+ }
1761
+ if (model && model.length > 120) {
1762
+ console.error("droppage: agent model must be 120 characters or fewer");
1763
+ process.exitCode = 1;
1764
+ return;
1765
+ }
1766
+ if (contact && !emailSchema.safeParse(contact).success) {
1767
+ console.error(`droppage: invalid contact email: ${contact}`);
1768
+ process.exitCode = 1;
1769
+ return;
1770
+ }
1771
+ const rating = options.rating === void 0 ? void 0 : Number(options.rating);
1772
+ if (rating !== void 0 && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
1773
+ console.error("droppage: --rating must be a whole number from 1 to 5");
1774
+ process.exitCode = 1;
1775
+ return;
1776
+ }
1777
+ if (options.kind !== "review" && (rating !== void 0 || options.allowQuote)) {
1778
+ console.error("droppage: --rating and --allow-quote require --kind review");
1779
+ process.exitCode = 1;
1780
+ return;
1781
+ }
1782
+ try {
1783
+ const result = await submitFeedback({
1784
+ message: cleanedMessage,
1785
+ kind: options.kind,
1786
+ actor: options.agent === void 0 ? "human" : "agent",
1787
+ ...model ? { model } : {},
1788
+ ...contact ? { contact } : {},
1789
+ ...rating !== void 0 ? { rating } : {},
1790
+ ...options.allowQuote ? { testimonial_consent: true } : {},
1791
+ source: "cli",
1792
+ cli_version: package_default.version
1793
+ });
1794
+ process.stdout.write(`${result.id}
1795
+ `);
1796
+ console.error("droppage: thanks \u2014 your feedback was sent.");
1797
+ } catch (error) {
1798
+ const message2 = error instanceof Error ? error.message : String(error);
1799
+ console.error(`droppage: feedback failed: ${message2}`);
1800
+ process.exitCode = 1;
1801
+ }
1802
+ });
1803
+ }
1804
+ var init_feedback2 = __esm({
1805
+ "src/commands/feedback.ts"() {
1806
+ "use strict";
1807
+ init_package();
1808
+ init_auth();
1809
+ init_feedback();
1810
+ }
1811
+ });
1812
+
1813
+ // src/commands/upgrade.ts
1814
+ import { spawn } from "child_process";
1815
+ function installLatest() {
1816
+ return new Promise((resolve2, reject) => {
1817
+ const child = spawn("npm", ["install", "-g", "droppage@latest"], {
1818
+ stdio: "inherit",
1819
+ shell: process.platform === "win32"
1820
+ });
1821
+ child.once("error", reject);
1822
+ child.once("close", (code) => code === 0 ? resolve2() : reject(new Error(`npm exited with status ${code ?? "unknown"}`)));
1823
+ });
1824
+ }
1825
+ function registerUpgrade(program2) {
1826
+ program2.command("upgrade").description("Install the latest droppage CLI release").action(async () => {
1827
+ try {
1828
+ await installLatest();
1829
+ console.error("droppage: upgrade complete; run `droppage --version` to confirm.");
1830
+ } catch (error) {
1831
+ console.error(`droppage: upgrade failed: ${error instanceof Error ? error.message : String(error)}`);
1832
+ process.exitCode = 1;
1833
+ }
1834
+ });
1835
+ }
1836
+ var init_upgrade = __esm({
1837
+ "src/commands/upgrade.ts"() {
1838
+ "use strict";
1839
+ }
1840
+ });
1841
+
1842
+ // src/commands/image.ts
1843
+ function formatBytes3(value) {
1844
+ if (value < 1e3) return `${value} B`;
1845
+ if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
1846
+ if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
1847
+ return `${Number((value / 1e9).toFixed(2))} GB`;
1848
+ }
1849
+ async function signedIn() {
1850
+ try {
1851
+ await requireAuth();
1852
+ return true;
1853
+ } catch (err) {
1854
+ if (err instanceof AuthRequiredError) return false;
1855
+ throw err;
1856
+ }
1857
+ }
1858
+ function fail(err) {
1859
+ console.error(`droppage: ${err instanceof Error ? err.message : String(err)}`);
1860
+ process.exitCode = 1;
1861
+ }
1862
+ function registerImage(program2) {
1863
+ const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local <img src> files)");
1864
+ image.command("upload").description("Add images to one of your pages and print their URLs (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each)").argument("<files...>", "image files").requiredOption("--page <slug>", "the page the images belong to (they go when it is deleted or expires)").option("--json", "print the results as JSON").action(async (files, options) => {
1865
+ if (!await signedIn()) return;
1866
+ const results = [];
1867
+ for (const file of files) {
1868
+ try {
1869
+ const uploaded = await uploadImage(file, options.page);
1870
+ results.push({ file, ...uploaded });
1871
+ if (!options.json) {
1872
+ process.stdout.write(`${uploaded.url}
1873
+ `);
1874
+ if (uploaded.existing) console.error(`droppage: ${file} is already on this page; same URL.`);
1875
+ }
1876
+ } catch (err) {
1877
+ fail(new Error(`${file}: ${err instanceof Error ? err.message : String(err)}`));
1878
+ }
1879
+ }
1880
+ if (options.json) process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1881
+ });
1882
+ image.command("list").description("List your pages' images (one page with --page, including its image budget)").option("--page <slug>", "only this page").option("--json", "print the list as JSON").action(async (options) => {
1883
+ if (!await signedIn()) return;
1884
+ try {
1885
+ const list = await listImages(options.page);
1886
+ if (options.json) {
1887
+ process.stdout.write(JSON.stringify(list, null, 2) + "\n");
1888
+ return;
1889
+ }
1890
+ if (list.images.length === 0) {
1891
+ console.error("droppage: no images. `droppage upload page.html` uploads the images the page uses.");
1892
+ } else {
1893
+ process.stdout.write(renderTable(
1894
+ ["ID", "PAGE", "NAME", "SIZE", "UPLOADED", "URL"],
1895
+ list.images.map((row) => [row.id, row.page ?? "", row.name ?? "", formatBytes3(row.bytes), formatDateCell(row.created_at), row.url]),
1896
+ ["left", "left", "left", "right", "left", "left"]
1897
+ ) + "\n");
1898
+ }
1899
+ if (list.page) {
1900
+ const limit = list.page.limit_bytes === null ? "unlimited" : formatBytes3(list.page.limit_bytes);
1901
+ console.error(`droppage: images on ${list.page.slug}: ${formatBytes3(list.page.used_bytes)} of ${limit} per page.`);
1902
+ }
1903
+ } catch (err) {
1904
+ fail(err);
1905
+ }
1906
+ });
1907
+ image.command("delete").description("Delete images (by id or URL); a browser that already loaded one may keep it up to a day").argument("<images...>", "image ids or URLs").action(async (ids) => {
1908
+ if (!await signedIn()) return;
1909
+ for (const value of ids) {
1910
+ try {
1911
+ const id = await deleteImage(value);
1912
+ console.error(`droppage: deleted image ${id}`);
1913
+ } catch (err) {
1914
+ fail(new Error(`${value}: ${err instanceof Error ? err.message : String(err)}`));
1915
+ }
1916
+ }
1917
+ });
1918
+ }
1919
+ var init_image = __esm({
1920
+ "src/commands/image.ts"() {
1921
+ "use strict";
1922
+ init_table();
1923
+ init_auth();
1924
+ init_images();
1925
+ }
1926
+ });
1927
+
1246
1928
  // src/index.ts
1247
1929
  var index_exports = {};
1248
1930
  __export(index_exports, {
@@ -1260,6 +1942,10 @@ function createProgram() {
1260
1942
  registerList(program2);
1261
1943
  registerDelete(program2);
1262
1944
  registerRecipients(program2);
1945
+ registerQuota(program2);
1946
+ registerImage(program2);
1947
+ registerFeedback(program2);
1948
+ registerUpgrade(program2);
1263
1949
  return program2;
1264
1950
  }
1265
1951
  var init_index = __esm({
@@ -1273,6 +1959,70 @@ var init_index = __esm({
1273
1959
  init_list();
1274
1960
  init_delete();
1275
1961
  init_recipients2();
1962
+ init_quota2();
1963
+ init_feedback2();
1964
+ init_upgrade();
1965
+ init_image();
1966
+ }
1967
+ });
1968
+
1969
+ // src/lib/update-check.ts
1970
+ var update_check_exports = {};
1971
+ __export(update_check_exports, {
1972
+ checkForUpdate: () => checkForUpdate
1973
+ });
1974
+ function stableVersion(value) {
1975
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
1976
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
1977
+ }
1978
+ function isNewerVersion(latest, current) {
1979
+ const next = stableVersion(latest);
1980
+ const installed = stableVersion(current);
1981
+ if (!next || !installed) return false;
1982
+ for (let index = 0; index < next.length; index += 1) {
1983
+ if (next[index] !== installed[index]) return next[index] > installed[index];
1984
+ }
1985
+ return false;
1986
+ }
1987
+ function shouldCheck() {
1988
+ return Boolean(process.stderr.isTTY) && !process.env.DROPPAGE_NO_UPDATE_CHECK && !process.env.CI;
1989
+ }
1990
+ async function checkForUpdate() {
1991
+ if (!shouldCheck()) return;
1992
+ try {
1993
+ const cache = getUpdateCheckConf();
1994
+ const lastCheckedAt = Date.parse(cache.get("lastCheckedAt") ?? "");
1995
+ const stale = !Number.isFinite(lastCheckedAt) || Date.now() - lastCheckedAt >= CHECK_INTERVAL_MS;
1996
+ let latest = cache.get("latest");
1997
+ if (stale) {
1998
+ const response = await fetch(REGISTRY_URL, {
1999
+ headers: { Accept: "application/json" },
2000
+ signal: AbortSignal.timeout(1500)
2001
+ });
2002
+ if (!response.ok) return;
2003
+ const body = await response.json();
2004
+ if (typeof body.version !== "string" || !body.version.trim()) return;
2005
+ latest = body.version.trim();
2006
+ cache.set("latest", latest);
2007
+ cache.set("lastCheckedAt", (/* @__PURE__ */ new Date()).toISOString());
2008
+ }
2009
+ if (latest && isNewerVersion(latest, package_default.version)) {
2010
+ process.stderr.write(
2011
+ `droppage ${package_default.version} \u2192 ${latest} available: npm install -g droppage@latest (or droppage upgrade)
2012
+ `
2013
+ );
2014
+ }
2015
+ } catch {
2016
+ }
2017
+ }
2018
+ var CHECK_INTERVAL_MS, REGISTRY_URL;
2019
+ var init_update_check = __esm({
2020
+ "src/lib/update-check.ts"() {
2021
+ "use strict";
2022
+ init_package();
2023
+ init_config();
2024
+ CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2025
+ REGISTRY_URL = "https://registry.npmjs.org/droppage/latest";
1276
2026
  }
1277
2027
  });
1278
2028
 
@@ -1290,3 +2040,5 @@ Upgrade Node.js: https://nodejs.org/
1290
2040
  var { createProgram: createProgram2 } = await Promise.resolve().then(() => (init_index(), index_exports));
1291
2041
  var program = createProgram2();
1292
2042
  await program.parseAsync(process.argv);
2043
+ var { checkForUpdate: checkForUpdate2 } = await Promise.resolve().then(() => (init_update_check(), update_check_exports));
2044
+ await checkForUpdate2();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droppage",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Publish HTML files as shareable, access-controlled live pages",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",