@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 +14 -19
- package/dist/cli/index.js +338 -71
- package/dist/index.d.ts +3 -1
- package/dist/index.js +278 -9
- package/dist/pg-store.d.ts +10 -2
- package/dist/pg-store.js +598 -0
- package/dist/runtime.d.ts +65 -0
- package/dist/runtime.js +181 -0
- package/infra/aws-ec2-user-data.sh +32 -31
- package/package.json +15 -6
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
|
|
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
|
[](https://www.npmjs.com/package/@hasna/shortlinks)
|
|
8
8
|
[](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
|
-
##
|
|
127
|
+
## PostgreSQL Runtime
|
|
129
128
|
|
|
130
|
-
|
|
129
|
+
Production serving can use a shortlinks-owned PostgreSQL database without any shared table-sync package:
|
|
131
130
|
|
|
132
131
|
```bash
|
|
133
|
-
|
|
134
|
-
shortlinks
|
|
135
|
-
|
|
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
|
-
|
|
144
|
-
shortlinks
|
|
145
|
-
shortlinks
|
|
146
|
-
shortlinks serve --
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
|
3934
|
-
const
|
|
3935
|
-
|
|
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
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
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() === "
|
|
4588
|
-
const store2 = await PgShortlinksStore.
|
|
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
|
|
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").
|
|
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 =
|
|
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 ? "
|
|
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
|
|
4926
|
-
|
|
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
|
|
4929
|
-
|
|
4930
|
-
|
|
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
|
-
|
|
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
|
|
4954
|
-
const
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
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
|
|
5029
|
-
const
|
|
5292
|
+
const runtime = getShortlinksRuntimeStatus(runtimeEnv());
|
|
5293
|
+
const dbPath = getDatabasePath(program2.opts().db);
|
|
5030
5294
|
const data = {
|
|
5031
5295
|
service: "shortlinks",
|
|
5032
|
-
|
|
5296
|
+
ok: runtime.ok,
|
|
5297
|
+
store: runtime.mode,
|
|
5033
5298
|
data_dir: getDataDir(),
|
|
5034
5299
|
config_path: getConfigPath(),
|
|
5035
|
-
db_path:
|
|
5036
|
-
db_exists: existsSync4(
|
|
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";
|