droppage 0.1.1 → 0.2.1

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 +114 -11
  2. package/dist/cli.js +819 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,17 +38,28 @@ 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
+ Slugs from 0.2.1 on never start with `-`. If an older page's slug does (say `-AbC12xYz9`), put `--` in front of
60
+ it, with any options before the `--`: `droppage expire -- -AbC12xYz9 never` or
61
+ `droppage update --expires 7d -- -AbC12xYz9 report.html`. The CLI prints the same hint when it happens.
62
+
52
63
  ## Access modes
53
64
 
54
65
  | Upload flag | Mode | Who can open the link |
@@ -59,22 +70,95 @@ Run `droppage <command> --help` for full option details.
59
70
  | `--private` | private | Only you (your account email). Useful for drafts. |
60
71
 
61
72
  `--password`, `--email`, and `--private` are mutually exclusive. `update` never changes the access
62
- mode — it only swaps the HTML.
73
+ mode — it only swaps the HTML. When you are signed in to droppage.dev, your own protected pages
74
+ open directly, without the password or email link.
63
75
 
64
76
  ## Rules for uploads
65
77
 
66
78
  - 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.
79
+ - Inline your CSS, JS, and data. Relative asset paths other than images will not resolve. Local
80
+ images referenced in `<img src>` are uploaded with the page (see below); sensitive images belong
81
+ inline as data: URIs.
82
+ - Views shown by `list` count one unique visitor per page. Refreshes, reopening in the same browser,
83
+ link previews from chat apps, and your own opens do not add views.
69
84
  - Preview URLs (`list --preview`) never increment the view count.
70
85
 
86
+ ## Images
87
+
88
+ 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
89
+ file, and upload it:
90
+
91
+ ```html
92
+ <img src="chart.png">
93
+ ```
94
+
95
+ ```bash
96
+ $ droppage upload report.html
97
+ droppage: image chart.png → https://droppage.dev/i/AbCdEfGhIjKl.png
98
+ https://droppage.dev/p/k3x9qd2m
99
+ ```
100
+
101
+ `upload` finds the images, uploads them to that page, rewrites the links to
102
+ `https://droppage.dev/i/<id>.<ext>`, then publishes. The image lines go to stderr; stdout is still
103
+ only the page URL.
104
+
105
+ - `update` uploads any new local images, keeps the same URL for an unchanged image, and deletes
106
+ the page's images the new HTML no longer uses.
107
+ - Deleting a page, or the page expiring, removes its images too. Images never outlive their page.
108
+ - Image budget per page by plan: Free 5 MB, Starter 20 MB, Builder 50 MB. Each image up to 4 MB.
109
+ PNG, JPEG, GIF, WebP, AVIF, SVG. Image traffic counts toward your monthly bandwidth.
110
+ - Prefer small formats: WebP or AVIF for photos and screenshots, SVG for charts and logos (often
111
+ several times smaller than PNG or JPEG). The CLI prints a tip on stderr when a PNG, JPEG, or GIF
112
+ is over 300 KB.
113
+ - **Images are public** to anyone with the URL. A page's password, email, or private gate does not
114
+ protect them. Keep sensitive images inline in the HTML as data: URIs instead.
115
+ - Advanced: `droppage image upload <files...> --page <slug>` adds images to an existing page and
116
+ prints their URLs; `droppage image list [--page <slug>] [--json]` shows them (`--json` prints
117
+ `{images:[{id,url,ext,content_type,bytes,name,created_at,page}], page?:{slug,used_bytes,limit_bytes}}`);
118
+ `droppage image delete <id-or-url...>` removes them.
119
+
120
+ ## Expiry
121
+
122
+ Pages can expire. Nothing expires unless you set it, on any plan.
123
+
124
+ ```bash
125
+ droppage upload report.html --expires 7d # also 30m, 12h, 2w, or a date like 2026-12-31
126
+ droppage update <slug> report.html --expires never
127
+ droppage expire <slug> 2026-12-31 # change it without re-uploading; `never` clears it
128
+ ```
129
+
130
+ After expiry the page shows "This page has expired." (HTTP 410) to everyone, including you, and
131
+ its images stop working. A daily cleanup then deletes the page and its images for good.
132
+ `droppage list` shows an EXPIRES column (`never` or a date); `list --json` rows have `expires_at`
133
+ (`null` means never).
134
+
135
+ ## Sharing reports and dashboards
136
+
137
+ droppage is a quick way to send an analysis to people who don't have your tools: a dashboard for your
138
+ team, a weekly KPI or stakeholder report, a client deliverable, a notebook export. They open a link.
139
+
140
+ - **Export one HTML file**, up to 5 MB, with the data inside it: `jupyter nbconvert --to html
141
+ analysis.ipynb`, Plotly's `fig.write_html("report.html", include_plotlyjs="cdn")`, Altair's
142
+ `chart.save("report.html")`, or your own Chart.js / ECharts page. Scripts run, so interactive
143
+ charts work, and chart libraries can load from a CDN.
144
+ - **Pages run in a sandbox**: `localStorage`, cookies, and same-origin requests are not available, so
145
+ keep the data in the file rather than fetching it.
146
+ - **Pick who sees it.** `--email cfo@example.com,ops@example.com` for named people (Builder; manage
147
+ them later with `droppage recipients`), `--password` for a group link (Starter and Builder),
148
+ `--private` for a draft. On the Free plan, pages can be public or private, without a password or email list.
149
+ - **Same link every week.** `droppage update <slug> report.html` replaces the report and keeps the
150
+ link and its access settings (Starter and Builder). Use `--expires 30d` for one-off deliverables.
151
+ - **Views are people.** `droppage list --json` shows `view_count` (unique visitors) and
152
+ `last_viewed_at` for each report.
153
+
71
154
  ## For agents and scripts
