@hasna/shortlinks 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,17 @@ shortlinks serve --port 8787
67
67
  shortlinks doctor
68
68
  ```
69
69
 
70
+ ## Local Domain Setup
71
+
72
+ Record a local mapping with the `machines` CLI and print the remaining hosts/proxy setup:
73
+
74
+ ```bash
75
+ shortlinks local setup has.na --port 8787
76
+ shortlinks local plan has.na --port 8787
77
+ ```
78
+
79
+ The command emits the `/etc/hosts` line, a Caddy reverse-proxy snippet, and certificate paths. Writing `/etc/hosts` still requires sudo on macOS.
80
+
70
81
  ## Custom Domains
71
82
 
72
83
  Add as many domains as you need:
@@ -96,7 +107,7 @@ shortlinks cloudflare worker \
96
107
  --origin https://shortlinks.hasna.xyz
97
108
  ```
98
109
 
99
- Upsert DNS when `CLOUDFLARE_API_TOKEN` is available:
110
+ Upsert DNS when `CLOUDFLARE_API_TOKEN` is available. Global API key auth is also supported with `CLOUDFLARE_API_KEY` plus `CLOUDFLARE_EMAIL`.
100
111
 
101
112
  ```bash
102
113
  shortlinks cloudflare dns has.na --target shortlinks.hasna.xyz
package/dist/cli/index.js CHANGED
@@ -2560,9 +2560,9 @@ var source_default = chalk;
2560
2560
 
2561
2561
  // src/cli/index.ts
2562
2562
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
2563
- import { dirname as dirname3, join as join4 } from "path";
2563
+ import { dirname as dirname3, join as join5 } from "path";
2564
2564
  import { fileURLToPath } from "url";
2565
- import { spawnSync as spawnSync2 } from "child_process";
2565
+ import { spawnSync as spawnSync3 } from "child_process";
2566
2566
 
2567
2567
  // src/store.ts
2568
2568
  import { createHash } from "crypto";
@@ -3224,11 +3224,25 @@ SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
3224
3224
  `);
3225
3225
  return { workerPath, wranglerPath };
3226
3226
  }
3227
+ function cloudflareAuthHeaders(token) {
3228
+ const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
3229
+ if (apiToken)
3230
+ return { authorization: `Bearer ${apiToken}` };
3231
+ const apiKey = process.env.CLOUDFLARE_API_KEY;
3232
+ const email = process.env.CLOUDFLARE_EMAIL;
3233
+ if (apiKey && email) {
3234
+ return {
3235
+ "x-auth-key": apiKey,
3236
+ "x-auth-email": email
3237
+ };
3238
+ }
3239
+ throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
3240
+ }
3227
3241
  async function cloudflareRequest(token, path, init = {}) {
3228
3242
  const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
3229
3243
  ...init,
3230
3244
  headers: {
3231
- authorization: `Bearer ${token}`,
3245
+ ...cloudflareAuthHeaders(token),
3232
3246
  "content-type": "application/json",
3233
3247
  ...init.headers || {}
3234
3248
  }
@@ -3266,8 +3280,6 @@ async function upsertCloudflareDnsRecord(options) {
3266
3280
  });
3267
3281
  if (options.dryRun)
3268
3282
  return plan;
3269
- if (!token)
3270
- throw new Error("CLOUDFLARE_API_TOKEN is required unless --dry-run is used.");
3271
3283
  const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
3272
3284
  const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
3273
3285
  const payload = JSON.stringify(plan.dnsRecord);
@@ -3312,6 +3324,54 @@ function runDomains(action, domain, options = {}) {
3312
3324
  };
3313
3325
  }
3314
3326
 
