@hasna/shortlinks 0.1.13 → 0.1.15

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.
@@ -6,25 +6,32 @@ export default {
6
6
  }
7
7
 
8
8
  const incoming = new URL(request.url);
9
+ const proxyTo = (targetOrigin, marker) => {
10
+ const upstream = new URL(incoming.pathname + incoming.search, targetOrigin);
11
+ const headers = new Headers(request.headers);
12
+ headers.set("x-forwarded-host", incoming.host);
13
+ headers.set("x-shortlinks-worker", marker);
14
+
15
+ return fetch(upstream.toString(), {
16
+ method: request.method,
17
+ headers,
18
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
19
+ redirect: "manual"
20
+ });
21
+ };
22
+
9
23
  const reserved = (env.SHORTLINKS_RESERVED_PATH_PREFIXES || "a")
10
24
  .split(",")
11
25
  .map((value) => value.trim().toLowerCase())
12
26
  .filter(Boolean);
13
27
  const firstSegment = decodeURIComponent(incoming.pathname.replace(/^\/+/, "").split("/")[0] || "").toLowerCase();
14
28
  if (firstSegment && reserved.includes(firstSegment)) {
29
+ if (env.ATTACHMENTS_ORIGIN) {
30
+ return proxyTo(env.ATTACHMENTS_ORIGIN, "attachments");
31
+ }
15
32
  return new Response("Reserved path prefix", { status: 404 });
16
33
  }
17
34
 
18
- const upstream = new URL(incoming.pathname + incoming.search, origin);
19
- const headers = new Headers(request.headers);
20
- headers.set("x-forwarded-host", incoming.host);
21
- headers.set("x-shortlinks-worker", "cloudflare");
22
-
23
- return fetch(upstream.toString(), {
24
- method: request.method,
25
- headers,
26
- body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
27
- redirect: "manual"
28
- });
35
+ return proxyTo(origin, "cloudflare");
29
36
  }
30
37
  };
@@ -4,4 +4,5 @@ compatibility_date = "2026-05-01"
4
4
 
5
5
  [vars]
6
6
  SHORTLINKS_ORIGIN = "https://shortlinks.example.com"
7
+ ATTACHMENTS_ORIGIN = ""
7
8
  SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
