@hasna/shortlinks 0.1.22 → 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
@@ -3846,6 +3846,172 @@ class ShortlinksStore {
3846
3846
 
3847
3847
  // src/pg-store.ts
3848
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
3849
4015
  function parseJsonObject3(value) {
3850
4016
  if (!value)
3851
4017
  return {};
@@ -3858,6 +4024,47 @@ function parseJsonObject3(value) {
3858
4024
  return {};
3859
4025
  }
3860
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
+ }
3861
4068
  function toIsoString(value) {
3862
4069
  if (value instanceof Date)
3863
4070
  return value.toISOString();
@@ -3926,13 +4133,15 @@ class PgShortlinksStore {
3926
4133
  constructor(pg) {
3927
4134
  this.pg = pg;
3928
4135
  }
3929
- static async fromConnectionString(connectionString) {
3930
- const { PgAdapterAsync } = await import("@hasna/cloud");
3931
- return new PgShortlinksStore(new PgAdapterAsync(connectionString));
4136
+ static async fromConnectionString(connectionString, options = {}) {
4137
+ return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
3932
4138
  }
3933
- static async fromCloud(service = "shortlinks") {
3934
- const { getConnectionString } = await import("@hasna/cloud");
3935
- 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) });
3936
4145
  }
3937
4146
  async close() {
3938
4147
  await this.pg.close?.();
@@ -4162,6 +4371,52 @@ class PgShortlinksStore {
4162
4371
  throw new Error("Could not generate an unused slug after 32 attempts.");
4163
4372
  }
4164
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
+ }
4165
4420
 
4166
4421
  // src/server.ts
4167
4422
  var REDIRECT_ALLOW_HEADER = "GET, HEAD";
@@ -4578,14 +4833,20 @@ function withStore(fn) {
4578
4833
  }
4579
4834
  function storeMode() {
4580
4835
  const opts = program2.opts();
4581
- const value = String(opts.cloud ? "cloud" : opts.store || process.env.SHORTLINKS_STORE || "local").toLowerCase();
4582
- if (value !== "local" && value !== "cloud")
4583
- throw new Error(`Unknown store mode: ${value}`);
4584
- 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());
4585
4846
  }
4586
4847
  async function withRuntimeStore(fn) {
4587
- if (storeMode() === "cloud") {
4588
- const store2 = await PgShortlinksStore.fromCloud("shortlinks");
4848
+ if (storeMode() === "postgres") {
4849
+ const store2 = await PgShortlinksStore.fromEnv();
4589
4850
  try {
4590
4851
  return await fn(store2);
4591
4852
  } finally {
@@ -4606,7 +4867,7 @@ function commandExists(command) {
4606
4867
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
4607
4868
  return result.status === 0;
4608
4869
  }
4609
- 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");
4610
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) => {
4611
4872
  try {
4612
4873
  const result = await withRuntimeStore(async (store) => {
@@ -4866,9 +5127,9 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
4866
5127
  handleError(error);
4867
5128
  }
4868
5129
  });
4869
- 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) => {
4870
5131
  try {
4871
- const store = opts.cloud || storeMode() === "cloud" ? await PgShortlinksStore.fromCloud("shortlinks") : undefined;
5132
+ const store = storeMode() === "postgres" ? await PgShortlinksStore.fromEnv() : undefined;
4872
5133
  const server = serveShortlinks({
4873
5134
  store,
4874
5135
  dbPath: program2.opts().db,
@@ -4876,7 +5137,7 @@ program2.command("serve").description("Run the redirect server that records clic
4876
5137
  port: Number(opts.port),
4877
5138
  defaultHost: opts.defaultHost
4878
5139
  });
4879
- const mode = store ? "cloud" : "local";
5140
+ const mode = store ? "postgres" : "local";
4880
5141
  console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
4881
5142
  } catch (error) {
4882
5143
  handleError(error);
@@ -4922,65 +5183,68 @@ cfCmd.command("dns <hostname>").description("Create or update the Cloudflare CNA
4922
5183
  handleError(error);
4923
5184
  }
4924
5185
  });
4925
- var cloudCmd = program2.command("cloud").description("@hasna/cloud sync helpers");
4926
- 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) => {
4927
5188
  try {
4928
- const { getConnectionString, applyPgMigrations } = await import("@hasna/cloud");
4929
- const conn = opts.connectionString || getConnectionString("shortlinks");
4930
- 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 });
4931
5209
  print2(result, opts, () => console.log(JSON.stringify(result, null, 2)));
4932
5210
  } catch (error) {
4933
5211
  handleError(error);
4934
5212
  }
4935
5213
  });