3327
+ // src/local.ts
3328
+ import { spawnSync as spawnSync2 } from "child_process";
3329
+ import { homedir as homedir2 } from "os";
3330
+ import { join as join4 } from "path";
3331
+ function createLocalSetupPlan(input) {
3332
+ const domain = normalizeHostname(input.domain);
3333
+ const targetHost = input.targetHost || "127.0.0.1";
3334
+ const port = input.port || 8787;
3335
+ const certDir = input.certDir || join4(homedir2(), ".hasna", "machines", "certs");
3336
+ const certPath = join4(certDir, `${domain}.pem`);
3337
+ const keyPath = join4(certDir, `${domain}-key.pem`);
3338
+ return {
3339
+ domain,
3340
+ targetHost,
3341
+ port,
3342
+ hostsEntry: `${targetHost} ${domain}`,
3343
+ caddySnippet: `${domain} {
3344
+ reverse_proxy ${targetHost}:${port}
3345
+ tls ${certPath} ${keyPath}
3346
+ }`,
3347
+ certPath,
3348
+ keyPath,
3349
+ machinesCommand: `machines dns add --domain ${domain} --target-host ${targetHost} --port ${port} --json`,
3350
+ sudoRequired: true
3351
+ };
3352
+ }
3353
+ function registerMachinesDns(input) {
3354
+ const plan = createLocalSetupPlan(input);
3355
+ const args = [
3356
+ "dns",
3357
+ "add",
3358
+ "--domain",
3359
+ plan.domain,
3360
+ "--target-host",
3361
+ plan.targetHost,
3362
+ "--port",
3363
+ String(plan.port),
3364
+ "--json"
3365
+ ];
3366
+ const result = spawnSync2("machines", args, { encoding: "utf-8" });
3367
+ return {
3368
+ command: `machines ${args.join(" ")}`,
3369
+ status: result.status,
3370
+ stdout: result.stdout || "",
3371
+ stderr: result.stderr || ""
3372
+ };
3373
+ }
3374
+
3315
3375
  // src/pg-migrations.ts
3316
3376
  var PG_MIGRATIONS = [
3317
3377
  `
@@ -3388,7 +3448,7 @@ var PG_MIGRATIONS = [
3388
3448
  // src/cli/index.ts
3389
3449
  function getPackageVersion() {
3390
3450
  try {
3391
- const pkgPath = join4(dirname3(fileURLToPath(import.meta.url)), "..", "..", "package.json");
3451
+ const pkgPath = join5(dirname3(fileURLToPath(import.meta.url)), "..", "..", "package.json");
3392
3452
  return JSON.parse(readFileSync3(pkgPath, "utf-8")).version || "0.0.0";
3393
3453
  } catch {
3394
3454
  return "0.0.0";
@@ -3435,7 +3495,7 @@ function formatLink(link) {
3435
3495
  return `${source_default.green(link.short_url || `${link.hostname}/${link.slug}`)} ${source_default.dim("->")} ${link.destination_url}`;
3436
3496
  }
3437
3497
  function commandExists(command) {
3438
- const result = spawnSync2("which", [command], { encoding: "utf-8" });
3498
+ const result = spawnSync3("which", [command], { encoding: "utf-8" });
3439
3499
  return result.status === 0;
3440
3500
  }
3441
3501
  program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and cloud sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("-j, --json", "Output JSON for agents and scripts");
@@ -3814,6 +3874,42 @@ cloudCmd.command("status").description("Show local and cloud configuration healt
3814
3874
  handleError(error);
3815
3875
  }
3816
3876
  });
3877
+ var localCmd = program2.command("local").description("Local domain setup helpers");
3878
+ localCmd.command("plan <domain>").description("Render hosts and reverse-proxy setup for a local shortlink domain").option("--port <port>", "Local redirect server port", "8787").option("--target-host <host>", "Local target host", "127.0.0.1").option("-j, --json", "Output JSON").action((domain, opts) => {
3879
+ try {
3880
+ const plan = createLocalSetupPlan({
3881
+ domain,
3882
+ port: Number(opts.port),
3883
+ targetHost: opts.targetHost
3884
+ });
3885
+ print(plan, opts, () => console.log(JSON.stringify(plan, null, 2)));
3886
+ } catch (error) {
3887
+ handleError(error);
3888
+ }
3889
+ });
3890
+ localCmd.command("setup <domain>").description("Record local domain mapping with machines and print remaining sudo-only setup").option("--port <port>", "Local redirect server port", "8787").option("--target-host <host>", "Local target host", "127.0.0.1").option("--skip-machines", "Do not call machines dns add").option("-j, --json", "Output JSON").action((domain, opts) => {
3891
+ try {
3892
+ const plan = createLocalSetupPlan({
3893
+ domain,
3894
+ port: Number(opts.port),
3895
+ targetHost: opts.targetHost
3896
+ });
3897
+ const machines = opts.skipMachines ? null : registerMachinesDns({
3898
+ domain,
3899
+ port: Number(opts.port),
3900
+ targetHost: opts.targetHost
3901
+ });
3902
+ const result = { plan, machines };
3903
+ print(result, opts, () => {
3904
+ if (machines && machines.status !== 0) {
3905
+ console.error(source_default.yellow(machines.stderr.trim() || "machines dns add failed"));
3906
+ }
3907
+ console.log(JSON.stringify(result, null, 2));
3908
+ });
3909
+ } catch (error) {
3910
+ handleError(error);
3911
+ }
3912
+ });
3817
3913
  program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action((opts) => {
3818
3914
  try {
3819
3915
  const stats = withStore((store) => store.totalStats());
@@ -3832,6 +3928,8 @@ program2.command("doctor").description("Check local shortlinks tooling and integ
3832
3928
  },
3833
3929
  environment: {
3834
3930
  cloudflare_api_token_present: Boolean(process.env.CLOUDFLARE_API_TOKEN),
3931
+ cloudflare_api_key_present: Boolean(process.env.CLOUDFLARE_API_KEY),
3932
+ cloudflare_email_present: Boolean(process.env.CLOUDFLARE_EMAIL),
3835
3933
  shortlinks_origin_present: Boolean(process.env.SHORTLINKS_ORIGIN)
3836
3934
  }
3837
3935
  };
@@ -36,7 +36,7 @@ export declare function writeWorkerFiles(options?: {
36
36
  workerPath: string;
37
37
  wranglerPath: string;
38
38
  };
39
- export declare function findCloudflareZoneId(hostname: string, token: string): Promise<string>;
39
+ export declare function findCloudflareZoneId(hostname: string, token?: string): Promise<string>;
40
40
  export declare function upsertCloudflareDnsRecord(options: CloudflareDnsOptions): Promise<CloudflareSetupPlan | {
41
41
  id: string;
42
42
  action: "created" | "updated";
@@ -87,11 +87,25 @@ SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
87
87
  `);