@@ -0,0 +1,30 @@
1
+ import type { AddDomainInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
2
+ export interface ShortlinksApiClientOptions {
3
+ baseUrl?: string;
4
+ token?: string;
5
+ }
6
+ export declare class ShortlinksApiClient {
7
+ private readonly options;
8
+ constructor(options?: ShortlinksApiClientOptions);
9
+ private url;
10
+ private headers;
11
+ totalStats(): Promise<{
12
+ domains: number;
13
+ links: number;
14
+ clicks: number;
15
+ }>;
16
+ createLink(input: CreateLinkInput): Promise<Link>;
17
+ listLinks(options?: {
18
+ domain?: string;
19
+ activeOnly?: boolean;
20
+ limit?: number;
21
+ }): Promise<Link[]>;
22
+ getLink(domainOrSlug: string, maybeSlug?: string): Promise<Link | null>;
23
+ setLinkActive(domainOrSlug: string, maybeSlugOrActive: string | boolean, maybeActive?: boolean): Promise<Link>;
24
+ deleteLink(domainOrSlug: string, maybeSlug?: string): Promise<Link>;
25
+ getStats(domainOrSlug: string, maybeSlug?: string): Promise<LinkStats>;
26
+ addDomain(input: AddDomainInput): Promise<Domain>;
27
+ listDomains(): Promise<Domain[]>;
28
+ getDomain(hostname: string): Promise<Domain | null>;
29
+ close(): Promise<void>;
30
+ }
package/dist/cli/index.js CHANGED
@@ -2121,17 +2121,31 @@ function saveConfig(config) {
2121
2121
  `);
2122
2122
  }
2123
2123
  function updateConfig(patch) {
2124
+ const current = loadConfig();
2124
2125
  const next = {
2125
- ...loadConfig(),
2126
+ ...current,
2126
2127
  ...patch,
2128
+ api: {
2129
+ ...current.api,
2130
+ ...patch.api
2131
+ },
2127
2132
  cloudflare: {
2128
- ...loadConfig().cloudflare,
2133
+ ...current.cloudflare,
2129
2134
  ...patch.cloudflare
2130
2135
  }
2131
2136
  };
2132
2137
  saveConfig(next);
2133
2138
  return next;
2134
2139
  }
2140
+ function getApiBaseUrl(config = loadConfig()) {
2141
+ const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
2142
+ return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
2143
+ }
2144
+ function getApiToken(config = loadConfig()) {
2145
+ const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
2146
+ const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
2147
+ return token || null;
2148
+ }
2135
2149
  function normalizeHostname(input) {
2136
2150
  const raw = input.trim().toLowerCase();
2137
2151
  if (!raw)
@@ -9473,6 +9487,156 @@ class PgShortlinksStore {
9473
9487
  // src/cli/index.ts
9474
9488
  init_config();
9475
9489
 
9490
+ // src/api-client.ts
9491
+ init_config();
9492
+ function normalizeBaseUrl(value) {
9493
+ return value.replace(/\/+$/, "");
9494
+ }
9495
+ function requireBaseUrl(options) {
9496
+ const baseUrl = options.baseUrl || getApiBaseUrl(loadConfig());
9497
+ if (!baseUrl) {
9498
+ throw new Error("Shortlinks API URL is not configured. Run `shortlinks config set api-url <url>` or set SHORTLINKS_API_URL.");
9499
+ }
9500
+ return normalizeBaseUrl(baseUrl);
9501
+ }
9502
+ function requireToken(options) {
9503
+ const token = options.token || getApiToken(loadConfig());
9504
+ if (!token) {
9505
+ throw new Error("Shortlinks API token is not configured. Set SHORTLINKS_API_TOKEN or run `shortlinks config set api-token-env <name>`.");
9506
+ }
9507
+ return token;
9508
+ }
9509
+ async function readJson(response) {
9510
+ const text = await response.text();
9511
+ let parsed = {};
9512
+ if (text) {
9513
+ try {
9514
+ parsed = JSON.parse(text);
9515
+ } catch {
9516
+ parsed = { error: text };
9517
+ }
9518
+ }
9519
+ if (!response.ok) {
9520
+ const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}`;
9521
+ throw new Error(message);
9522
+ }
9523
+ return parsed;
9524
+ }
9525
+
9526
+ class ShortlinksApiClient {
9527
+ options;
9528
+ constructor(options = {}) {
9529
+ this.options = options;
9530
+ }
9531
+ url(path, query) {
9532
+ const url = new URL(`${requireBaseUrl(this.options)}${path}`);
9533
+ for (const [key, value] of Object.entries(query ?? {})) {
9534
+ if (value !== undefined && value !== "")
9535
+ url.searchParams.set(key, String(value));
9536
+ }
9537
+ return url.toString();
9538
+ }
9539
+ headers(extra) {
9540
+ return {
9541
+ authorization: `Bearer ${requireToken(this.options)}`,
9542
+ ...extra ?? {}
9543
+ };
9544
+ }
9545
+ async totalStats() {
9546
+ const response = await fetch(this.url("/stats"), { headers: this.headers() });
9547
+ return readJson(response);
9548
+ }
9549
+ async createLink(input) {
9550
+ const response = await fetch(this.url("/links"), {
9551
+ method: "POST",
9552
+ headers: this.headers({ "content-type": "application/json" }),
9553
+ body: JSON.stringify({
9554
+ destination_url: input.destinationUrl,
9555
+ domain: input.domain,
9556
+ slug: input.slug,
9557
+ title: input.title,
9558
+ expires_at: input.expiresAt,
9559
+ length: input.slugLength
9560
+ })
9561
+ });
9562
+ return readJson(response);
9563
+ }
9564
+ async listLinks(options = {}) {
9565
+ const response = await fetch(this.url("/links", {
9566
+ domain: options.domain,
9567
+ active: options.activeOnly,
9568
+ limit: options.limit
9569
+ }), { headers: this.headers() });
9570
+ return readJson(response);
9571
+ }
9572
+ async getLink(domainOrSlug, maybeSlug) {
9573
+ const slug = maybeSlug ?? domainOrSlug;
9574
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
9575
+ domain: maybeSlug ? domainOrSlug : undefined
9576
+ }), { headers: this.headers() });
9577
+ if (response.status === 404)
9578
+ return null;
9579
+ return readJson(response);
9580
+ }
9581
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
9582
+ const hasDomain = typeof maybeSlugOrActive === "string";
9583
+ const slug = hasDomain ? maybeSlugOrActive : domainOrSlug;
9584
+ const active = hasDomain ? Boolean(maybeActive) : Boolean(maybeSlugOrActive);
9585
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}/active`, {
9586
+ domain: hasDomain ? domainOrSlug : undefined
9587
+ }), {
9588
+ method: "POST",
9589
+ headers: this.headers({ "content-type": "application/json" }),
9590
+ body: JSON.stringify({ active })
9591
+ });
9592
+ return readJson(response);
9593
+ }
9594
+ async deleteLink(domainOrSlug, maybeSlug) {
9595
+ const slug = maybeSlug ?? domainOrSlug;
9596
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
9597
+ domain: maybeSlug ? domainOrSlug : undefined
9598
+ }), {
9599
+ method: "DELETE",
9600
+ headers: this.headers()
9601
+ });
9602
+ return readJson(response);
9603
+ }
9604
+ async getStats(domainOrSlug, maybeSlug) {
9605
+ const slug = maybeSlug ?? domainOrSlug;
9606
+ const response = await fetch(this.url(`/stats/${encodeURIComponent(slug)}`, {
9607
+ domain: maybeSlug ? domainOrSlug : undefined
9608
+ }), { headers: this.headers() });
9609
+ return readJson(response);
9610
+ }
9611
+ async addDomain(input) {
9612
+ const response = await fetch(this.url("/domains"), {
9613
+ method: "POST",
9614
+ headers: this.headers({ "content-type": "application/json" }),
9615
+ body: JSON.stringify({
9616
+ hostname: input.hostname,
9617
+ provider: input.provider,
9618
+ default_domain: input.defaultDomain,
9619
+ origin_url: input.originUrl,
9620
+ notes: input.notes
9621
+ })
9622
+ });
9623
+ return readJson(response);
9624
+ }
9625
+ async listDomains() {
9626
+ const response = await fetch(this.url("/domains"), { headers: this.headers() });
9627
+ return readJson(response);
9628
+ }
9629
+ async getDomain(hostname2) {
9630
+ const response = await fetch(this.url(`/domains/${encodeURIComponent(hostname2)}`), { headers: this.headers() });
9631
+ if (response.status === 404)
9632
+ return null;
9633
+ return readJson(response);
9634
+ }
9635
+ async close() {
9636
+ return;
9637
+ }
9638
+ }
9639
+
9476
9640
  // src/server.ts
9477
9641
  function json(data, status = 200) {
9478
9642
  return new Response(JSON.stringify(data, null, 2), {
@@ -9480,6 +9644,120 @@ function json(data, status = 200) {
9480
9644
  headers: { "content-type": "application/json; charset=utf-8" }
9481
9645
  });
9482
9646
  }
9647
+ function apiToken(options) {
9648
+ return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
9649
+ }
9650
+ function requestToken(request) {
9651
+ const auth = request.headers.get("authorization") || "";
9652
+ const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
9653
+ return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
9654
+ }
9655
+ function isAuthorized(request, options) {
9656
+ const token = apiToken(options);
9657
+ if (!token)
9658
+ return true;
9659
+ return requestToken(request) === token;
9660
+ }
9661
+ async function readJsonBody(request) {
9662
+ try {
9663
+ const parsed = await request.json();
9664
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
9665
+ } catch {
9666
+ return {};
9667
+ }
9668
+ }
9669
+ function requireMethod(store, method) {
9670
+ const fn = store[method];
9671
+ if (typeof fn !== "function")
9672
+ throw new Error(`Store does not support ${String(method)}.`);
9673
+ return fn.bind(store);
9674
+ }
9675
+ async function handleApi(request, apiPath, store, options) {
9676
+ if (apiPath === "/health") {
9677
+ return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
9678
+ }
9679
+ if (!isAuthorized(request, options))
9680
+ return json({ error: "Unauthorized" }, 401);
9681
+ const url = new URL(request.url);
9682
+ const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
9683
+ try {
9684
+ if (apiPath === "/links" && request.method === "GET") {
9685
+ const listLinks = requireMethod(store, "listLinks");
9686
+ return json(await listLinks({
9687
+ domain: url.searchParams.get("domain") || undefined,
9688
+ activeOnly: url.searchParams.get("active") === "true",
9689
+ limit: Number(url.searchParams.get("limit") || "100")
9690
+ }));
9691
+ }
9692
+ if (apiPath === "/links" && request.method === "POST") {
9693
+ const body = await readJsonBody(request);
9694
+ const destinationUrl = String(body.destination_url || body.url || "");
9695
+ if (!destinationUrl)
9696
+ return json({ error: "destination_url is required" }, 400);
9697
+ const createLink = requireMethod(store, "createLink");
9698
+ return json(await createLink({
9699
+ destinationUrl,
9700
+ domain: typeof body.domain === "string" ? body.domain : undefined,
9701
+ slug: typeof body.slug === "string" ? body.slug : undefined,
9702
+ title: typeof body.title === "string" ? body.title : undefined,
9703
+ expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
9704
+ slugLength: typeof body.length === "number" ? body.length : undefined
9705
+ }), 201);
9706
+ }
9707
+ if (segments[0] === "links" && segments[1] && request.method === "GET") {
9708
+ const getLink = requireMethod(store, "getLink");
9709
+ const domain = url.searchParams.get("domain") || undefined;
9710
+ const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
9711
+ return link ? json(link) : json({ error: "Link not found." }, 404);
9712
+ }
9713
+ if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
9714
+ const deleteLink = requireMethod(store, "deleteLink");
9715
+ const domain = url.searchParams.get("domain") || undefined;
9716
+ return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
9717
+ }
9718
+ if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
9719
+ const body = await readJsonBody(request);
9720
+ const setLinkActive = requireMethod(store, "setLinkActive");
9721
+ const domain = url.searchParams.get("domain") || undefined;
9722
+ const active = Boolean(body.active);
9723
+ return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
9724
+ }
9725
+ if (apiPath === "/stats" && request.method === "GET") {
9726
+ return json(await store.totalStats());
9727
+ }
9728
+ if (segments[0] === "stats" && segments[1] && request.method === "GET") {
9729
+ const getStats = requireMethod(store, "getStats");
9730
+ const domain = url.searchParams.get("domain") || undefined;
9731
+ return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
9732
+ }
9733
+ if (apiPath === "/domains" && request.method === "GET") {
9734
+ const listDomains = requireMethod(store, "listDomains");
9735
+ return json(await listDomains());
9736
+ }
9737
+ if (apiPath === "/domains" && request.method === "POST") {
9738
+ const body = await readJsonBody(request);
9739
+ const hostname2 = String(body.hostname || "");
9740
+ if (!hostname2)
9741
+ return json({ error: "hostname is required" }, 400);
9742
+ const addDomain = requireMethod(store, "addDomain");
9743
+ return json(await addDomain({
9744
+ hostname: hostname2,
9745
+ provider: typeof body.provider === "string" ? body.provider : "manual",
9746
+ defaultDomain: Boolean(body.default_domain || body.default),
9747
+ originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
9748
+ notes: typeof body.notes === "string" ? body.notes : undefined
9749
+ }), 201);
9750
+ }
9751
+ if (segments[0] === "domains" && segments[1] && request.method === "GET") {
9752
+ const getDomain = requireMethod(store, "getDomain");
9753
+ const domain = await getDomain(segments[1]);
9754
+ return domain ? json(domain) : json({ error: "Domain not found." }, 404);
9755
+ }
9756
+ } catch (error) {
9757
+ return json({ error: error instanceof Error ? error.message : String(error) }, 400);
9758
+ }
9759
+ return json({ error: "Not found." }, 404);
9760
+ }
9483
9761
  function getHost(request, fallback) {
9484
9762
  const forwarded = request.headers.get("x-forwarded-host");
9485
9763
  const host = forwarded || request.headers.get("host") || fallback || "";
@@ -9498,8 +9776,13 @@ function createShortlinksHandler(options = {}) {
9498
9776
  const store = options.store || new ShortlinksStore(options.dbPath);
9499
9777
  const redirectStatus = options.redirectStatus || 302;
9500
9778
  const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
9779
+ const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
9501
9780
  return async (request) => {
9502
9781
  const url = new URL(request.url);
9782
+ if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
9783
+ const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
9784
+ return handleApi(request, apiPath, store, options);
9785
+ }
9503
9786
  if (url.pathname === "/healthz") {
9504
9787
  return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
9505
9788
  }
@@ -9585,26 +9868,33 @@ function generateWorkerScript() {
9585
9868
  }
9586
9869
 
9587
9870
  const incoming = new URL(request.url);
9871
+ const proxyTo = (targetOrigin, marker) => {
9872
+ const upstream = new URL(incoming.pathname + incoming.search, targetOrigin);
9873
+ const headers = new Headers(request.headers);
9874
+ headers.set("x-forwarded-host", incoming.host);
9875
+ headers.set("x-shortlinks-worker", marker);
9876
+
9877
+ return fetch(upstream.toString(), {
9878
+ method: request.method,
9879
+ headers,
9880
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
9881
+ redirect: "manual"
9882
+ });
9883
+ };
9884
+
9588
9885
  const reserved = (env.SHORTLINKS_RESERVED_PATH_PREFIXES || "a")
9589
9886
  .split(",")
9590
9887
  .map((value) => value.trim().toLowerCase())
9591
9888
  .filter(Boolean);
9592
9889
  const firstSegment = decodeURIComponent(incoming.pathname.replace(/^\\/+/, "").split("/")[0] || "").toLowerCase();
9593
9890
  if (firstSegment && reserved.includes(firstSegment)) {
9891
+ if (env.ATTACHMENTS_ORIGIN) {
9892
+ return proxyTo(env.ATTACHMENTS_ORIGIN, "attachments");
9893
+ }
9594
9894
  return new Response("Reserved path prefix", { status: 404 });
9595
9895
  }
9596
9896
 
9597
- const upstream = new URL(incoming.pathname + incoming.search, origin);
9598
- const headers = new Headers(request.headers);
9599
- headers.set("x-forwarded-host", incoming.host);
9600
- headers.set("x-shortlinks-worker", "cloudflare");
9601
-
9602
- return fetch(upstream.toString(), {
9603
- method: request.method,
9604
- headers,
9605
- body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
9606
- redirect: "manual"
9607
- });
9897
+ return proxyTo(origin, "cloudflare");
9608
9898
  }
9609
9899
  };
9610
9900
  `;
