@hasna/shortlinks 0.1.22 → 0.1.24

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/dist/cli/index.js CHANGED
@@ -33,7 +33,7 @@ var __toESM = (mod, isNodeMode, target) => {
33
33
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
34
  var __require = import.meta.require;
35
35
 
36
- // node_modules/commander/lib/error.js
36
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/error.js
37
37
  var require_error = __commonJS((exports) => {
38
38
  class CommanderError extends Error {
39
39
  constructor(exitCode, code, message) {
@@ -57,7 +57,7 @@ var require_error = __commonJS((exports) => {
57
57
  exports.InvalidArgumentError = InvalidArgumentError;
58
58
  });
59
59
 
60
- // node_modules/commander/lib/argument.js
60
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/argument.js
61
61
  var require_argument = __commonJS((exports) => {
62
62
  var { InvalidArgumentError } = require_error();
63
63
 
@@ -136,7 +136,7 @@ var require_argument = __commonJS((exports) => {
136
136
  exports.humanReadableArgName = humanReadableArgName;
137
137
  });
138
138
 
139
- // node_modules/commander/lib/help.js
139
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/help.js
140
140
  var require_help = __commonJS((exports) => {
141
141
  var { humanReadableArgName } = require_argument();
142
142
 
@@ -486,7 +486,7 @@ ${itemIndentStr}`);
486
486
  exports.stripColor = stripColor;
487
487
  });
488
488
 
489
- // node_modules/commander/lib/option.js
489
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/option.js
490
490
  var require_option = __commonJS((exports) => {
491
491
  var { InvalidArgumentError } = require_error();
492
492
 
@@ -664,7 +664,7 @@ var require_option = __commonJS((exports) => {
664
664
  exports.DualOptions = DualOptions;
665
665
  });
666
666
 
667
- // node_modules/commander/lib/suggestSimilar.js
667
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/suggestSimilar.js
668
668
  var require_suggestSimilar = __commonJS((exports) => {
669
669
  var maxDistance = 3;
670
670
  function editDistance(a, b) {
@@ -737,7 +737,7 @@ var require_suggestSimilar = __commonJS((exports) => {
737
737
  exports.suggestSimilar = suggestSimilar;
738
738
  });
739
739
 
740
- // node_modules/commander/lib/command.js
740
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/command.js
741
741
  var require_command = __commonJS((exports) => {
742
742
  var EventEmitter = __require("events").EventEmitter;
743
743
  var childProcess = __require("child_process");
@@ -979,7 +979,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
979
979
  this._exitCallback = (err) => {
980
980
  if (err.code !== "commander.executeSubCommandAsync") {
981
981
  throw err;
982
- }
982
+ } else {}
983
983
  };
984
984
  }
985
985
  return this;
@@ -2047,7 +2047,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
2047
2047
  exports.useColor = useColor;
2048
2048
  });
2049
2049
 
2050
- // node_modules/commander/index.js
2050
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/index.js
2051
2051
  var require_commander = __commonJS((exports) => {
2052
2052
  var { Argument } = require_argument();
2053
2053
  var { Command } = require_command();
@@ -2067,1557 +2067,948 @@ var require_commander = __commonJS((exports) => {
2067
2067
  exports.InvalidOptionArgumentError = InvalidArgumentError;
2068
2068
  });
2069
2069
 
2070
- // node_modules/@hasna/events/dist/commander.js
2071
- import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
2072
- import { existsSync } from "fs";
2073
- import { homedir } from "os";
2074
- import { join } from "path";
2075
- import { createHmac, timingSafeEqual } from "crypto";
2076
- import { randomUUID } from "crypto";
2077
- import { spawn } from "child_process";
2078
- import { randomUUID as randomUUID2 } from "crypto";
2079
- function getPathValue(input, path) {
2080
- return path.split(".").reduce((value, part) => {
2081
- if (value && typeof value === "object" && part in value) {
2082
- return value[part];
2083
- }
2084
- return;
2085
- }, input);
2070
+ // src/runtime.ts
2071
+ var SHORTLINKS_RUNTIME_ENV = {
2072
+ store: "HASNA_SHORTLINKS_STORE",
2073
+ databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
2074
+ databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
2075
+ };
2076
+ var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
2077
+ store: "SHORTLINKS_STORE",
2078
+ databaseUrl: "SHORTLINKS_DATABASE_URL",
2079
+ databaseSsl: "SHORTLINKS_DATABASE_SSL"
2080
+ };
2081
+ var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
2082
+ var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
2083
+ var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
2084
+ function getCanonicalShortlinksPostgresConfig() {
2085
+ return {
2086
+ cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
2087
+ database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
2088
+ runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
2089
+ primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
2090
+ fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
2091
+ };
2086
2092
  }
2087
- function wildcardToRegExp(pattern) {
2088
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
2089
- return new RegExp(`^${escaped}$`);
2093
+ function parseShortlinksStoreMode(value) {
2094
+ const normalized = clean(value)?.toLowerCase();
2095
+ if (!normalized)
2096
+ return "local";
2097
+ if (normalized === "local" || normalized === "postgres")
2098
+ return normalized;
2099
+ if (normalized === "pg")
2100
+ return "postgres";
2101
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
2090
2102
  }
2091
- function matchString(value, matcher) {
2092
- if (matcher === undefined)
2093
- return true;
2094
- if (value === undefined)
2095
- return false;
2096
- const matchers = Array.isArray(matcher) ? matcher : [matcher];
2097
- return matchers.some((item) => wildcardToRegExp(item).test(value));
2103
+ function getShortlinksStoreMode(env = process.env) {
2104
+ return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
2098
2105
  }
2099
- function matchRecord(input, matcher) {
2100
- if (!matcher)
2101
- return true;
2102
- return Object.entries(matcher).every(([path, expected]) => {
2103
- const actual = getPathValue(input, path);
2104
- if (typeof expected === "string" || Array.isArray(expected)) {
2105
- return matchString(actual === undefined ? undefined : String(actual), expected);
2106
- }
2107
- return actual === expected;
2108
- });
2106
+ function getShortlinksDatabaseUrl(env = process.env) {
2107
+ return readRuntimeEnv(env, "databaseUrl").value;
2109
2108
  }
2110
- function eventMatchesFilter(event, filter) {
2111
- return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
2109
+ function getShortlinksDatabaseSsl(env = process.env) {
2110
+ return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
2112
2111
  }
2113
- function channelMatchesEvent(channel, event) {
2114
- if (!channel.enabled)
2115
- return false;
2116
- if (!channel.filters || channel.filters.length === 0)
2117
- return true;
2118
- return channel.filters.some((filter) => eventMatchesFilter(event, filter));
2112
+ function getShortlinksRuntimeEnvName(env, key) {
2113
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
2114
+ if (clean(env[primary]))
2115
+ return primary;
2116
+ return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
2119
2117
  }
2120
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
2121
- var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
2122
- function getEventsDataDir(override) {
2123
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
2118
+ function loadShortlinksRuntimeConfig(env = process.env) {
2119
+ const mode = getShortlinksStoreMode(env);
2120
+ const databaseUrl = getShortlinksDatabaseUrl(env);
2121
+ return {
2122
+ service: "shortlinks",
2123
+ mode,
2124
+ ...databaseUrl ? {
2125
+ database: {
2126
+ provider: "postgres",
2127
+ url: databaseUrl,
2128
+ ssl: getShortlinksDatabaseSsl(env)
2129
+ }
2130
+ } : {}
2131
+ };
2124
2132
  }
2125
-
2126
- class JsonEventsStore {
2127
- dataDir;
2128
- channelsPath;
2129
- eventsPath;
2130
- deliveriesPath;
2131
- constructor(dataDir = getEventsDataDir()) {
2132
- this.dataDir = dataDir;
2133
- this.channelsPath = join(dataDir, "channels.json");
2134
- this.eventsPath = join(dataDir, "events.json");
2135
- this.deliveriesPath = join(dataDir, "deliveries.json");
2136
- }
2137
- async init() {
2138
- await mkdir(this.dataDir, { recursive: true, mode: 448 });
2139
- await chmod(this.dataDir, 448).catch(() => {
2140
- return;
2141
- });
2142
- await this.ensureArrayFile(this.channelsPath);
2143
- await this.ensureArrayFile(this.eventsPath);
2144
- await this.ensureArrayFile(this.deliveriesPath);
2145
- }
2146
- async addChannel(channel) {
2147
- await this.init();
2148
- const channels = await this.readJson(this.channelsPath, []);
2149
- const index = channels.findIndex((item) => item.id === channel.id);
2150
- if (index >= 0) {
2151
- channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
2152
- } else {
2153
- channels.push(channel);
2154
- }
2155
- await this.writeJson(this.channelsPath, channels);
2156
- return index >= 0 ? channels[index] : channel;
2157
- }
2158
- async listChannels() {
2159
- await this.init();
2160
- return this.readJson(this.channelsPath, []);
2161
- }
2162
- async getChannel(id) {
2163
- const channels = await this.listChannels();
2164
- return channels.find((channel) => channel.id === id);
2165
- }
2166
- async removeChannel(id) {
2167
- await this.init();
2168
- const channels = await this.readJson(this.channelsPath, []);
2169
- const next = channels.filter((channel) => channel.id !== id);
2170
- await this.writeJson(this.channelsPath, next);
2171
- return next.length !== channels.length;
2172
- }
2173
- async appendEvent(event) {
2174
- await this.init();
2175
- const events = await this.readJson(this.eventsPath, []);
2176
- events.push(event);
2177
- await this.writeJson(this.eventsPath, events);
2178
- return event;
2179
- }
2180
- async listEvents() {
2181
- await this.init();
2182
- return this.readJson(this.eventsPath, []);
2183
- }
2184
- async findEventByIdentity(identity) {
2185
- const events = await this.listEvents();
2186
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
2187
- }
2188
- async appendDelivery(result) {
2189
- await this.init();
2190
- const deliveries = await this.readJson(this.deliveriesPath, []);
2191
- deliveries.push(result);
2192
- await this.writeJson(this.deliveriesPath, deliveries);
2193
- return result;
2194
- }
2195
- async listDeliveries() {
2196
- await this.init();
2197
- return this.readJson(this.deliveriesPath, []);
2133
+ function assertShortlinksPostgresConfig(config) {
2134
+ if (config.mode !== "postgres")
2135
+ return;
2136
+ if (!config.database?.url) {
2137
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
2198
2138
  }
2199
- async exportData() {
2200
- return {
2201
- channels: await this.listChannels(),
2202
- events: await this.listEvents(),
2203
- deliveries: await this.listDeliveries()
2204
- };
2139
+ }
2140
+ function getShortlinksRuntimeStatus(env = process.env) {
2141
+ const issues = [];
2142
+ const warnings = [];
2143
+ let config;
2144
+ try {
2145
+ config = loadShortlinksRuntimeConfig(env);
2146
+ } catch (error) {
2147
+ issues.push(error instanceof Error ? error.message : String(error));
2148
+ config = { service: "shortlinks", mode: "local" };
2205
2149
  }
2206
- async ensureArrayFile(path) {
2207
- if (!existsSync(path)) {
2208
- await writeFile(path, `[]
2209
- `, { encoding: "utf-8", mode: 384 });
2210
- }
2211
- await chmod(path, 384).catch(() => {
2212
- return;
2213
- });
2150
+ try {
2151
+ assertShortlinksPostgresConfig(config);
2152
+ } catch (error) {
2153
+ issues.push(error instanceof Error ? error.message : String(error));
2214
2154
  }
2215
- async readJson(path, fallback) {
2216
- try {
2217
- const raw = await readFile(path, "utf-8");
2218
- if (!raw.trim())
2219
- return fallback;
2220
- return JSON.parse(raw);
2221
- } catch (error) {
2222
- if (error.code === "ENOENT")
2223
- return fallback;
2224
- throw error;
2225
- }
2155
+ if (config.mode === "local" && config.database?.url) {
2156
+ warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
2226
2157
  }
2227
- async writeJson(path, value) {
2228
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
2229
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
2230
- `, { encoding: "utf-8", mode: 384 });
2231
- await rename(tempPath, path);
2232
- await chmod(path, 384).catch(() => {
2233
- return;
2234
- });
2158
+ return {
2159
+ ok: issues.length === 0,
2160
+ service: "shortlinks",
2161
+ mode: config.mode,
2162
+ local_default: config.mode === "local",
2163
+ postgres_enabled: config.mode === "postgres",
2164
+ database: {
2165
+ configured: Boolean(config.database?.url),
2166
+ provider: config.database?.provider ?? null,
2167
+ redacted_url: redactDatabaseUrl(config.database?.url),
2168
+ ssl: config.database?.ssl ?? null
2169
+ },
2170
+ env: runtimeEnvStatus(env),
2171
+ canonical: getCanonicalShortlinksPostgresConfig(),
2172
+ issues,
2173
+ warnings,
2174
+ no_network: true
2175
+ };
2176
+ }
2177
+ function redactDatabaseUrl(value) {
2178
+ if (!value)
2179
+ return null;
2180
+ try {
2181
+ const url = new URL(value);
2182
+ if (url.username)
2183
+ url.username = "***";
2184
+ if (url.password)
2185
+ url.password = "***";
2186
+ for (const key of Array.from(url.searchParams.keys())) {
2187
+ if (isSensitiveQueryKey(key))
2188
+ url.searchParams.set(key, "***");
2189
+ }
2190
+ return url.toString();
2191
+ } catch {
2192
+ return "(redacted)";
2235
2193
  }
2236
2194
  }
2237
- var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
2238
- function buildSignatureBase(timestamp, body) {
2239
- return `${timestamp}.${body}`;
2195
+ function runtimeEnvStatus(env) {
2196
+ return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
2197
+ const activeName = getShortlinksRuntimeEnvName(env, key);
2198
+ return [
2199
+ key,
2200
+ {
2201
+ name,
2202
+ active_name: activeName,
2203
+ configured: Boolean(clean(env[activeName]))
2204
+ }
2205
+ ];
2206
+ }));
2240
2207
  }
2241
- function signPayload(secret, timestamp, body) {
2242
- const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
2243
- return `sha256=${digest}`;
2208
+ function readRuntimeEnv(env, key) {
2209
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
2210
+ const primaryValue = clean(env[primary]);
2211
+ if (primaryValue)
2212
+ return { name: primary, value: primaryValue };
2213
+ const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
2214
+ return { name: fallback, value: clean(env[fallback]) };
2244
2215
  }
2245
- function now() {
2246
- return new Date().toISOString();
2216
+ function parseBoolean(value, fallback) {
2217
+ const normalized = clean(value)?.toLowerCase();
2218
+ if (!normalized)
2219
+ return fallback;
2220
+ if (["1", "true", "yes", "on"].includes(normalized))
2221
+ return true;
2222
+ if (["0", "false", "no", "off"].includes(normalized))
2223
+ return false;
2224
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
2247
2225
  }
2248
- function truncate(value, max = 4096) {
2249
- return value.length > max ? `${value.slice(0, max)}...` : value;
2226
+ function isSensitiveQueryKey(key) {
2227
+ return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
2250
2228
  }
2251
- function buildWebhookRequest(event, channel) {
2252
- if (!channel.webhook)
2253
- throw new Error(`Channel ${channel.id} has no webhook config`);
2254
- const body = JSON.stringify(event);
2255
- const timestamp = event.time;
2256
- const headers = {
2257
- "Content-Type": "application/json",
2258
- "User-Agent": "@hasna/events",
2259
- "X-Hasna-Event-Id": event.id,
2260
- "X-Hasna-Event-Type": event.type,
2261
- "X-Hasna-Timestamp": timestamp,
2262
- ...channel.webhook.headers
2263
- };
2264
- if (channel.webhook.secret) {
2265
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
2266
- }
2267
- return { body, headers };
2229
+ function clean(value) {
2230
+ const trimmed = value?.trim();
2231
+ return trimmed ? trimmed : undefined;
2268
2232
  }
2269
- async function dispatchWebhook(event, channel, options = {}) {
2270
- if (!channel.webhook)
2271
- throw new Error(`Channel ${channel.id} has no webhook config`);
2272
- const startedAt = now();
2273
- const { body, headers } = buildWebhookRequest(event, channel);
2274
- const controller = new AbortController;
2275
- const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
2233
+
2234
+ // src/pg-store.ts
2235
+ import { createHash } from "crypto";
2236
+
2237
+ // src/config.ts
2238
+ import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2239
+ import { randomBytes } from "crypto";
2240
+ import { homedir } from "os";
2241
+ import { dirname, join, resolve } from "path";
2242
+ var SERVICE_NAME = "shortlinks";
2243
+ var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
2244
+ function getDataDir() {
2245
+ return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
2246
+ }
2247
+ function ensureDataDir() {
2248
+ const dir = getDataDir();
2249
+ mkdirSync(dir, { recursive: true });
2250
+ return dir;
2251
+ }
2252
+ function getConfigPath() {
2253
+ return join(ensureDataDir(), "config.json");
2254
+ }
2255
+ function getClickSaltPath() {
2256
+ return join(ensureDataDir(), "click-salt");
2257
+ }
2258
+ function getDatabasePath(explicitPath) {
2259
+ if (explicitPath)
2260
+ return resolve(explicitPath);
2261
+ if (process.env.SHORTLINKS_DB)
2262
+ return resolve(process.env.SHORTLINKS_DB);
2263
+ return join(ensureDataDir(), `${SERVICE_NAME}.db`);
2264
+ }
2265
+ function readClickSaltFile(path) {
2276
2266
  try {
2277
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
2278
- method: "POST",
2279
- headers,
2280
- body,
2281
- signal: controller.signal
2282
- });
2283
- const responseBody = truncate(await response.text());
2284
- return {
2285
- attempt: 1,
2286
- status: response.ok ? "success" : "failed",
2287
- startedAt,
2288
- completedAt: now(),
2289
- responseStatus: response.status,
2290
- responseBody,
2291
- error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
2292
- };
2293
- } catch (error) {
2294
- return {
2295
- attempt: 1,
2296
- status: "failed",
2297
- startedAt,
2298
- completedAt: now(),
2299
- error: error instanceof Error ? error.message : String(error)
2300
- };
2301
- } finally {
2302
- clearTimeout(timeout);
2267
+ const saved = readFileSync(path, "utf-8").trim();
2268
+ return saved || null;
2269
+ } catch {
2270
+ return null;
2303
2271
  }
2304
2272
  }
2305
- async function dispatchCommand(event, channel) {
2306
- if (!channel.command)
2307
- throw new Error(`Channel ${channel.id} has no command config`);
2308
- const startedAt = now();
2309
- const eventJson = JSON.stringify(event);
2310
- const env = {
2311
- ...process.env,
2312
- ...channel.command.env,
2313
- HASNA_CHANNEL_ID: channel.id,
2314
- HASNA_EVENT_ID: event.id,
2315
- HASNA_EVENT_TYPE: event.type,
2316
- HASNA_EVENT_SOURCE: event.source,
2317
- HASNA_EVENT_SUBJECT: event.subject ?? "",
2318
- HASNA_EVENT_SEVERITY: event.severity,
2319
- HASNA_EVENT_TIME: event.time,
2320
- HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
2321
- HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
2322
- HASNA_EVENT_JSON: eventJson
2323
- };
2324
- return new Promise((resolve) => {
2325
- const child = spawn(channel.command.command, channel.command.args ?? [], {
2326
- cwd: channel.command.cwd,
2327
- env,
2328
- stdio: ["pipe", "pipe", "pipe"]
2329
- });
2330
- let stdout = "";
2331
- let stderr = "";
2332
- const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
2333
- child.stdin.end(eventJson);
2334
- child.stdout.on("data", (chunk) => {
2335
- stdout += chunk.toString();
2336
- });
2337
- child.stderr.on("data", (chunk) => {
2338
- stderr += chunk.toString();
2339
- });
2340
- child.on("error", (error) => {
2341
- clearTimeout(timeout);
2342
- resolve({
2343
- attempt: 1,
2344
- status: "failed",
2345
- startedAt,
2346
- completedAt: now(),
2347
- stdout: truncate(stdout),
2348
- stderr: truncate(stderr),
2349
- error: error.message
2350
- });
2351
- });
2352
- child.on("close", (code, signal) => {
2353
- clearTimeout(timeout);
2354
- const success = code === 0;
2355
- resolve({
2356
- attempt: 1,
2357
- status: success ? "success" : "failed",
2358
- startedAt,
2359
- completedAt: now(),
2360
- stdout: truncate(stdout),
2361
- stderr: truncate(stderr),
2362
- error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
2363
- });
2364
- });
2365
- });
2366
- }
2367
- async function dispatchChannel(event, channel, options = {}) {
2368
- if (channel.transport === "webhook")
2369
- return dispatchWebhook(event, channel, options);
2370
- if (channel.transport === "command")
2371
- return dispatchCommand(event, channel);
2372
- return {
2373
- attempt: 1,
2374
- status: "skipped",
2375
- startedAt: now(),
2376
- completedAt: now(),
2377
- error: `Unsupported transport: ${channel.transport}`
2378
- };
2379
- }
2380
- function createDeliveryResult(event, channel, attempts) {
2381
- const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
2382
- return {
2383
- id: randomUUID(),
2384
- eventId: event.id,
2385
- channelId: channel.id,
2386
- transport: channel.transport,
2387
- status,
2388
- attempts,
2389
- createdAt: attempts[0]?.startedAt ?? now(),
2390
- completedAt: attempts.at(-1)?.completedAt ?? now()
2391
- };
2392
- }
2393
- function createEvent(input) {
2394
- return {
2395
- id: input.id ?? randomUUID2(),
2396
- source: input.source,
2397
- type: input.type,
2398
- time: normalizeTime(input.time),
2399
- subject: input.subject,
2400
- severity: input.severity ?? "info",
2401
- data: input.data ?? {},
2402
- message: input.message,
2403
- dedupeKey: input.dedupeKey,
2404
- schemaVersion: input.schemaVersion ?? "1.0",
2405
- metadata: input.metadata ?? {}
2406
- };
2273
+ function clickSaltError(path, error) {
2274
+ const detail = error instanceof Error ? error.message : String(error);
2275
+ return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
2407
2276
  }
2408
-
2409
- class EventsClient {
2410
- store;
2411
- redactors;
2412
- transportOptions;
2413
- constructor(options = {}) {
2414
- this.store = options.store ?? new JsonEventsStore(options.dataDir);
2415
- this.redactors = options.redactors ?? [];
2416
- this.transportOptions = { fetchImpl: options.fetchImpl };
2417
- }
2418
- async addChannel(input) {
2419
- const timestamp = new Date().toISOString();
2420
- return this.store.addChannel({
2421
- ...input,
2422
- createdAt: input.createdAt ?? timestamp,
2423
- updatedAt: input.updatedAt ?? timestamp
2424
- });
2425
- }
2426
- async listChannels() {
2427
- return this.store.listChannels();
2428
- }
2429
- async removeChannel(id) {
2430
- return this.store.removeChannel(id);
2431
- }
2432
- async emit(input, options = {}) {
2433
- const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
2434
- if (options.dedupe !== false) {
2435
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
2436
- if (existing) {
2437
- return { event: existing, deliveries: [], deduped: true };
2438
- }
2277
+ function getClickSalt() {
2278
+ const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
2279
+ if (explicit)
2280
+ return explicit;
2281
+ const path = getClickSaltPath();
2282
+ const saved = readClickSaltFile(path);
2283
+ if (saved)
2284
+ return saved;
2285
+ const generated = randomBytes(32).toString("hex");
2286
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
2287
+ try {
2288
+ writeFileSync(tempPath, `${generated}
2289
+ `, { flag: "wx", mode: 384 });
2290
+ try {
2291
+ linkSync(tempPath, path);
2292
+ return generated;
2293
+ } catch (error) {
2294
+ const winner = readClickSaltFile(path);
2295
+ if (winner)
2296
+ return winner;
2297
+ throw clickSaltError(path, error);
2298
+ } finally {
2299
+ try {
2300
+ unlinkSync(tempPath);
2301
+ } catch {}
2439
2302
  }
2440
- await this.store.appendEvent(event);
2441
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
2442
- return { event, deliveries, deduped: false };
2443
- }
2444
- async listEvents() {
2445
- return this.store.listEvents();
2303
+ } catch (error) {
2304
+ const winner = readClickSaltFile(path);
2305
+ if (winner)
2306
+ return winner;
2307
+ throw clickSaltError(path, error);
2446
2308
  }
2447
- async listDeliveries() {
2448
- return this.store.listDeliveries();
2309
+ }
2310
+ function loadConfig() {
2311
+ const path = getConfigPath();
2312
+ if (!existsSync(path))
2313
+ return {};
2314
+ try {
2315
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
2316
+ return parsed && typeof parsed === "object" ? parsed : {};
2317
+ } catch {
2318
+ return {};
2449
2319
  }
2450
- async deliver(event) {
2451
- const channels = await this.store.listChannels();
2452
- const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
2453
- const deliveries = [];
2454
- for (const channel of selected) {
2455
- const eventForChannel = await this.applyRedaction(event, channel);
2456
- const result = await this.deliverWithRetry(eventForChannel, channel);
2457
- await this.store.appendDelivery(result);
2458
- deliveries.push(result);
2320
+ }
2321
+ function saveConfig(config) {
2322
+ const path = getConfigPath();
2323
+ mkdirSync(dirname(path), { recursive: true });
2324
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}
2325
+ `);
2326
+ }
2327
+ function updateConfig(patch) {
2328
+ const next = {
2329
+ ...loadConfig(),
2330
+ ...patch,
2331
+ cloudflare: {
2332
+ ...loadConfig().cloudflare,
2333
+ ...patch.cloudflare
2459
2334
  }
2460
- return deliveries;
2335
+ };
2336
+ saveConfig(next);
2337
+ return next;
2338
+ }
2339
+ function normalizeHostname(input) {
2340
+ const raw = input.trim().toLowerCase();
2341
+ if (!raw)
2342
+ throw new Error("Domain is required.");
2343
+ const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
2344
+ let hostname;
2345
+ try {
2346
+ hostname = new URL(withProtocol).hostname;
2347
+ } catch {
2348
+ throw new Error(`Invalid domain: ${input}`);
2461
2349
  }
2462
- async testChannel(id, input = {}) {
2463
- const channel = await this.store.getChannel(id);
2464
- if (!channel)
2465
- throw new Error(`Channel not found: ${id}`);
2466
- const event = createEvent({
2467
- source: input.source ?? "hasna.events",
2468
- type: input.type ?? "events.test",
2469
- subject: input.subject ?? id,
2470
- severity: input.severity ?? "info",
2471
- data: input.data ?? { test: true },
2472
- message: input.message ?? "Hasna events test delivery",
2473
- dedupeKey: input.dedupeKey,
2474
- schemaVersion: input.schemaVersion,
2475
- metadata: input.metadata,
2476
- time: input.time,
2477
- id: input.id
2478
- });
2479
- const eventForChannel = await this.applyRedaction(event, channel);
2480
- const result = await this.deliverWithRetry(eventForChannel, channel);
2481
- await this.store.appendDelivery(result);
2482
- return result;
2350
+ hostname = hostname.replace(/\.$/, "");
2351
+ const labels = hostname.split(".");
2352
+ const labelsAreValid = labels.every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9-]+$/.test(label) && !label.startsWith("-") && !label.endsWith("-"));
2353
+ if (hostname.length > 253 || !labelsAreValid) {
2354
+ throw new Error(`Invalid domain: ${input}`);
2483
2355
  }
2484
- async replay(options = {}) {
2485
- const events = (await this.store.listEvents()).filter((event) => {
2486
- if (options.eventId && event.id !== options.eventId)
2487
- return false;
2488
- if (options.source && event.source !== options.source)
2489
- return false;
2490
- if (options.type && event.type !== options.type)
2491
- return false;
2492
- return true;
2493
- });
2494
- if (options.dryRun)
2495
- return { events, deliveries: [] };
2496
- const deliveries = [];
2497
- for (const event of events) {
2498
- deliveries.push(...await this.deliver(event));
2499
- }
2500
- return { events, deliveries };
2356
+ return hostname;
2357
+ }
2358
+ function formatShortUrl(hostname, slug, publicBaseUrl) {
2359
+ if (publicBaseUrl) {
2360
+ const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
2361
+ return new URL(slug, base).toString();
2501
2362
  }
2502
- async applyRedaction(event, channel) {
2503
- let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
2504
- for (const redactor of this.redactors) {
2505
- next = await redactor(next, channel);
2506
- }
2507
- return next;
2363
+ return `https://${hostname}/${slug}`;
2364
+ }
2365
+
2366
+ // src/database.ts
2367
+ import { Database } from "bun:sqlite";
2368
+ import { mkdirSync as mkdirSync2 } from "fs";
2369
+ import { dirname as dirname2 } from "path";
2370
+ function now() {
2371
+ return new Date().toISOString();
2372
+ }
2373
+ function makeId(prefix) {
2374
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
2375
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
2376
+ return `${prefix}_${hex}`;
2377
+ }
2378
+ var SQLITE_MIGRATIONS = [
2379
+ `
2380
+ CREATE TABLE IF NOT EXISTS domains (
2381
+ id TEXT PRIMARY KEY,
2382
+ hostname TEXT NOT NULL UNIQUE,
2383
+ provider TEXT NOT NULL DEFAULT 'manual',
2384
+ default_domain INTEGER NOT NULL DEFAULT 0,
2385
+ cloudflare_zone_id TEXT,
2386
+ cloudflare_account_id TEXT,
2387
+ cloudflare_worker_name TEXT,
2388
+ origin_url TEXT,
2389
+ notes TEXT,
2390
+ metadata TEXT NOT NULL DEFAULT '{}',
2391
+ machine_id TEXT,
2392
+ synced_at TEXT,
2393
+ created_at TEXT NOT NULL,
2394
+ updated_at TEXT NOT NULL
2395
+ );
2396
+
2397
+ CREATE TABLE IF NOT EXISTS links (
2398
+ id TEXT PRIMARY KEY,
2399
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
2400
+ slug TEXT NOT NULL,
2401
+ destination_url TEXT NOT NULL,
2402
+ title TEXT,
2403
+ active INTEGER NOT NULL DEFAULT 1,
2404
+ expires_at TEXT,
2405
+ metadata TEXT NOT NULL DEFAULT '{}',
2406
+ machine_id TEXT,
2407
+ synced_at TEXT,
2408
+ created_at TEXT NOT NULL,
2409
+ updated_at TEXT NOT NULL,
2410
+ UNIQUE(domain_id, slug)
2411
+ );
2412
+
2413
+ CREATE TABLE IF NOT EXISTS clicks (
2414
+ id TEXT PRIMARY KEY,
2415
+ link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
2416
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
2417
+ slug TEXT NOT NULL,
2418
+ clicked_at TEXT NOT NULL,
2419
+ ip_hash TEXT,
2420
+ user_agent TEXT,
2421
+ referer TEXT,
2422
+ country TEXT,
2423
+ city TEXT,
2424
+ metadata TEXT NOT NULL DEFAULT '{}',
2425
+ machine_id TEXT,
2426
+ synced_at TEXT,
2427
+ created_at TEXT NOT NULL,
2428
+ updated_at TEXT NOT NULL
2429
+ );
2430
+
2431
+ CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
2432
+ CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
2433
+ CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
2434
+ CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
2435
+ CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
2436
+ CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
2437
+ CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
2438
+ CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
2439
+ CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
2440
+ `
2441
+ ];
2442
+
2443
+ class ShortlinksDatabase {
2444
+ db;
2445
+ path;
2446
+ constructor(path) {
2447
+ this.path = getDatabasePath(path);
2448
+ mkdirSync2(dirname2(this.path), { recursive: true });
2449
+ this.db = new Database(this.path);
2450
+ this.db.exec("PRAGMA foreign_keys = ON;");
2451
+ this.applyMigrations();
2508
2452
  }
2509
- async deliverWithRetry(event, channel) {
2510
- const policy = normalizeRetryPolicy(channel.retry);
2511
- const attempts = [];
2512
- for (let index = 0;index < policy.maxAttempts; index += 1) {
2513
- const attempt = await dispatchChannel(event, channel, this.transportOptions);
2514
- attempt.attempt = index + 1;
2515
- if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
2516
- attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
2517
- }
2518
- attempts.push(attempt);
2519
- if (attempt.status !== "failed")
2520
- break;
2521
- if (attempt.nextBackoffMs)
2522
- await Bun.sleep(attempt.nextBackoffMs);
2453
+ close() {
2454
+ this.db.close();
2455
+ }
2456
+ applyMigrations() {
2457
+ this.db.exec(`
2458
+ CREATE TABLE IF NOT EXISTS _migrations (
2459
+ id INTEGER PRIMARY KEY,
2460
+ applied_at TEXT NOT NULL
2461
+ );
2462
+ `);
2463
+ for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
2464
+ const id = i + 1;
2465
+ const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
2466
+ if (applied)
2467
+ continue;
2468
+ const migration = SQLITE_MIGRATIONS[i];
2469
+ const apply = this.db.transaction(() => {
2470
+ this.db.exec(migration);
2471
+ this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
2472
+ });
2473
+ apply();
2523
2474
  }
2524
- return createDeliveryResult(event, channel, attempts);
2525
2475
  }
2526
2476
  }
2527
- function redactPaths(event, paths, replacement = "[REDACTED]") {
2528
- if (paths.length === 0)
2529
- return event;
2530
- const copy = structuredClone(event);
2531
- for (const path of paths) {
2532
- setPath(copy, path, replacement);
2477
+
2478
+ // src/machine.ts
2479
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2480
+ import { hostname } from "os";
2481
+ import { join as join2 } from "path";
2482
+
2483
+ // src/slug.ts
2484
+ import { randomBytes as randomBytes2 } from "crypto";
2485
+ var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
2486
+ var DEFAULT_SLUG_LENGTH = 7;
2487
+ function randomToken(length = DEFAULT_SLUG_LENGTH) {
2488
+ if (length < 1 || length > 128)
2489
+ throw new Error("Token length must be between 1 and 128.");
2490
+ const bytes = randomBytes2(length);
2491
+ let out = "";
2492
+ for (let i = 0;i < length; i += 1) {
2493
+ out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
2533
2494
  }
2534
- return copy;
2495
+ return out;
2535
2496
  }
2536
- function sanitizeChannelForOutput(channel) {
2537
- const copy = structuredClone(channel);
2538
- if (copy.webhook?.secret)
2539
- copy.webhook.secret = "[REDACTED]";
2540
- if (copy.command?.env) {
2541
- copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
2497
+ function normalizeSlug(slug) {
2498
+ const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
2499
+ if (!normalized)
2500
+ throw new Error("Slug is required.");
2501
+ if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
2502
+ throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
2542
2503
  }
2543
- return copy;
2544
- }
2545
- function sanitizeChannelsForOutput(channels) {
2546
- return channels.map(sanitizeChannelForOutput);
2547
- }
2548
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
2549
- return redactValue(event, replacement);
2504
+ return normalized;
2550
2505
  }
2551
- function shouldRedactKey(key) {
2552
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
2506
+
2507
+ // src/machine.ts
2508
+ function getMachineId() {
2509
+ const path = join2(ensureDataDir(), "machine-id");
2510
+ if (existsSync2(path)) {
2511
+ const existing = readFileSync2(path, "utf-8").trim();
2512
+ if (existing)
2513
+ return existing;
2514
+ }
2515
+ const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2516
+ const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
2517
+ writeFileSync2(path, `${id}
2518
+ `);
2519
+ return id;
2553
2520
  }
2554
- function redactValue(value, replacement) {
2555
- if (Array.isArray(value))
2556
- return value.map((item) => redactValue(item, replacement));
2557
- if (!value || typeof value !== "object")
2521
+
2522
+ // src/pg-store.ts
2523
+ function parseJsonObject(value) {
2524
+ if (!value)
2525
+ return {};
2526
+ if (typeof value === "object" && !Array.isArray(value))
2558
2527
  return value;
2559
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
2560
- key,
2561
- shouldRedactKey(key) ? replacement : redactValue(item, replacement)
2562
- ]));
2563
- }
2564
- function setPath(input, path, replacement) {
2565
- const parts = path.split(".");
2566
- let cursor = input;
2567
- for (const part of parts.slice(0, -1)) {
2568
- const next = cursor[part];
2569
- if (!next || typeof next !== "object")
2570
- return;
2571
- cursor = next;
2528
+ try {
2529
+ const parsed = JSON.parse(String(value));
2530
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2531
+ } catch {
2532
+ return {};
2572
2533
  }
2573
- const last = parts.at(-1);
2574
- if (last && last in cursor)
2575
- cursor[last] = replacement;
2576
2534
  }
2577
- function normalizeTime(value) {
2578
- if (!value)
2579
- return new Date().toISOString();
2580
- return value instanceof Date ? value.toISOString() : value;
2535
+ async function loadPgPool() {
2536
+ const importer = new Function("specifier", "return import(specifier)");
2537
+ const module = await importer("pg");
2538
+ return module.Pool;
2581
2539
  }
2582
- function normalizeRetryPolicy(policy) {
2540
+ function toPostgresSql(sql) {
2541
+ let index = 0;
2542
+ return sql.replace(/\?/g, () => `$${++index}`);
2543
+ }
2544
+ function createPgPoolConfig(connectionString, options = {}) {
2545
+ const ssl = options.ssl ?? true;
2583
2546
  return {
2584
- maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
2585
- backoffMs: Math.max(0, policy?.backoffMs ?? 250),
2586
- multiplier: Math.max(1, policy?.multiplier ?? 2)
2547
+ connectionString,
2548
+ ...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
2587
2549
  };
2588
2550
  }
2589
- function parseJsonObject(value, fallback) {
2590
- if (!value)
2591
- return fallback;
2592
- const parsed = JSON.parse(value);
2593
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2594
- throw new Error("Expected a JSON object");
2551
+
2552
+ class PgPoolAdapter {
2553
+ pool;
2554
+ constructor(pool) {
2555
+ this.pool = pool;
2595
2556
  }
2596
- return parsed;
2597
- }
2598
- function parseHeaders(values) {
2599
- if (!values?.length)
2600
- return;
2601
- const headers = {};
2602
- for (const value of values) {
2603
- const separator = value.indexOf("=");
2604
- if (separator === -1)
2605
- throw new Error(`Invalid header, expected name=value: ${value}`);
2606
- headers[value.slice(0, separator)] = value.slice(separator + 1);
2557
+ static async create(connectionString, options = {}) {
2558
+ const Pool = await loadPgPool();
2559
+ return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
2560
+ }
2561
+ async get(sql, ...params) {
2562
+ const result = await this.pool.query(toPostgresSql(sql), params);
2563
+ return result.rows[0] ?? null;
2564
+ }
2565
+ async all(sql, ...params) {
2566
+ const result = await this.pool.query(toPostgresSql(sql), params);
2567
+ return result.rows;
2568
+ }
2569
+ async run(sql, ...params) {
2570
+ return this.pool.query(toPostgresSql(sql), params);
2571
+ }
2572
+ async close() {
2573
+ await this.pool.end();
2607
2574
  }
2608
- return headers;
2609
2575
  }
2610
- function parseFilter(options) {
2611
- const filter2 = {};
2612
- if (options.source)
2613
- filter2.source = options.source;
2614
- if (options.type)
2615
- filter2.type = options.type;
2616
- if (options.subject)
2617
- filter2.subject = options.subject;
2618
- if (options.severity)
2619
- filter2.severity = options.severity;
2620
- return Object.keys(filter2).length > 0 ? [filter2] : undefined;
2621
- }
2622
- function createClient(options) {
2623
- if (options.createClient)
2624
- return options.createClient();
2625
- return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
2626
- }
2627
- function print(value, json, text) {
2628
- if (json)
2629
- console.log(JSON.stringify(value, null, 2));
2630
- else
2631
- console.log(text);
2576
+ function toIsoString(value) {
2577
+ if (value instanceof Date)
2578
+ return value.toISOString();
2579
+ return String(value);
2632
2580
  }
2633
- function hasJsonOption(options) {
2634
- return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
2581
+ function nullableIso(value) {
2582
+ if (value === null || value === undefined)
2583
+ return null;
2584
+ return toIsoString(value);
2635
2585
  }
2636
- function wantsJson(actionOptions, command) {
2637
- return hasJsonOption(actionOptions) || hasJsonOption(command);
2586
+ function domainFromRow(row) {
2587
+ return {
2588
+ ...row,
2589
+ default_domain: Boolean(row.default_domain),
2590
+ synced_at: nullableIso(row.synced_at),
2591
+ created_at: toIsoString(row.created_at),
2592
+ updated_at: toIsoString(row.updated_at),
2593
+ metadata: parseJsonObject(row.metadata)
2594
+ };
2638
2595
  }
2639
- function registerWebhookCommands(program, options) {
2640
- const webhooks = program.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
2641
- webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
2642
- const timestamp = new Date().toISOString();
2643
- const channel = {
2644
- id: actionOptions.id,
2645
- name: actionOptions.name,
2646
- enabled: !actionOptions.disabled,
2647
- transport: actionOptions.transport,
2648
- filters: parseFilter(actionOptions),
2649
- retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
2650
- redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
2651
- createdAt: timestamp,
2652
- updatedAt: timestamp
2653
- };
2654
- if (actionOptions.transport === "webhook") {
2655
- channel.webhook = { url: target, secret: actionOptions.secret, headers: parseHeaders(actionOptions.header), timeoutMs: actionOptions.timeoutMs };
2656
- } else if (actionOptions.transport === "command") {
2657
- channel.command = { command: target, args: actionOptions.arg ?? [], timeoutMs: actionOptions.timeoutMs };
2658
- } else {
2659
- throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
2660
- }
2661
- const saved = await createClient(options).addChannel(channel);
2662
- print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
2663
- });
2664
- webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
2665
- const channels = await createClient(options).listChannels();
2666
- if (wantsJson(actionOptions, command)) {
2667
- console.log(JSON.stringify(sanitizeChannelsForOutput(channels), null, 2));
2668
- return;
2669
- }
2670
- if (!channels.length) {
2671
- console.log("No channels configured.");
2672
- return;
2673
- }
2674
- for (const channel of channels) {
2675
- console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
2676
- }
2677
- });
2678
- webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
2679
- const removed = await createClient(options).removeChannel(id);
2680
- print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
2681
- });
2682
- webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
2683
- const result = await createClient(options).testChannel(id, {
2684
- source: options.source,
2685
- type: actionOptions.type,
2686
- subject: actionOptions.subject ?? id,
2687
- message: actionOptions.message,
2688
- data: parseJsonObject(actionOptions.data, { test: true })
2689
- });
2690
- print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
2691
- });
2692
- return webhooks;
2596
+ function linkFromRow(row) {
2597
+ return {
2598
+ ...row,
2599
+ active: Boolean(row.active),
2600
+ expires_at: nullableIso(row.expires_at),
2601
+ synced_at: nullableIso(row.synced_at),
2602
+ created_at: toIsoString(row.created_at),
2603
+ updated_at: toIsoString(row.updated_at),
2604
+ metadata: parseJsonObject(row.metadata),
2605
+ short_url: formatShortUrl(row.hostname, row.slug)
2606
+ };
2693
2607
  }
2694
- function registerEventCommands(program, options) {
2695
- const events = program.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
2696
- events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions, command) => {
2697
- const result = await createClient(options).emit({
2698
- source: actionOptions.source ?? options.source,
2699
- type,
2700
- subject: actionOptions.subject,
2701
- severity: actionOptions.severity,
2702
- message: actionOptions.message,
2703
- dedupeKey: actionOptions.dedupeKey,
2704
- data: parseJsonObject(actionOptions.data, {}),
2705
- metadata: parseJsonObject(actionOptions.metadata, {})
2706
- }, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
2707
- print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
2708
- });
2709
- events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
2710
- let rows = await createClient(options).listEvents();
2711
- if (actionOptions.source)
2712
- rows = rows.filter((event) => event.source === actionOptions.source);
2713
- if (actionOptions.type)
2714
- rows = rows.filter((event) => event.type === actionOptions.type);
2715
- if (actionOptions.limit)
2716
- rows = rows.slice(-actionOptions.limit);
2717
- if (wantsJson(actionOptions, command)) {
2718
- console.log(JSON.stringify(rows, null, 2));
2719
- return;
2720
- }
2721
- if (!rows.length) {
2722
- console.log("No events recorded.");
2723
- return;
2724
- }
2725
- for (const event of rows)
2726
- console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
2727
- });
2728
- events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
2729
- const result = await createClient(options).replay({
2730
- eventId: actionOptions.id,
2731
- source: actionOptions.source,
2732
- type: actionOptions.type,
2733
- dryRun: actionOptions.dryRun
2734
- });
2735
- print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
2736
- });
2737
- return events;
2608
+ function validateDestinationUrl(url) {
2609
+ let parsed;
2610
+ try {
2611
+ parsed = new URL(url);
2612
+ } catch {
2613
+ throw new Error(`Invalid destination URL: ${url}`);
2614
+ }
2615
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2616
+ throw new Error("Destination URL must start with http:// or https://.");
2617
+ }
2618
+ return parsed.toString();
2738
2619
  }
2739
- function registerEventsCommands(program, options) {
2740
- registerWebhookCommands(program, options);
2741
- registerEventCommands(program, options);
2620
+ function isoOrNull(input) {
2621
+ if (!input)
2622
+ return null;
2623
+ const date = new Date(input);
2624
+ if (Number.isNaN(date.getTime()))
2625
+ throw new Error(`Invalid date: ${input}`);
2626
+ return date.toISOString();
2742
2627
  }
2743
- function parseNumber(value) {
2744
- const parsed = Number(value);
2745
- if (!Number.isFinite(parsed))
2746
- throw new Error(`Expected a number, got ${value}`);
2747
- return parsed;
2628
+ function clickFromRow(row) {
2629
+ return {
2630
+ ...row,
2631
+ clicked_at: toIsoString(row.clicked_at),
2632
+ synced_at: nullableIso(row.synced_at),
2633
+ created_at: toIsoString(row.created_at),
2634
+ updated_at: toIsoString(row.updated_at),
2635
+ metadata: parseJsonObject(row.metadata)
2636
+ };
2748
2637
  }
2749
- function collectValues(value, previous) {
2750
- previous.push(value);
2751
- return previous;
2638
+ function createKitPgAdapter(client) {
2639
+ return {
2640
+ async get(sql, ...params) {
2641
+ return client.get(toPostgresSql(sql), params);
2642
+ },
2643
+ async all(sql, ...params) {
2644
+ return client.many(toPostgresSql(sql), params);
2645
+ },
2646
+ async run(sql, ...params) {
2647
+ return client.query(toPostgresSql(sql), params);
2648
+ }
2649
+ };
2752
2650
  }
2753
2651
 
2754
- // node_modules/commander/esm.mjs
2755
- var import__ = __toESM(require_commander(), 1);
2756
- var {
2757
- program,
2758
- createCommand,
2759
- createArgument,
2760
- createOption,
2761
- CommanderError,
2762
- InvalidArgumentError,
2763
- InvalidOptionArgumentError,
2764
- Command,
2765
- Argument,
2766
- Option,
2767
- Help
2768
- } = import__.default;
2769
-
2770
- // node_modules/chalk/source/vendor/ansi-styles/index.js
2771
- var ANSI_BACKGROUND_OFFSET = 10;
2772
- var wrapAnsi16 = (offset = 0) => (code) => `\x1B[${code + offset}m`;
2773
- var wrapAnsi256 = (offset = 0) => (code) => `\x1B[${38 + offset};5;${code}m`;
2774
- var wrapAnsi16m = (offset = 0) => (red, green, blue) => `\x1B[${38 + offset};2;${red};${green};${blue}m`;
2775
- var styles = {
2776
- modifier: {
2777
- reset: [0, 0],
2778
- bold: [1, 22],
2779
- dim: [2, 22],
2780
- italic: [3, 23],
2781
- underline: [4, 24],
2782
- overline: [53, 55],
2783
- inverse: [7, 27],
2784
- hidden: [8, 28],
2785
- strikethrough: [9, 29]
2786
- },
2787
- color: {
2788
- black: [30, 39],
2789
- red: [31, 39],
2790
- green: [32, 39],
2791
- yellow: [33, 39],
2792
- blue: [34, 39],
2793
- magenta: [35, 39],
2794
- cyan: [36, 39],
2795
- white: [37, 39],
2796
- blackBright: [90, 39],
2797
- gray: [90, 39],
2798
- grey: [90, 39],
2799
- redBright: [91, 39],
2800
- greenBright: [92, 39],
2801
- yellowBright: [93, 39],
2802
- blueBright: [94, 39],
2803
- magentaBright: [95, 39],
2804
- cyanBright: [96, 39],
2805
- whiteBright: [97, 39]
2806
- },
2807
- bgColor: {
2808
- bgBlack: [40, 49],
2809
- bgRed: [41, 49],
2810
- bgGreen: [42, 49],
2811
- bgYellow: [43, 49],
2812
- bgBlue: [44, 49],
2813
- bgMagenta: [45, 49],
2814
- bgCyan: [46, 49],
2815
- bgWhite: [47, 49],
2816
- bgBlackBright: [100, 49],
2817
- bgGray: [100, 49],
2818
- bgGrey: [100, 49],
2819
- bgRedBright: [101, 49],
2820
- bgGreenBright: [102, 49],
2821
- bgYellowBright: [103, 49],
2822
- bgBlueBright: [104, 49],
2823
- bgMagentaBright: [105, 49],
2824
- bgCyanBright: [106, 49],
2825
- bgWhiteBright: [107, 49]
2652
+ class PgShortlinksStore {
2653
+ pg;
2654
+ constructor(pg) {
2655
+ this.pg = pg;
2826
2656
  }
2827
- };
2828
- var modifierNames = Object.keys(styles.modifier);
2829
- var foregroundColorNames = Object.keys(styles.color);
2830
- var backgroundColorNames = Object.keys(styles.bgColor);
2831
- var colorNames = [...foregroundColorNames, ...backgroundColorNames];
2832
- function assembleStyles() {
2833
- const codes = new Map;
2834
- for (const [groupName, group] of Object.entries(styles)) {
2835
- for (const [styleName, style] of Object.entries(group)) {
2836
- styles[styleName] = {
2837
- open: `\x1B[${style[0]}m`,
2838
- close: `\x1B[${style[1]}m`
2839
- };
2840
- group[styleName] = styles[styleName];
2841
- codes.set(style[0], style[1]);
2657
+ static async fromConnectionString(connectionString, options = {}) {
2658
+ return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
2659
+ }
2660
+ static fromQueryClient(client) {
2661
+ return new PgShortlinksStore(createKitPgAdapter(client));
2662
+ }
2663
+ static async fromEnv(env = process.env) {
2664
+ const connectionString = getShortlinksDatabaseUrl(env);
2665
+ if (!connectionString) {
2666
+ throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
2842
2667
  }
2843
- Object.defineProperty(styles, groupName, {
2844
- value: group,
2845
- enumerable: false
2846
- });
2668
+ return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
2847
2669
  }
2848
- Object.defineProperty(styles, "codes", {
2849
- value: codes,
2850
- enumerable: false
2851
- });
2852
- styles.color.close = "\x1B[39m";
2853
- styles.bgColor.close = "\x1B[49m";
2854
- styles.color.ansi = wrapAnsi16();
2855
- styles.color.ansi256 = wrapAnsi256();
2856
- styles.color.ansi16m = wrapAnsi16m();
2857
- styles.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
2858
- styles.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
2859
- styles.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
2860
- Object.defineProperties(styles, {
2861
- rgbToAnsi256: {
2862
- value(red, green, blue) {
2863
- if (red === green && green === blue) {
2864
- if (red < 8) {
2865
- return 16;
2866
- }
2867
- if (red > 248) {
2868
- return 231;
2869
- }
2870
- return Math.round((red - 8) / 247 * 24) + 232;
2871
- }
2872
- return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
2873
- },
2874
- enumerable: false
2875
- },
2876
- hexToRgb: {
2877
- value(hex) {
2878
- const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
2879
- if (!matches) {
2880
- return [0, 0, 0];
2881
- }
2882
- let [colorString] = matches;
2883
- if (colorString.length === 3) {
2884
- colorString = [...colorString].map((character) => character + character).join("");
2885
- }
2886
- const integer = Number.parseInt(colorString, 16);
2887
- return [
2888
- integer >> 16 & 255,
2889
- integer >> 8 & 255,
2890
- integer & 255
2891
- ];
2892
- },
2893
- enumerable: false
2894
- },
2895
- hexToAnsi256: {
2896
- value: (hex) => styles.rgbToAnsi256(...styles.hexToRgb(hex)),
2897
- enumerable: false
2898
- },
2899
- ansi256ToAnsi: {
2900
- value(code) {
2901
- if (code < 8) {
2902
- return 30 + code;
2903
- }
2904
- if (code < 16) {
2905
- return 90 + (code - 8);
2906
- }
2907
- let red;
2908
- let green;
2909
- let blue;
2910
- if (code >= 232) {
2911
- red = ((code - 232) * 10 + 8) / 255;
2912
- green = red;
2913
- blue = red;
2914
- } else {
2915
- code -= 16;
2916
- const remainder = code % 36;
2917
- red = Math.floor(code / 36) / 5;
2918
- green = Math.floor(remainder / 6) / 5;
2919
- blue = remainder % 6 / 5;
2920
- }
2921
- const value = Math.max(red, green, blue) * 2;
2922
- if (value === 0) {
2923
- return 30;
2924
- }
2925
- let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
2926
- if (value === 2) {
2927
- result += 60;
2928
- }
2929
- return result;
2930
- },
2931
- enumerable: false
2932
- },
2933
- rgbToAnsi: {
2934
- value: (red, green, blue) => styles.ansi256ToAnsi(styles.rgbToAnsi256(red, green, blue)),
2935
- enumerable: false
2936
- },
2937
- hexToAnsi: {
2938
- value: (hex) => styles.ansi256ToAnsi(styles.hexToAnsi256(hex)),
2939
- enumerable: false
2940
- }
2941
- });
2942
- return styles;
2943
- }
2944
- var ansiStyles = assembleStyles();
2945
- var ansi_styles_default = ansiStyles;
2946
-
2947
- // node_modules/chalk/source/vendor/supports-color/index.js
2948
- import process2 from "process";
2949
- import os from "os";
2950
- import tty from "tty";
2951
- function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process2.argv) {
2952
- const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
2953
- const position = argv.indexOf(prefix + flag);
2954
- const terminatorPosition = argv.indexOf("--");
2955
- return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
2956
- }
2957
- var { env } = process2;
2958
- var flagForceColor;
2959
- if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
2960
- flagForceColor = 0;
2961
- } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
2962
- flagForceColor = 1;
2963
- }
2964
- function envForceColor() {
2965
- if ("FORCE_COLOR" in env) {
2966
- if (env.FORCE_COLOR === "true") {
2967
- return 1;
2968
- }
2969
- if (env.FORCE_COLOR === "false") {
2970
- return 0;
2670
+ async close() {
2671
+ await this.pg.close?.();
2672
+ }
2673
+ async addDomain(input) {
2674
+ const hostname2 = normalizeHostname(input.hostname);
2675
+ const timestamp = now();
2676
+ const machineId = getMachineId();
2677
+ const existing = await this.getDomain(hostname2);
2678
+ const id = existing?.id || makeId("dom");
2679
+ if (input.defaultDomain) {
2680
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
2971
2681
  }
2972
- return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3);
2682
+ await this.pg.run(`
2683
+ INSERT INTO domains (
2684
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
2685
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
2686
+ )
2687
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
2688
+ ON CONFLICT(hostname) DO UPDATE SET
2689
+ provider = excluded.provider,
2690
+ default_domain = excluded.default_domain,
2691
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
2692
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
2693
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
2694
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
2695
+ notes = COALESCE(excluded.notes, domains.notes),
2696
+ metadata = excluded.metadata,
2697
+ machine_id = excluded.machine_id,
2698
+ synced_at = NULL,
2699
+ updated_at = excluded.updated_at
2700
+ `, id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
2701
+ return await this.getDomain(hostname2);
2973
2702
  }
2974
- }
2975
- function translateLevel(level) {
2976
- if (level === 0) {
2977
- return false;
2703
+ async listDomains() {
2704
+ const rows = await this.pg.all(`
2705
+ SELECT * FROM domains
2706
+ ORDER BY default_domain DESC, hostname ASC
2707
+ `);
2708
+ return rows.map(domainFromRow);
2978
2709
  }
2979
- return {
2980
- level,
2981
- hasBasic: true,
2982
- has256: level >= 2,
2983
- has16m: level >= 3
2984
- };
2985
- }
2986
- function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
2987
- const noFlagForceColor = envForceColor();
2988
- if (noFlagForceColor !== undefined) {
2989
- flagForceColor = noFlagForceColor;
2710
+ async getDomain(hostnameOrId) {
2711
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
2712
+ const row = await this.pg.get(`
2713
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
2714
+ `, normalized, hostnameOrId);
2715
+ return row ? domainFromRow(row) : null;
2990
2716
  }
2991
- const forceColor = sniffFlags ? flagForceColor : noFlagForceColor;
2992
- if (forceColor === 0) {
2993
- return 0;
2717
+ async getDefaultDomain() {
2718
+ const row = await this.pg.get(`
2719
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
2720
+ `);
2721
+ return row ? domainFromRow(row) : null;
2994
2722
  }
2995
- if (sniffFlags) {
2996
- if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
2997
- return 3;
2723
+ async createLink(input) {
2724
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
2725
+ if (!domain) {
2726
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
2998
2727
  }
2999
- if (hasFlag("color=256")) {
3000
- return 2;
2728
+ const destinationUrl = validateDestinationUrl(input.destinationUrl);
2729
+ const timestamp = now();
2730
+ const machineId = getMachineId();
2731
+ const expiresAt = isoOrNull(input.expiresAt);
2732
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
2733
+ try {
2734
+ await this.pg.run(`
2735
+ INSERT INTO links (
2736
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
2737
+ machine_id, synced_at, created_at, updated_at
2738
+ )
2739
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
2740
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
2741
+ } catch (error) {
2742
+ const message = error instanceof Error ? error.message : String(error);
2743
+ if (message.includes("unique") || message.includes("duplicate")) {
2744
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
2745
+ }
2746
+ throw error;
3001
2747
  }
2748
+ return await this.getLink(domain.hostname, slug);
3002
2749
  }
3003
- if ("TF_BUILD" in env && "AGENT_NAME" in env) {
3004
- return 1;
3005
- }
3006
- if (haveStream && !streamIsTTY && forceColor === undefined) {
3007
- return 0;
3008
- }
3009
- const min = forceColor || 0;
3010
- if (env.TERM === "dumb") {
3011
- return min;
3012
- }
3013
- if (process2.platform === "win32") {
3014
- const osRelease = os.release().split(".");
3015
- if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
3016
- return Number(osRelease[2]) >= 14931 ? 3 : 2;
2750
+ async listLinks(options = {}) {
2751
+ const params = [];
2752
+ let where = "WHERE 1 = 1";
2753
+ if (options.domain) {
2754
+ where += " AND d.hostname = ?";
2755
+ params.push(normalizeHostname(options.domain));
3017
2756
  }
3018
- return 1;
2757
+ if (options.activeOnly)
2758
+ where += " AND l.active = 1";
2759
+ params.push(options.limit || 100);
2760
+ const rows = await this.pg.all(`
2761
+ SELECT l.*, d.hostname
2762
+ FROM links l
2763
+ JOIN domains d ON d.id = l.domain_id
2764
+ ${where}
2765
+ ORDER BY l.created_at DESC
2766
+ LIMIT ?
2767
+ `, ...params);
2768
+ return rows.map(linkFromRow);
3019
2769
  }
3020
- if ("CI" in env) {
3021
- if (["GITHUB_ACTIONS", "GITEA_ACTIONS", "CIRCLECI"].some((key) => (key in env))) {
3022
- return 3;
3023
- }
3024
- if (["TRAVIS", "APPVEYOR", "GITLAB_CI", "BUILDKITE", "DRONE"].some((sign) => (sign in env)) || env.CI_NAME === "codeship") {
3025
- return 1;
2770
+ async getLink(domainOrSlug, maybeSlug) {
2771
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
2772
+ const params = [slug];
2773
+ let domainClause = "";
2774
+ if (maybeSlug) {
2775
+ domainClause = "AND d.hostname = ?";
2776
+ params.push(normalizeHostname(domainOrSlug));
3026
2777
  }
3027
- return min;
3028
- }
3029
- if ("TEAMCITY_VERSION" in env) {
3030
- return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
2778
+ const row = await this.pg.get(`
2779
+ SELECT l.*, d.hostname
2780
+ FROM links l
2781
+ JOIN domains d ON d.id = l.domain_id
2782
+ WHERE l.slug = ? ${domainClause}
2783
+ ORDER BY d.default_domain DESC, l.created_at ASC
2784
+ LIMIT 1
2785
+ `, ...params);
2786
+ return row ? linkFromRow(row) : null;
3031
2787
  }
3032
- if (env.COLORTERM === "truecolor") {
3033
- return 3;
2788
+ async totalStats() {
2789
+ const row = await this.pg.get(`
2790
+ SELECT
2791
+ (SELECT COUNT(*)::int FROM domains) AS domains,
2792
+ (SELECT COUNT(*)::int FROM links) AS links,
2793
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
2794
+ `);
2795
+ return row;
3034
2796
  }
3035
- if (env.TERM === "xterm-kitty") {
3036
- return 3;
2797
+ async resolve(hostname2, slug) {
2798
+ const normalizedHost = normalizeHostname(hostname2);
2799
+ const normalizedSlug = normalizeSlug(slug);
2800
+ const row = await this.pg.get(`
2801
+ SELECT l.*, d.hostname
2802
+ FROM links l
2803
+ JOIN domains d ON d.id = l.domain_id
2804
+ WHERE d.hostname = ? AND l.slug = ?
2805
+ LIMIT 1
2806
+ `, normalizedHost, normalizedSlug);
2807
+ if (row)
2808
+ return linkFromRow(row);
2809
+ const fallback = await this.pg.get(`
2810
+ SELECT l.*, d.hostname
2811
+ FROM links l
2812
+ JOIN domains d ON d.id = l.domain_id
2813
+ WHERE d.default_domain = 1 AND l.slug = ?
2814
+ ORDER BY d.created_at ASC
2815
+ LIMIT 1
2816
+ `, normalizedSlug);
2817
+ return fallback ? linkFromRow(fallback) : null;
3037
2818
  }
3038
- if (env.TERM === "xterm-ghostty") {
3039
- return 3;
2819
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
2820
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
2821
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
2822
+ if (!link)
2823
+ throw new Error("Link not found.");
2824
+ const timestamp = now();
2825
+ await this.pg.run(`
2826
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
2827
+ `, active ? 1 : 0, timestamp, link.id);
2828
+ return await this.getLink(link.hostname, link.slug);
3040
2829
  }
3041
- if (env.TERM === "wezterm") {
3042
- return 3;
2830
+ async deleteLink(domainOrSlug, maybeSlug) {
2831
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
2832
+ if (!link)
2833
+ throw new Error("Link not found.");
2834
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
2835
+ return link;
3043
2836
  }
3044
- if ("TERM_PROGRAM" in env) {
3045
- const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
3046
- switch (env.TERM_PROGRAM) {
3047
- case "iTerm.app": {
3048
- return version >= 3 ? 3 : 2;
3049
- }
3050
- case "Apple_Terminal": {
3051
- return 2;
3052
- }
3053
- }
2837
+ async recordClick(link, input = {}) {
2838
+ const timestamp = now();
2839
+ const machineId = getMachineId();
2840
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
2841
+ const id = makeId("clk");
2842
+ await this.pg.run(`
2843
+ INSERT INTO clicks (
2844
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
2845
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
2846
+ )
2847
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
2848
+ `, id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
2849
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
2850
+ return clickFromRow(row);
3054
2851
  }
3055
- if (/-256(color)?$/i.test(env.TERM)) {
3056
- return 2;
2852
+ async getStats(domainOrSlug, maybeSlug) {
2853
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
2854
+ if (!link)
2855
+ throw new Error("Link not found.");
2856
+ const summary = await this.pg.get(`
2857
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
2858
+ `, link.id);
2859
+ const topReferrers = await this.pg.all(`
2860
+ SELECT referer, COUNT(*)::int AS clicks
2861
+ FROM clicks
2862
+ WHERE link_id = ?
2863
+ GROUP BY referer
2864
+ ORDER BY clicks DESC
2865
+ LIMIT 10
2866
+ `, link.id);
2867
+ const topUserAgents = await this.pg.all(`
2868
+ SELECT user_agent, COUNT(*)::int AS clicks
2869
+ FROM clicks
2870
+ WHERE link_id = ?
2871
+ GROUP BY user_agent
2872
+ ORDER BY clicks DESC
2873
+ LIMIT 10
2874
+ `, link.id);
2875
+ return {
2876
+ link,
2877
+ clicks: summary.clicks,
2878
+ last_clicked_at: nullableIso(summary.last_clicked_at),
2879
+ top_referrers: topReferrers,
2880
+ top_user_agents: topUserAgents
2881
+ };
3057
2882
  }
3058
- if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
3059
- return 1;
2883
+ hashIp(ip) {
2884
+ return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
3060
2885
  }
3061
- if ("COLORTERM" in env) {
3062
- return 1;
2886
+ async generateAvailableSlug(domainId, length) {
2887
+ for (let attempt = 0;attempt < 32; attempt += 1) {
2888
+ const slug = randomToken(length);
2889
+ const exists = await this.pg.get(`
2890
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
2891
+ `, domainId, slug);
2892
+ if (!exists)
2893
+ return slug;
2894
+ }
2895
+ throw new Error("Could not generate an unused slug after 32 attempts.");
3063
2896
  }
3064
- return min;
3065
- }
3066
- function createSupportsColor(stream, options = {}) {
3067
- const level = _supportsColor(stream, {
3068
- streamIsTTY: stream && stream.isTTY,
3069
- ...options
3070
- });
3071
- return translateLevel(level);
3072
2897
  }
3073
- var supportsColor = {
3074
- stdout: createSupportsColor({ isTTY: tty.isatty(1) }),
3075
- stderr: createSupportsColor({ isTTY: tty.isatty(2) })
3076
- };
3077
- var supports_color_default = supportsColor;
3078
-
3079
- // node_modules/chalk/source/utilities.js
3080
- function stringReplaceAll(string, substring, replacer) {
3081
- let index = string.indexOf(substring);
3082
- if (index === -1) {
3083
- return string;
2898
+ async function applyPostgresMigrations(connectionString, migrations, options = {}) {
2899
+ const Pool = await loadPgPool();
2900
+ const pool = new Pool(createPgPoolConfig(connectionString, options));
2901
+ const client = await pool.connect();
2902
+ const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
2903
+ const get = async (sql, ...params) => {
2904
+ const result = await run(sql, ...params);
2905
+ return result.rows[0] ?? null;
2906
+ };
2907
+ const applied = [];
2908
+ const skipped = [];
2909
+ try {
2910
+ await run("BEGIN");
2911
+ await run(`
2912
+ SELECT pg_advisory_xact_lock(hashtext(?))
2913
+ `, "shortlinks:migrations");
2914
+ await run(`
2915
+ CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
2916
+ id INTEGER PRIMARY KEY,
2917
+ service TEXT NOT NULL DEFAULT 'shortlinks',
2918
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
2919
+ )
2920
+ `);
2921
+ for (let i = 0;i < migrations.length; i += 1) {
2922
+ const id = i + 1;
2923
+ const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
2924
+ if (existing) {
2925
+ skipped.push(id);
2926
+ continue;
2927
+ }
2928
+ await run(migrations[i]);
2929
+ await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
2930
+ applied.push(id);
2931
+ }
2932
+ await run("COMMIT");
2933
+ return { service: "shortlinks", applied, skipped };
2934
+ } catch (error) {
2935
+ try {
2936
+ await run("ROLLBACK");
2937
+ } catch {}
2938
+ throw error;
2939
+ } finally {
2940
+ client.release();
2941
+ await pool.end();
3084
2942
  }
3085
- const substringLength = substring.length;
3086
- let endIndex = 0;
3087
- let returnValue = "";
3088
- do {
3089
- returnValue += string.slice(endIndex, index) + substring + replacer;
3090
- endIndex = index + substringLength;
3091
- index = string.indexOf(substring, endIndex);
3092
- } while (index !== -1);
3093
- returnValue += string.slice(endIndex);
3094
- return returnValue;
3095
- }
3096
- function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
3097
- let endIndex = 0;
3098
- let returnValue = "";
3099
- do {
3100
- const gotCR = string[index - 1] === "\r";
3101
- returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? `\r
3102
- ` : `
3103
- `) + postfix;
3104
- endIndex = index + 1;
3105
- index = string.indexOf(`
3106
- `, endIndex);
3107
- } while (index !== -1);
3108
- returnValue += string.slice(endIndex);
3109
- return returnValue;
3110
2943
  }
3111
2944
 
3112
- // node_modules/chalk/source/index.js
3113
- var { stdout: stdoutColor, stderr: stderrColor } = supports_color_default;
3114
- var GENERATOR = Symbol("GENERATOR");
3115
- var STYLER = Symbol("STYLER");
3116
- var IS_EMPTY = Symbol("IS_EMPTY");
3117
- var levelMapping = [
3118
- "ansi",
3119
- "ansi",
3120
- "ansi256",
3121
- "ansi16m"
3122
- ];
3123
- var styles2 = Object.create(null);
3124
- var applyOptions = (object, options = {}) => {
3125
- if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) {
3126
- throw new Error("The `level` option should be an integer from 0 to 3");
2945
+ // src/store.ts
2946
+ import { createHash as createHash2 } from "crypto";
2947
+ function parseJsonObject2(value) {
2948
+ if (!value)
2949
+ return {};
2950
+ try {
2951
+ const parsed = JSON.parse(value);
2952
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2953
+ } catch {
2954
+ return {};
3127
2955
  }
3128
- const colorLevel = stdoutColor ? stdoutColor.level : 0;
3129
- object.level = options.level === undefined ? colorLevel : options.level;
3130
- };
3131
- var chalkFactory = (options) => {
3132
- const chalk = (...strings) => strings.join(" ");
3133
- applyOptions(chalk, options);
3134
- Object.setPrototypeOf(chalk, createChalk.prototype);
3135
- return chalk;
3136
- };
3137
- function createChalk(options) {
3138
- return chalkFactory(options);
3139
2956
  }
3140
- Object.setPrototypeOf(createChalk.prototype, Function.prototype);
3141
- for (const [styleName, style] of Object.entries(ansi_styles_default)) {
3142
- styles2[styleName] = {
3143
- get() {
3144
- const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
3145
- Object.defineProperty(this, styleName, { value: builder });
3146
- return builder;
3147
- }
2957
+ function domainFromRow2(row) {
2958
+ return {
2959
+ ...row,
2960
+ default_domain: Boolean(row.default_domain),
2961
+ metadata: parseJsonObject2(row.metadata)
3148
2962
  };
3149
2963
  }
3150
- styles2.visible = {
3151
- get() {
3152
- const builder = createBuilder(this, this[STYLER], true);
3153
- Object.defineProperty(this, "visible", { value: builder });
3154
- return builder;
3155
- }
3156
- };
3157
- var getModelAnsi = (model, level, type, ...arguments_) => {
3158
- if (model === "rgb") {
3159
- if (level === "ansi16m") {
3160
- return ansi_styles_default[type].ansi16m(...arguments_);
3161
- }
3162
- if (level === "ansi256") {
3163
- return ansi_styles_default[type].ansi256(ansi_styles_default.rgbToAnsi256(...arguments_));
3164
- }
3165
- return ansi_styles_default[type].ansi(ansi_styles_default.rgbToAnsi(...arguments_));
3166
- }
3167
- if (model === "hex") {
3168
- return getModelAnsi("rgb", level, type, ...ansi_styles_default.hexToRgb(...arguments_));
3169
- }
3170
- return ansi_styles_default[type][model](...arguments_);
3171
- };
3172
- var usedModels = ["rgb", "hex", "ansi256"];
3173
- for (const model of usedModels) {
3174
- styles2[model] = {
3175
- get() {
3176
- const { level } = this;
3177
- return function(...arguments_) {
3178
- const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansi_styles_default.color.close, this[STYLER]);
3179
- return createBuilder(this, styler, this[IS_EMPTY]);
3180
- };
3181
- }
3182
- };
3183
- const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
3184
- styles2[bgModel] = {
3185
- get() {
3186
- const { level } = this;
3187
- return function(...arguments_) {
3188
- const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansi_styles_default.bgColor.close, this[STYLER]);
3189
- return createBuilder(this, styler, this[IS_EMPTY]);
3190
- };
3191
- }
2964
+ function linkFromRow2(row) {
2965
+ const config = loadConfig();
2966
+ const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
2967
+ return {
2968
+ ...row,
2969
+ active: Boolean(row.active),
2970
+ metadata: parseJsonObject2(row.metadata),
2971
+ short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
3192
2972
  };
3193
2973
  }
3194
- var proto = Object.defineProperties(() => {}, {
3195
- ...styles2,
3196
- level: {
3197
- enumerable: true,
3198
- get() {
3199
- return this[GENERATOR].level;
3200
- },
3201
- set(level) {
3202
- this[GENERATOR].level = level;
3203
- }
3204
- }
3205
- });
3206
- var createStyler = (open, close, parent) => {
3207
- let openAll;
3208
- let closeAll;
3209
- if (parent === undefined) {
3210
- openAll = open;
3211
- closeAll = close;
3212
- } else {
3213
- openAll = parent.openAll + open;
3214
- closeAll = close + parent.closeAll;
3215
- }
2974
+ function clickFromRow2(row) {
3216
2975
  return {
3217
- open,
3218
- close,
3219
- openAll,
3220
- closeAll,
3221
- parent
2976
+ ...row,
2977
+ metadata: parseJsonObject2(row.metadata)
3222
2978
  };
3223
- };
3224
- var createBuilder = (self, _styler, _isEmpty) => {
3225
- const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
3226
- Object.setPrototypeOf(builder, proto);
3227
- builder[GENERATOR] = self;
3228
- builder[STYLER] = _styler;
3229
- builder[IS_EMPTY] = _isEmpty;
3230
- return builder;
3231
- };
3232
- var applyStyle = (self, string) => {
3233
- if (self.level <= 0 || !string) {
3234
- return self[IS_EMPTY] ? "" : string;
3235
- }
3236
- let styler = self[STYLER];
3237
- if (styler === undefined) {
3238
- return string;
3239
- }
3240
- const { openAll, closeAll } = styler;
3241
- if (string.includes("\x1B")) {
3242
- while (styler !== undefined) {
3243
- string = stringReplaceAll(string, styler.close, styler.open);
3244
- styler = styler.parent;
3245
- }
3246
- }
3247
- const lfIndex = string.indexOf(`
3248
- `);
3249
- if (lfIndex !== -1) {
3250
- string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
3251
- }
3252
- return openAll + string + closeAll;
3253
- };
3254
- Object.defineProperties(createChalk.prototype, styles2);
3255
- var chalk = createChalk();
3256
- var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
3257
- var source_default = chalk;
3258
-
3259
- // src/cli/index.ts
3260
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
3261
- import { dirname as dirname3, join as join6 } from "path";
3262
- import { fileURLToPath } from "url";
3263
- import { spawnSync as spawnSync3 } from "child_process";
3264
-
3265
- // src/store.ts
3266
- import { createHash } from "crypto";
3267
-
3268
- // src/database.ts
3269
- import { Database } from "bun:sqlite";
3270
- import { mkdirSync as mkdirSync2 } from "fs";
3271
- import { dirname as dirname2 } from "path";
3272
-
3273
- // src/config.ts
3274
- import { existsSync as existsSync2, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3275
- import { randomBytes } from "crypto";
3276
- import { homedir as homedir2 } from "os";
3277
- import { dirname, join as join2, resolve } from "path";
3278
- var SERVICE_NAME = "shortlinks";
3279
- var DEFAULT_DATA_DIR = join2(homedir2(), ".hasna", SERVICE_NAME);
3280
- function getDataDir() {
3281
- return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
3282
- }
3283
- function ensureDataDir() {
3284
- const dir = getDataDir();
3285
- mkdirSync(dir, { recursive: true });
3286
- return dir;
3287
- }
3288
- function getConfigPath() {
3289
- return join2(ensureDataDir(), "config.json");
3290
- }
3291
- function getClickSaltPath() {
3292
- return join2(ensureDataDir(), "click-salt");
3293
- }
3294
- function getDatabasePath(explicitPath) {
3295
- if (explicitPath)
3296
- return resolve(explicitPath);
3297
- if (process.env.SHORTLINKS_DB)
3298
- return resolve(process.env.SHORTLINKS_DB);
3299
- return join2(ensureDataDir(), `${SERVICE_NAME}.db`);
3300
2979
  }
3301
- function readClickSaltFile(path) {
2980
+ function validateDestinationUrl2(url) {
2981
+ let parsed;
3302
2982
  try {
3303
- const saved = readFileSync(path, "utf-8").trim();
3304
- return saved || null;
2983
+ parsed = new URL(url);
3305
2984
  } 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);
2985
+ throw new Error(`Invalid destination URL: ${url}`);
3344
2986
  }
3345
- }
3346
- function loadConfig() {
3347
- const path = getConfigPath();
3348
- if (!existsSync2(path))
3349
- return {};
3350
- try {
3351
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
3352
- return parsed && typeof parsed === "object" ? parsed : {};
3353
- } catch {
3354
- return {};
2987
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2988
+ throw new Error("Destination URL must start with http:// or https://.");
3355
2989
  }
2990
+ return parsed.toString();
3356
2991
  }
3357
- function saveConfig(config) {
3358
- const path = getConfigPath();
3359
- mkdirSync(dirname(path), { recursive: true });
3360
- writeFileSync(path, `${JSON.stringify(config, null, 2)}
3361
- `);
3362
- }
3363
- function updateConfig(patch) {
3364
- const next = {
3365
- ...loadConfig(),
3366
- ...patch,
3367
- cloudflare: {
3368
- ...loadConfig().cloudflare,
3369
- ...patch.cloudflare
3370
- }
3371
- };
3372
- saveConfig(next);
3373
- return next;
2992
+ function isoOrNull2(input) {
2993
+ if (!input)
2994
+ return null;
2995
+ const date = new Date(input);
2996
+ if (Number.isNaN(date.getTime()))
2997
+ throw new Error(`Invalid date: ${input}`);
2998
+ return date.toISOString();
3374
2999
  }
3375
- function normalizeHostname(input) {
3376
- const raw = input.trim().toLowerCase();
3377
- if (!raw)
3378
- throw new Error("Domain is required.");
3379
- const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
3380
- let hostname;
3381
- try {
3382
- hostname = new URL(withProtocol).hostname;
3383
- } catch {
3384
- throw new Error(`Invalid domain: ${input}`);
3385
- }
3386
- hostname = hostname.replace(/\.$/, "");
3387
- const labels = hostname.split(".");
3388
- const labelsAreValid = labels.every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9-]+$/.test(label) && !label.startsWith("-") && !label.endsWith("-"));
3389
- if (hostname.length > 253 || !labelsAreValid) {
3390
- throw new Error(`Invalid domain: ${input}`);
3000
+
3001
+ class ShortlinksStore {
3002
+ database;
3003
+ constructor(dbPath) {
3004
+ this.database = new ShortlinksDatabase(dbPath);
3391
3005
  }
3392
- return hostname;
3393
- }
3394
- function formatShortUrl(hostname, slug, publicBaseUrl) {
3395
- if (publicBaseUrl) {
3396
- const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
3397
- return new URL(slug, base).toString();
3398
- }
3399
- return `https://${hostname}/${slug}`;
3400
- }
3401
-
3402
- // src/database.ts
3403
- function now2() {
3404
- return new Date().toISOString();
3405
- }
3406
- function makeId(prefix) {
3407
- const bytes = crypto.getRandomValues(new Uint8Array(12));
3408
- const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
3409
- return `${prefix}_${hex}`;
3410
- }
3411
- var SQLITE_MIGRATIONS = [
3412
- `
3413
- CREATE TABLE IF NOT EXISTS domains (
3414
- id TEXT PRIMARY KEY,
3415
- hostname TEXT NOT NULL UNIQUE,
3416
- provider TEXT NOT NULL DEFAULT 'manual',
3417
- default_domain INTEGER NOT NULL DEFAULT 0,
3418
- cloudflare_zone_id TEXT,
3419
- cloudflare_account_id TEXT,
3420
- cloudflare_worker_name TEXT,
3421
- origin_url TEXT,
3422
- notes TEXT,
3423
- metadata TEXT NOT NULL DEFAULT '{}',
3424
- machine_id TEXT,
3425
- synced_at TEXT,
3426
- created_at TEXT NOT NULL,
3427
- updated_at TEXT NOT NULL
3428
- );
3429
-
3430
- CREATE TABLE IF NOT EXISTS links (
3431
- id TEXT PRIMARY KEY,
3432
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
3433
- slug TEXT NOT NULL,
3434
- destination_url TEXT NOT NULL,
3435
- title TEXT,
3436
- active INTEGER NOT NULL DEFAULT 1,
3437
- expires_at TEXT,
3438
- metadata TEXT NOT NULL DEFAULT '{}',
3439
- machine_id TEXT,
3440
- synced_at TEXT,
3441
- created_at TEXT NOT NULL,
3442
- updated_at TEXT NOT NULL,
3443
- UNIQUE(domain_id, slug)
3444
- );
3445
-
3446
- CREATE TABLE IF NOT EXISTS clicks (
3447
- id TEXT PRIMARY KEY,
3448
- link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
3449
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
3450
- slug TEXT NOT NULL,
3451
- clicked_at TEXT NOT NULL,
3452
- ip_hash TEXT,
3453
- user_agent TEXT,
3454
- referer TEXT,
3455
- country TEXT,
3456
- city TEXT,
3457
- metadata TEXT NOT NULL DEFAULT '{}',
3458
- machine_id TEXT,
3459
- synced_at TEXT,
3460
- created_at TEXT NOT NULL,
3461
- updated_at TEXT NOT NULL
3462
- );
3463
-
3464
- CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
3465
- CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
3466
- CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
3467
- CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
3468
- CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
3469
- CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
3470
- CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
3471
- CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
3472
- CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
3473
- `
3474
- ];
3475
-
3476
- class ShortlinksDatabase {
3477
- db;
3478
- path;
3479
- constructor(path) {
3480
- this.path = getDatabasePath(path);
3481
- mkdirSync2(dirname2(this.path), { recursive: true });
3482
- this.db = new Database(this.path);
3483
- this.db.exec("PRAGMA foreign_keys = ON;");
3484
- this.applyMigrations();
3485
- }
3486
- close() {
3487
- this.db.close();
3488
- }
3489
- applyMigrations() {
3490
- this.db.exec(`
3491
- CREATE TABLE IF NOT EXISTS _migrations (
3492
- id INTEGER PRIMARY KEY,
3493
- applied_at TEXT NOT NULL
3494
- );
3495
- `);
3496
- for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
3497
- const id = i + 1;
3498
- const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
3499
- if (applied)
3500
- continue;
3501
- const migration = SQLITE_MIGRATIONS[i];
3502
- const apply = this.db.transaction(() => {
3503
- this.db.exec(migration);
3504
- this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now2());
3505
- });
3506
- apply();
3507
- }
3508
- }
3509
- }
3510
-
3511
- // src/machine.ts
3512
- import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
3513
- import { hostname } from "os";
3514
- import { join as join3 } from "path";
3515
-
3516
- // src/slug.ts
3517
- import { randomBytes as randomBytes2 } from "crypto";
3518
- var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
3519
- var DEFAULT_SLUG_LENGTH = 7;
3520
- function randomToken(length = DEFAULT_SLUG_LENGTH) {
3521
- if (length < 1 || length > 128)
3522
- throw new Error("Token length must be between 1 and 128.");
3523
- const bytes = randomBytes2(length);
3524
- let out = "";
3525
- for (let i = 0;i < length; i += 1) {
3526
- out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
3527
- }
3528
- return out;
3529
- }
3530
- function normalizeSlug(slug) {
3531
- const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
3532
- if (!normalized)
3533
- throw new Error("Slug is required.");
3534
- if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
3535
- throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
3536
- }
3537
- return normalized;
3538
- }
3539
-
3540
- // src/machine.ts
3541
- function getMachineId() {
3542
- const path = join3(ensureDataDir(), "machine-id");
3543
- if (existsSync3(path)) {
3544
- const existing = readFileSync2(path, "utf-8").trim();
3545
- if (existing)
3546
- return existing;
3547
- }
3548
- const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
3549
- const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
3550
- writeFileSync2(path, `${id}
3551
- `);
3552
- return id;
3553
- }
3554
-
3555
- // src/store.ts
3556
- function parseJsonObject2(value) {
3557
- if (!value)
3558
- return {};
3559
- try {
3560
- const parsed = JSON.parse(value);
3561
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3562
- } catch {
3563
- return {};
3564
- }
3565
- }
3566
- function domainFromRow(row) {
3567
- return {
3568
- ...row,
3569
- default_domain: Boolean(row.default_domain),
3570
- metadata: parseJsonObject2(row.metadata)
3571
- };
3572
- }
3573
- function linkFromRow(row) {
3574
- const config = loadConfig();
3575
- const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
3576
- return {
3577
- ...row,
3578
- active: Boolean(row.active),
3579
- metadata: parseJsonObject2(row.metadata),
3580
- short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
3581
- };
3582
- }
3583
- function clickFromRow(row) {
3584
- return {
3585
- ...row,
3586
- metadata: parseJsonObject2(row.metadata)
3587
- };
3588
- }
3589
- function validateDestinationUrl(url) {
3590
- let parsed;
3591
- try {
3592
- parsed = new URL(url);
3593
- } catch {
3594
- throw new Error(`Invalid destination URL: ${url}`);
3595
- }
3596
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
3597
- throw new Error("Destination URL must start with http:// or https://.");
3598
- }
3599
- return parsed.toString();
3600
- }
3601
- function isoOrNull(input) {
3602
- if (!input)
3603
- return null;
3604
- const date = new Date(input);
3605
- if (Number.isNaN(date.getTime()))
3606
- throw new Error(`Invalid date: ${input}`);
3607
- return date.toISOString();
3608
- }
3609
-
3610
- class ShortlinksStore {
3611
- database;
3612
- constructor(dbPath) {
3613
- this.database = new ShortlinksDatabase(dbPath);
3614
- }
3615
- close() {
3616
- this.database.close();
3006
+ close() {
3007
+ this.database.close();
3617
3008
  }
3618
3009
  addDomain(input) {
3619
3010
  const hostname2 = normalizeHostname(input.hostname);
3620
- const timestamp = now2();
3011
+ const timestamp = now();
3621
3012
  const machineId = getMachineId();
3622
3013
  const existing = this.getDomain(hostname2);
3623
3014
  const id = existing?.id || makeId("dom");
@@ -3651,14 +3042,14 @@ class ShortlinksStore {
3651
3042
  SELECT * FROM domains
3652
3043
  ORDER BY default_domain DESC, hostname ASC
3653
3044
  `).all();
3654
- return rows.map(domainFromRow);
3045
+ return rows.map(domainFromRow2);
3655
3046
  }
3656
3047
  getDomain(hostnameOrId) {
3657
3048
  const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
3658
3049
  const row = this.database.db.query(`
3659
3050
  SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
3660
3051
  `).get(normalized, hostnameOrId);
3661
- return row ? domainFromRow(row) : null;
3052
+ return row ? domainFromRow2(row) : null;
3662
3053
  }
3663
3054
  getDefaultDomain() {
3664
3055
  const config = loadConfig();
@@ -3670,17 +3061,17 @@ class ShortlinksStore {
3670
3061
  const row = this.database.db.query(`
3671
3062
  SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
3672
3063
  `).get();
3673
- return row ? domainFromRow(row) : null;
3064
+ return row ? domainFromRow2(row) : null;
3674
3065
  }
3675
3066
  createLink(input) {
3676
3067
  const domain = input.domain ? this.getDomain(input.domain) : this.getDefaultDomain();
3677
3068
  if (!domain) {
3678
3069
  throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
3679
3070
  }
3680
- const destinationUrl = validateDestinationUrl(input.destinationUrl);
3681
- const timestamp = now2();
3071
+ const destinationUrl = validateDestinationUrl2(input.destinationUrl);
3072
+ const timestamp = now();
3682
3073
  const machineId = getMachineId();
3683
- const expiresAt = isoOrNull(input.expiresAt);
3074
+ const expiresAt = isoOrNull2(input.expiresAt);
3684
3075
  const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
3685
3076
  try {
3686
3077
  this.database.db.query(`
@@ -3718,7 +3109,7 @@ class ShortlinksStore {
3718
3109
  ORDER BY l.created_at DESC
3719
3110
  LIMIT ?
3720
3111
  `).all(...params);
3721
- return rows.map(linkFromRow);
3112
+ return rows.map(linkFromRow2);
3722
3113
  }
3723
3114
  getLink(domainOrSlug, maybeSlug) {
3724
3115
  const slug = normalizeSlug(maybeSlug || domainOrSlug);
@@ -3736,7 +3127,7 @@ class ShortlinksStore {
3736
3127
  ORDER BY d.default_domain DESC, l.created_at ASC
3737
3128
  LIMIT 1
3738
3129
  `).get(...params);
3739
- return row ? linkFromRow(row) : null;
3130
+ return row ? linkFromRow2(row) : null;
3740
3131
  }
3741
3132
  resolve(hostname2, slug) {
3742
3133
  const normalizedSlug = normalizeSlug(slug);
@@ -3749,7 +3140,7 @@ class ShortlinksStore {
3749
3140
  LIMIT 1
3750
3141
  `).get(normalizedHost, normalizedSlug);
3751
3142
  if (row)
3752
- return linkFromRow(row);
3143
+ return linkFromRow2(row);
3753
3144
  const fallback = this.getDefaultDomain();
3754
3145
  if (!fallback || fallback.hostname === normalizedHost)
3755
3146
  return null;
@@ -3760,7 +3151,7 @@ class ShortlinksStore {
3760
3151
  const link = typeof maybeSlugOrActive === "boolean" ? this.getLink(domainOrSlug) : this.getLink(domainOrSlug, maybeSlugOrActive);
3761
3152
  if (!link)
3762
3153
  throw new Error("Link not found.");
3763
- const timestamp = now2();
3154
+ const timestamp = now();
3764
3155
  this.database.db.query(`
3765
3156
  UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
3766
3157
  `).run(active ? 1 : 0, timestamp, link.id);
@@ -3774,7 +3165,7 @@ class ShortlinksStore {
3774
3165
  return link;
3775
3166
  }
3776
3167
  recordClick(link, input = {}) {
3777
- const timestamp = now2();
3168
+ const timestamp = now();
3778
3169
  const machineId = getMachineId();
3779
3170
  const ipHash = input.ip ? this.hashIp(input.ip) : null;
3780
3171
  const id = makeId("clk");
@@ -3786,7 +3177,7 @@ class ShortlinksStore {
3786
3177
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
3787
3178
  `).run(id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
3788
3179
  const row = this.database.db.query("SELECT * FROM clicks WHERE id = ?").get(id);
3789
- return clickFromRow(row);
3180
+ return clickFromRow2(row);
3790
3181
  }
3791
3182
  getStats(domainOrSlug, maybeSlug) {
3792
3183
  const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
@@ -3840,562 +3231,1647 @@ class ShortlinksStore {
3840
3231
  throw new Error("Could not generate an unused slug after 32 attempts.");
3841
3232
  }
3842
3233
  hashIp(ip) {
3843
- return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
3234
+ return createHash2("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
3844
3235
  }
3845
3236
  }
3846
3237
 
3847
- // src/pg-store.ts
3848
- import { createHash as createHash2 } from "crypto";
3849
- function parseJsonObject3(value) {
3850
- if (!value)
3851
- return {};
3852
- if (typeof value === "object" && !Array.isArray(value))
3853
- return value;
3854
- try {
3855
- const parsed = JSON.parse(String(value));
3856
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3857
- } catch {
3858
- return {};
3859
- }
3238
+ // src/server.ts
3239
+ var REDIRECT_ALLOW_HEADER = "GET, HEAD";
3240
+ function json(data, status = 200, headers) {
3241
+ const responseHeaders = new Headers(headers);
3242
+ responseHeaders.set("content-type", "application/json; charset=utf-8");
3243
+ return new Response(JSON.stringify(data, null, 2), {
3244
+ status,
3245
+ headers: responseHeaders
3246
+ });
3860
3247
  }
3861
- function toIsoString(value) {
3862
- if (value instanceof Date)
3863
- return value.toISOString();
3864
- return String(value);
3248
+ function getHost(request, fallback) {
3249
+ const forwarded = request.headers.get("x-forwarded-host");
3250
+ const host = forwarded || request.headers.get("host") || fallback || "";
3251
+ return host.split(",")[0].trim().split(":")[0];
3865
3252
  }
3866
- function nullableIso(value) {
3867
- if (value === null || value === undefined)
3868
- return null;
3869
- return toIsoString(value);
3253
+ function getClientIp(request) {
3254
+ const forwarded = request.headers.get("x-forwarded-for");
3255
+ if (forwarded)
3256
+ return forwarded.split(",")[0].trim();
3257
+ return request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip");
3870
3258
  }
3871
- function domainFromRow2(row) {
3872
- return {
3873
- ...row,
3874
- default_domain: Boolean(row.default_domain),
3875
- synced_at: nullableIso(row.synced_at),
3876
- created_at: toIsoString(row.created_at),
3877
- updated_at: toIsoString(row.updated_at),
3878
- metadata: parseJsonObject3(row.metadata)
3259
+ function isExpired(link) {
3260
+ return Boolean(link.expires_at && new Date(link.expires_at).getTime() <= Date.now());
3261
+ }
3262
+ function logRecordClickError(link) {
3263
+ console.error(`[shortlinks] Click analytics recording failed for ${link.hostname}/${link.slug}.`);
3264
+ }
3265
+ function createShortlinksHandler(options = {}) {
3266
+ const store = options.store || new ShortlinksStore(options.dbPath);
3267
+ const redirectStatus = options.redirectStatus || 302;
3268
+ return async (request) => {
3269
+ const url = new URL(request.url);
3270
+ if (url.pathname === "/healthz") {
3271
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
3272
+ }
3273
+ if (url.pathname === "/" || url.pathname === "") {
3274
+ return json({ service: "shortlinks", ok: true });
3275
+ }
3276
+ let slug = "";
3277
+ try {
3278
+ slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
3279
+ } catch {
3280
+ return json({ error: "Invalid slug." }, 400);
3281
+ }
3282
+ if (!slug)
3283
+ return json({ error: "Missing slug." }, 404);
3284
+ if (request.method !== "GET" && request.method !== "HEAD") {
3285
+ return json({ error: "Method not allowed." }, 405, { allow: REDIRECT_ALLOW_HEADER });
3286
+ }
3287
+ const host = getHost(request, options.defaultHost);
3288
+ if (!host)
3289
+ return json({ error: "Missing Host header." }, 400);
3290
+ let link = null;
3291
+ try {
3292
+ link = await store.resolve(host, slug);
3293
+ } catch {
3294
+ return json({ error: "Shortlink not found.", slug, host }, 404);
3295
+ }
3296
+ if (!link)
3297
+ return json({ error: "Shortlink not found.", slug, host }, 404);
3298
+ if (!link.active)
3299
+ return json({ error: "Shortlink is disabled.", slug, host }, 410);
3300
+ if (isExpired(link))
3301
+ return json({ error: "Shortlink is expired.", slug, host }, 410);
3302
+ if (request.method === "GET") {
3303
+ try {
3304
+ await store.recordClick(link, {
3305
+ ip: getClientIp(request),
3306
+ userAgent: request.headers.get("user-agent"),
3307
+ referer: request.headers.get("referer"),
3308
+ country: request.headers.get("cf-ipcountry"),
3309
+ metadata: {
3310
+ path: url.pathname,
3311
+ query: url.search
3312
+ }
3313
+ });
3314
+ } catch (error) {
3315
+ if (options.onRecordClickError) {
3316
+ try {
3317
+ await options.onRecordClickError(error, { link, request });
3318
+ } catch {
3319
+ logRecordClickError(link);
3320
+ }
3321
+ } else {
3322
+ logRecordClickError(link);
3323
+ }
3324
+ }
3325
+ }
3326
+ return Response.redirect(link.destination_url, redirectStatus);
3879
3327
  };
3880
3328
  }
3881
- function linkFromRow2(row) {
3329
+ function serveShortlinks(options = {}) {
3330
+ const host = options.host || "127.0.0.1";
3331
+ const port = options.port || 8787;
3332
+ const fetch2 = createShortlinksHandler(options);
3333
+ return Bun.serve({ hostname: host, port, fetch: fetch2 });
3334
+ }
3335
+
3336
+ // src/cloudflare.ts
3337
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
3338
+ import { join as join3 } from "path";
3339
+ function createCloudflarePlan(input) {
3340
+ const hostname2 = normalizeHostname(input.hostname);
3341
+ const target = normalizeHostname(input.target);
3342
+ const workerName = input.workerName || "shortlinks";
3343
+ const proxied = input.proxied ?? true;
3882
3344
  return {
3883
- ...row,
3884
- active: Boolean(row.active),
3885
- expires_at: nullableIso(row.expires_at),
3886
- synced_at: nullableIso(row.synced_at),
3887
- created_at: toIsoString(row.created_at),
3888
- updated_at: toIsoString(row.updated_at),
3889
- metadata: parseJsonObject3(row.metadata),
3890
- short_url: formatShortUrl(row.hostname, row.slug)
3345
+ hostname: hostname2,
3346
+ target,
3347
+ proxied,
3348
+ workerName,
3349
+ origin: input.origin,
3350
+ dnsRecord: {
3351
+ type: "CNAME",
3352
+ name: hostname2,
3353
+ content: target,
3354
+ proxied
3355
+ },
3356
+ wranglerCommand: `wrangler deploy cloudflare/${workerName}.js --name ${workerName}`
3891
3357
  };
3892
3358
  }
3893
- function validateDestinationUrl2(url) {
3894
- let parsed;
3895
- try {
3896
- parsed = new URL(url);
3897
- } catch {
3898
- throw new Error(`Invalid destination URL: ${url}`);
3899
- }
3900
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
3901
- throw new Error("Destination URL must start with http:// or https://.");
3359
+ function generateWorkerScript() {
3360
+ return `export default {
3361
+ async fetch(request, env) {
3362
+ const origin = env.SHORTLINKS_ORIGIN;
3363
+ if (!origin) {
3364
+ return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
3365
+ }
3366
+
3367
+ const incoming = new URL(request.url);
3368
+ const upstream = new URL(incoming.pathname + incoming.search, origin);
3369
+ const headers = new Headers(request.headers);
3370
+ headers.set("x-forwarded-host", incoming.host);
3371
+ headers.set("x-shortlinks-worker", "cloudflare");
3372
+
3373
+ return fetch(upstream.toString(), {
3374
+ method: request.method,
3375
+ headers,
3376
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
3377
+ redirect: "manual"
3378
+ });
3902
3379
  }
3903
- return parsed.toString();
3380
+ };
3381
+ `;
3904
3382
  }
3905
- function isoOrNull2(input) {
3906
- if (!input)
3907
- return null;
3908
- const date = new Date(input);
3909
- if (Number.isNaN(date.getTime()))
3910
- throw new Error(`Invalid date: ${input}`);
3911
- return date.toISOString();
3383
+ function writeWorkerFiles(options = {}) {
3384
+ const outDir = options.outDir || "cloudflare";
3385
+ const workerName = options.workerName || "shortlinks";
3386
+ mkdirSync3(outDir, { recursive: true });
3387
+ const workerPath = join3(outDir, `${workerName}.js`);
3388
+ const wranglerPath = join3(outDir, "wrangler.example.toml");
3389
+ writeFileSync3(workerPath, generateWorkerScript());
3390
+ writeFileSync3(wranglerPath, `name = "${workerName}"
3391
+ main = "${workerName}.js"
3392
+ compatibility_date = "2026-05-01"
3393
+
3394
+ [vars]
3395
+ SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
3396
+ `);
3397
+ return { workerPath, wranglerPath };
3912
3398
  }
3913
- function clickFromRow2(row) {
3914
- return {
3915
- ...row,
3916
- clicked_at: toIsoString(row.clicked_at),
3917
- synced_at: nullableIso(row.synced_at),
3918
- created_at: toIsoString(row.created_at),
3919
- updated_at: toIsoString(row.updated_at),
3920
- metadata: parseJsonObject3(row.metadata)
3921
- };
3399
+ function cloudflareAuthHeaders(token) {
3400
+ const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
3401
+ if (apiToken)
3402
+ return { authorization: `Bearer ${apiToken}` };
3403
+ const apiKey = process.env.CLOUDFLARE_API_KEY;
3404
+ const email = process.env.CLOUDFLARE_EMAIL;
3405
+ if (apiKey && email) {
3406
+ return {
3407
+ "x-auth-key": apiKey,
3408
+ "x-auth-email": email
3409
+ };
3410
+ }
3411
+ throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
3922
3412
  }
3923
-
3924
- class PgShortlinksStore {
3925
- pg;
3926
- constructor(pg) {
3927
- this.pg = pg;
3413
+ async function cloudflareRequest(token, path, init = {}) {
3414
+ const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
3415
+ ...init,
3416
+ headers: {
3417
+ ...cloudflareAuthHeaders(token),
3418
+ "content-type": "application/json",
3419
+ ...init.headers || {}
3420
+ }
3421
+ });
3422
+ const body = await response.json();
3423
+ if (!response.ok || body.success === false) {
3424
+ const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
3425
+ throw new Error(`Cloudflare API failed: ${message}`);
3928
3426
  }
3929
- static async fromConnectionString(connectionString) {
3930
- const { PgAdapterAsync } = await import("@hasna/cloud");
3931
- return new PgShortlinksStore(new PgAdapterAsync(connectionString));
3427
+ return body.result;
3428
+ }
3429
+ function candidateZones(hostname2) {
3430
+ const parts = normalizeHostname(hostname2).split(".");
3431
+ const candidates = [];
3432
+ for (let i = 0;i < parts.length - 1; i += 1) {
3433
+ candidates.push(parts.slice(i).join("."));
3932
3434
  }
3933
- static async fromCloud(service = "shortlinks") {
3934
- const { getConnectionString } = await import("@hasna/cloud");
3935
- return PgShortlinksStore.fromConnectionString(getConnectionString(service));
3435
+ return candidates;
3436
+ }
3437
+ async function findCloudflareZoneId(hostname2, token) {
3438
+ for (const zone of candidateZones(hostname2)) {
3439
+ const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
3440
+ if (result[0]?.id)
3441
+ return result[0].id;
3936
3442
  }
3937
- async close() {
3938
- await this.pg.close?.();
3443
+ throw new Error(`Could not find a Cloudflare zone for ${hostname2}. Pass --zone-id explicitly.`);
3444
+ }
3445
+ async function upsertCloudflareDnsRecord(options) {
3446
+ const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
3447
+ const plan = createCloudflarePlan({
3448
+ hostname: options.hostname,
3449
+ target: options.target,
3450
+ origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
3451
+ proxied: options.proxied
3452
+ });
3453
+ if (options.dryRun)
3454
+ return plan;
3455
+ const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
3456
+ const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
3457
+ const payload = JSON.stringify(plan.dnsRecord);
3458
+ if (existing[0]?.id) {
3459
+ const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
3460
+ method: "PUT",
3461
+ body: payload
3462
+ });
3463
+ return { id: updated.id, action: "updated" };
3939
3464
  }
3940
- async addDomain(input) {
3941
- const hostname2 = normalizeHostname(input.hostname);
3942
- const timestamp = now2();
3943
- const machineId = getMachineId();
3944
- const existing = await this.getDomain(hostname2);
3945
- const id = existing?.id || makeId("dom");
3946
- if (input.defaultDomain) {
3947
- await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
3465
+ const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
3466
+ method: "POST",
3467
+ body: payload
3468
+ });
3469
+ return { id: created.id, action: "created" };
3470
+ }
3471
+
3472
+ // node_modules/.pnpm/@hasna+events@0.1.13/node_modules/@hasna/events/dist/commander.js
3473
+ import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
3474
+ import { existsSync as existsSync3 } from "fs";
3475
+ import { homedir as homedir2 } from "os";
3476
+ import { join as join4 } from "path";
3477
+ import { createHmac, timingSafeEqual } from "crypto";
3478
+ import { randomUUID } from "crypto";
3479
+ import { spawn } from "child_process";
3480
+ import { randomUUID as randomUUID2 } from "crypto";
3481
+ function getPathValue(input, path) {
3482
+ return path.split(".").reduce((value, part) => {
3483
+ if (value && typeof value === "object" && part in value) {
3484
+ return value[part];
3485
+ }
3486
+ return;
3487
+ }, input);
3488
+ }
3489
+ function getFieldValues(input, path) {
3490
+ const values = [];
3491
+ const push = (value) => {
3492
+ if (!values.some((item) => Object.is(item, value)))
3493
+ values.push(value);
3494
+ };
3495
+ if (path.includes(".") && path in input)
3496
+ push(input[path]);
3497
+ const nestedValue = getPathValue(input, path);
3498
+ if (nestedValue !== undefined || !path.includes("."))
3499
+ push(nestedValue);
3500
+ return values;
3501
+ }
3502
+ function wildcardToRegExp(pattern, options = {}) {
3503
+ let body = "";
3504
+ for (let index = 0;index < pattern.length; index += 1) {
3505
+ const char = pattern[index];
3506
+ if (char === "*") {
3507
+ if (pattern[index + 1] === "*") {
3508
+ body += ".*";
3509
+ index += 1;
3510
+ } else {
3511
+ body += options.segmentSafe ? "[^/]*" : ".*";
3512
+ }
3513
+ } else {
3514
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
3948
3515
  }
3949
- await this.pg.run(`
3950
- INSERT INTO domains (
3951
- id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
3952
- cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
3953
- )
3954
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
3955
- ON CONFLICT(hostname) DO UPDATE SET
3956
- provider = excluded.provider,
3957
- default_domain = excluded.default_domain,
3958
- cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
3959
- cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
3960
- cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
3961
- origin_url = COALESCE(excluded.origin_url, domains.origin_url),
3962
- notes = COALESCE(excluded.notes, domains.notes),
3963
- metadata = excluded.metadata,
3964
- machine_id = excluded.machine_id,
3965
- synced_at = NULL,
3966
- updated_at = excluded.updated_at
3967
- `, id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
3968
- return await this.getDomain(hostname2);
3969
3516
  }
3970
- async listDomains() {
3971
- const rows = await this.pg.all(`
3972
- SELECT * FROM domains
3973
- ORDER BY default_domain DESC, hostname ASC
3974
- `);
3975
- return rows.map(domainFromRow2);
3517
+ return new RegExp(`^${body}$`);
3518
+ }
3519
+ function matchString(value, matcher, options = {}) {
3520
+ if (matcher === undefined)
3521
+ return true;
3522
+ if (value === undefined)
3523
+ return false;
3524
+ const matchers = Array.isArray(matcher) ? matcher : [matcher];
3525
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
3526
+ }
3527
+ function matchRecord(input, matcher) {
3528
+ if (!matcher)
3529
+ return true;
3530
+ return Object.entries(matcher).every(([path, expected]) => {
3531
+ const actualValues = getFieldValues(input, path);
3532
+ return matchField(actualValues, expected, path);
3533
+ });
3534
+ }
3535
+ function matchField(actualValues, expected, path) {
3536
+ if (isNegativeMatcher(expected)) {
3537
+ return !actualValues.some((actual) => matchPositiveField(actual, expected.not, path));
3976
3538
  }
3977
- async getDomain(hostnameOrId) {
3978
- const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
3979
- const row = await this.pg.get(`
3980
- SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
3981
- `, normalized, hostnameOrId);
3982
- return row ? domainFromRow2(row) : null;
3539
+ return actualValues.some((actual) => matchPositiveField(actual, expected, path));
3540
+ }
3541
+ function matchPositiveField(actual, expected, path) {
3542
+ if (typeof expected === "string" || Array.isArray(expected)) {
3543
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
3544
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
3545
+ }));
3983
3546
  }
3984
- async getDefaultDomain() {
3985
- const row = await this.pg.get(`
3986
- SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
3987
- `);
3988
- return row ? domainFromRow2(row) : null;
3547
+ if (Array.isArray(actual)) {
3548
+ return actual.some((item) => item === expected);
3989
3549
  }
3990
- async createLink(input) {
3991
- const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
3992
- if (!domain) {
3993
- throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
3994
- }
3995
- const destinationUrl = validateDestinationUrl2(input.destinationUrl);
3996
- const timestamp = now2();
3997
- const machineId = getMachineId();
3998
- const expiresAt = isoOrNull2(input.expiresAt);
3999
- const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
4000
- try {
4001
- await this.pg.run(`
4002
- INSERT INTO links (
4003
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
4004
- machine_id, synced_at, created_at, updated_at
4005
- )
4006
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
4007
- `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
4008
- } catch (error) {
4009
- const message = error instanceof Error ? error.message : String(error);
4010
- if (message.includes("unique") || message.includes("duplicate")) {
4011
- throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
4012
- }
4013
- throw error;
4014
- }
4015
- return await this.getLink(domain.hostname, slug);
3550
+ return actual === expected;
3551
+ }
3552
+ function stringCandidates(actual) {
3553
+ if (actual === undefined)
3554
+ return [];
3555
+ if (Array.isArray(actual)) {
3556
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
4016
3557
  }
4017
- async listLinks(options = {}) {
4018
- const params = [];
4019
- let where = "WHERE 1 = 1";
4020
- if (options.domain) {
4021
- where += " AND d.hostname = ?";
4022
- params.push(normalizeHostname(options.domain));
4023
- }
4024
- if (options.activeOnly)
4025
- where += " AND l.active = 1";
4026
- params.push(options.limit || 100);
4027
- const rows = await this.pg.all(`
4028
- SELECT l.*, d.hostname
4029
- FROM links l
4030
- JOIN domains d ON d.id = l.domain_id
4031
- ${where}
4032
- ORDER BY l.created_at DESC
4033
- LIMIT ?
4034
- `, ...params);
4035
- return rows.map(linkFromRow2);
3558
+ return [String(actual)];
3559
+ }
3560
+ function isPrimitiveFieldValue(value) {
3561
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
3562
+ }
3563
+ function isNegativeMatcher(value) {
3564
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
3565
+ }
3566
+ function eventMatchesFilter(event, filter) {
3567
+ return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
3568
+ }
3569
+ function channelMatchesEvent(channel, event) {
3570
+ if (!channel.enabled)
3571
+ return false;
3572
+ if (!channel.filters || channel.filters.length === 0)
3573
+ return true;
3574
+ return channel.filters.some((filter) => eventMatchesFilter(event, filter));
3575
+ }
3576
+ var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
3577
+ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
3578
+ function getEventsDataDir(override) {
3579
+ return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join4(homedir2(), ".hasna", "events");
3580
+ }
3581
+ function getActiveEventsDirEnv() {
3582
+ if (process.env[HASNA_EVENTS_DIR_ENV])
3583
+ return HASNA_EVENTS_DIR_ENV;
3584
+ if (process.env[HASNA_EVENTS_HOME_ENV])
3585
+ return HASNA_EVENTS_HOME_ENV;
3586
+ return null;
3587
+ }
3588
+
3589
+ class JsonEventsStore {
3590
+ dataDir;
3591
+ channelsPath;
3592
+ eventsPath;
3593
+ deliveriesPath;
3594
+ constructor(dataDir = getEventsDataDir()) {
3595
+ this.dataDir = dataDir;
3596
+ this.channelsPath = join4(dataDir, "channels.json");
3597
+ this.eventsPath = join4(dataDir, "events.json");
3598
+ this.deliveriesPath = join4(dataDir, "deliveries.json");
4036
3599
  }
4037
- async getLink(domainOrSlug, maybeSlug) {
4038
- const slug = normalizeSlug(maybeSlug || domainOrSlug);
4039
- const params = [slug];
4040
- let domainClause = "";
4041
- if (maybeSlug) {
4042
- domainClause = "AND d.hostname = ?";
4043
- params.push(normalizeHostname(domainOrSlug));
3600
+ async init() {
3601
+ await mkdir(this.dataDir, { recursive: true, mode: 448 });
3602
+ await chmod(this.dataDir, 448).catch(() => {
3603
+ return;
3604
+ });
3605
+ await this.ensureArrayFile(this.channelsPath);
3606
+ await this.ensureArrayFile(this.eventsPath);
3607
+ await this.ensureArrayFile(this.deliveriesPath);
3608
+ }
3609
+ async addChannel(channel) {
3610
+ await this.init();
3611
+ const channels = await this.readJson(this.channelsPath, []);
3612
+ const index = channels.findIndex((item) => item.id === channel.id);
3613
+ if (index >= 0) {
3614
+ channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
3615
+ } else {
3616
+ channels.push(channel);
4044
3617
  }
4045
- const row = await this.pg.get(`
4046
- SELECT l.*, d.hostname
4047
- FROM links l
4048
- JOIN domains d ON d.id = l.domain_id
4049
- WHERE l.slug = ? ${domainClause}
4050
- ORDER BY d.default_domain DESC, l.created_at ASC
4051
- LIMIT 1
4052
- `, ...params);
4053
- return row ? linkFromRow2(row) : null;
3618
+ await this.writeJson(this.channelsPath, channels);
3619
+ return index >= 0 ? channels[index] : channel;
4054
3620
  }
4055
- async totalStats() {
4056
- const row = await this.pg.get(`
4057
- SELECT
4058
- (SELECT COUNT(*)::int FROM domains) AS domains,
4059
- (SELECT COUNT(*)::int FROM links) AS links,
4060
- (SELECT COUNT(*)::int FROM clicks) AS clicks
4061
- `);
4062
- return row;
3621
+ async listChannels() {
3622
+ await this.init();
3623
+ return this.readJson(this.channelsPath, []);
4063
3624
  }
4064
- async resolve(hostname2, slug) {
4065
- const normalizedHost = normalizeHostname(hostname2);
4066
- const normalizedSlug = normalizeSlug(slug);
4067
- const row = await this.pg.get(`
4068
- SELECT l.*, d.hostname
4069
- FROM links l
4070
- JOIN domains d ON d.id = l.domain_id
4071
- WHERE d.hostname = ? AND l.slug = ?
4072
- LIMIT 1
4073
- `, normalizedHost, normalizedSlug);
4074
- if (row)
4075
- return linkFromRow2(row);
4076
- const fallback = await this.pg.get(`
4077
- SELECT l.*, d.hostname
4078
- FROM links l
4079
- JOIN domains d ON d.id = l.domain_id
4080
- WHERE d.default_domain = 1 AND l.slug = ?
4081
- ORDER BY d.created_at ASC
4082
- LIMIT 1
4083
- `, normalizedSlug);
4084
- return fallback ? linkFromRow2(fallback) : null;
3625
+ async getChannel(id) {
3626
+ const channels = await this.listChannels();
3627
+ return channels.find((channel) => channel.id === id);
4085
3628
  }
4086
- async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
4087
- const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
4088
- const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
4089
- if (!link)
4090
- throw new Error("Link not found.");
4091
- const timestamp = now2();
4092
- await this.pg.run(`
4093
- UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
4094
- `, active ? 1 : 0, timestamp, link.id);
4095
- return await this.getLink(link.hostname, link.slug);
3629
+ async removeChannel(id) {
3630
+ await this.init();
3631
+ const channels = await this.readJson(this.channelsPath, []);
3632
+ const next = channels.filter((channel) => channel.id !== id);
3633
+ await this.writeJson(this.channelsPath, next);
3634
+ return next.length !== channels.length;
4096
3635
  }
4097
- async deleteLink(domainOrSlug, maybeSlug) {
4098
- const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
4099
- if (!link)
4100
- throw new Error("Link not found.");
4101
- await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
4102
- return link;
3636
+ async appendEvent(event) {
3637
+ await this.init();
3638
+ const events = await this.readJson(this.eventsPath, []);
3639
+ events.push(event);
3640
+ await this.writeJson(this.eventsPath, events);
3641
+ return event;
4103
3642
  }
4104
- async recordClick(link, input = {}) {
4105
- const timestamp = now2();
4106
- const machineId = getMachineId();
4107
- const ipHash = input.ip ? this.hashIp(input.ip) : null;
4108
- const id = makeId("clk");
4109
- await this.pg.run(`
4110
- INSERT INTO clicks (
4111
- id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
4112
- country, city, metadata, machine_id, synced_at, created_at, updated_at
4113
- )
4114
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
4115
- `, id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
4116
- const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
4117
- return clickFromRow2(row);
3643
+ async listEvents() {
3644
+ await this.init();
3645
+ return this.readJson(this.eventsPath, []);
4118
3646
  }
4119
- async getStats(domainOrSlug, maybeSlug) {
4120
- const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
4121
- if (!link)
4122
- throw new Error("Link not found.");
4123
- const summary = await this.pg.get(`
4124
- SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
4125
- `, link.id);
4126
- const topReferrers = await this.pg.all(`
4127
- SELECT referer, COUNT(*)::int AS clicks
4128
- FROM clicks
4129
- WHERE link_id = ?
4130
- GROUP BY referer
4131
- ORDER BY clicks DESC
4132
- LIMIT 10
4133
- `, link.id);
4134
- const topUserAgents = await this.pg.all(`
4135
- SELECT user_agent, COUNT(*)::int AS clicks
4136
- FROM clicks
4137
- WHERE link_id = ?
4138
- GROUP BY user_agent
4139
- ORDER BY clicks DESC
4140
- LIMIT 10
4141
- `, link.id);
3647
+ async findEventByIdentity(identity) {
3648
+ const events = await this.listEvents();
3649
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
3650
+ }
3651
+ async appendDelivery(result) {
3652
+ await this.init();
3653
+ const deliveries = await this.readJson(this.deliveriesPath, []);
3654
+ deliveries.push(result);
3655
+ await this.writeJson(this.deliveriesPath, deliveries);
3656
+ return result;
3657
+ }
3658
+ async listDeliveries() {
3659
+ await this.init();
3660
+ return this.readJson(this.deliveriesPath, []);
3661
+ }
3662
+ async exportData() {
4142
3663
  return {
4143
- link,
4144
- clicks: summary.clicks,
4145
- last_clicked_at: nullableIso(summary.last_clicked_at),
4146
- top_referrers: topReferrers,
4147
- top_user_agents: topUserAgents
3664
+ channels: await this.listChannels(),
3665
+ events: await this.listEvents(),
3666
+ deliveries: await this.listDeliveries()
4148
3667
  };
4149
3668
  }
4150
- hashIp(ip) {
4151
- return createHash2("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
3669
+ async ensureArrayFile(path) {
3670
+ if (!existsSync3(path)) {
3671
+ await writeFile(path, `[]
3672
+ `, { encoding: "utf-8", mode: 384 });
3673
+ }
3674
+ await chmod(path, 384).catch(() => {
3675
+ return;
3676
+ });
4152
3677
  }
4153
- async generateAvailableSlug(domainId, length) {
4154
- for (let attempt = 0;attempt < 32; attempt += 1) {
4155
- const slug = randomToken(length);
4156
- const exists = await this.pg.get(`
4157
- SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
4158
- `, domainId, slug);
4159
- if (!exists)
4160
- return slug;
3678
+ async readJson(path, fallback) {
3679
+ try {
3680
+ const raw = await readFile(path, "utf-8");
3681
+ if (!raw.trim())
3682
+ return fallback;
3683
+ return JSON.parse(raw);
3684
+ } catch (error) {
3685
+ if (error.code === "ENOENT")
3686
+ return fallback;
3687
+ throw error;
4161
3688
  }
4162
- throw new Error("Could not generate an unused slug after 32 attempts.");
3689
+ }
3690
+ async writeJson(path, value) {
3691
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
3692
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
3693
+ `, { encoding: "utf-8", mode: 384 });
3694
+ await rename(tempPath, path);
3695
+ await chmod(path, 384).catch(() => {
3696
+ return;
3697
+ });
4163
3698
  }
4164
3699
  }
4165
-
4166
- // src/server.ts
4167
- var REDIRECT_ALLOW_HEADER = "GET, HEAD";
4168
- function json(data, status = 200, headers) {
4169
- const responseHeaders = new Headers(headers);
4170
- responseHeaders.set("content-type", "application/json; charset=utf-8");
4171
- return new Response(JSON.stringify(data, null, 2), {
3700
+ async function getEventsStatus(dataDir) {
3701
+ const store = new JsonEventsStore(dataDir);
3702
+ await store.init();
3703
+ const [channels, events, deliveries] = await Promise.all([
3704
+ store.listChannels(),
3705
+ store.listEvents(),
3706
+ store.listDeliveries()
3707
+ ]);
3708
+ const transports = channels.reduce((counts, channel) => {
3709
+ counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
3710
+ return counts;
3711
+ }, {});
3712
+ return {
3713
+ service: "events",
3714
+ schemaVersion: "1.0",
3715
+ dataDir: store.dataDir,
3716
+ env: {
3717
+ primary: HASNA_EVENTS_DIR_ENV,
3718
+ fallback: HASNA_EVENTS_HOME_ENV,
3719
+ active: getActiveEventsDirEnv()
3720
+ },
3721
+ files: {
3722
+ channels: statusFile(store.dataDir, "channels.json", channels.length),
3723
+ events: statusFile(store.dataDir, "events.json", events.length),
3724
+ deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
3725
+ },
3726
+ counts: {
3727
+ channels: channels.length,
3728
+ enabledChannels: channels.filter((channel) => channel.enabled).length,
3729
+ disabledChannels: channels.filter((channel) => !channel.enabled).length,
3730
+ events: events.length,
3731
+ deliveries: deliveries.length
3732
+ },
3733
+ transports,
3734
+ safety: {
3735
+ includesEventPayloads: false,
3736
+ includesWebhookSecrets: false,
3737
+ listOutputsRedactSecrets: true,
3738
+ statusOutputIsMetadataOnly: true
3739
+ }
3740
+ };
3741
+ }
3742
+ function statusFile(dataDir, fileName, records) {
3743
+ const path = join4(dataDir, fileName);
3744
+ return { path, exists: existsSync3(path), records };
3745
+ }
3746
+ var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
3747
+ function buildSignatureBase(timestamp, body) {
3748
+ return `${timestamp}.${body}`;
3749
+ }
3750
+ function signPayload(secret, timestamp, body) {
3751
+ const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
3752
+ return `sha256=${digest}`;
3753
+ }
3754
+ function now2() {
3755
+ return new Date().toISOString();
3756
+ }
3757
+ function truncate(value, max = 4096) {
3758
+ return value.length > max ? `${value.slice(0, max)}...` : value;
3759
+ }
3760
+ function buildWebhookRequest(event, channel) {
3761
+ if (!channel.webhook)
3762
+ throw new Error(`Channel ${channel.id} has no webhook config`);
3763
+ const body = JSON.stringify(event);
3764
+ const timestamp = event.time;
3765
+ const headers = {
3766
+ "Content-Type": "application/json",
3767
+ "User-Agent": "@hasna/events",
3768
+ "X-Hasna-Event-Id": event.id,
3769
+ "X-Hasna-Event-Type": event.type,
3770
+ "X-Hasna-Timestamp": timestamp,
3771
+ ...channel.webhook.headers
3772
+ };
3773
+ if (channel.webhook.secret) {
3774
+ headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
3775
+ }
3776
+ return { body, headers };
3777
+ }
3778
+ async function dispatchWebhook(event, channel, options = {}) {
3779
+ if (!channel.webhook)
3780
+ throw new Error(`Channel ${channel.id} has no webhook config`);
3781
+ const startedAt = now2();
3782
+ const { body, headers } = buildWebhookRequest(event, channel);
3783
+ const controller = new AbortController;
3784
+ const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
3785
+ try {
3786
+ const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
3787
+ method: "POST",
3788
+ headers,
3789
+ body,
3790
+ signal: controller.signal
3791
+ });
3792
+ const responseBody = truncate(await response.text());
3793
+ return {
3794
+ attempt: 1,
3795
+ status: response.ok ? "success" : "failed",
3796
+ startedAt,
3797
+ completedAt: now2(),
3798
+ responseStatus: response.status,
3799
+ responseBody,
3800
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
3801
+ };
3802
+ } catch (error) {
3803
+ return {
3804
+ attempt: 1,
3805
+ status: "failed",
3806
+ startedAt,
3807
+ completedAt: now2(),
3808
+ error: error instanceof Error ? error.message : String(error)
3809
+ };
3810
+ } finally {
3811
+ clearTimeout(timeout);
3812
+ }
3813
+ }
3814
+ async function dispatchCommand(event, channel) {
3815
+ if (!channel.command)
3816
+ throw new Error(`Channel ${channel.id} has no command config`);
3817
+ const startedAt = now2();
3818
+ const eventJson = JSON.stringify(event);
3819
+ const env = {
3820
+ ...process.env,
3821
+ ...channel.command.env,
3822
+ HASNA_CHANNEL_ID: channel.id,
3823
+ HASNA_EVENT_ID: event.id,
3824
+ HASNA_EVENT_TYPE: event.type,
3825
+ HASNA_EVENT_SOURCE: event.source,
3826
+ HASNA_EVENT_SUBJECT: event.subject ?? "",
3827
+ HASNA_EVENT_SEVERITY: event.severity,
3828
+ HASNA_EVENT_TIME: event.time,
3829
+ HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
3830
+ HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
3831
+ HASNA_EVENT_JSON: eventJson
3832
+ };
3833
+ return new Promise((resolve2) => {
3834
+ const child = spawn(channel.command.command, channel.command.args ?? [], {
3835
+ cwd: channel.command.cwd,
3836
+ env,
3837
+ stdio: ["pipe", "pipe", "pipe"]
3838
+ });
3839
+ let stdout = "";
3840
+ let stderr = "";
3841
+ const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
3842
+ child.stdin.end(eventJson);
3843
+ child.stdout.on("data", (chunk) => {
3844
+ stdout += chunk.toString();
3845
+ });
3846
+ child.stderr.on("data", (chunk) => {
3847
+ stderr += chunk.toString();
3848
+ });
3849
+ child.on("error", (error) => {
3850
+ clearTimeout(timeout);
3851
+ resolve2({
3852
+ attempt: 1,
3853
+ status: "failed",
3854
+ startedAt,
3855
+ completedAt: now2(),
3856
+ stdout: truncate(stdout),
3857
+ stderr: truncate(stderr),
3858
+ error: error.message
3859
+ });
3860
+ });
3861
+ child.on("close", (code, signal) => {
3862
+ clearTimeout(timeout);
3863
+ const success = code === 0;
3864
+ resolve2({
3865
+ attempt: 1,
3866
+ status: success ? "success" : "failed",
3867
+ startedAt,
3868
+ completedAt: now2(),
3869
+ stdout: truncate(stdout),
3870
+ stderr: truncate(stderr),
3871
+ error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
3872
+ });
3873
+ });
3874
+ });
3875
+ }
3876
+ async function dispatchChannel(event, channel, options = {}) {
3877
+ if (channel.transport === "webhook")
3878
+ return dispatchWebhook(event, channel, options);
3879
+ if (channel.transport === "command")
3880
+ return dispatchCommand(event, channel);
3881
+ return {
3882
+ attempt: 1,
3883
+ status: "skipped",
3884
+ startedAt: now2(),
3885
+ completedAt: now2(),
3886
+ error: `Unsupported transport: ${channel.transport}`
3887
+ };
3888
+ }
3889
+ function createDeliveryResult(event, channel, attempts) {
3890
+ const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
3891
+ return {
3892
+ id: randomUUID(),
3893
+ eventId: event.id,
3894
+ channelId: channel.id,
3895
+ transport: channel.transport,
4172
3896
  status,
4173
- headers: responseHeaders
3897
+ attempts,
3898
+ createdAt: attempts[0]?.startedAt ?? now2(),
3899
+ completedAt: attempts.at(-1)?.completedAt ?? now2()
3900
+ };
3901
+ }
3902
+ function createEvent(input) {
3903
+ return {
3904
+ id: input.id ?? randomUUID2(),
3905
+ source: input.source,
3906
+ type: input.type,
3907
+ time: normalizeTime(input.time),
3908
+ subject: input.subject,
3909
+ severity: input.severity ?? "info",
3910
+ data: input.data ?? {},
3911
+ message: input.message,
3912
+ dedupeKey: input.dedupeKey,
3913
+ schemaVersion: input.schemaVersion ?? "1.0",
3914
+ metadata: input.metadata ?? {}
3915
+ };
3916
+ }
3917
+
3918
+ class EventsClient {
3919
+ store;
3920
+ redactors;
3921
+ transportOptions;
3922
+ constructor(options = {}) {
3923
+ this.store = options.store ?? new JsonEventsStore(options.dataDir);
3924
+ this.redactors = options.redactors ?? [];
3925
+ this.transportOptions = { fetchImpl: options.fetchImpl };
3926
+ }
3927
+ async addChannel(input) {
3928
+ const timestamp = new Date().toISOString();
3929
+ return this.store.addChannel({
3930
+ ...input,
3931
+ createdAt: input.createdAt ?? timestamp,
3932
+ updatedAt: input.updatedAt ?? timestamp
3933
+ });
3934
+ }
3935
+ async listChannels() {
3936
+ return this.store.listChannels();
3937
+ }
3938
+ async removeChannel(id) {
3939
+ return this.store.removeChannel(id);
3940
+ }
3941
+ async emit(input, options = {}) {
3942
+ const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
3943
+ if (options.dedupe !== false) {
3944
+ const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
3945
+ if (existing) {
3946
+ return { event: existing, deliveries: [], deduped: true };
3947
+ }
3948
+ }
3949
+ await this.store.appendEvent(event);
3950
+ const deliveries = options.deliver === false ? [] : await this.deliver(event);
3951
+ return { event, deliveries, deduped: false };
3952
+ }
3953
+ async listEvents() {
3954
+ return this.store.listEvents();
3955
+ }
3956
+ async listDeliveries() {
3957
+ return this.store.listDeliveries();
3958
+ }
3959
+ async deliver(event) {
3960
+ const channels = await this.store.listChannels();
3961
+ const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
3962
+ const deliveries = [];
3963
+ for (const channel of selected) {
3964
+ const eventForChannel = await this.applyRedaction(event, channel);
3965
+ const result = await this.deliverWithRetry(eventForChannel, channel);
3966
+ await this.store.appendDelivery(result);
3967
+ deliveries.push(result);
3968
+ }
3969
+ return deliveries;
3970
+ }
3971
+ async matchChannel(id, input = {}) {
3972
+ const channel = await this.store.getChannel(id);
3973
+ if (!channel)
3974
+ throw new Error(`Channel not found: ${id}`);
3975
+ const event = createEvent({
3976
+ source: input.source ?? "hasna.events",
3977
+ type: input.type ?? "events.test",
3978
+ subject: input.subject ?? id,
3979
+ severity: input.severity ?? "info",
3980
+ data: input.data ?? { test: true },
3981
+ message: input.message ?? "Hasna events test delivery",
3982
+ dedupeKey: input.dedupeKey,
3983
+ schemaVersion: input.schemaVersion,
3984
+ metadata: input.metadata,
3985
+ time: input.time,
3986
+ id: input.id
3987
+ });
3988
+ const matched = channelMatchesEvent(channel, event);
3989
+ return {
3990
+ channelId: channel.id,
3991
+ matched,
3992
+ event,
3993
+ filters: channel.filters,
3994
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
3995
+ };
3996
+ }
3997
+ async testChannel(id, input = {}, options = {}) {
3998
+ const channel = await this.store.getChannel(id);
3999
+ if (!channel)
4000
+ throw new Error(`Channel not found: ${id}`);
4001
+ const match = await this.matchChannel(id, input);
4002
+ const event = match.event;
4003
+ if (options.honorFilters && !match.matched) {
4004
+ const timestamp = new Date().toISOString();
4005
+ const result2 = createDeliveryResult(event, channel, [{
4006
+ attempt: 1,
4007
+ status: "skipped",
4008
+ startedAt: timestamp,
4009
+ completedAt: timestamp,
4010
+ error: match.reason
4011
+ }]);
4012
+ result2.metadata = { reason: "filter_mismatch" };
4013
+ await this.store.appendDelivery(result2);
4014
+ return result2;
4015
+ }
4016
+ const eventForChannel = await this.applyRedaction(event, channel);
4017
+ const result = await this.deliverWithRetry(eventForChannel, channel);
4018
+ await this.store.appendDelivery(result);
4019
+ return result;
4020
+ }
4021
+ async replay(options = {}) {
4022
+ const events = (await this.store.listEvents()).filter((event) => {
4023
+ if (options.eventId && event.id !== options.eventId)
4024
+ return false;
4025
+ if (options.source && event.source !== options.source)
4026
+ return false;
4027
+ if (options.type && event.type !== options.type)
4028
+ return false;
4029
+ return true;
4030
+ });
4031
+ if (options.dryRun)
4032
+ return { events, deliveries: [] };
4033
+ const deliveries = [];
4034
+ for (const event of events) {
4035
+ deliveries.push(...await this.deliver(event));
4036
+ }
4037
+ return { events, deliveries };
4038
+ }
4039
+ async applyRedaction(event, channel) {
4040
+ let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
4041
+ for (const redactor of this.redactors) {
4042
+ next = await redactor(next, channel);
4043
+ }
4044
+ return next;
4045
+ }
4046
+ async deliverWithRetry(event, channel) {
4047
+ const policy = normalizeRetryPolicy(channel.retry);
4048
+ const attempts = [];
4049
+ for (let index = 0;index < policy.maxAttempts; index += 1) {
4050
+ const attempt = await dispatchChannel(event, channel, this.transportOptions);
4051
+ attempt.attempt = index + 1;
4052
+ if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
4053
+ attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
4054
+ }
4055
+ attempts.push(attempt);
4056
+ if (attempt.status !== "failed")
4057
+ break;
4058
+ if (attempt.nextBackoffMs)
4059
+ await Bun.sleep(attempt.nextBackoffMs);
4060
+ }
4061
+ return createDeliveryResult(event, channel, attempts);
4062
+ }
4063
+ }
4064
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
4065
+ if (paths.length === 0)
4066
+ return event;
4067
+ const copy = structuredClone(event);
4068
+ for (const path of paths) {
4069
+ setPath(copy, path, replacement);
4070
+ }
4071
+ return copy;
4072
+ }
4073
+ function sanitizeChannelForOutput(channel) {
4074
+ const copy = structuredClone(channel);
4075
+ if (copy.webhook?.secret)
4076
+ copy.webhook.secret = "[REDACTED]";
4077
+ if (copy.command?.env) {
4078
+ copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
4079
+ }
4080
+ return copy;
4081
+ }
4082
+ function sanitizeChannelsForOutput(channels) {
4083
+ return channels.map(sanitizeChannelForOutput);
4084
+ }
4085
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
4086
+ return redactValue(event, replacement);
4087
+ }
4088
+ function shouldRedactKey(key) {
4089
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
4090
+ }
4091
+ function redactValue(value, replacement) {
4092
+ if (Array.isArray(value))
4093
+ return value.map((item) => redactValue(item, replacement));
4094
+ if (!value || typeof value !== "object")
4095
+ return value;
4096
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
4097
+ key,
4098
+ shouldRedactKey(key) ? replacement : redactValue(item, replacement)
4099
+ ]));
4100
+ }
4101
+ function setPath(input, path, replacement) {
4102
+ const parts = path.split(".");
4103
+ let cursor = input;
4104
+ for (const part of parts.slice(0, -1)) {
4105
+ const next = cursor[part];
4106
+ if (!next || typeof next !== "object")
4107
+ return;
4108
+ cursor = next;
4109
+ }
4110
+ const last = parts.at(-1);
4111
+ if (last && last in cursor)
4112
+ cursor[last] = replacement;
4113
+ }
4114
+ function normalizeTime(value) {
4115
+ if (!value)
4116
+ return new Date().toISOString();
4117
+ return value instanceof Date ? value.toISOString() : value;
4118
+ }
4119
+ function normalizeRetryPolicy(policy) {
4120
+ return {
4121
+ maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
4122
+ backoffMs: Math.max(0, policy?.backoffMs ?? 250),
4123
+ multiplier: Math.max(1, policy?.multiplier ?? 2)
4124
+ };
4125
+ }
4126
+ function parseFieldMatchers(values, label, typed = false) {
4127
+ if (!values?.length)
4128
+ return;
4129
+ const result = {};
4130
+ for (const value of values) {
4131
+ const parsed = parseMatcherExpression(value, label);
4132
+ const path = parsed.path;
4133
+ if (path in result)
4134
+ throw new Error(`Duplicate ${label} filter path: ${path}`);
4135
+ const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
4136
+ result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
4137
+ }
4138
+ return result;
4139
+ }
4140
+ function parseFilterOptions(options) {
4141
+ const filter2 = {};
4142
+ if (options.source)
4143
+ filter2.source = options.source;
4144
+ if (options.type)
4145
+ filter2.type = options.type;
4146
+ if (options.subject)
4147
+ filter2.subject = options.subject;
4148
+ if (options.severity)
4149
+ filter2.severity = options.severity;
4150
+ const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
4151
+ const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
4152
+ if (Object.keys(data).length > 0)
4153
+ filter2.data = data;
4154
+ if (Object.keys(metadata).length > 0)
4155
+ filter2.metadata = metadata;
4156
+ return Object.keys(filter2).length > 0 ? [filter2] : undefined;
4157
+ }
4158
+ function mergeMatchers(...records) {
4159
+ const result = {};
4160
+ for (const record of records) {
4161
+ if (!record)
4162
+ continue;
4163
+ for (const [path, value] of Object.entries(record)) {
4164
+ if (path in result)
4165
+ throw new Error(`Duplicate filter path: ${path}`);
4166
+ result[path] = value;
4167
+ }
4168
+ }
4169
+ return result;
4170
+ }
4171
+ function parseTypedMatcherValue(value, label) {
4172
+ const parsed = JSON.parse(value);
4173
+ if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
4174
+ return parsed;
4175
+ }
4176
+ throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
4177
+ }
4178
+ function parseMatcherExpression(value, label) {
4179
+ const negativeSeparator = value.indexOf("!=");
4180
+ if (negativeSeparator > 0) {
4181
+ return {
4182
+ path: value.slice(0, negativeSeparator),
4183
+ rawValue: value.slice(negativeSeparator + 2),
4184
+ negated: true
4185
+ };
4186
+ }
4187
+ const separator = value.indexOf("=");
4188
+ if (separator <= 0)
4189
+ throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
4190
+ return {
4191
+ path: value.slice(0, separator),
4192
+ rawValue: value.slice(separator + 1),
4193
+ negated: false
4194
+ };
4195
+ }
4196
+ function parseJsonObject3(value, fallback) {
4197
+ if (!value)
4198
+ return fallback;
4199
+ const parsed = JSON.parse(value);
4200
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4201
+ throw new Error("Expected a JSON object");
4202
+ }
4203
+ return parsed;
4204
+ }
4205
+ function parseHeaders(values) {
4206
+ if (!values?.length)
4207
+ return;
4208
+ const headers = {};
4209
+ for (const value of values) {
4210
+ const separator = value.indexOf("=");
4211
+ if (separator === -1)
4212
+ throw new Error(`Invalid header, expected name=value: ${value}`);
4213
+ headers[value.slice(0, separator)] = value.slice(separator + 1);
4214
+ }
4215
+ return headers;
4216
+ }
4217
+ function createClient(options) {
4218
+ if (options.createClient)
4219
+ return options.createClient();
4220
+ return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
4221
+ }
4222
+ function print(value, json2, text) {
4223
+ if (json2)
4224
+ console.log(JSON.stringify(value, null, 2));
4225
+ else
4226
+ console.log(text);
4227
+ }
4228
+ function hasJsonOption(options) {
4229
+ return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
4230
+ }
4231
+ function wantsJson(actionOptions, command) {
4232
+ return hasJsonOption(actionOptions) || hasJsonOption(command);
4233
+ }
4234
+ function registerChannelCommands(program, options) {
4235
+ const channels = program.command(options.channelsCommandName ?? "channels").description("Manage Hasna event channels");
4236
+ channels.command("add").description("Add or replace a channel").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--data <path=value...>", "Event data field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
4237
+ const timestamp = new Date().toISOString();
4238
+ const channel = {
4239
+ id: actionOptions.id,
4240
+ name: actionOptions.name,
4241
+ enabled: !actionOptions.disabled,
4242
+ transport: actionOptions.transport,
4243
+ filters: parseFilterOptions(actionOptions),
4244
+ retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
4245
+ redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
4246
+ createdAt: timestamp,
4247
+ updatedAt: timestamp
4248
+ };
4249
+ if (actionOptions.transport === "webhook") {
4250
+ channel.webhook = { url: target, secret: actionOptions.secret, headers: parseHeaders(actionOptions.header), timeoutMs: actionOptions.timeoutMs };
4251
+ } else if (actionOptions.transport === "command") {
4252
+ channel.command = { command: target, args: actionOptions.arg ?? [], timeoutMs: actionOptions.timeoutMs };
4253
+ } else {
4254
+ throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
4255
+ }
4256
+ const saved = await createClient(options).addChannel(channel);
4257
+ print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
4258
+ });
4259
+ channels.command("list").description("List configured channels").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
4260
+ const channels2 = await createClient(options).listChannels();
4261
+ if (wantsJson(actionOptions, command)) {
4262
+ console.log(JSON.stringify(sanitizeChannelsForOutput(channels2), null, 2));
4263
+ return;
4264
+ }
4265
+ if (!channels2.length) {
4266
+ console.log("No channels configured.");
4267
+ return;
4268
+ }
4269
+ for (const channel of channels2) {
4270
+ console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
4271
+ }
4272
+ });
4273
+ channels.command("status").description("Show events channel storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
4274
+ const status = await getEventsStatus(options.dataDir);
4275
+ print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
4276
+ });
4277
+ channels.command("remove").description("Remove a channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
4278
+ const removed = await createClient(options).removeChannel(id);
4279
+ print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
4280
+ });
4281
+ channels.command("test").description("Send a test event to one channel").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--honor-filters", "Skip delivery when the sample event does not match channel filters", false).option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
4282
+ const result = await createClient(options).testChannel(id, {
4283
+ source: actionOptions.source ?? options.source,
4284
+ type: actionOptions.type,
4285
+ subject: actionOptions.subject ?? id,
4286
+ message: actionOptions.message,
4287
+ data: parseJsonObject3(actionOptions.data, { test: true }),
4288
+ metadata: parseJsonObject3(actionOptions.metadata, {})
4289
+ }, { honorFilters: actionOptions.honorFilters });
4290
+ print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
4291
+ });
4292
+ channels.command("match").description("Check whether a sample event matches one channel without delivering").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events match preview").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
4293
+ const result = await createClient(options).matchChannel(id, {
4294
+ source: actionOptions.source ?? options.source,
4295
+ type: actionOptions.type,
4296
+ subject: actionOptions.subject ?? id,
4297
+ message: actionOptions.message,
4298
+ data: parseJsonObject3(actionOptions.data, { test: true }),
4299
+ metadata: parseJsonObject3(actionOptions.metadata, {})
4300
+ });
4301
+ print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
4302
+ });
4303
+ return channels;
4304
+ }
4305
+ function registerEventCommands(program, options) {
4306
+ const events = program.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
4307
+ events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions, command) => {
4308
+ const result = await createClient(options).emit({
4309
+ source: actionOptions.source ?? options.source,
4310
+ type,
4311
+ subject: actionOptions.subject,
4312
+ severity: actionOptions.severity,
4313
+ message: actionOptions.message,
4314
+ dedupeKey: actionOptions.dedupeKey,
4315
+ data: parseJsonObject3(actionOptions.data, {}),
4316
+ metadata: parseJsonObject3(actionOptions.metadata, {})
4317
+ }, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
4318
+ print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
4319
+ });
4320
+ events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
4321
+ let rows = await createClient(options).listEvents();
4322
+ if (actionOptions.source)
4323
+ rows = rows.filter((event) => event.source === actionOptions.source);
4324
+ if (actionOptions.type)
4325
+ rows = rows.filter((event) => event.type === actionOptions.type);
4326
+ if (actionOptions.limit)
4327
+ rows = rows.slice(-actionOptions.limit);
4328
+ if (wantsJson(actionOptions, command)) {
4329
+ console.log(JSON.stringify(rows, null, 2));
4330
+ return;
4331
+ }
4332
+ if (!rows.length) {
4333
+ console.log("No events recorded.");
4334
+ return;
4335
+ }
4336
+ for (const event of rows)
4337
+ console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
4338
+ });
4339
+ events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
4340
+ const result = await createClient(options).replay({
4341
+ eventId: actionOptions.id,
4342
+ source: actionOptions.source,
4343
+ type: actionOptions.type,
4344
+ dryRun: actionOptions.dryRun
4345
+ });
4346
+ print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
4347
+ });
4348
+ return events;
4349
+ }
4350
+ function registerEventsCommands(program, options) {
4351
+ registerChannelCommands(program, options);
4352
+ registerEventCommands(program, options);
4353
+ }
4354
+ function parseNumber(value) {
4355
+ const parsed = Number(value);
4356
+ if (!Number.isFinite(parsed))
4357
+ throw new Error(`Expected a number, got ${value}`);
4358
+ return parsed;
4359
+ }
4360
+ function collectValues(value, previous) {
4361
+ previous.push(value);
4362
+ return previous;
4363
+ }
4364
+
4365
+ // node_modules/.pnpm/commander@13.1.0/node_modules/commander/esm.mjs
4366
+ var import__ = __toESM(require_commander(), 1);
4367
+ var {
4368
+ program,
4369
+ createCommand,
4370
+ createArgument,
4371
+ createOption,
4372
+ CommanderError,
4373
+ InvalidArgumentError,
4374
+ InvalidOptionArgumentError,
4375
+ Command,
4376
+ Argument,
4377
+ Option,
4378
+ Help
4379
+ } = import__.default;
4380
+
4381
+ // node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
4382
+ var ANSI_BACKGROUND_OFFSET = 10;
4383
+ var wrapAnsi16 = (offset = 0) => (code) => `\x1B[${code + offset}m`;
4384
+ var wrapAnsi256 = (offset = 0) => (code) => `\x1B[${38 + offset};5;${code}m`;
4385
+ var wrapAnsi16m = (offset = 0) => (red, green, blue) => `\x1B[${38 + offset};2;${red};${green};${blue}m`;
4386
+ var styles = {
4387
+ modifier: {
4388
+ reset: [0, 0],
4389
+ bold: [1, 22],
4390
+ dim: [2, 22],
4391
+ italic: [3, 23],
4392
+ underline: [4, 24],
4393
+ overline: [53, 55],
4394
+ inverse: [7, 27],
4395
+ hidden: [8, 28],
4396
+ strikethrough: [9, 29]
4397
+ },
4398
+ color: {
4399
+ black: [30, 39],
4400
+ red: [31, 39],
4401
+ green: [32, 39],
4402
+ yellow: [33, 39],
4403
+ blue: [34, 39],
4404
+ magenta: [35, 39],
4405
+ cyan: [36, 39],
4406
+ white: [37, 39],
4407
+ blackBright: [90, 39],
4408
+ gray: [90, 39],
4409
+ grey: [90, 39],
4410
+ redBright: [91, 39],
4411
+ greenBright: [92, 39],
4412
+ yellowBright: [93, 39],
4413
+ blueBright: [94, 39],
4414
+ magentaBright: [95, 39],
4415
+ cyanBright: [96, 39],
4416
+ whiteBright: [97, 39]
4417
+ },
4418
+ bgColor: {
4419
+ bgBlack: [40, 49],
4420
+ bgRed: [41, 49],
4421
+ bgGreen: [42, 49],
4422
+ bgYellow: [43, 49],
4423
+ bgBlue: [44, 49],
4424
+ bgMagenta: [45, 49],
4425
+ bgCyan: [46, 49],
4426
+ bgWhite: [47, 49],
4427
+ bgBlackBright: [100, 49],
4428
+ bgGray: [100, 49],
4429
+ bgGrey: [100, 49],
4430
+ bgRedBright: [101, 49],
4431
+ bgGreenBright: [102, 49],
4432
+ bgYellowBright: [103, 49],
4433
+ bgBlueBright: [104, 49],
4434
+ bgMagentaBright: [105, 49],
4435
+ bgCyanBright: [106, 49],
4436
+ bgWhiteBright: [107, 49]
4437
+ }
4438
+ };
4439
+ var modifierNames = Object.keys(styles.modifier);
4440
+ var foregroundColorNames = Object.keys(styles.color);
4441
+ var backgroundColorNames = Object.keys(styles.bgColor);
4442
+ var colorNames = [...foregroundColorNames, ...backgroundColorNames];
4443
+ function assembleStyles() {
4444
+ const codes = new Map;
4445
+ for (const [groupName, group] of Object.entries(styles)) {
4446
+ for (const [styleName, style] of Object.entries(group)) {
4447
+ styles[styleName] = {
4448
+ open: `\x1B[${style[0]}m`,
4449
+ close: `\x1B[${style[1]}m`
4450
+ };
4451
+ group[styleName] = styles[styleName];
4452
+ codes.set(style[0], style[1]);
4453
+ }
4454
+ Object.defineProperty(styles, groupName, {
4455
+ value: group,
4456
+ enumerable: false
4457
+ });
4458
+ }
4459
+ Object.defineProperty(styles, "codes", {
4460
+ value: codes,
4461
+ enumerable: false
4462
+ });
4463
+ styles.color.close = "\x1B[39m";
4464
+ styles.bgColor.close = "\x1B[49m";
4465
+ styles.color.ansi = wrapAnsi16();
4466
+ styles.color.ansi256 = wrapAnsi256();
4467
+ styles.color.ansi16m = wrapAnsi16m();
4468
+ styles.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
4469
+ styles.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
4470
+ styles.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
4471
+ Object.defineProperties(styles, {
4472
+ rgbToAnsi256: {
4473
+ value(red, green, blue) {
4474
+ if (red === green && green === blue) {
4475
+ if (red < 8) {
4476
+ return 16;
4477
+ }
4478
+ if (red > 248) {
4479
+ return 231;
4480
+ }
4481
+ return Math.round((red - 8) / 247 * 24) + 232;
4482
+ }
4483
+ return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
4484
+ },
4485
+ enumerable: false
4486
+ },
4487
+ hexToRgb: {
4488
+ value(hex) {
4489
+ const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
4490
+ if (!matches) {
4491
+ return [0, 0, 0];
4492
+ }
4493
+ let [colorString] = matches;
4494
+ if (colorString.length === 3) {
4495
+ colorString = [...colorString].map((character) => character + character).join("");
4496
+ }
4497
+ const integer = Number.parseInt(colorString, 16);
4498
+ return [
4499
+ integer >> 16 & 255,
4500
+ integer >> 8 & 255,
4501
+ integer & 255
4502
+ ];
4503
+ },
4504
+ enumerable: false
4505
+ },
4506
+ hexToAnsi256: {
4507
+ value: (hex) => styles.rgbToAnsi256(...styles.hexToRgb(hex)),
4508
+ enumerable: false
4509
+ },
4510
+ ansi256ToAnsi: {
4511
+ value(code) {
4512
+ if (code < 8) {
4513
+ return 30 + code;
4514
+ }
4515
+ if (code < 16) {
4516
+ return 90 + (code - 8);
4517
+ }
4518
+ let red;
4519
+ let green;
4520
+ let blue;
4521
+ if (code >= 232) {
4522
+ red = ((code - 232) * 10 + 8) / 255;
4523
+ green = red;
4524
+ blue = red;
4525
+ } else {
4526
+ code -= 16;
4527
+ const remainder = code % 36;
4528
+ red = Math.floor(code / 36) / 5;
4529
+ green = Math.floor(remainder / 6) / 5;
4530
+ blue = remainder % 6 / 5;
4531
+ }
4532
+ const value = Math.max(red, green, blue) * 2;
4533
+ if (value === 0) {
4534
+ return 30;
4535
+ }
4536
+ let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
4537
+ if (value === 2) {
4538
+ result += 60;
4539
+ }
4540
+ return result;
4541
+ },
4542
+ enumerable: false
4543
+ },
4544
+ rgbToAnsi: {
4545
+ value: (red, green, blue) => styles.ansi256ToAnsi(styles.rgbToAnsi256(red, green, blue)),
4546
+ enumerable: false
4547
+ },
4548
+ hexToAnsi: {
4549
+ value: (hex) => styles.ansi256ToAnsi(styles.hexToAnsi256(hex)),
4550
+ enumerable: false
4551
+ }
4174
4552
  });
4553
+ return styles;
4175
4554
  }
4176
- function getHost(request, fallback) {
4177
- const forwarded = request.headers.get("x-forwarded-host");
4178
- const host = forwarded || request.headers.get("host") || fallback || "";
4179
- return host.split(",")[0].trim().split(":")[0];
4555
+ var ansiStyles = assembleStyles();
4556
+ var ansi_styles_default = ansiStyles;
4557
+
4558
+ // node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/supports-color/index.js
4559
+ import process2 from "process";
4560
+ import os from "os";
4561
+ import tty from "tty";
4562
+ function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process2.argv) {
4563
+ const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
4564
+ const position = argv.indexOf(prefix + flag);
4565
+ const terminatorPosition = argv.indexOf("--");
4566
+ return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
4180
4567
  }
4181
- function getClientIp(request) {
4182
- const forwarded = request.headers.get("x-forwarded-for");
4183
- if (forwarded)
4184
- return forwarded.split(",")[0].trim();
4185
- return request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip");
4568
+ var { env } = process2;
4569
+ var flagForceColor;
4570
+ if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
4571
+ flagForceColor = 0;
4572
+ } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
4573
+ flagForceColor = 1;
4186
4574
  }
4187
- function isExpired(link) {
4188
- return Boolean(link.expires_at && new Date(link.expires_at).getTime() <= Date.now());
4575
+ function envForceColor() {
4576
+ if ("FORCE_COLOR" in env) {
4577
+ if (env.FORCE_COLOR === "true") {
4578
+ return 1;
4579
+ }
4580
+ if (env.FORCE_COLOR === "false") {
4581
+ return 0;
4582
+ }
4583
+ return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3);
4584
+ }
4189
4585
  }
4190
- function logRecordClickError(link) {
4191
- console.error(`[shortlinks] Click analytics recording failed for ${link.hostname}/${link.slug}.`);
4586
+ function translateLevel(level) {
4587
+ if (level === 0) {
4588
+ return false;
4589
+ }
4590
+ return {
4591
+ level,
4592
+ hasBasic: true,
4593
+ has256: level >= 2,
4594
+ has16m: level >= 3
4595
+ };
4192
4596
  }
4193
- function createShortlinksHandler(options = {}) {
4194
- const store = options.store || new ShortlinksStore(options.dbPath);
4195
- const redirectStatus = options.redirectStatus || 302;
4196
- return async (request) => {
4197
- const url = new URL(request.url);
4198
- if (url.pathname === "/healthz") {
4199
- return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
4597
+ function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
4598
+ const noFlagForceColor = envForceColor();
4599
+ if (noFlagForceColor !== undefined) {
4600
+ flagForceColor = noFlagForceColor;
4601
+ }
4602
+ const forceColor = sniffFlags ? flagForceColor : noFlagForceColor;
4603
+ if (forceColor === 0) {
4604
+ return 0;
4605
+ }
4606
+ if (sniffFlags) {
4607
+ if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
4608
+ return 3;
4200
4609
  }
4201
- if (url.pathname === "/" || url.pathname === "") {
4202
- return json({ service: "shortlinks", ok: true });
4610
+ if (hasFlag("color=256")) {
4611
+ return 2;
4203
4612
  }
4204
- let slug = "";
4205
- try {
4206
- slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
4207
- } catch {
4208
- return json({ error: "Invalid slug." }, 400);
4613
+ }
4614
+ if ("TF_BUILD" in env && "AGENT_NAME" in env) {
4615
+ return 1;
4616
+ }
4617
+ if (haveStream && !streamIsTTY && forceColor === undefined) {
4618
+ return 0;
4619
+ }
4620
+ const min = forceColor || 0;
4621
+ if (env.TERM === "dumb") {
4622
+ return min;
4623
+ }
4624
+ if (process2.platform === "win32") {
4625
+ const osRelease = os.release().split(".");
4626
+ if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
4627
+ return Number(osRelease[2]) >= 14931 ? 3 : 2;
4209
4628
  }
4210
- if (!slug)
4211
- return json({ error: "Missing slug." }, 404);
4212
- if (request.method !== "GET" && request.method !== "HEAD") {
4213
- return json({ error: "Method not allowed." }, 405, { allow: REDIRECT_ALLOW_HEADER });
4629
+ return 1;
4630
+ }
4631
+ if ("CI" in env) {
4632
+ if (["GITHUB_ACTIONS", "GITEA_ACTIONS", "CIRCLECI"].some((key) => (key in env))) {
4633
+ return 3;
4214
4634
  }
4215
- const host = getHost(request, options.defaultHost);
4216
- if (!host)
4217
- return json({ error: "Missing Host header." }, 400);
4218
- let link = null;
4219
- try {
4220
- link = await store.resolve(host, slug);
4221
- } catch {
4222
- return json({ error: "Shortlink not found.", slug, host }, 404);
4635
+ if (["TRAVIS", "APPVEYOR", "GITLAB_CI", "BUILDKITE", "DRONE"].some((sign) => (sign in env)) || env.CI_NAME === "codeship") {
4636
+ return 1;
4223
4637
  }
4224
- if (!link)
4225
- return json({ error: "Shortlink not found.", slug, host }, 404);
4226
- if (!link.active)
4227
- return json({ error: "Shortlink is disabled.", slug, host }, 410);
4228
- if (isExpired(link))
4229
- return json({ error: "Shortlink is expired.", slug, host }, 410);
4230
- if (request.method === "GET") {
4231
- try {
4232
- await store.recordClick(link, {
4233
- ip: getClientIp(request),
4234
- userAgent: request.headers.get("user-agent"),
4235
- referer: request.headers.get("referer"),
4236
- country: request.headers.get("cf-ipcountry"),
4237
- metadata: {
4238
- path: url.pathname,
4239
- query: url.search
4240
- }
4241
- });
4242
- } catch (error) {
4243
- if (options.onRecordClickError) {
4244
- try {
4245
- await options.onRecordClickError(error, { link, request });
4246
- } catch {
4247
- logRecordClickError(link);
4248
- }
4249
- } else {
4250
- logRecordClickError(link);
4251
- }
4638
+ return min;
4639
+ }
4640
+ if ("TEAMCITY_VERSION" in env) {
4641
+ return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
4642
+ }
4643
+ if (env.COLORTERM === "truecolor") {
4644
+ return 3;
4645
+ }
4646
+ if (env.TERM === "xterm-kitty") {
4647
+ return 3;
4648
+ }
4649
+ if (env.TERM === "xterm-ghostty") {
4650
+ return 3;
4651
+ }
4652
+ if (env.TERM === "wezterm") {
4653
+ return 3;
4654
+ }
4655
+ if ("TERM_PROGRAM" in env) {
4656
+ const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
4657
+ switch (env.TERM_PROGRAM) {
4658
+ case "iTerm.app": {
4659
+ return version >= 3 ? 3 : 2;
4660
+ }
4661
+ case "Apple_Terminal": {
4662
+ return 2;
4252
4663
  }
4253
4664
  }
4254
- return Response.redirect(link.destination_url, redirectStatus);
4255
- };
4665
+ }
4666
+ if (/-256(color)?$/i.test(env.TERM)) {
4667
+ return 2;
4668
+ }
4669
+ if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
4670
+ return 1;
4671
+ }
4672
+ if ("COLORTERM" in env) {
4673
+ return 1;
4674
+ }
4675
+ return min;
4256
4676
  }
4257
- function serveShortlinks(options = {}) {
4258
- const host = options.host || "127.0.0.1";
4259
- const port = options.port || 8787;
4260
- const fetch2 = createShortlinksHandler(options);
4261
- return Bun.serve({ hostname: host, port, fetch: fetch2 });
4677
+ function createSupportsColor(stream, options = {}) {
4678
+ const level = _supportsColor(stream, {
4679
+ streamIsTTY: stream && stream.isTTY,
4680
+ ...options
4681
+ });
4682
+ return translateLevel(level);
4262
4683
  }
4684
+ var supportsColor = {
4685
+ stdout: createSupportsColor({ isTTY: tty.isatty(1) }),
4686
+ stderr: createSupportsColor({ isTTY: tty.isatty(2) })
4687
+ };
4688
+ var supports_color_default = supportsColor;
4263
4689
 
4264
- // src/cloudflare.ts
4265
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
4266
- import { join as join4 } from "path";
4267
- function createCloudflarePlan(input) {
4268
- const hostname2 = normalizeHostname(input.hostname);
4269
- const target = normalizeHostname(input.target);
4270
- const workerName = input.workerName || "shortlinks";
4271
- const proxied = input.proxied ?? true;
4272
- return {
4273
- hostname: hostname2,
4274
- target,
4275
- proxied,
4276
- workerName,
4277
- origin: input.origin,
4278
- dnsRecord: {
4279
- type: "CNAME",
4280
- name: hostname2,
4281
- content: target,
4282
- proxied
4283
- },
4284
- wranglerCommand: `wrangler deploy cloudflare/${workerName}.js --name ${workerName}`
4285
- };
4690
+ // node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/utilities.js
4691
+ function stringReplaceAll(string, substring, replacer) {
4692
+ let index = string.indexOf(substring);
4693
+ if (index === -1) {
4694
+ return string;
4695
+ }
4696
+ const substringLength = substring.length;
4697
+ let endIndex = 0;
4698
+ let returnValue = "";
4699
+ do {
4700
+ returnValue += string.slice(endIndex, index) + substring + replacer;
4701
+ endIndex = index + substringLength;
4702
+ index = string.indexOf(substring, endIndex);
4703
+ } while (index !== -1);
4704
+ returnValue += string.slice(endIndex);
4705
+ return returnValue;
4706
+ }
4707
+ function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
4708
+ let endIndex = 0;
4709
+ let returnValue = "";
4710
+ do {
4711
+ const gotCR = string[index - 1] === "\r";
4712
+ returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? `\r
4713
+ ` : `
4714
+ `) + postfix;
4715
+ endIndex = index + 1;
4716
+ index = string.indexOf(`
4717
+ `, endIndex);
4718
+ } while (index !== -1);
4719
+ returnValue += string.slice(endIndex);
4720
+ return returnValue;
4286
4721
  }
4287
- function generateWorkerScript() {
4288
- return `export default {
4289
- async fetch(request, env) {
4290
- const origin = env.SHORTLINKS_ORIGIN;
4291
- if (!origin) {
4292
- return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
4293
- }
4294
-
4295
- const incoming = new URL(request.url);
4296
- const upstream = new URL(incoming.pathname + incoming.search, origin);
4297
- const headers = new Headers(request.headers);
4298
- headers.set("x-forwarded-host", incoming.host);
4299
- headers.set("x-shortlinks-worker", "cloudflare");
4300
4722
 
4301
- return fetch(upstream.toString(), {
4302
- method: request.method,
4303
- headers,
4304
- body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
4305
- redirect: "manual"
4306
- });
4723
+ // node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/index.js
4724
+ var { stdout: stdoutColor, stderr: stderrColor } = supports_color_default;
4725
+ var GENERATOR = Symbol("GENERATOR");
4726
+ var STYLER = Symbol("STYLER");
4727
+ var IS_EMPTY = Symbol("IS_EMPTY");
4728
+ var levelMapping = [
4729
+ "ansi",
4730
+ "ansi",
4731
+ "ansi256",
4732
+ "ansi16m"
4733
+ ];
4734
+ var styles2 = Object.create(null);
4735
+ var applyOptions = (object, options = {}) => {
4736
+ if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) {
4737
+ throw new Error("The `level` option should be an integer from 0 to 3");
4307
4738
  }
4739
+ const colorLevel = stdoutColor ? stdoutColor.level : 0;
4740
+ object.level = options.level === undefined ? colorLevel : options.level;
4308
4741
  };
4309
- `;
4742
+ var chalkFactory = (options) => {
4743
+ const chalk = (...strings) => strings.join(" ");
4744
+ applyOptions(chalk, options);
4745
+ Object.setPrototypeOf(chalk, createChalk.prototype);
4746
+ return chalk;
4747
+ };
4748
+ function createChalk(options) {
4749
+ return chalkFactory(options);
4310
4750
  }
4311
- function writeWorkerFiles(options = {}) {
4312
- const outDir = options.outDir || "cloudflare";
4313
- const workerName = options.workerName || "shortlinks";
4314
- mkdirSync3(outDir, { recursive: true });
4315
- const workerPath = join4(outDir, `${workerName}.js`);
4316
- const wranglerPath = join4(outDir, "wrangler.example.toml");
4317
- writeFileSync3(workerPath, generateWorkerScript());
4318
- writeFileSync3(wranglerPath, `name = "${workerName}"
4319
- main = "${workerName}.js"
4320
- compatibility_date = "2026-05-01"
4321
-
4322
- [vars]
4323
- SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
4324
- `);
4325
- return { workerPath, wranglerPath };
4751
+ Object.setPrototypeOf(createChalk.prototype, Function.prototype);
4752
+ for (const [styleName, style] of Object.entries(ansi_styles_default)) {
4753
+ styles2[styleName] = {
4754
+ get() {
4755
+ const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
4756
+ Object.defineProperty(this, styleName, { value: builder });
4757
+ return builder;
4758
+ }
4759
+ };
4326
4760
  }
4327
- function cloudflareAuthHeaders(token) {
4328
- const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
4329
- if (apiToken)
4330
- return { authorization: `Bearer ${apiToken}` };
4331
- const apiKey = process.env.CLOUDFLARE_API_KEY;
4332
- const email = process.env.CLOUDFLARE_EMAIL;
4333
- if (apiKey && email) {
4334
- return {
4335
- "x-auth-key": apiKey,
4336
- "x-auth-email": email
4337
- };
4761
+ styles2.visible = {
4762
+ get() {
4763
+ const builder = createBuilder(this, this[STYLER], true);
4764
+ Object.defineProperty(this, "visible", { value: builder });
4765
+ return builder;
4338
4766
  }
4339
- throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
4340
- }
4341
- async function cloudflareRequest(token, path, init = {}) {
4342
- const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
4343
- ...init,
4344
- headers: {
4345
- ...cloudflareAuthHeaders(token),
4346
- "content-type": "application/json",
4347
- ...init.headers || {}
4767
+ };
4768
+ var getModelAnsi = (model, level, type, ...arguments_) => {
4769
+ if (model === "rgb") {
4770
+ if (level === "ansi16m") {
4771
+ return ansi_styles_default[type].ansi16m(...arguments_);
4348
4772
  }
4349
- });
4350
- const body = await response.json();
4351
- if (!response.ok || body.success === false) {
4352
- const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
4353
- throw new Error(`Cloudflare API failed: ${message}`);
4773
+ if (level === "ansi256") {
4774
+ return ansi_styles_default[type].ansi256(ansi_styles_default.rgbToAnsi256(...arguments_));
4775
+ }
4776
+ return ansi_styles_default[type].ansi(ansi_styles_default.rgbToAnsi(...arguments_));
4354
4777
  }
4355
- return body.result;
4356
- }
4357
- function candidateZones(hostname2) {
4358
- const parts = normalizeHostname(hostname2).split(".");
4359
- const candidates = [];
4360
- for (let i = 0;i < parts.length - 1; i += 1) {
4361
- candidates.push(parts.slice(i).join("."));
4778
+ if (model === "hex") {
4779
+ return getModelAnsi("rgb", level, type, ...ansi_styles_default.hexToRgb(...arguments_));
4362
4780
  }
4363
- return candidates;
4781
+ return ansi_styles_default[type][model](...arguments_);
4782
+ };
4783
+ var usedModels = ["rgb", "hex", "ansi256"];
4784
+ for (const model of usedModels) {
4785
+ styles2[model] = {
4786
+ get() {
4787
+ const { level } = this;
4788
+ return function(...arguments_) {
4789
+ const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansi_styles_default.color.close, this[STYLER]);
4790
+ return createBuilder(this, styler, this[IS_EMPTY]);
4791
+ };
4792
+ }
4793
+ };
4794
+ const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
4795
+ styles2[bgModel] = {
4796
+ get() {
4797
+ const { level } = this;
4798
+ return function(...arguments_) {
4799
+ const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansi_styles_default.bgColor.close, this[STYLER]);
4800
+ return createBuilder(this, styler, this[IS_EMPTY]);
4801
+ };
4802
+ }
4803
+ };
4364
4804
  }
4365
- async function findCloudflareZoneId(hostname2, token) {
4366
- for (const zone of candidateZones(hostname2)) {
4367
- const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
4368
- if (result[0]?.id)
4369
- return result[0].id;
4805
+ var proto = Object.defineProperties(() => {}, {
4806
+ ...styles2,
4807
+ level: {
4808
+ enumerable: true,
4809
+ get() {
4810
+ return this[GENERATOR].level;
4811
+ },
4812
+ set(level) {
4813
+ this[GENERATOR].level = level;
4814
+ }
4370
4815
  }
4371
- throw new Error(`Could not find a Cloudflare zone for ${hostname2}. Pass --zone-id explicitly.`);
4372
- }
4373
- async function upsertCloudflareDnsRecord(options) {
4374
- const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
4375
- const plan = createCloudflarePlan({
4376
- hostname: options.hostname,
4377
- target: options.target,
4378
- origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
4379
- proxied: options.proxied
4380
- });
4381
- if (options.dryRun)
4382
- return plan;
4383
- const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
4384
- const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
4385
- const payload = JSON.stringify(plan.dnsRecord);
4386
- if (existing[0]?.id) {
4387
- const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
4388
- method: "PUT",
4389
- body: payload
4390
- });
4391
- return { id: updated.id, action: "updated" };
4816
+ });
4817
+ var createStyler = (open, close, parent) => {
4818
+ let openAll;
4819
+ let closeAll;
4820
+ if (parent === undefined) {
4821
+ openAll = open;
4822
+ closeAll = close;
4823
+ } else {
4824
+ openAll = parent.openAll + open;
4825
+ closeAll = close + parent.closeAll;
4392
4826
  }
4393
- const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
4394
- method: "POST",
4395
- body: payload
4396
- });
4397
- return { id: created.id, action: "created" };
4398
- }
4827
+ return {
4828
+ open,
4829
+ close,
4830
+ openAll,
4831
+ closeAll,
4832
+ parent
4833
+ };
4834
+ };
4835
+ var createBuilder = (self, _styler, _isEmpty) => {
4836
+ const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
4837
+ Object.setPrototypeOf(builder, proto);
4838
+ builder[GENERATOR] = self;
4839
+ builder[STYLER] = _styler;
4840
+ builder[IS_EMPTY] = _isEmpty;
4841
+ return builder;
4842
+ };
4843
+ var applyStyle = (self, string) => {
4844
+ if (self.level <= 0 || !string) {
4845
+ return self[IS_EMPTY] ? "" : string;
4846
+ }
4847
+ let styler = self[STYLER];
4848
+ if (styler === undefined) {
4849
+ return string;
4850
+ }
4851
+ const { openAll, closeAll } = styler;
4852
+ if (string.includes("\x1B")) {
4853
+ while (styler !== undefined) {
4854
+ string = stringReplaceAll(string, styler.close, styler.open);
4855
+ styler = styler.parent;
4856
+ }
4857
+ }
4858
+ const lfIndex = string.indexOf(`
4859
+ `);
4860
+ if (lfIndex !== -1) {
4861
+ string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
4862
+ }
4863
+ return openAll + string + closeAll;
4864
+ };
4865
+ Object.defineProperties(createChalk.prototype, styles2);
4866
+ var chalk = createChalk();
4867
+ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
4868
+ var source_default = chalk;
4869
+
4870
+ // src/cli/index.ts
4871
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
4872
+ import { dirname as dirname3, join as join6 } from "path";
4873
+ import { fileURLToPath } from "url";
4874
+ import { spawnSync as spawnSync3 } from "child_process";
4399
4875
 
4400
4876
  // src/domains-cli.ts
4401
4877
  import { spawnSync } from "child_process";
@@ -4578,14 +5054,20 @@ function withStore(fn) {
4578
5054
  }
4579
5055
  function storeMode() {
4580
5056
  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;
5057
+ return parseShortlinksStoreMode(opts.store || process.env.HASNA_SHORTLINKS_STORE || process.env.SHORTLINKS_STORE || "local");
5058
+ }
5059
+ function runtimeEnv() {
5060
+ const opts = program2.opts();
5061
+ return opts.store ? { ...process.env, HASNA_SHORTLINKS_STORE: opts.store } : process.env;
5062
+ }
5063
+ function localStatsIfDatabaseExists(dbPath) {
5064
+ if (!existsSync4(dbPath))
5065
+ return null;
5066
+ return withStore((store) => store.totalStats());
4585
5067
  }
4586
5068
  async function withRuntimeStore(fn) {
4587
- if (storeMode() === "cloud") {
4588
- const store2 = await PgShortlinksStore.fromCloud("shortlinks");
5069
+ if (storeMode() === "postgres") {
5070
+ const store2 = await PgShortlinksStore.fromEnv();
4589
5071
  try {
4590
5072
  return await fn(store2);
4591
5073
  } finally {
@@ -4606,7 +5088,7 @@ function commandExists(command) {
4606
5088
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
4607
5089
  return result.status === 0;
4608
5090
  }
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");
5091
+ 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
5092
  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
5093
  try {
4612
5094
  const result = await withRuntimeStore(async (store) => {
@@ -4866,9 +5348,9 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
4866
5348
  handleError(error);
4867
5349
  }
4868
5350
  });
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) => {
5351
+ 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
5352
  try {
4871
- const store = opts.cloud || storeMode() === "cloud" ? await PgShortlinksStore.fromCloud("shortlinks") : undefined;
5353
+ const store = storeMode() === "postgres" ? await PgShortlinksStore.fromEnv() : undefined;
4872
5354
  const server = serveShortlinks({
4873
5355
  store,
4874
5356
  dbPath: program2.opts().db,
@@ -4876,7 +5358,7 @@ program2.command("serve").description("Run the redirect server that records clic
4876
5358
  port: Number(opts.port),
4877
5359
  defaultHost: opts.defaultHost
4878
5360
  });
4879
- const mode = store ? "cloud" : "local";
5361
+ const mode = store ? "postgres" : "local";
4880
5362
  console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
4881
5363
  } catch (error) {
4882
5364
  handleError(error);
@@ -4922,65 +5404,68 @@ cfCmd.command("dns <hostname>").description("Create or update the Cloudflare CNA
4922
5404
  handleError(error);
4923
5405
  }
4924
5406
  });
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) => {
5407
+ var postgresCmd = program2.command("postgres").description("Shortlinks-owned PostgreSQL runtime helpers");
5408
+ 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
5409
  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");
5410
+ const conn = opts.connectionString || getShortlinksDatabaseUrl();
5411
+ if (!conn)
5412
+ throw new Error("HASNA_SHORTLINKS_DATABASE_URL or --connection-string is required.");
5413
+ const ssl = opts.ssl === false ? false : getShortlinksDatabaseSsl();
5414
+ if (opts.dryRun) {
5415
+ const result2 = {
5416
+ service: "shortlinks",
5417
+ dry_run: true,
5418
+ no_network: true,
5419
+ database: {
5420
+ configured: true,
5421
+ redacted_url: redactDatabaseUrl(conn),
5422
+ ssl
5423
+ },
5424
+ migrations: PG_MIGRATIONS.length
5425
+ };
5426
+ print2(result2, opts, () => console.log(JSON.stringify(result2, null, 2)));
5427
+ return;
5428
+ }
5429
+ const result = await applyPostgresMigrations(conn, PG_MIGRATIONS, { ssl });
4931
5430
  print2(result, opts, () => console.log(JSON.stringify(result, null, 2)));
4932
5431
  } catch (error) {
4933
5432
  handleError(error);
4934
5433
  }
4935
5434
  });
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"));
5435
+ postgresCmd.command("status").description("Show local and PostgreSQL runtime configuration health without opening network connections").option("-j, --json", "Output JSON").action((opts) => {
4952
5436
  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?.();
5437
+ const dbPath = getDatabasePath(program2.opts().db);
5438
+ const data = {
5439
+ ...getShortlinksRuntimeStatus(runtimeEnv()),
5440
+ service: "shortlinks",
5441
+ db_path: dbPath,
5442
+ db_exists: existsSync4(dbPath)
5443
+ };
5444
+ print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
5445
+ } catch (error) {
5446
+ handleError(error);
4968
5447
  }
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) => {
5448
+ });
5449
+ 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
5450
  try {
4975
- const { getCloudConfig } = await import("@hasna/cloud");
4976
- const stats = withStore((store) => store.totalStats());
4977
- const config = getCloudConfig();
5451
+ const status = getShortlinksRuntimeStatus(runtimeEnv());
4978
5452
  const data = {
5453
+ ok: status.ok,
4979
5454
  service: "shortlinks",
4980
- db_path: getDatabasePath(program2.opts().db),
4981
- local: stats,
4982
- cloud_mode: config.mode,
4983
- rds_host: config.rds?.host || null
5455
+ dry_run: true,
5456
+ no_network: true,
5457
+ status,
5458
+ postgres: {
5459
+ required: status.mode === "postgres",
5460
+ configured: status.database.configured,
5461
+ schema_sql: opts.schemaSql ? PG_MIGRATIONS : []
5462
+ },
5463
+ steps: [
5464
+ "Read local SQLite state",
5465
+ status.mode === "postgres" ? "Prepare direct shortlinks Postgres runtime" : "Keep serving from local SQLite",
5466
+ "Run migrations with shortlinks postgres migrate before serving from Postgres",
5467
+ "Report planned changes without opening network connections"
5468
+ ]
4984
5469
  };
4985
5470
  print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
4986
5471
  } catch (error) {
@@ -5025,23 +5510,26 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
5025
5510
  });
5026
5511
  program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action(async (opts) => {
5027
5512
  try {
5028
- const mode = storeMode();
5029
- const stats = await withRuntimeStore((store) => store.totalStats());
5513
+ const runtime = getShortlinksRuntimeStatus(runtimeEnv());
5514
+ const dbPath = getDatabasePath(program2.opts().db);
5030
5515
  const data = {
5031
5516
  service: "shortlinks",
5032
- store: mode,
5517
+ ok: runtime.ok,
5518
+ store: runtime.mode,
5033
5519
  data_dir: getDataDir(),
5034
5520
  config_path: getConfigPath(),
5035
- db_path: getDatabasePath(program2.opts().db),
5036
- db_exists: existsSync4(getDatabasePath(program2.opts().db)),
5037
- stats,
5521
+ db_path: dbPath,
5522
+ db_exists: existsSync4(dbPath),
5523
+ stats: localStatsIfDatabaseExists(dbPath),
5524
+ runtime,
5525
+ no_network: true,
5038
5526
  commands: {
5039
5527
  domains: commandExists("domains"),
5040
- cloud: commandExists("cloud"),
5041
5528
  wrangler: commandExists("wrangler"),
5042
5529
  secrets: commandExists("secrets")
5043
5530
  },
5044
5531
  environment: {
5532
+ shortlinks_database_url_present: Boolean(getShortlinksDatabaseUrl(runtimeEnv())),
5045
5533
  cloudflare_api_token_present: Boolean(process.env.CLOUDFLARE_API_TOKEN),
5046
5534
  cloudflare_api_key_present: Boolean(process.env.CLOUDFLARE_API_KEY),
5047
5535
  cloudflare_email_present: Boolean(process.env.CLOUDFLARE_EMAIL),