88
88
  return { workerPath, wranglerPath };
89
89
  }
90
+ function cloudflareAuthHeaders(token) {
91
+ const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
92
+ if (apiToken)
93
+ return { authorization: `Bearer ${apiToken}` };
94
+ const apiKey = process.env.CLOUDFLARE_API_KEY;
95
+ const email = process.env.CLOUDFLARE_EMAIL;
96
+ if (apiKey && email) {
97
+ return {
98
+ "x-auth-key": apiKey,
99
+ "x-auth-email": email
100
+ };
101
+ }
102
+ throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
103
+ }
90
104
  async function cloudflareRequest(token, path, init = {}) {
91
105
  const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
92
106
  ...init,
93
107
  headers: {
94
- authorization: `Bearer ${token}`,
108
+ ...cloudflareAuthHeaders(token),
95
109
  "content-type": "application/json",
96
110
  ...init.headers || {}
97
111
  }
@@ -129,8 +143,6 @@ async function upsertCloudflareDnsRecord(options) {
129
143
  });
130
144
  if (options.dryRun)
131
145
  return plan;
132
- if (!token)
133
- throw new Error("CLOUDFLARE_API_TOKEN is required unless --dry-run is used.");
134
146
  const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
135
147
  const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
136
148
  const payload = JSON.stringify(plan.dnsRecord);
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { ShortlinksDatabase, SQLITE_MIGRATIONS, makeId, now } from "./database.j
2
2
  export { ShortlinksStore } from "./store.js";
3
3
  export { createShortlinksHandler, serveShortlinks } from "./server.js";
4
4
  export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
5
+ export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
5
6
  export { PG_MIGRATIONS } from "./pg-migrations.js";