@@ -9622,14 +9912,15 @@ compatibility_date = "2026-05-01"
9622
9912
 
9623
9913
  [vars]
9624
9914
  SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
9915
+ ATTACHMENTS_ORIGIN = "${options.attachmentsOrigin || ""}"
9625
9916
  SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
9626
9917
  `);
9627
9918
  return { workerPath, wranglerPath };
9628
9919
  }
9629
9920
  function cloudflareAuthHeaders(token) {
9630
- const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
9631
- if (apiToken)
9632
- return { authorization: `Bearer ${apiToken}` };
9921
+ const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
9922
+ if (apiToken2)
9923
+ return { authorization: `Bearer ${apiToken2}` };
9633
9924
  const apiKey = process.env.CLOUDFLARE_API_KEY;
9634
9925
  const email = process.env.CLOUDFLARE_EMAIL;
9635
9926
  if (apiKey && email) {
@@ -9815,13 +10106,19 @@ function withStore(fn) {
9815
10106
  }
9816
10107
  function storeMode() {
9817
10108
  const opts = program2.opts();
9818
- const value = String(opts.remote ? "remote" : opts.store || process.env.SHORTLINKS_STORE || "local").toLowerCase();
9819
- if (value !== "local" && value !== "remote")
10109
+ const config = loadConfig();
10110
+ const value = String(opts.remote ? "remote" : opts.store || process.env.SHORTLINKS_STORE || config.mode || "local").toLowerCase();
10111
+ if (value !== "local" && value !== "remote" && value !== "api")
9820
10112
  throw new Error(`Unknown store mode: ${value}`);
9821
10113
  return value;
9822
10114
  }
9823
10115
  async function withRuntimeStore(fn) {
9824
- if (storeMode() === "remote") {
10116
+ const mode = storeMode();
10117
+ if (mode === "api") {
10118
+ const store2 = new ShortlinksApiClient({ baseUrl: program2.opts().apiUrl });
10119
+ return await fn(store2);
10120
+ }
10121
+ if (mode === "remote") {
9825
10122
  const store2 = await PgShortlinksStore.fromStorage("shortlinks");
9826
10123
  try {
9827
10124
  return await fn(store2);
@@ -9843,7 +10140,7 @@ function commandExists(command) {
9843
10140
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
9844
10141
  return result.status === 0;
9845
10142
  }
9846
- program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and storage sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: remote or local", process.env.SHORTLINKS_STORE || "local").addOption(new Option("--remote", "Use the shortlinks PostgreSQL storage database directly")).option("-j, --json", "Output JSON for agents and scripts");
10143
+ program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and storage sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: local, remote, or api", process.env.SHORTLINKS_STORE || loadConfig().mode || "local").option("--api-url <url>", "Shortlinks HTTP API base URL").addOption(new Option("--remote", "Use the shortlinks PostgreSQL storage database directly")).option("-j, --json", "Output JSON for agents and scripts");
9847
10144
  program2.command("init").description("Initialize local shortlinks storage").option("--domain <hostname>", "Add a default shortlink domain").option("--public-base-url <url>", "Public URL base for generated links").option("-j, --json", "Output JSON").action(async (opts) => {
9848
10145
  try {
9849
10146
  const result = await withRuntimeStore(async (store) => {
@@ -9883,19 +10180,42 @@ program2.command("init").description("Initialize local shortlinks storage").opti
9883
10180
  });
9884
10181
  var configCmd = program2.command("config").description("View and update local config");
9885
10182
  configCmd.command("show").description("Show local config").option("-j, --json", "Output JSON").action((opts) => {
9886
- const data = { path: getConfigPath(), config: loadConfig() };
10183
+ const config = loadConfig();
10184
+ const data = {
10185
+ path: getConfigPath(),
10186
+ config: {
10187
+ ...config,
10188
+ api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
10189
+ }
10190
+ };
9887
10191
  print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
9888
10192
  });
9889
- configCmd.command("set <key> <value>").description("Set config value: default-domain, public-base-url, cloudflare-account-id, cloudflare-worker-name, cloudflare-origin").option("-j, --json", "Output JSON").action((key, value, opts) => {
10193
+ configCmd.command("set <key> <value>").description("Set config value: mode, default-domain, public-base-url, api-url, api-token, api-token-env, cloudflare-account-id, cloudflare-worker-name, cloudflare-origin").option("-j, --json", "Output JSON").action((key, value, opts) => {
9890
10194
  try {
9891
10195
  let config = loadConfig();
9892
10196
  switch (key) {
10197
+ case "mode": {
10198
+ const mode = value.toLowerCase();
10199
+ if (mode !== "local" && mode !== "remote" && mode !== "api")
10200
+ throw new Error("mode must be local, remote, or api");
10201
+ config = updateConfig({ mode });
10202
+ break;
10203
+ }
9893
10204
  case "default-domain":
9894
10205
  config = updateConfig({ defaultDomain: value, publicBaseUrl: config.publicBaseUrl || `https://${value}` });