72
155
 
73
156
  droppage keeps a strict stream contract so it is safe to call from automation:
74
157
 
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**.
158
+ - `upload` and `update` print **only the URL** on stdout, even when they upload images;
159
+ `image upload` prints **only the image URL(s)**; `expire` prints **nothing** on stdout.
160
+ - `list --json`, `image list --json`, `recipients list --json`, and `quota --json` print **only JSON** on stdout.
161
+ - Progress, warnings, prompts, errors, and the per-image `droppage: image … → …` lines go to **stderr**.
78
162
  - Non-zero exit code on any failure.
79
163
  - Use `--yes` on `delete` / `recipients remove` for non-interactive runs.
80
164
 
@@ -84,10 +168,28 @@ Ready-made instruction packs for Codex, Cursor, and Claude-style agents live in
84
168
  ## Updating
85
169
 
86
170
  ```bash
87
- npm update -g droppage # upgrade to the latest release
171
+ droppage upgrade # upgrade to the latest release
88
172
  droppage --version # confirm the installed version
89
173
  ```
90
174
 
175
+ After a command finishes, interactive terminals check npm for a new release at most once every 24
176
+ hours and put any update notice on stderr only. The check sends no identifier or usage data, and
177
+ all errors are ignored. Set `DROPPAGE_NO_UPDATE_CHECK=1` to opt out. This registry lookup is the
178
+ CLI's only non-backend network request; droppage still collects no telemetry.
179
+
180
+ Before bulk publishing, use `droppage quota --json`. A `warn` level means the monthly meter is at
181
+ least 80% used; `limit` means new uploads, updates, or previews may pause. Existing links remain
182
+ live. Delete unneeded pages or review plan options at [droppage.dev/account](https://droppage.dev/account).
183
+
184
+ Feedback is public and login is optional:
185
+
186
+ ```bash
187
+ droppage feedback "The preview command should…" --kind idea
188
+ droppage feedback "Upload failed after…" --kind bug --agent "my-model"
189
+ ```
190
+
191
+ Agent-authored feedback should always use `--agent` and include the model name.
192
+
91
193
  Releases are published from GitHub Actions with
92
194
  [npm provenance](https://docs.npmjs.com/generating-provenance-statements), so every version on npm
93
195
  is verifiably built from a tagged commit in the public repository.
@@ -102,6 +204,7 @@ setups, these environment variables are honoured (in order of precedence):
102
204
  | `DROPPAGE_SUPABASE_URL` / `DROPPAGE_SUPABASE_ANON_KEY` | Point the CLI at another backend. |
103
205
  | `SUPABASE_URL` / `SUPABASE_ANON_KEY` | Fallback names for the same. |
104
206
  | `DROPPAGE_SHARE_BASE_URL` | Origin used when printing share URLs (default `https://droppage.dev`). |
207
+ | `DROPPAGE_NO_UPDATE_CHECK` | Set to `1` to disable the optional 24-hour npm release check. |
105
208
 
106
209
  ## Security
107
210
 
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.1",
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,29 +588,197 @@ 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() {
505
- return generate();
731
+ return first() + rest();
732
+ }
733
+ function dashSlugHint(error) {
734
+ const slug = DASH_SLUG_ERROR.exec(error)?.[1];
735
+ if (!slug) return "";
736
+ return `droppage: if ${slug} is a page, put -- in front of it (options go before the --), for example:
737
+ droppage expire -- ${slug} never
738
+ droppage update --expires 7d -- ${slug} report.html
739
+ `;
506
740
  }
507
- var SLUG_LENGTH, alphabet, generate;
741
+ var SLUG_LENGTH, alphanumeric, alphabet, first, rest, DASH_SLUG_ERROR;
508
742
  var init_slug = __esm({
509
743
  "src/lib/slug.ts"() {
510
744
  "use strict";
511
745
  SLUG_LENGTH = 10;
512
- alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
513
- generate = customAlphabet(alphabet, SLUG_LENGTH);
746
+ alphanumeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
747
+ alphabet = `${alphanumeric}_-`;
748
+ first = customAlphabet(alphanumeric, 1);
749
+ rest = customAlphabet(alphabet, SLUG_LENGTH - 1);
750
+ DASH_SLUG_ERROR = new RegExp(`unknown option '(-[A-Za-z0-9_][A-Za-z0-9_-]{${SLUG_LENGTH - 2}})'`);
751
+ }
752
+ });
753
+
754
+ // src/lib/supabase-error.ts
755
+ function asError(err) {
756
+ if (err instanceof Error) return err;
757
+ if (err !== null && typeof err === "object" && typeof err.message === "string") {
758
+ const error = new Error(err.message);
759
+ const code = err.code;
760
+ if (typeof code === "string") Object.assign(error, { code });
761
+ return error;
762
+ }
763
+ return new Error(String(err));
764
+ }
765
+ var init_supabase_error = __esm({
766
+ "src/lib/supabase-error.ts"() {
767
+ "use strict";
514
768
  }
515
769
  });
516
770
 
517
771
  // src/lib/validate-html.ts
518
- import { extname } from "path";
772
+ import { extname as extname2 } from "path";
773
+ function isHostedImage(url) {
774
+ return url.startsWith(`${resolveShareBaseUrl()}/i/`);
775
+ }
519
776
  function formatBytes(bytes) {
520
777
  const mb = bytes / (1024 * 1024);
521
778
  return `${mb.toFixed(1)} MB`;
522
779
  }
523
780
  function validateHtmlUpload(path, bytes) {
524
- const ext = extname(path).toLowerCase();
781
+ const ext = extname2(path).toLowerCase();
525
782
  if (!ALLOWED_EXTENSIONS.has(ext)) {
526
783
  throw new Error(
527
784
  `Unsupported file type: ${path} (expected .html or .htm)`
@@ -553,7 +810,8 @@ function validateHtmlUpload(path, bytes) {
553
810
  "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
811
  );
555
812
  }
556
- if (EXTERNAL_ASSET_RE.test(body)) {
813
+ const external = [...body.matchAll(EXTERNAL_ASSET_RE)].map((m) => m[1]).filter((url) => !isHostedImage(url));
814
+ if (external.length > 0) {
557
815
  warnings.push(
558
816
  "HTML references external assets (//host or http(s)://); they must be publicly reachable to render in the shared page."
559
817
  );
@@ -564,6 +822,7 @@ var MAX_UPLOAD_BYTES, ALLOWED_EXTENSIONS, SNIFF_BYTES, HTML_MARKERS, RELATIVE_AS
564
822
  var init_validate_html = __esm({
565
823
  "src/lib/validate-html.ts"() {
566
824
  "use strict";
825
+ init_share_url();
567
826
  MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
568
827
  ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
569
828
  SNIFF_BYTES = 4096;
@@ -574,33 +833,51 @@ var init_validate_html = __esm({
574
833
  /<body[\s>]/i,
575
834
  /<div[\s>]/i
576
835
  ];
577
- RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/))/i;
578
- EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["'](\/\/|https?:\/\/)/i;
836
+ RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/)(?!i\/[A-Za-z0-9]{12}\.[a-z]+["']))/i;
837
+ EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["']((?:\/\/|https?:\/\/)[^"']*)/gi;
579
838
  }
580
839
  });
581
840
 
582
841
  // src/services/upload.ts
583
- import { readFile } from "fs/promises";
842
+ import { readFile as readFile3 } from "fs/promises";
584
843
  async function upload({
585
844
  file,
586
845
  user,
587
846
  passwordHash,
588
847
  allowedEmails,
589
- accessMode
848
+ accessMode,
849
+ prepare,
850
+ expiresAt
590
851
  }) {
591
- const bytes = await readFile(file);
852
+ let bytes = await readFile3(file);
592
853
  const { warnings } = validateHtmlUpload(file, bytes);
593
- for (const w of warnings) console.error(`droppage: warning: ${w}`);
854
+ if (!prepare) for (const w of warnings) console.error(`droppage: warning: ${w}`);
594
855
  const supabase = createSupabaseClient();
856
+ const resolvedMode = accessMode ?? (allowedEmails ? "email" : passwordHash ? "password" : "public");
857
+ const preflight = await supabase.rpc("assert_page_create_allowed", {
858
+ p_access_mode: resolvedMode,
859
+ p_recipient_count: allowedEmails?.length ?? 0
860
+ });
861
+ if (preflight.error) throw asError(preflight.error);
595
862
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
596
863
  const slug = createSlug();
597
864
  const storagePath = `${user.id}/${slug}.html`;
865
+ if (prepare) {
866
+ bytes = await prepare(slug);
867
+ if (attempt === 0) for (const w of validateHtmlUpload(file, bytes).warnings) console.error(`droppage: warning: ${w}`);
868
+ }
598
869
  const up = await supabase.storage.from(BUCKET).upload(storagePath, bytes, {
599
870
  contentType: "text/html; charset=utf-8",
600
871
  upsert: false
601
872
  });
602
- if (up.error) throw up.error;
603
- const resolvedMode = accessMode ?? (allowedEmails ? "email" : passwordHash ? "password" : "public");
873
+ if (up.error) {
874
+ if (/row-level security/i.test(up.error.message)) {
875
+ throw new Error(
876
+ `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.`
877
+ );
878
+ }
879
+ throw asError(up.error);
880
+ }
604
881
  const ins = await supabase.rpc("create_page_with_entitlements", {
605
882
  p_slug: slug,
606
883
  p_storage_path: storagePath,
@@ -608,7 +885,17 @@ async function upload({
608
885
  p_password_hash: resolvedMode === "password" ? passwordHash : null,
609
886
  p_allowed_emails: allowedEmails ?? null
610
887
  });
611
- if (!ins.error) return buildShareUrl(slug);
888
+ if (!ins.error) {
889
+ if (expiresAt) {
890
+ const expiry = await supabase.rpc("set_page_expiry", { p_slug: slug, p_expires_at: expiresAt.toISOString() });
891
+ if (expiry.error) {
892
+ throw new Error(
893
+ `published ${buildShareUrl(slug)} but could not set its expiry (${expiry.error.message}); set it with \`droppage expire ${slug} <when>\` or delete it`
894
+ );
895
+ }
896
+ }
897
+ return buildShareUrl(slug);
898
+ }
612
899
  const rm = await supabase.storage.from(BUCKET).remove([storagePath]);
613
900
  if (rm.error) {
614
901
  console.error(
@@ -616,7 +903,7 @@ async function upload({
616
903
  );
617
904
  }
618
905
  if (ins.error.code === "23505") continue;
619
- throw ins.error;
906
+ throw asError(ins.error);
620
907
  }
621
908
  throw new Error(
622
909
  `Could not allocate a unique slug after ${MAX_RETRIES} attempts`
@@ -628,6 +915,7 @@ var init_upload = __esm({
628
915
  "use strict";
629
916
  init_share_url();
630
917
  init_slug();
918
+ init_supabase_error();
631
919
  init_supabase();
632
920
  init_validate_html();
633
921
  BUCKET = "pages";
@@ -642,7 +930,7 @@ function registerUpload(program2) {
642
930
  "--email <address>",
643
931
  "restrict access to an email allowlist (repeatable; comma-separated)",
644
932
  (val, prev = []) => prev.concat(val.split(","))
645
- ).option("--private", "restrict access to only you (owner-only draft)").action(
933
+ ).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
934
  async (file, options) => {
647
935
  let user;
648
936
  try {
@@ -707,21 +995,28 @@ function registerUpload(program2) {
707
995
  allowedEmails = [ownerEmail];
708
996
  accessMode = "private";
709
997
  }
998
+ const expiresAt = options.expires ? parseExpiry(options.expires) ?? void 0 : void 0;
710
999
  console.error(`droppage: uploading ${file}...`);
1000
+ const prepare = await hasLocalImages(file) ? async (slug) => (await attachLocalImages(file, slug)).html : void 0;
711
1001
  const url = await upload({
712
1002
  file,
713
1003
  user,
714
1004
  passwordHash,
715
1005
  allowedEmails,
716
- accessMode
1006
+ accessMode,
1007
+ prepare,
1008
+ expiresAt
717
1009
  });
1010
+ if (expiresAt) console.error(`droppage: expires ${expiresAt.toISOString()}`);
718
1011
  console.error("droppage: done");
719
1012
  process.stdout.write(url + "\n");
720
1013
  } catch (err) {
721
1014
  process.exitCode = 1;
722
- console.error(
723
- `droppage: upload failed: ${err instanceof Error ? err.message : String(err)}`
724
- );
1015
+ const message = err instanceof Error ? err.message : String(err);
1016
+ console.error(`droppage: upload failed: ${message}`);
1017
+ if (/quota reached|require (Starter|Builder)/i.test(message)) {
1018
+ console.error("run `droppage quota` to see your limits");
1019
+ }
725
1020
  }
726
1021
  }
727
1022
  );
@@ -730,17 +1025,19 @@ var init_upload2 = __esm({
730
1025
  "src/commands/upload.ts"() {
731
1026
  "use strict";
732
1027
  init_password();
1028
+ init_duration();
1029
+ init_page_images();
733
1030
  init_auth();
734
1031
  init_upload();
735
1032
  }
736
1033
  });
737
1034
 
738
1035
  // src/services/pages.ts
739
- import { readFile as readFile2 } from "fs/promises";
1036
+ import { readFile as readFile4 } from "fs/promises";
740
1037
  async function listPages(user) {
741
1038
  const supabase = createSupabaseClient();
742
1039
  const { data, error } = await supabase.from("pages").select(
743
- "slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at"
1040
+ "slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at, expires_at"
744
1041
  ).eq("owner_id", user.id).order("created_at", { ascending: false });
745
1042
  if (error) throw error;
746
1043
  return data ?? [];
@@ -782,9 +1079,10 @@ async function deletePage(slug, user) {
782
1079
  async function updatePageHtml({
783
1080
  slug,
784
1081
  file,
785
- user
1082
+ user,
1083
+ html
786
1084
  }) {
787
- const bytes = await readFile2(file);
1085
+ const bytes = html ?? await readFile4(file);
788
1086
  const { warnings } = validateHtmlUpload(file, bytes);
789
1087
  for (const warning of warnings) {
790
1088
  console.error(`droppage: warning: ${warning}`);
@@ -793,7 +1091,7 @@ async function updatePageHtml({
793
1091
  const preflight = await supabase.rpc("assert_page_update_allowed", {
794
1092
  p_slug: slug
795
1093
  });
796
- if (preflight.error) throw preflight.error;
1094
+ if (preflight.error) throw asError(preflight.error);
797
1095
  const { data: row, error: selectError } = await supabase.from("pages").select("slug, storage_path").eq("slug", slug).eq("owner_id", user.id).maybeSingle();
798
1096
  if (selectError) throw selectError;
799
1097
  if (!row) throw new Error("page not found or not owned");
@@ -815,11 +1113,24 @@ async function updatePageHtml({
815
1113
  url: buildShareUrl(slug)
816
1114
  };
817
1115
  }
1116
+ async function setPageExpiry(slug, expiresAt) {
1117
+ const supabase = createSupabaseClient();
1118
+ const { error } = await supabase.rpc("set_page_expiry", {
1119
+ p_slug: slug,
1120
+ p_expires_at: expiresAt ? expiresAt.toISOString() : null
1121
+ });
1122
+ if (error) throw asError(error);
1123
+ }
1124
+ async function assertUpdateAllowed(slug) {
1125
+ const { error } = await createSupabaseClient().rpc("assert_page_update_allowed", { p_slug: slug });
1126
+ if (error) throw asError(error);
1127
+ }
818
1128
  var BUCKET2, NOT_FOUND_MESSAGE;
819
1129
  var init_pages = __esm({
820
1130
  "src/services/pages.ts"() {
821
1131
  "use strict";
822
1132
  init_share_url();
1133
+ init_supabase_error();
823
1134
  init_supabase();
824
1135
  init_validate_html();
825
1136
  BUCKET2 = "pages";
@@ -828,8 +1139,9 @@ var init_pages = __esm({
828
1139
  });
829
1140
 
830
1141
  // src/commands/update.ts
1142
+ import { readFile as readFile5 } from "fs/promises";
831
1143
  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) => {
1144
+ 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
1145
  let user;
834
1146
  try {
835
1147
  user = await requireAuth();
@@ -838,22 +1150,53 @@ function registerUpdate(program2) {
838
1150
  throw err;
839
1151
  }
840
1152
  try {
1153
+ const expiresAt = options.expires === void 0 ? void 0 : parseExpiry(options.expires);
841
1154
  console.error(`droppage: updating ${slug} from ${file}...`);
842
- const result = await updatePageHtml({ slug, file, user });
1155
+ await assertUpdateAllowed(slug);
1156
+ validateHtmlUpload(file, await readFile5(file));
1157
+ const { html } = await attachLocalImages(file, slug, { update: true });
1158
+ const result = await updatePageHtml({ slug, file, user, html });
1159
+ const removed = await pruneUnusedImages(slug);
1160
+ if (removed) console.error(`droppage: removed ${removed} image(s) the page no longer uses`);
1161
+ if (expiresAt !== void 0) {
1162
+ await setPageExpiry(slug, expiresAt);
1163
+ console.error(expiresAt ? `droppage: expires ${expiresAt.toISOString()}` : "droppage: never expires");
1164
+ }
843
1165
  console.error(`droppage: updated ${slug}`);
844
1166
  process.stdout.write(result.url + "\n");
845
1167
  } catch (err) {
846
1168
  process.exitCode = 1;
847
- console.error(
848
- `droppage: update failed: ${err instanceof Error ? err.message : String(err)}`
849
- );
1169
+ const message = err instanceof Error ? err.message : String(err);
1170
+ console.error(`droppage: update failed: ${message}`);
1171
+ if (/quota reached|limit reached|require (Starter|Builder)/i.test(message)) {
1172
+ console.error("run `droppage quota` to see your limits");
1173
+ }
1174
+ }
1175
+ });
1176
+ 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) => {
1177
+ try {
1178
+ await requireAuth();
1179
+ } catch (err) {
1180
+ if (err instanceof AuthRequiredError) return;
1181
+ throw err;
1182
+ }
1183
+ try {
1184
+ const expiresAt = parseExpiry(when);
1185
+ await setPageExpiry(slug, expiresAt);
1186
+ console.error(expiresAt ? `droppage: ${slug} expires ${expiresAt.toISOString()}` : `droppage: ${slug} never expires`);
1187
+ } catch (err) {
1188
+ process.exitCode = 1;
1189
+ console.error(`droppage: expire failed: ${err instanceof Error ? err.message : String(err)}`);
850
1190
  }
851
1191
  });
852
1192
  }
853
1193
  var init_update = __esm({
854
1194
  "src/commands/update.ts"() {
855
1195
  "use strict";
1196
+ init_validate_html();
1197
+ init_duration();
856
1198
  init_auth();
1199
+ init_page_images();
857
1200
  init_pages();
858
1201
  }
859
1202
  });
@@ -883,7 +1226,8 @@ function pageTableCells(row) {
883
1226
  formatModeCell(row.access_mode, row.allowed_emails),
884
1227
  formatDateCell(row.created_at),
885
1228
  String(row.view_count),
886
- formatDateCell(row.last_viewed_at)
1229
+ formatDateCell(row.last_viewed_at),
1230
+ row.expires_at ? row.expires_at.slice(0, 16).replace("T", " ") : "never"
887
1231
  ];
888
1232
  }
889
1233
  var PAGE_TABLE_HEADERS, PAGE_TABLE_ALIGN;
@@ -895,13 +1239,15 @@ var init_table = __esm({
895
1239
  "MODE",
896
1240
  "CREATED",
897
1241
  "VIEWS",
898
- "LAST VIEWED"
1242
+ "LAST VIEWED",
1243
+ "EXPIRES"
899
1244
  ];
900
1245
  PAGE_TABLE_ALIGN = [
901
1246
  "left",
902
1247
  "left",
903
1248
  "left",
904
1249
  "right",
1250
+ "left",
905
1251
  "left"
906
1252
  ];
907
1253
  }
@@ -1243,6 +1589,353 @@ var init_recipients2 = __esm({
1243
1589
  }
1244
1590
  });
1245
1591
 
1592
+ // src/services/quota.ts
1593
+ async function fetchQuota() {
1594
+ const supabase = createSupabaseClient();
1595
+ const { data } = await supabase.auth.getSession();
1596
+ const token = data.session?.access_token;
1597
+ if (!token) throw new QuotaRequestError("not signed in; run `droppage login`", 401);
1598
+ let response;
1599
+ try {
1600
+ response = await fetch(`${resolveShareBaseUrl()}/api/account`, {
1601
+ method: "GET",
1602
+ headers: { Authorization: `Bearer ${token}` }
1603
+ });
1604
+ } catch (err) {
1605
+ throw new QuotaRequestError(
1606
+ `could not reach ${resolveShareBaseUrl()}: ${err instanceof Error ? err.message : String(err)}`,
1607
+ 0
1608
+ );
1609
+ }
1610
+ const text = await response.text();
1611
+ let body = null;
1612
+ try {
1613
+ body = text ? JSON.parse(text) : null;
1614
+ } catch {
1615
+ }
1616
+ if (!response.ok) {
1617
+ const message = body?.error ?? `request failed (HTTP ${response.status})`;
1618
+ throw new QuotaRequestError(message, response.status);
1619
+ }
1620
+ 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) {
1621
+ throw new QuotaRequestError(`request failed (HTTP ${response.status})`, response.status);
1622
+ }
1623
+ return body;
1624
+ }
1625
+ var QuotaRequestError;
1626
+ var init_quota = __esm({
1627
+ "src/services/quota.ts"() {
1628
+ "use strict";
1629
+ init_share_url();
1630
+ init_supabase();
1631
+ QuotaRequestError = class extends Error {
1632
+ constructor(message, status) {
1633
+ super(message);
1634
+ this.status = status;
1635
+ this.name = "QuotaRequestError";
1636
+ }
1637
+ };
1638
+ }
1639
+ });
1640
+
1641
+ // src/commands/quota.ts
1642
+ function formatBytes2(value) {
1643
+ if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
1644
+ if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
1645
+ return `${Number((value / 1e9).toFixed(2))} GB`;
1646
+ }
1647
+ function usedAndLimit(usage) {
1648
+ const used = usage.meter === "views" ? `${usage.used} views` : formatBytes2(usage.used);
1649
+ if (usage.limit === null) return `${used} / unlimited`;
1650
+ const limit = usage.meter === "views" ? `${usage.limit} views` : formatBytes2(usage.limit);
1651
+ return `${used} / ${limit} (${usage.percent}%)`;
1652
+ }
1653
+ function registerQuota(program2) {
1654
+ program2.command("quota").description("Show your plan, current usage, and publishing limits").option("--json", "print the account API response as JSON").action(async (options) => {
1655
+ try {
1656
+ await requireAuth();
1657
+ } catch (err) {
1658
+ if (err instanceof AuthRequiredError) return;
1659
+ throw err;
1660
+ }
1661
+ try {
1662
+ const result = await fetchQuota();
1663
+ if (options.json) {
1664
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1665
+ return;
1666
+ }
1667
+ const enabled = [
1668
+ result.entitlements.can_update && "update",
1669
+ result.entitlements.can_preview && "preview",
1670
+ result.entitlements.can_password && "password",
1671
+ result.entitlements.can_email_allowlist && "email"
1672
+ ].filter((feature) => Boolean(feature));
1673
+ const pageLimit = result.usage.pages.limit === null ? "unlimited" : String(result.usage.pages.limit);
1674
+ process.stdout.write(renderTable(
1675
+ ["ITEM", "VALUE"],
1676
+ [
1677
+ ["Plan", result.entitlements.plan_name],
1678
+ ["Pages", `${result.usage.pages.used} / ${pageLimit}`],
1679
+ ...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)}`]] : [],
1680
+ ["Monthly meter", usedAndLimit(result.usage)],
1681
+ ["Period ends", result.usage.period_end],
1682
+ ["Features", enabled.length ? enabled.join(", ") : "basic publishing"]
1683
+ ]
1684
+ ) + "\n");
1685
+ console.error(`droppage: quota level ${result.usage.level}.`);
1686
+ } catch (err) {
1687
+ const message = err instanceof Error ? err.message : String(err);
1688
+ console.error(`droppage: quota failed: ${message}`);
1689
+ process.exitCode = 1;
1690
+ }
1691
+ });
1692
+ }
1693
+ var init_quota2 = __esm({
1694
+ "src/commands/quota.ts"() {
1695
+ "use strict";
1696
+ init_table();
1697
+ init_auth();
1698
+ init_quota();
1699
+ }
1700
+ });
1701
+
1702
+ // src/services/feedback.ts
1703
+ async function submitFeedback(input2) {
1704
+ const { data } = await createSupabaseClient().auth.getSession();
1705
+ const token = data.session?.access_token;
1706
+ const base = resolveShareBaseUrl();
1707
+ let response;
1708
+ try {
1709
+ response = await fetch(`${base}/api/feedback`, {
1710
+ method: "POST",
1711
+ headers: {
1712
+ "Content-Type": "application/json",
1713
+ ...token ? { Authorization: `Bearer ${token}` } : {}
1714
+ },
1715
+ body: JSON.stringify(input2)
1716
+ });
1717
+ } catch (error) {
1718
+ throw new FeedbackRequestError(
1719
+ `could not reach ${base}: ${error instanceof Error ? error.message : String(error)}`,
1720
+ 0
1721
+ );
1722
+ }
1723
+ const text = await response.text();
1724
+ let body = null;
1725
+ try {
1726
+ body = text ? JSON.parse(text) : null;
1727
+ } catch {
1728
+ }
1729
+ if (!response.ok || !body?.ok || !body.id) {
1730
+ throw new FeedbackRequestError(
1731
+ body?.error ?? `request failed (HTTP ${response.status})`,
1732
+ response.status
1733
+ );
1734
+ }
1735
+ return { ok: true, id: body.id };
1736
+ }
1737
+ var FeedbackRequestError;
1738
+ var init_feedback = __esm({
1739
+ "src/services/feedback.ts"() {
1740
+ "use strict";
1741
+ init_share_url();
1742
+ init_supabase();
1743
+ FeedbackRequestError = class extends Error {
1744
+ constructor(message, status) {
1745
+ super(message);
1746
+ this.status = status;
1747
+ this.name = "FeedbackRequestError";
1748
+ }
1749
+ };
1750
+ }
1751
+ });
1752
+
1753
+ // src/commands/feedback.ts
1754
+ function isKind(value) {
1755
+ return value === "idea" || value === "bug" || value === "review" || value === "other";
1756
+ }
1757
+ function registerFeedback(program2) {
1758
+ 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) => {
1759
+ const cleanedMessage = message.trim();
1760
+ const model = typeof options.agent === "string" ? options.agent.trim() : void 0;
1761
+ const contact = options.contact?.trim().toLowerCase();
1762
+ if (cleanedMessage.length < 3 || cleanedMessage.length > 4e3) {
1763
+ console.error("droppage: feedback must be between 3 and 4000 characters");
1764
+ process.exitCode = 1;
1765
+ return;
1766
+ }
1767
+ if (!isKind(options.kind)) {
1768
+ console.error("droppage: --kind must be idea, bug, review, or other");
1769
+ process.exitCode = 1;
1770
+ return;
1771
+ }
1772
+ if (model && model.length > 120) {
1773
+ console.error("droppage: agent model must be 120 characters or fewer");
1774
+ process.exitCode = 1;
1775
+ return;
1776
+ }
1777
+ if (contact && !emailSchema.safeParse(contact).success) {
1778
+ console.error(`droppage: invalid contact email: ${contact}`);
1779
+ process.exitCode = 1;
1780
+ return;
1781
+ }
1782
+ const rating = options.rating === void 0 ? void 0 : Number(options.rating);
1783
+ if (rating !== void 0 && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
1784
+ console.error("droppage: --rating must be a whole number from 1 to 5");
1785
+ process.exitCode = 1;
1786
+ return;
1787
+ }
1788
+ if (options.kind !== "review" && (rating !== void 0 || options.allowQuote)) {
1789
+ console.error("droppage: --rating and --allow-quote require --kind review");
1790
+ process.exitCode = 1;
1791
+ return;
1792
+ }
1793
+ try {
1794
+ const result = await submitFeedback({
1795
+ message: cleanedMessage,
1796
+ kind: options.kind,
1797
+ actor: options.agent === void 0 ? "human" : "agent",
1798
+ ...model ? { model } : {},
1799
+ ...contact ? { contact } : {},
1800
+ ...rating !== void 0 ? { rating } : {},
1801
+ ...options.allowQuote ? { testimonial_consent: true } : {},
1802
+ source: "cli",
1803
+ cli_version: package_default.version
1804
+ });
1805
+ process.stdout.write(`${result.id}
1806
+ `);
1807
+ console.error("droppage: thanks \u2014 your feedback was sent.");
1808
+ } catch (error) {
1809
+ const message2 = error instanceof Error ? error.message : String(error);
1810
+ console.error(`droppage: feedback failed: ${message2}`);
1811
+ process.exitCode = 1;
1812
+ }
1813
+ });
1814
+ }
1815
+ var init_feedback2 = __esm({
1816
+ "src/commands/feedback.ts"() {
1817
+ "use strict";
1818
+ init_package();
1819
+ init_auth();
1820
+ init_feedback();
1821
+ }
1822
+ });
1823
+
1824
+ // src/commands/upgrade.ts
1825
+ import { spawn } from "child_process";
1826
+ function installLatest() {
1827
+ return new Promise((resolve2, reject) => {
1828
+ const child = spawn("npm", ["install", "-g", "droppage@latest"], {
1829
+ stdio: "inherit",
1830
+ shell: process.platform === "win32"
1831
+ });
1832
+ child.once("error", reject);
1833
+ child.once("close", (code) => code === 0 ? resolve2() : reject(new Error(`npm exited with status ${code ?? "unknown"}`)));
1834
+ });
1835
+ }
1836
+ function registerUpgrade(program2) {
1837
+ program2.command("upgrade").description("Install the latest droppage CLI release").action(async () => {
1838
+ try {
1839
+ await installLatest();
1840
+ console.error("droppage: upgrade complete; run `droppage --version` to confirm.");
1841
+ } catch (error) {
1842
+ console.error(`droppage: upgrade failed: ${error instanceof Error ? error.message : String(error)}`);
1843
+ process.exitCode = 1;
1844
+ }
1845
+ });
1846
+ }
1847
+ var init_upgrade = __esm({
1848
+ "src/commands/upgrade.ts"() {
1849
+ "use strict";
1850
+ }
1851
+ });
1852
+
1853
+ // src/commands/image.ts
1854
+ function formatBytes3(value) {
1855
+ if (value < 1e3) return `${value} B`;
1856
+ if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
1857
+ if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
1858
+ return `${Number((value / 1e9).toFixed(2))} GB`;
1859
+ }
1860
+ async function signedIn() {
1861
+ try {
1862
+ await requireAuth();
1863
+ return true;
1864
+ } catch (err) {
1865
+ if (err instanceof AuthRequiredError) return false;
1866
+ throw err;
1867
+ }
1868
+ }
1869
+ function fail(err) {
1870
+ console.error(`droppage: ${err instanceof Error ? err.message : String(err)}`);
1871
+ process.exitCode = 1;
1872
+ }
1873
+ function registerImage(program2) {
1874
+ const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local <img src> files)");
1875
+ 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) => {
1876
+ if (!await signedIn()) return;
1877
+ const results = [];
1878
+ for (const file of files) {
1879
+ try {
1880
+ const uploaded = await uploadImage(file, options.page);
1881
+ results.push({ file, ...uploaded });
1882
+ if (!options.json) {
1883
+ process.stdout.write(`${uploaded.url}
1884
+ `);
1885
+ if (uploaded.existing) console.error(`droppage: ${file} is already on this page; same URL.`);
1886
+ }
1887
+ } catch (err) {
1888
+ fail(new Error(`${file}: ${err instanceof Error ? err.message : String(err)}`));
1889
+ }
1890
+ }
1891
+ if (options.json) process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1892
+ });
1893
+ 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) => {
1894
+ if (!await signedIn()) return;
1895
+ try {
1896
+ const list = await listImages(options.page);
1897
+ if (options.json) {
1898
+ process.stdout.write(JSON.stringify(list, null, 2) + "\n");
1899
+ return;
1900
+ }
1901
+ if (list.images.length === 0) {
1902
+ console.error("droppage: no images. `droppage upload page.html` uploads the images the page uses.");
1903
+ } else {
1904
+ process.stdout.write(renderTable(
1905
+ ["ID", "PAGE", "NAME", "SIZE", "UPLOADED", "URL"],
1906
+ list.images.map((row) => [row.id, row.page ?? "", row.name ?? "", formatBytes3(row.bytes), formatDateCell(row.created_at), row.url]),
1907
+ ["left", "left", "left", "right", "left", "left"]
1908
+ ) + "\n");
1909
+ }
1910
+ if (list.page) {
1911
+ const limit = list.page.limit_bytes === null ? "unlimited" : formatBytes3(list.page.limit_bytes);
1912
+ console.error(`droppage: images on ${list.page.slug}: ${formatBytes3(list.page.used_bytes)} of ${limit} per page.`);
1913
+ }
1914
+ } catch (err) {
1915
+ fail(err);
1916
+ }
1917
+ });
1918
+ 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) => {
1919
+ if (!await signedIn()) return;
1920
+ for (const value of ids) {
1921
+ try {
1922
+ const id = await deleteImage(value);
1923
+ console.error(`droppage: deleted image ${id}`);
1924
+ } catch (err) {
1925
+ fail(new Error(`${value}: ${err instanceof Error ? err.message : String(err)}`));
1926
+ }
1927
+ }
1928
+ });
1929
+ }
1930
+ var init_image = __esm({
1931
+ "src/commands/image.ts"() {
1932
+ "use strict";
1933
+ init_table();
1934
+ init_auth();
1935
+ init_images();
1936
+ }
1937
+ });
1938
+
1246
1939
  // src/index.ts
1247
1940
  var index_exports = {};
1248
1941
  __export(index_exports, {
@@ -1252,7 +1945,7 @@ import { Command } from "commander";
1252
1945
  function createProgram() {
1253
1946
  const program2 = new Command().name("droppage").description(
1254
1947
  "Publish HTML files as shareable, access-controlled live pages"
1255
- ).version(package_default.version);
1948
+ ).version(package_default.version).configureOutput({ outputError: (str, write) => write(str + dashSlugHint(str)) });
1256
1949
  registerLogin(program2);
1257
1950
  registerLogout(program2);
1258
1951
  registerUpload(program2);
@@ -1260,6 +1953,10 @@ function createProgram() {
1260
1953
  registerList(program2);
1261
1954
  registerDelete(program2);
1262
1955
  registerRecipients(program2);
1956
+ registerQuota(program2);
1957
+ registerImage(program2);
1958
+ registerFeedback(program2);
1959
+ registerUpgrade(program2);
1263
1960
  return program2;
1264
1961
  }
1265
1962
  var init_index = __esm({
@@ -1273,6 +1970,71 @@ var init_index = __esm({
1273
1970
  init_list();
1274
1971
  init_delete();
1275
1972
  init_recipients2();
1973
+ init_quota2();
1974
+ init_feedback2();
1975
+ init_upgrade();
1976
+ init_image();
1977
+ init_slug();
1978
+ }
1979
+ });
1980
+
1981
+ // src/lib/update-check.ts
1982
+ var update_check_exports = {};
1983
+ __export(update_check_exports, {
1984
+ checkForUpdate: () => checkForUpdate
1985
+ });
1986
+ function stableVersion(value) {
1987
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
1988
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
1989
+ }
1990
+ function isNewerVersion(latest, current) {
1991
+ const next = stableVersion(latest);
1992
+ const installed = stableVersion(current);
1993
+ if (!next || !installed) return false;
1994
+ for (let index = 0; index < next.length; index += 1) {
1995
+ if (next[index] !== installed[index]) return next[index] > installed[index];
1996
+ }
1997
+ return false;
1998
+ }
1999
+ function shouldCheck() {
2000
+ return Boolean(process.stderr.isTTY) && !process.env.DROPPAGE_NO_UPDATE_CHECK && !process.env.CI;
2001
+ }
2002
+ async function checkForUpdate() {
2003
+ if (!shouldCheck()) return;
2004
+ try {
2005
+ const cache = getUpdateCheckConf();
2006
+ const lastCheckedAt = Date.parse(cache.get("lastCheckedAt") ?? "");
2007
+ const stale = !Number.isFinite(lastCheckedAt) || Date.now() - lastCheckedAt >= CHECK_INTERVAL_MS;
2008
+ let latest = cache.get("latest");
2009
+ if (stale) {
2010
+ const response = await fetch(REGISTRY_URL, {
2011
+ headers: { Accept: "application/json" },
2012
+ signal: AbortSignal.timeout(1500)
2013
+ });
2014
+ if (!response.ok) return;
2015
+ const body = await response.json();
2016
+ if (typeof body.version !== "string" || !body.version.trim()) return;
2017
+ latest = body.version.trim();
2018
+ cache.set("latest", latest);
2019
+ cache.set("lastCheckedAt", (/* @__PURE__ */ new Date()).toISOString());
2020
+ }
2021
+ if (latest && isNewerVersion(latest, package_default.version)) {
2022
+ process.stderr.write(
2023
+ `droppage ${package_default.version} \u2192 ${latest} available: npm install -g droppage@latest (or droppage upgrade)
2024
+ `
2025
+ );
2026
+ }
2027
+ } catch {
2028
+ }
2029
+ }
2030
+ var CHECK_INTERVAL_MS, REGISTRY_URL;
2031
+ var init_update_check = __esm({
2032
+ "src/lib/update-check.ts"() {
2033
+ "use strict";
2034
+ init_package();
2035
+ init_config();
2036
+ CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2037
+ REGISTRY_URL = "https://registry.npmjs.org/droppage/latest";
1276
2038
  }
1277
2039
  });
1278
2040
 
@@ -1290,3 +2052,5 @@ Upgrade Node.js: https://nodejs.org/
1290
2052
  var { createProgram: createProgram2 } = await Promise.resolve().then(() => (init_index(), index_exports));
1291
2053
  var program = createProgram2();
1292
2054
  await program.parseAsync(process.argv);
2055
+ var { checkForUpdate: checkForUpdate2 } = await Promise.resolve().then(() => (init_update_check(), update_check_exports));
2056
+ 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.1",
4
4
  "description": "Publish HTML files as shareable, access-controlled live pages",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",