6
7
  export { formatShortUrl, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
7
8
  export { normalizeSlug, randomToken } from "./slug.js";
package/dist/index.js CHANGED
@@ -656,11 +656,25 @@ SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
656
656
  `);
657
657
  return { workerPath, wranglerPath };
658
658
  }
659
+ function cloudflareAuthHeaders(token) {
660
+ const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
661
+ if (apiToken)
662
+ return { authorization: `Bearer ${apiToken}` };
663
+ const apiKey = process.env.CLOUDFLARE_API_KEY;
664
+ const email = process.env.CLOUDFLARE_EMAIL;
665
+ if (apiKey && email) {
666
+ return {
667
+ "x-auth-key": apiKey,
668
+ "x-auth-email": email
669
+ };
670
+ }
671
+ throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
672
+ }
659
673
  async function cloudflareRequest(token, path, init = {}) {
660
674
  const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
661
675
  ...init,
662
676
  headers: {
663
- authorization: `Bearer ${token}`,
677
+ ...cloudflareAuthHeaders(token),
664
678
  "content-type": "application/json",
665
679
  ...init.headers || {}
666
680
  }
@@ -698,8 +712,6 @@ async function upsertCloudflareDnsRecord(options) {
698
712
  });
699
713
  if (options.dryRun)
700
714
  return plan;
701
- if (!token)
702
- throw new Error("CLOUDFLARE_API_TOKEN is required unless --dry-run is used.");
703
715
  const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
704
716
  const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
705
717
  const payload = JSON.stringify(plan.dnsRecord);
@@ -716,6 +728,53 @@ async function upsertCloudflareDnsRecord(options) {
716
728
  });
717
729
  return { id: created.id, action: "created" };
718
730
  }
731
+ // src/local.ts
732
+ import { spawnSync } from "child_process";
733
+ import { homedir as homedir2 } from "os";
734
+ import { join as join4 } from "path";
735
+ function createLocalSetupPlan(input) {
736
+ const domain = normalizeHostname(input.domain);
737
+ const targetHost = input.targetHost || "127.0.0.1";
738
+ const port = input.port || 8787;
739
+ const certDir = input.certDir || join4(homedir2(), ".hasna", "machines", "certs");
740
+ const certPath = join4(certDir, `${domain}.pem`);
741
+ const keyPath = join4(certDir, `${domain}-key.pem`);
742
+ return {
743
+ domain,
744
+ targetHost,
745
+ port,
746
+ hostsEntry: `${targetHost} ${domain}`,
747
+ caddySnippet: `${domain} {
748
+ reverse_proxy ${targetHost}:${port}
749
+ tls ${certPath} ${keyPath}
750
+ }`,
751
+ certPath,
752
+ keyPath,
753
+ machinesCommand: `machines dns add --domain ${domain} --target-host ${targetHost} --port ${port} --json`,
754
+ sudoRequired: true
755
+ };
756
+ }
757
+ function registerMachinesDns(input) {
758
+ const plan = createLocalSetupPlan(input);
759
+ const args = [
760
+ "dns",
761
+ "add",
762
+ "--domain",
763
+ plan.domain,
764
+ "--target-host",
765
+ plan.targetHost,
766
+ "--port",
767
+ String(plan.port),
768
+ "--json"
769
+ ];
770
+ const result = spawnSync("machines", args, { encoding: "utf-8" });
771
+ return {
772
+ command: `machines ${args.join(" ")}`,
773
+ status: result.status,
774
+ stdout: result.stdout || "",
775
+ stderr: result.stderr || ""
776
+ };
777
+ }
719
778
  // src/pg-migrations.ts
720
779
  var PG_MIGRATIONS = [
721
780
  `
@@ -793,6 +852,7 @@ export {
793
852
  upsertCloudflareDnsRecord,
794
853
  serveShortlinks,
795
854
  saveConfig,
855
+ registerMachinesDns,
796
856
  randomToken,
797
857
  now,
798
858
  normalizeSlug,
@@ -805,6 +865,7 @@ export {
805
865
  generateWorkerScript,
806
866
  formatShortUrl,
807
867
  createShortlinksHandler,
868
+ createLocalSetupPlan,
808
869
  createCloudflarePlan,
809
870
  ShortlinksStore,
810
871
  ShortlinksDatabase,
@@ -0,0 +1,27 @@
1
+ export interface LocalSetupPlan {
2
+ domain: string;
3
+ targetHost: string;
4
+ port: number;
5
+ hostsEntry: string;
6
+ caddySnippet: string;
7
+ certPath: string;
8
+ keyPath: string;
9
+ machinesCommand: string;
10
+ sudoRequired: boolean;
11
+ }
12
+ export declare function createLocalSetupPlan(input: {
13
+ domain: string;
14
+ port?: number;
15
+ targetHost?: string;
16
+ certDir?: string;
17
+ }): LocalSetupPlan;
18
+ export declare function registerMachinesDns(input: {
19
+ domain: string;
20
+ port?: number;
21
+ targetHost?: string;
22
+ }): {
23
+ command: string;
24
+ status: number | null;
25
+ stdout: string;
26
+ stderr: string;
27
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and @hasna cloud sync",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",