9895
10206
  break;
9896
10207
  case "public-base-url":
9897
10208
  config = updateConfig({ publicBaseUrl: value });
9898
10209
  break;
10210
+ case "api-url":
10211
+ config = updateConfig({ api: { baseUrl: value.replace(/\/+$/, "") } });
10212
+ break;
10213
+ case "api-token":
10214
+ config = updateConfig({ api: { token: value } });
10215
+ break;
10216
+ case "api-token-env":
10217
+ config = updateConfig({ api: { tokenEnv: value } });
10218
+ break;
9899
10219
  case "cloudflare-account-id":
9900
10220
  config = updateConfig({ cloudflare: { accountId: value } });
9901
10221
  break;
@@ -9908,7 +10228,11 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
9908
10228
  default:
9909
10229
  throw new Error(`Unknown config key: ${key}`);
9910
10230
  }
9911
- print2({ path: getConfigPath(), config }, opts, () => console.log(source_default.green(`Set ${key}.`)));
10231
+ const masked = {
10232
+ ...config,
10233
+ api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
10234
+ };
10235
+ print2({ path: getConfigPath(), config: masked }, opts, () => console.log(source_default.green(`Set ${key}.`)));
9912
10236
  } catch (error) {
9913
10237
  handleError(error);
9914
10238
  }