4936
- async function syncCloud(direction, opts) {
4937
- const {
4938
- getCloudConfig,
4939
- getConnectionString,
4940
- SqliteAdapter,
4941
- PgAdapterAsync,
4942
- listSqliteTables,
4943
- listPgTables,
4944
- syncPush,
4945
- syncPull
4946
- } = await import("@hasna/cloud");
4947
- const config = getCloudConfig();
4948
- if (config.mode === "local")
4949
- throw new Error("Cloud mode is local. Run `cloud setup` first.");
4950
- const local = new SqliteAdapter(getDatabasePath(program2.opts().db));
4951
- 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) => {
4952
5215
  try {
4953
- const requestedTables = opts.tables ? opts.tables.split(",").map((t) => t.trim()).filter(Boolean) : null;
4954
- const localTables = requestedTables || listSqliteTables(local).filter((t) => !t.startsWith("_"));
4955
- const remoteTables = requestedTables || await listPgTables(remote).catch(() => localTables);
4956
- const tables = [...new Set(direction === "pull" ? remoteTables : direction === "push" ? localTables : [...localTables, ...remoteTables])];
4957
- const results = [];
4958
- if (direction === "pull" || direction === "sync") {
4959
- results.push({ direction: "pull", tables: await syncPull(remote, local, { tables }) });
4960
- }
4961
- if (direction === "push" || direction === "sync") {
4962
- results.push({ direction: "push", tables: await syncPush(local, remote, { tables }) });
4963
- }
4964
- print2({ service: "shortlinks", results }, opts, () => console.log(JSON.stringify({ service: "shortlinks", results }, null, 2)));
4965
- } finally {
4966
- local.close?.();
4967
- 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);
4968
5226
  }
4969
- }
4970
- for (const direction of ["push", "pull", "sync"]) {
4971
- 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));
4972
- }
4973
- 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) => {
4974
5229
  try {
4975
- const { getCloudConfig } = await import("@hasna/cloud");
4976
- const stats = withStore((store) => store.totalStats());
4977
- const config = getCloudConfig();
5230
+ const status = getShortlinksRuntimeStatus(runtimeEnv());
4978
5231
  const data = {
5232
+ ok: status.ok,
4979
5233
  service: "shortlinks",
4980
- db_path: getDatabasePath(program2.opts().db),
4981
- local: stats,
4982
- cloud_mode: config.mode,
4983
- 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
+ ]
4984
5248
  };
4985
5249
  print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
4986
5250
  } catch (error) {
@@ -5025,23 +5289,26 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
5025
5289
  });
5026
5290
  program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action(async (opts) => {
5027
5291
  try {
5028
- const mode = storeMode();
5029
- const stats = await withRuntimeStore((store) => store.totalStats());
5292
+ const runtime = getShortlinksRuntimeStatus(runtimeEnv());
5293
+ const dbPath = getDatabasePath(program2.opts().db);
5030
5294
  const data = {
5031
5295
  service: "shortlinks",
5032
- store: mode,
5296
+ ok: runtime.ok,
5297
+ store: runtime.mode,
5033
5298
  data_dir: getDataDir(),
5034
5299
  config_path: getConfigPath(),
5035
- db_path: getDatabasePath(program2.opts().db),
5036
- db_exists: existsSync4(getDatabasePath(program2.opts().db)),
5037
- stats,
5300
+ db_path: dbPath,
5301
+ db_exists: existsSync4(dbPath),
5302
+ stats: localStatsIfDatabaseExists(dbPath),
5303
+ runtime,
5304
+ no_network: true,
5038
5305
  commands: {
5039
5306
  domains: commandExists("domains"),
5040
- cloud: commandExists("cloud"),
5041
5307
  wrangler: commandExists("wrangler"),
5042
5308
  secrets: commandExists("secrets")
5043
5309
  },
5044
5310
  environment: {
5311
+ shortlinks_database_url_present: Boolean(getShortlinksDatabaseUrl(runtimeEnv())),
5045
5312
  cloudflare_api_token_present: Boolean(process.env.CLOUDFLARE_API_TOKEN),
5046
5313
  cloudflare_api_key_present: Boolean(process.env.CLOUDFLARE_API_KEY),
5047
5314
  cloudflare_email_present: Boolean(process.env.CLOUDFLARE_EMAIL),
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export { ShortlinksDatabase, SQLITE_MIGRATIONS, makeId, now } from "./database.js";
2
2
  export { ShortlinksStore } from "./store.js";
3
- export { PgShortlinksStore } from "./pg-store.js";
3
+ export { PgShortlinksStore, applyPostgresMigrations } from "./pg-store.js";
4
4
  export { createShortlinksHandler, serveShortlinks } from "./server.js";
5
5
  export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
6
6
  export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
7
7
  export { PG_MIGRATIONS } from "./pg-migrations.js";
8
+ export { CANONICAL_SHORTLINKS_POSTGRES_CLUSTER, CANONICAL_SHORTLINKS_POSTGRES_DATABASE, CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH, SHORTLINKS_RUNTIME_ENV, SHORTLINKS_RUNTIME_FALLBACK_ENV, assertShortlinksPostgresConfig, getCanonicalShortlinksPostgresConfig, getShortlinksDatabaseSsl, getShortlinksDatabaseUrl, getShortlinksRuntimeEnvName, getShortlinksRuntimeStatus, getShortlinksStoreMode, loadShortlinksRuntimeConfig, parseShortlinksStoreMode, redactDatabaseUrl, } from "./runtime.js";
8
9
  export { formatShortUrl, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
9
10
  export { normalizeSlug, randomToken } from "./slug.js";
10
11
  export type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
12
+ export type { CanonicalShortlinksPostgresConfig, RuntimeEnvStatus, ShortlinksPostgresConfig, ShortlinksRuntimeConfig, ShortlinksRuntimeEnv, ShortlinksRuntimeStatus, ShortlinksStoreMode, } from "./runtime.js";