@hasna/shortlinks 0.1.21 → 0.1.23

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  CLI-only shortlink management for custom domains.
4
4
 
5
- `shortlinks` creates Bitly-style short URLs, supports multiple domains, records click analytics, can run a tiny redirect server, and includes helper commands for Cloudflare DNS/Workers, `@hasna/domains`, and `@hasna/cloud` sync. Production serving can run directly against the shared RDS database with `--cloud`; local SQLite is only for explicit local/offline use.
5
+ `shortlinks` creates Bitly-style short URLs, supports multiple domains, records click analytics, can run a tiny redirect server, and includes helper commands for Cloudflare DNS/Workers and `@hasna/domains`. It defaults to local SQLite and can serve from an app-owned PostgreSQL database when `HASNA_SHORTLINKS_STORE=postgres` and `HASNA_SHORTLINKS_DATABASE_URL` are configured.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@hasna/shortlinks)](https://www.npmjs.com/package/@hasna/shortlinks)
8
8
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
@@ -64,7 +64,6 @@ shortlinks link enable home --domain has.na
64
64
  shortlinks stats home --domain has.na
65
65
 
66
66
  shortlinks serve --port 8787
67
- shortlinks serve --cloud --port 8787
68
67
  shortlinks doctor
69
68
  ```
70
69
 
@@ -125,36 +124,32 @@ shortlinks domain buy new-short-domain.ai --dry-run
125
124
 
126
125
  This package does not install or call any removed `connect-*` packages.
127
126
 
128
- ## Cloud Sync
127
+ ## PostgreSQL Runtime
129
128
 
130
- `shortlinks` is compatible with `@hasna/cloud` conventions:
129
+ Production serving can use a shortlinks-owned PostgreSQL database without any shared table-sync package:
131
130
 
132
131
  ```bash
133
- cloud setup
134
- shortlinks cloud migrate
135
- shortlinks cloud push
136
- shortlinks cloud pull
137
- shortlinks cloud sync
138
- ```
139
-
140
- The cloud database service name is `shortlinks`.
141
- Use direct RDS mode for production and live management:
132
+ export HASNA_SHORTLINKS_STORE=postgres
133
+ export HASNA_SHORTLINKS_DATABASE_URL=postgres://shortlinks:password@db.example.com:5432/shortlinks
134
+ export HASNA_SHORTLINKS_DATABASE_SSL=true
142
135
 
143
- ```bash
144
- shortlinks --cloud create https://example.com
145
- shortlinks --cloud link list
146
- shortlinks serve --cloud --host 127.0.0.1 --port 8787
136
+ shortlinks postgres status
137
+ shortlinks postgres plan --schema-sql
138
+ shortlinks postgres migrate
139
+ shortlinks --store postgres serve --host 127.0.0.1 --port 8787 --default-host has.na
147
140
  ```
148
141
 
142
+ The canonical production runtime secret path is `hasna/xyz/opensource/shortlinks/prod/postgres`. Use the URL environment variables above rather than writing shared runtime config files into the shortlinks data directory.
143
+
149
144
  ## AWS Origin
150
145
 
151
146
  For an apex domain that needs stable A records, `infra/aws-ec2-user-data.sh` bootstraps a small EC2 redirect origin with:
152
147
 
153
148
  - `@hasna/shortlinks` installed through Bun
154
- - direct reads and click writes against the `shortlinks` RDS database through `@hasna/cloud`
149
+ - direct reads and click writes against the app-owned `shortlinks` PostgreSQL database
155
150
  - Caddy terminating HTTPS and proxying to `shortlinks serve`
156
151
 
157
- The script reads the RDS password from AWS Secrets Manager through the instance role; it does not contain secret values.
152
+ The script reads the connection settings from AWS Secrets Manager through the instance role; it does not contain secret values.
158
153
 
159
154
  ## Development
160
155
 
package/dist/cli/index.js CHANGED
@@ -3271,7 +3271,8 @@ import { mkdirSync as mkdirSync2 } from "fs";
3271
3271
  import { dirname as dirname2 } from "path";
3272
3272
 
3273
3273
  // src/config.ts
3274
- import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
3274
+ import { existsSync as existsSync2, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3275
+ import { randomBytes } from "crypto";
3275
3276
  import { homedir as homedir2 } from "os";
3276
3277
  import { dirname, join as join2, resolve } from "path";
3277
3278
  var SERVICE_NAME = "shortlinks";
@@ -3287,6 +3288,9 @@ function ensureDataDir() {
3287
3288
  function getConfigPath() {
3288
3289
  return join2(ensureDataDir(), "config.json");
3289
3290
  }
3291
+ function getClickSaltPath() {
3292
+ return join2(ensureDataDir(), "click-salt");
3293
+ }
3290
3294
  function getDatabasePath(explicitPath) {
3291
3295
  if (explicitPath)
3292
3296
  return resolve(explicitPath);
@@ -3294,6 +3298,51 @@ function getDatabasePath(explicitPath) {
3294
3298
  return resolve(process.env.SHORTLINKS_DB);
3295
3299
  return join2(ensureDataDir(), `${SERVICE_NAME}.db`);
3296
3300
  }
3301
+ function readClickSaltFile(path) {
3302
+ try {
3303
+ const saved = readFileSync(path, "utf-8").trim();
3304
+ return saved || null;
3305
+ } catch {
3306
+ return null;
3307
+ }
3308
+ }
3309
+ function clickSaltError(path, error) {
3310
+ const detail = error instanceof Error ? error.message : String(error);
3311
+ return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
3312
+ }
3313
+ function getClickSalt() {
3314
+ const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
3315
+ if (explicit)
3316
+ return explicit;
3317
+ const path = getClickSaltPath();
3318
+ const saved = readClickSaltFile(path);
3319
+ if (saved)
3320
+ return saved;
3321
+ const generated = randomBytes(32).toString("hex");
3322
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
3323
+ try {
3324
+ writeFileSync(tempPath, `${generated}
3325
+ `, { flag: "wx", mode: 384 });
3326
+ try {
3327
+ linkSync(tempPath, path);
3328
+ return generated;
3329
+ } catch (error) {
3330
+ const winner = readClickSaltFile(path);
3331
+ if (winner)
3332
+ return winner;
3333
+ throw clickSaltError(path, error);
3334
+ } finally {
3335
+ try {
3336
+ unlinkSync(tempPath);
3337
+ } catch {}
3338
+ }
3339
+ } catch (error) {
3340
+ const winner = readClickSaltFile(path);
3341
+ if (winner)
3342
+ return winner;
3343
+ throw clickSaltError(path, error);
3344
+ }
3345
+ }
3297
3346
  function loadConfig() {
3298
3347
  const path = getConfigPath();
3299
3348
  if (!existsSync2(path))
@@ -3465,13 +3514,13 @@ import { hostname } from "os";
3465
3514
  import { join as join3 } from "path";
3466
3515
 
3467
3516
  // src/slug.ts
3468
- import { randomBytes } from "crypto";
3517
+ import { randomBytes as randomBytes2 } from "crypto";
3469
3518
  var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
3470
3519
  var DEFAULT_SLUG_LENGTH = 7;
3471
3520
  function randomToken(length = DEFAULT_SLUG_LENGTH) {
3472
3521
  if (length < 1 || length > 128)
3473
3522
  throw new Error("Token length must be between 1 and 128.");
3474
- const bytes = randomBytes(length);
3523
+ const bytes = randomBytes2(length);
3475
3524
  let out = "";
3476
3525
  for (let i = 0;i < length; i += 1) {
3477
3526
  out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
@@ -3791,13 +3840,178 @@ class ShortlinksStore {
3791
3840
  throw new Error("Could not generate an unused slug after 32 attempts.");
3792
3841
  }
3793
3842
  hashIp(ip) {
3794
- const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-local";
3795
- return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
3843
+ return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
3796
3844
  }
3797
3845
  }
3798
3846
 
3799
3847
  // src/pg-store.ts
3800
3848
  import { createHash as createHash2 } from "crypto";
3849
+
3850
+ // src/runtime.ts
3851
+ var SHORTLINKS_RUNTIME_ENV = {
3852
+ store: "HASNA_SHORTLINKS_STORE",
3853
+ databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
3854
+ databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
3855
+ };
3856
+ var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
3857
+ store: "SHORTLINKS_STORE",
3858
+ databaseUrl: "SHORTLINKS_DATABASE_URL",
3859
+ databaseSsl: "SHORTLINKS_DATABASE_SSL"
3860
+ };
3861
+ var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
3862
+ var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
3863
+ var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
3864
+ function getCanonicalShortlinksPostgresConfig() {
3865
+ return {
3866
+ cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
3867
+ database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
3868
+ runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
3869
+ primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
3870
+ fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
3871
+ };
3872
+ }
3873
+ function parseShortlinksStoreMode(value) {
3874
+ const normalized = clean(value)?.toLowerCase();
3875
+ if (!normalized)
3876
+ return "local";
3877
+ if (normalized === "local" || normalized === "postgres")
3878
+ return normalized;
3879
+ if (normalized === "pg")
3880
+ return "postgres";
3881
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
3882
+ }
3883
+ function getShortlinksStoreMode(env2 = process.env) {
3884
+ return parseShortlinksStoreMode(readRuntimeEnv(env2, "store").value);
3885
+ }
3886
+ function getShortlinksDatabaseUrl(env2 = process.env) {
3887
+ return readRuntimeEnv(env2, "databaseUrl").value;
3888
+ }
3889
+ function getShortlinksDatabaseSsl(env2 = process.env) {
3890
+ return parseBoolean(readRuntimeEnv(env2, "databaseSsl").value, true);
3891
+ }
3892
+ function getShortlinksRuntimeEnvName(env2, key) {
3893
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
3894
+ if (clean(env2[primary]))
3895
+ return primary;
3896
+ return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
3897
+ }
3898
+ function loadShortlinksRuntimeConfig(env2 = process.env) {
3899
+ const mode = getShortlinksStoreMode(env2);
3900
+ const databaseUrl = getShortlinksDatabaseUrl(env2);
3901
+ return {
3902
+ service: "shortlinks",
3903
+ mode,
3904
+ ...databaseUrl ? {
3905
+ database: {
3906
+ provider: "postgres",
3907
+ url: databaseUrl,
3908
+ ssl: getShortlinksDatabaseSsl(env2)
3909
+ }
3910
+ } : {}
3911
+ };
3912
+ }
3913
+ function assertShortlinksPostgresConfig(config) {
3914
+ if (config.mode !== "postgres")
3915
+ return;
3916
+ if (!config.database?.url) {
3917
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
3918
+ }
3919
+ }
3920
+ function getShortlinksRuntimeStatus(env2 = process.env) {
3921
+ const issues = [];
3922
+ const warnings = [];
3923
+ let config;
3924
+ try {
3925
+ config = loadShortlinksRuntimeConfig(env2);
3926
+ } catch (error) {
3927
+ issues.push(error instanceof Error ? error.message : String(error));
3928
+ config = { service: "shortlinks", mode: "local" };
3929
+ }
3930
+ try {
3931
+ assertShortlinksPostgresConfig(config);
3932
+ } catch (error) {
3933
+ issues.push(error instanceof Error ? error.message : String(error));
3934
+ }
3935
+ if (config.mode === "local" && config.database?.url) {
3936
+ warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
3937
+ }
3938
+ return {
3939
+ ok: issues.length === 0,
3940
+ service: "shortlinks",
3941
+ mode: config.mode,
3942
+ local_default: config.mode === "local",
3943
+ postgres_enabled: config.mode === "postgres",
3944
+ database: {
3945
+ configured: Boolean(config.database?.url),
3946
+ provider: config.database?.provider ?? null,
3947
+ redacted_url: redactDatabaseUrl(config.database?.url),
3948
+ ssl: config.database?.ssl ?? null
3949
+ },
3950
+ env: runtimeEnvStatus(env2),
3951
+ canonical: getCanonicalShortlinksPostgresConfig(),
3952
+ issues,
3953
+ warnings,
3954
+ no_network: true
3955
+ };
3956
+ }
3957
+ function redactDatabaseUrl(value) {
3958
+ if (!value)
3959
+ return null;
3960
+ try {
3961
+ const url = new URL(value);
3962
+ if (url.username)
3963
+ url.username = "***";
3964
+ if (url.password)
3965
+ url.password = "***";
3966
+ for (const key of Array.from(url.searchParams.keys())) {
3967
+ if (isSensitiveQueryKey(key))
3968
+ url.searchParams.set(key, "***");
3969
+ }
3970
+ return url.toString();
3971
+ } catch {
3972
+ return "(redacted)";
3973
+ }
3974
+ }
3975
+ function runtimeEnvStatus(env2) {
3976
+ return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
3977
+ const activeName = getShortlinksRuntimeEnvName(env2, key);
3978
+ return [
3979
+ key,
3980
+ {
3981
+ name,
3982
+ active_name: activeName,
3983
+ configured: Boolean(clean(env2[activeName]))
3984
+ }
3985
+ ];
3986
+ }));
3987
+ }
3988
+ function readRuntimeEnv(env2, key) {
3989
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
3990
+ const primaryValue = clean(env2[primary]);
3991
+ if (primaryValue)
3992
+ return { name: primary, value: primaryValue };
3993
+ const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
3994
+ return { name: fallback, value: clean(env2[fallback]) };
3995
+ }
3996
+ function parseBoolean(value, fallback) {
3997
+ const normalized = clean(value)?.toLowerCase();
3998
+ if (!normalized)
3999
+ return fallback;
4000
+ if (["1", "true", "yes", "on"].includes(normalized))
4001
+ return true;
4002
+ if (["0", "false", "no", "off"].includes(normalized))
4003
+ return false;
4004
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
4005
+ }
4006
+ function isSensitiveQueryKey(key) {
4007
+ return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
4008
+ }
4009
+ function clean(value) {
4010
+ const trimmed = value?.trim();
4011
+ return trimmed ? trimmed : undefined;
4012
+ }
4013
+
4014
+ // src/pg-store.ts
3801
4015
  function parseJsonObject3(value) {
3802
4016
  if (!value)
3803
4017
  return {};
@@ -3810,6 +4024,47 @@ function parseJsonObject3(value) {
3810
4024
  return {};
3811
4025
  }
3812
4026
  }
4027
+ async function loadPgPool() {
4028
+ const importer = new Function("specifier", "return import(specifier)");
4029
+ const module = await importer("pg");
4030
+ return module.Pool;
4031
+ }
4032
+ function toPostgresSql(sql) {
4033
+ let index = 0;
4034
+ return sql.replace(/\?/g, () => `$${++index}`);
4035
+ }
4036
+ function createPgPoolConfig(connectionString, options = {}) {
4037
+ const ssl = options.ssl ?? true;
4038
+ return {
4039
+ connectionString,
4040
+ ...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
4041
+ };
4042
+ }
4043
+
4044
+ class PgPoolAdapter {
4045
+ pool;
4046
+ constructor(pool) {
4047
+ this.pool = pool;
4048
+ }
4049
+ static async create(connectionString, options = {}) {
4050
+ const Pool = await loadPgPool();
4051
+ return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
4052
+ }
4053
+ async get(sql, ...params) {
4054
+ const result = await this.pool.query(toPostgresSql(sql), params);
4055
+ return result.rows[0] ?? null;
4056
+ }
4057
+ async all(sql, ...params) {
4058
+ const result = await this.pool.query(toPostgresSql(sql), params);
4059
+ return result.rows;
4060
+ }
4061
+ async run(sql, ...params) {
4062
+ return this.pool.query(toPostgresSql(sql), params);
4063
+ }
4064
+ async close() {
4065
+ await this.pool.end();
4066
+ }
4067
+ }
3813
4068
  function toIsoString(value) {
3814
4069
  if (value instanceof Date)
3815
4070
  return value.toISOString();
@@ -3878,13 +4133,15 @@ class PgShortlinksStore {
3878
4133
  constructor(pg) {
3879
4134
  this.pg = pg;
3880
4135
  }
3881
- static async fromConnectionString(connectionString) {
3882
- const { PgAdapterAsync } = await import("@hasna/cloud");
3883
- return new PgShortlinksStore(new PgAdapterAsync(connectionString));
4136
+ static async fromConnectionString(connectionString, options = {}) {
4137
+ return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
3884
4138
  }
3885
- static async fromCloud(service = "shortlinks") {
3886
- const { getConnectionString } = await import("@hasna/cloud");
3887
- return PgShortlinksStore.fromConnectionString(getConnectionString(service));
4139
+ static async fromEnv(env2 = process.env) {
4140
+ const connectionString = getShortlinksDatabaseUrl(env2);
4141
+ if (!connectionString) {
4142
+ throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
4143
+ }
4144
+ return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env2) });
3888
4145
  }
3889
4146
  async close() {
3890
4147
  await this.pg.close?.();
@@ -4100,8 +4357,7 @@ class PgShortlinksStore {
4100
4357
  };
4101
4358
  }
4102
4359
  hashIp(ip) {
4103
- const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-cloud";
4104
- return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
4360
+ return createHash2("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
4105
4361
  }
4106
4362
  async generateAvailableSlug(domainId, length) {
4107
4363
  for (let attempt = 0;attempt < 32; attempt += 1) {
@@ -4115,6 +4371,52 @@ class PgShortlinksStore {
4115
4371
  throw new Error("Could not generate an unused slug after 32 attempts.");
4116
4372
  }
4117
4373
  }
4374
+ async function applyPostgresMigrations(connectionString, migrations, options = {}) {
4375
+ const Pool = await loadPgPool();
4376
+ const pool = new Pool(createPgPoolConfig(connectionString, options));
4377
+ const client = await pool.connect();
4378
+ const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
4379
+ const get = async (sql, ...params) => {
4380
+ const result = await run(sql, ...params);
4381
+ return result.rows[0] ?? null;
4382
+ };
4383
+ const applied = [];
4384
+ const skipped = [];
4385
+ try {
4386
+ await run("BEGIN");
4387
+ await run(`
4388
+ SELECT pg_advisory_xact_lock(hashtext(?))
4389
+ `, "shortlinks:migrations");
4390
+ await run(`
4391
+ CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
4392
+ id INTEGER PRIMARY KEY,
4393
+ service TEXT NOT NULL DEFAULT 'shortlinks',
4394
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
4395
+ )
4396
+ `);
4397
+ for (let i = 0;i < migrations.length; i += 1) {
4398
+ const id = i + 1;
4399
+ const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
4400
+ if (existing) {
4401
+ skipped.push(id);
4402
+ continue;
4403
+ }
4404
+ await run(migrations[i]);
4405
+ await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
4406
+ applied.push(id);
4407
+ }
4408
+ await run("COMMIT");
4409
+ return { service: "shortlinks", applied, skipped };
4410
+ } catch (error) {
4411
+ try {
4412
+ await run("ROLLBACK");
4413
+ } catch {}
4414
+ throw error;
4415
+ } finally {
4416
+ client.release();
4417
+ await pool.end();
4418
+ }
4419
+ }
4118
4420
 
4119
4421
  // src/server.ts
4120
4422
  var REDIRECT_ALLOW_HEADER = "GET, HEAD";
@@ -4531,14 +4833,20 @@ function withStore(fn) {
4531
4833
  }
4532
4834
  function storeMode() {
4533
4835
  const opts = program2.opts();
4534
- const value = String(opts.cloud ? "cloud" : opts.store || process.env.SHORTLINKS_STORE || "local").toLowerCase();
4535
- if (value !== "local" && value !== "cloud")
4536
- throw new Error(`Unknown store mode: ${value}`);
4537
- return value;
4836
+ return parseShortlinksStoreMode(opts.store || process.env.HASNA_SHORTLINKS_STORE || process.env.SHORTLINKS_STORE || "local");
4837
+ }
4838
+ function runtimeEnv() {
4839
+ const opts = program2.opts();
4840
+ return opts.store ? { ...process.env, HASNA_SHORTLINKS_STORE: opts.store } : process.env;
4841
+ }
4842
+ function localStatsIfDatabaseExists(dbPath) {
4843
+ if (!existsSync4(dbPath))
4844
+ return null;
4845
+ return withStore((store) => store.totalStats());
4538
4846
  }
4539
4847
  async function withRuntimeStore(fn) {
4540
- if (storeMode() === "cloud") {
4541
- const store2 = await PgShortlinksStore.fromCloud("shortlinks");
4848
+ if (storeMode() === "postgres") {
4849
+ const store2 = await PgShortlinksStore.fromEnv();
4542
4850
  try {
4543
4851
  return await fn(store2);
4544
4852
  } finally {
@@ -4559,7 +4867,7 @@ function commandExists(command) {
4559
4867
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
4560
4868
  return result.status === 0;
4561
4869
  }
4562
- 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("--store <mode>", "Data store mode: cloud or local", process.env.SHORTLINKS_STORE || "local").option("--cloud", "Use the shortlinks PostgreSQL database directly").option("-j, --json", "Output JSON for agents and scripts");
4870
+ program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and app-owned Postgres runtime support").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: local or postgres", process.env.HASNA_SHORTLINKS_STORE || process.env.SHORTLINKS_STORE || "local").option("-j, --json", "Output JSON for agents and scripts");
4563
4871
  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) => {
4564
4872
  try {
4565
4873
  const result = await withRuntimeStore(async (store) => {
@@ -4819,9 +5127,9 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
4819
5127
  handleError(error);
4820
5128
  }
4821
5129
  });
4822
- 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("--cloud", "Serve directly from the shortlinks PostgreSQL database").action(async (opts) => {
5130
+ 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").action(async (opts) => {
4823
5131
  try {
4824
- const store = opts.cloud || storeMode() === "cloud" ? await PgShortlinksStore.fromCloud("shortlinks") : undefined;
5132
+ const store = storeMode() === "postgres" ? await PgShortlinksStore.fromEnv() : undefined;
4825
5133
  const server = serveShortlinks({
4826
5134
  store,
4827
5135
  dbPath: program2.opts().db,
@@ -4829,7 +5137,7 @@ program2.command("serve").description("Run the redirect server that records clic
4829
5137
  port: Number(opts.port),
4830
5138
  defaultHost: opts.defaultHost
4831
5139
  });
4832
- const mode = store ? "cloud" : "local";
5140
+ const mode = store ? "postgres" : "local";
4833
5141
  console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
4834
5142
  } catch (error) {
4835
5143
  handleError(error);
@@ -4875,65 +5183,68 @@ cfCmd.command("dns <hostname>").description("Create or update the Cloudflare CNA
4875
5183
  handleError(error);
4876
5184
  }
4877
5185
  });
4878
- var cloudCmd = program2.command("cloud").description("@hasna/cloud sync helpers");
4879
- cloudCmd.command("migrate").description("Apply shortlinks PostgreSQL migrations").option("--connection-string <url>", "PostgreSQL connection string").option("-j, --json", "Output JSON").action(async (opts) => {
5186
+ var postgresCmd = program2.command("postgres").description("Shortlinks-owned PostgreSQL runtime helpers");
5187
+ postgresCmd.command("migrate").description("Apply shortlinks PostgreSQL migrations").option("--connection-string <url>", "PostgreSQL connection string").option("--no-ssl", "Disable PostgreSQL TLS only for local development").option("--dry-run", "Show migration settings without opening a network connection").option("-j, --json", "Output JSON").action(async (opts) => {
4880
5188
  try {
4881
- const { getConnectionString, applyPgMigrations } = await import("@hasna/cloud");
4882
- const conn = opts.connectionString || getConnectionString("shortlinks");
4883
- const result = await applyPgMigrations(conn, PG_MIGRATIONS, "shortlinks");
5189
+ const conn = opts.connectionString || getShortlinksDatabaseUrl();
5190
+ if (!conn)
5191
+ throw new Error("HASNA_SHORTLINKS_DATABASE_URL or --connection-string is required.");
5192
+ const ssl = opts.ssl === false ? false : getShortlinksDatabaseSsl();
5193
+ if (opts.dryRun) {
5194
+ const result2 = {
5195
+ service: "shortlinks",
5196
+ dry_run: true,
5197
+ no_network: true,
5198
+ database: {
5199
+ configured: true,
5200
+ redacted_url: redactDatabaseUrl(conn),
5201
+ ssl
5202
+ },
5203
+ migrations: PG_MIGRATIONS.length
5204
+ };
5205
+ print2(result2, opts, () => console.log(JSON.stringify(result2, null, 2)));
5206
+ return;
5207
+ }
5208
+ const result = await applyPostgresMigrations(conn, PG_MIGRATIONS, { ssl });
4884
5209
  print2(result, opts, () => console.log(JSON.stringify(result, null, 2)));
4885
5210
  } catch (error) {
4886
5211
  handleError(error);
4887
5212
  }
4888
5213
  });
4889
- async function syncCloud(direction, opts) {
4890
- const {
4891
- getCloudConfig,
4892
- getConnectionString,
4893
- SqliteAdapter,
4894
- PgAdapterAsync,
4895
- listSqliteTables,
4896
- listPgTables,
4897
- syncPush,
4898
- syncPull
4899
- } = await import("@hasna/cloud");
4900
- const config = getCloudConfig();
4901
- if (config.mode === "local")
4902
- throw new Error("Cloud mode is local. Run `cloud setup` first.");
4903
- const local = new SqliteAdapter(getDatabasePath(program2.opts().db));
4904
- const remote = new PgAdapterAsync(getConnectionString("shortlinks"));
5214
+ postgresCmd.command("status").description("Show local and PostgreSQL runtime configuration health without opening network connections").option("-j, --json", "Output JSON").action((opts) => {
4905
5215
  try {
4906
- const requestedTables = opts.tables ? opts.tables.split(",").map((t) => t.trim()).filter(Boolean) : null;
4907
- const localTables = requestedTables || listSqliteTables(local).filter((t) => !t.startsWith("_"));
4908
- const remoteTables = requestedTables || await listPgTables(remote).catch(() => localTables);
4909
- const tables = [...new Set(direction === "pull" ? remoteTables : direction === "push" ? localTables : [...localTables, ...remoteTables])];
4910
- const results = [];
4911
- if (direction === "pull" || direction === "sync") {
4912
- results.push({ direction: "pull", tables: await syncPull(remote, local, { tables }) });
4913
- }
4914
- if (direction === "push" || direction === "sync") {
4915
- results.push({ direction: "push", tables: await syncPush(local, remote, { tables }) });
4916
- }
4917
- print2({ service: "shortlinks", results }, opts, () => console.log(JSON.stringify({ service: "shortlinks", results }, null, 2)));
4918
- } finally {
4919
- local.close?.();
4920
- await remote.close?.();
5216
+ const dbPath = getDatabasePath(program2.opts().db);
5217
+ const data = {
5218
+ ...getShortlinksRuntimeStatus(runtimeEnv()),
5219
+ service: "shortlinks",
5220
+ db_path: dbPath,
5221
+ db_exists: existsSync4(dbPath)
5222
+ };
5223
+ print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
5224
+ } catch (error) {
5225
+ handleError(error);
4921
5226
  }
4922
- }
4923
- for (const direction of ["push", "pull", "sync"]) {
4924
- cloudCmd.command(direction).description(`${direction === "sync" ? "Bidirectionally sync" : direction === "push" ? "Push" : "Pull"} shortlinks data ${direction === "pull" ? "from" : "to"} PostgreSQL`).option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Output JSON").action((opts) => syncCloud(direction, opts).catch(handleError));
4925
- }
4926
- cloudCmd.command("status").description("Show local and cloud configuration health").option("-j, --json", "Output JSON").action(async (opts) => {
5227
+ });
5228
+ postgresCmd.command("plan").description("Render a dry-run PostgreSQL setup plan").option("--schema-sql", "Include migration SQL").option("-j, --json", "Output JSON").action((opts) => {
4927
5229
  try {
4928
- const { getCloudConfig } = await import("@hasna/cloud");
4929
- const stats = withStore((store) => store.totalStats());
4930
- const config = getCloudConfig();
5230
+ const status = getShortlinksRuntimeStatus(runtimeEnv());
4931
5231
  const data = {
5232
+ ok: status.ok,
4932
5233
  service: "shortlinks",
4933
- db_path: getDatabasePath(program2.opts().db),
4934
- local: stats,
4935
- cloud_mode: config.mode,
4936
- rds_host: config.rds?.host || null
5234
+ dry_run: true,
5235
+ no_network: true,
5236
+ status,
5237
+ postgres: {
5238
+ required: status.mode === "postgres",
5239
+ configured: status.database.configured,
5240
+ schema_sql: opts.schemaSql ? PG_MIGRATIONS : []
5241
+ },
5242
+ steps: [
5243
+ "Read local SQLite state",
5244
+ status.mode === "postgres" ? "Prepare direct shortlinks Postgres runtime" : "Keep serving from local SQLite",
5245
+ "Run migrations with shortlinks postgres migrate before serving from Postgres",
5246
+ "Report planned changes without opening network connections"
5247
+ ]
4937
5248
  };
4938
5249
  print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
4939
5250
  } catch (error) {
@@ -4978,23 +5289,26 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
4978
5289
  });
4979
5290
  program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action(async (opts) => {
4980
5291
  try {
4981
- const mode = storeMode();
4982
- const stats = await withRuntimeStore((store) => store.totalStats());
5292
+ const runtime = getShortlinksRuntimeStatus(runtimeEnv());
5293
+ const dbPath = getDatabasePath(program2.opts().db);
4983
5294
  const data = {
4984
5295
  service: "shortlinks",
4985
- store: mode,
5296
+ ok: runtime.ok,
5297
+ store: runtime.mode,
4986
5298
  data_dir: getDataDir(),
4987
5299
  config_path: getConfigPath(),
4988
- db_path: getDatabasePath(program2.opts().db),
4989
- db_exists: existsSync4(getDatabasePath(program2.opts().db)),
4990
- stats,
5300
+ db_path: dbPath,
5301
+ db_exists: existsSync4(dbPath),
5302
+ stats: localStatsIfDatabaseExists(dbPath),
5303
+ runtime,
5304
+ no_network: true,
4991
5305
  commands: {
4992
5306
  domains: commandExists("domains"),
4993
- cloud: commandExists("cloud"),
4994
5307
  wrangler: commandExists("wrangler"),
4995
5308
  secrets: commandExists("secrets")
4996
5309
  },
4997
5310
  environment: {
5311
+ shortlinks_database_url_present: Boolean(getShortlinksDatabaseUrl(runtimeEnv())),
4998
5312
  cloudflare_api_token_present: Boolean(process.env.CLOUDFLARE_API_TOKEN),
4999
5313
  cloudflare_api_key_present: Boolean(process.env.CLOUDFLARE_API_KEY),
5000
5314
  cloudflare_email_present: Boolean(process.env.CLOUDFLARE_EMAIL),