@@ -10103,7 +10427,7 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
10103
10427
  handleError(error);
10104
10428
  }
10105
10429
  });
10106
- program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").option("--remote", "Serve directly from the shortlinks PostgreSQL storage database").addOption(new Option("--cloud", "Deprecated alias for --remote").hideHelp()).action(async (opts) => {
10430
+ program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").option("--api-path-prefix <path>", "Admin API path prefix", process.env.SHORTLINKS_API_PATH_PREFIX || "/api").option("--remote", "Serve directly from the shortlinks PostgreSQL storage database").addOption(new Option("--cloud", "Deprecated alias for --remote").hideHelp()).action(async (opts) => {
10107
10431
  try {
10108
10432
  const store = opts.remote || opts.cloud || storeMode() === "remote" ? await PgShortlinksStore.fromStorage("shortlinks") : undefined;
10109
10433
  const server = serveShortlinks({
@@ -10111,7 +10435,8 @@ program2.command("serve").description("Run the redirect server that records clic
10111
10435
  dbPath: program2.opts().db,
10112
10436
  host: opts.host,
10113
10437
  port: Number(opts.port),
10114
- defaultHost: opts.defaultHost
10438
+ defaultHost: opts.defaultHost,
10439
+ apiPathPrefix: opts.apiPathPrefix
10115
10440
  });
10116
10441
  const mode = store ? "remote" : "local";
10117
10442
  console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
@@ -10134,9 +10459,14 @@ cfCmd.command("plan <hostname>").description("Print the Cloudflare setup plan").
10134
10459
  handleError(error);
10135
10460
  }
10136
10461
  });
10137
- cfCmd.command("worker").description("Write Cloudflare Worker files").option("--out-dir <dir>", "Output directory", "cloudflare").option("--worker <name>", "Worker name", "shortlinks").option("--origin <url>", "Origin redirect server URL", process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com").option("-j, --json", "Output JSON").action((opts) => {
10462
+ cfCmd.command("worker").description("Write Cloudflare Worker files").option("--out-dir <dir>", "Output directory", "cloudflare").option("--worker <name>", "Worker name", "shortlinks").option("--origin <url>", "Origin redirect server URL", process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com").option("--attachments-origin <url>", "Origin URL for reserved attachment paths", process.env.ATTACHMENTS_ORIGIN).option("-j, --json", "Output JSON").action((opts) => {
10138
10463
  try {
10139
- const result = writeWorkerFiles({ outDir: opts.outDir, workerName: opts.worker, origin: opts.origin });
10464
+ const result = writeWorkerFiles({
10465
+ outDir: opts.outDir,
10466
+ workerName: opts.worker,
10467
+ origin: opts.origin,
10468
+ attachmentsOrigin: opts.attachmentsOrigin
10469
+ });
10140
10470
  print2(result, opts, () => {
10141
10471
  console.log(source_default.green(`Wrote ${result.workerPath}`));
10142
10472
  console.log(source_default.green(`Wrote ${result.wranglerPath}`));
@@ -32,6 +32,7 @@ export declare function writeWorkerFiles(options?: {
32
32
  outDir?: string;
33
33
  workerName?: string;
34
34
  origin?: string;
35
+ attachmentsOrigin?: string;
35
36
  }): {
36
37
  workerPath: string;
37
38
  wranglerPath: string;
@@ -56,26 +56,33 @@ function generateWorkerScript() {
56
56
  }
57
57
 
58
58
  const incoming = new URL(request.url);
59
+ const proxyTo = (targetOrigin, marker) => {
60
+ const upstream = new URL(incoming.pathname + incoming.search, targetOrigin);
61
+ const headers = new Headers(request.headers);
62
+ headers.set("x-forwarded-host", incoming.host);
63
+ headers.set("x-shortlinks-worker", marker);
64
+
65
+ return fetch(upstream.toString(), {
66
+ method: request.method,
67
+ headers,
68
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
69
+ redirect: "manual"
70
+ });
71
+ };
72
+
59
73
  const reserved = (env.SHORTLINKS_RESERVED_PATH_PREFIXES || "a")
60
74
  .split(",")
61
75
  .map((value) => value.trim().toLowerCase())
62
76
  .filter(Boolean);
63
77
  const firstSegment = decodeURIComponent(incoming.pathname.replace(/^\\/+/, "").split("/")[0] || "").toLowerCase();
64
78
  if (firstSegment && reserved.includes(firstSegment)) {
79
+ if (env.ATTACHMENTS_ORIGIN) {
80
+ return proxyTo(env.ATTACHMENTS_ORIGIN, "attachments");
81
+ }
65
82
  return new Response("Reserved path prefix", { status: 404 });
66
83
  }
67
84
 
68
- const upstream = new URL(incoming.pathname + incoming.search, origin);
69
- const headers = new Headers(request.headers);
70
- headers.set("x-forwarded-host", incoming.host);
71
- headers.set("x-shortlinks-worker", "cloudflare");
72
-
73
- return fetch(upstream.toString(), {
74
- method: request.method,
75
- headers,
76
- body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
77
- redirect: "manual"
78
- });
85
+ return proxyTo(origin, "cloudflare");
79
86
  }
80
87
  };
81
88
  `;
@@ -93,6 +100,7 @@ compatibility_date = "2026-05-01"
93
100
 
94
101
  [vars]
95
102
  SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
103
+ ATTACHMENTS_ORIGIN = "${options.attachmentsOrigin || ""}"
96
104
  SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
97
105
  `);
98
106
  return { workerPath, wranglerPath };