clipr-cli 0.0.5
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/api-KFVDRB2Q.js +90 -0
- package/dist/api-KFVDRB2Q.js.map +1 -0
- package/dist/api-LBSFNXNQ.js +88 -0
- package/dist/api-LBSFNXNQ.js.map +1 -0
- package/dist/api.d.ts +86 -0
- package/dist/api.js +165 -0
- package/dist/api.js.map +1 -0
- package/dist/chunk-I7CHG5Z3.js +92 -0
- package/dist/chunk-I7CHG5Z3.js.map +1 -0
- package/dist/github-OYIAPRKM.js +155 -0
- package/dist/github-OYIAPRKM.js.map +1 -0
- package/dist/github-PSGU4YYI.js +157 -0
- package/dist/github-PSGU4YYI.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +698 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-5YGDHNVJ.js +8 -0
- package/dist/json-adapter-5YGDHNVJ.js.map +1 -0
- package/package.json +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
JsonBackendAdapter
|
|
4
|
+
} from "./chunk-I7CHG5Z3.js";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { resolve as resolve3 } from "path";
|
|
8
|
+
import { DEFAULT_DB_PATH as DEFAULT_DB_PATH2, loadConfig } from "@clipr/core";
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
|
|
11
|
+
// src/commands/build.ts
|
|
12
|
+
import { existsSync, mkdirSync } from "fs";
|
|
13
|
+
import { readFile, writeFile } from "fs/promises";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { appendUtm, hasUtm } from "@clipr/core";
|
|
16
|
+
|
|
17
|
+
// src/utils/output.ts
|
|
18
|
+
import pc from "picocolors";
|
|
19
|
+
function success(msg) {
|
|
20
|
+
console.log(pc.green("\u2713"), msg);
|
|
21
|
+
}
|
|
22
|
+
function error(msg) {
|
|
23
|
+
console.error(pc.red("\u2717"), msg);
|
|
24
|
+
}
|
|
25
|
+
function info(msg) {
|
|
26
|
+
console.log(pc.cyan("\u2139"), msg);
|
|
27
|
+
}
|
|
28
|
+
function dim(msg) {
|
|
29
|
+
return pc.dim(msg);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/commands/build.ts
|
|
33
|
+
function buildRedirectHtml(entry) {
|
|
34
|
+
const target = hasUtm(entry.utm) ? appendUtm(entry.url, entry.utm) : entry.url;
|
|
35
|
+
const escaped = target.replace(/"/g, """).replace(/</g, "<");
|
|
36
|
+
return `<!DOCTYPE html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8">
|
|
40
|
+
<meta http-equiv="refresh" content="0;url=${escaped}">
|
|
41
|
+
<title>Redirecting...</title>
|
|
42
|
+
<link rel="canonical" href="${escaped}">
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<script>window.location.replace("${target.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")</script>
|
|
46
|
+
<noscript><a href="${escaped}">Click here</a></noscript>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
async function build(opts) {
|
|
52
|
+
const inputPath = opts.input ?? "urls.json";
|
|
53
|
+
const outputDir = opts.output ?? "dist";
|
|
54
|
+
if (!existsSync(inputPath)) {
|
|
55
|
+
error(`Input file not found: ${inputPath}`);
|
|
56
|
+
info("Run `clipr init` to create a urls.json database, or specify --input <path>.");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
let db;
|
|
60
|
+
try {
|
|
61
|
+
const raw = await readFile(inputPath, "utf-8");
|
|
62
|
+
db = JSON.parse(raw);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
error(`Failed to parse ${inputPath}: ${msg}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
const entries = Object.values(db.urls);
|
|
69
|
+
if (entries.length === 0) {
|
|
70
|
+
info("No URLs to build. Add some with `clipr shorten <url>`.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
mkdirSync(outputDir, { recursive: true });
|
|
74
|
+
info(`Building ${entries.length} redirect page${entries.length === 1 ? "" : "s"}...`);
|
|
75
|
+
let built = 0;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (entry.expiresAt && new Date(entry.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
78
|
+
console.log(` ${dim(`skip "${entry.slug}" (expired)`)}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const slugDir = join(outputDir, entry.slug);
|
|
82
|
+
mkdirSync(slugDir, { recursive: true });
|
|
83
|
+
const html = buildRedirectHtml(entry);
|
|
84
|
+
await writeFile(join(slugDir, "index.html"), html);
|
|
85
|
+
built++;
|
|
86
|
+
}
|
|
87
|
+
const indexHtml = `<!DOCTYPE html>
|
|
88
|
+
<html lang="en">
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="utf-8">
|
|
91
|
+
<title>clipr</title>
|
|
92
|
+
</head>
|
|
93
|
+
<body>
|
|
94
|
+
<p>${built} redirect${built === 1 ? "" : "s"} active.</p>
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
`;
|
|
98
|
+
await writeFile(join(outputDir, "index.html"), indexHtml);
|
|
99
|
+
success(`Built ${built} redirect page${built === 1 ? "" : "s"} to ${outputDir}/`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/commands/config.ts
|
|
103
|
+
async function config(key, value, backend) {
|
|
104
|
+
if (key === "baseUrl") {
|
|
105
|
+
if (value === void 0) {
|
|
106
|
+
const current = await backend.getBaseUrl();
|
|
107
|
+
console.log(current || dim("(not set)"));
|
|
108
|
+
} else {
|
|
109
|
+
await backend.setBaseUrl(value);
|
|
110
|
+
success(`baseUrl set to "${value}"`);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
error(`Unknown config key "${key}". Available keys: baseUrl`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/commands/delete.ts
|
|
119
|
+
async function del(slug, backend) {
|
|
120
|
+
const deleted = await backend.delete(slug);
|
|
121
|
+
if (deleted) {
|
|
122
|
+
success(`Deleted slug "${slug}"`);
|
|
123
|
+
} else {
|
|
124
|
+
error(`Slug "${slug}" not found`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/commands/deploy.ts
|
|
130
|
+
import { execFile } from "child_process";
|
|
131
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
132
|
+
import { tmpdir } from "os";
|
|
133
|
+
import { join as join2 } from "path";
|
|
134
|
+
import { promisify } from "util";
|
|
135
|
+
var exec = promisify(execFile);
|
|
136
|
+
async function deploy(backend, opts) {
|
|
137
|
+
if (!/^[a-f0-9]{32}$/.test(opts.namespaceId)) {
|
|
138
|
+
error("Invalid namespace ID format. Expected 32-character hex string.");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
const entries = await backend.list();
|
|
142
|
+
if (entries.length === 0) {
|
|
143
|
+
info("No URLs to deploy. Run `clipr shorten <url>` first.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const kvPairs = entries.map((entry) => ({
|
|
147
|
+
key: entry.slug,
|
|
148
|
+
value: JSON.stringify(entry)
|
|
149
|
+
}));
|
|
150
|
+
kvPairs.push({
|
|
151
|
+
key: "_index",
|
|
152
|
+
value: JSON.stringify(entries.map((e) => e.slug))
|
|
153
|
+
});
|
|
154
|
+
const tmpFile = join2(tmpdir(), `clipr-deploy-${Date.now()}.json`);
|
|
155
|
+
await writeFile2(tmpFile, JSON.stringify(kvPairs, null, 2));
|
|
156
|
+
info(`Deploying ${entries.length} URL${entries.length === 1 ? "" : "s"} to KV...`);
|
|
157
|
+
try {
|
|
158
|
+
const args = ["kv", "bulk", "put", tmpFile, "--namespace-id", opts.namespaceId];
|
|
159
|
+
if (opts.preview) {
|
|
160
|
+
args.push("--preview");
|
|
161
|
+
}
|
|
162
|
+
const { stdout, stderr } = await exec("npx", ["wrangler", ...args]);
|
|
163
|
+
if (stdout) console.log(dim(stdout.trim()));
|
|
164
|
+
if (stderr && !stderr.includes("deprecated")) console.error(dim(stderr.trim()));
|
|
165
|
+
success(`Deployed ${entries.length} URL${entries.length === 1 ? "" : "s"} to Cloudflare KV`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
168
|
+
error(`Deploy failed: ${msg}`);
|
|
169
|
+
info("Make sure wrangler is installed and you are logged in (`npx wrangler login`)");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/commands/edit.ts
|
|
175
|
+
import { normalizeSlug, validateSlug } from "@clipr/core";
|
|
176
|
+
async function edit(slug, backend, opts) {
|
|
177
|
+
const updates = {};
|
|
178
|
+
if (opts.url) {
|
|
179
|
+
updates.url = opts.url;
|
|
180
|
+
}
|
|
181
|
+
if (opts.slug) {
|
|
182
|
+
const newSlug = normalizeSlug(opts.slug);
|
|
183
|
+
const result = validateSlug(newSlug);
|
|
184
|
+
if (!result.valid) {
|
|
185
|
+
error(`Invalid slug: ${result.reason}`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
updates.slug = newSlug;
|
|
189
|
+
}
|
|
190
|
+
if (opts.tags) {
|
|
191
|
+
updates.tags = opts.tags.split(",").map((t) => t.trim());
|
|
192
|
+
}
|
|
193
|
+
if (opts.expires) {
|
|
194
|
+
updates.expiresAt = new Date(opts.expires).toISOString();
|
|
195
|
+
}
|
|
196
|
+
if (opts.description) {
|
|
197
|
+
updates.description = opts.description;
|
|
198
|
+
}
|
|
199
|
+
if (Object.keys(updates).length === 0) {
|
|
200
|
+
error("No updates specified. Use --url, --slug, --tags, --expires, or --description.");
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const updated = await backend.update(slug, updates);
|
|
205
|
+
success(`Updated "${slug}"${updates.slug ? ` \u2192 "${updated.slug}"` : ""}`);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
208
|
+
error(msg);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/commands/export.ts
|
|
214
|
+
import { writeFile as writeFile3 } from "fs/promises";
|
|
215
|
+
function toCsv(entries) {
|
|
216
|
+
const header = "slug,url,description,createdAt,expiresAt,tags";
|
|
217
|
+
const rows = entries.map((e) => {
|
|
218
|
+
const desc = (e.description ?? "").replace(/,/g, ";");
|
|
219
|
+
const tags = (e.tags ?? []).join(";");
|
|
220
|
+
return `${e.slug},${e.url},${desc},${e.createdAt},${e.expiresAt ?? ""},${tags}`;
|
|
221
|
+
});
|
|
222
|
+
return `${[header, ...rows].join("\n")}
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
async function exportUrls(backend, opts) {
|
|
226
|
+
const entries = await backend.list();
|
|
227
|
+
if (entries.length === 0) {
|
|
228
|
+
info("No URLs to export.");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const format = opts.format ?? "json";
|
|
232
|
+
let output;
|
|
233
|
+
if (format === "csv") {
|
|
234
|
+
output = toCsv(entries);
|
|
235
|
+
} else {
|
|
236
|
+
output = `${JSON.stringify(entries, null, 2)}
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
if (opts.output) {
|
|
240
|
+
await writeFile3(opts.output, output);
|
|
241
|
+
success(`Exported ${entries.length} URL${entries.length === 1 ? "" : "s"} to ${opts.output}`);
|
|
242
|
+
} else {
|
|
243
|
+
process.stdout.write(output);
|
|
244
|
+
if (process.stderr.isTTY) {
|
|
245
|
+
info(`${dim(`${entries.length} URL${entries.length === 1 ? "" : "s"} exported`)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/commands/import.ts
|
|
251
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
252
|
+
import { resolve } from "path";
|
|
253
|
+
function parseCsv(content) {
|
|
254
|
+
const lines = content.trim().split("\n");
|
|
255
|
+
if (lines.length < 2) return [];
|
|
256
|
+
const header = lines[0]?.split(",").map((h) => h.trim().toLowerCase());
|
|
257
|
+
const slugIdx = header.indexOf("slug");
|
|
258
|
+
const urlIdx = header.indexOf("url");
|
|
259
|
+
const descIdx = header.indexOf("description");
|
|
260
|
+
const expiresIdx = header.indexOf("expiresat");
|
|
261
|
+
const tagsIdx = header.indexOf("tags");
|
|
262
|
+
if (slugIdx === -1 || urlIdx === -1) {
|
|
263
|
+
throw new Error('CSV must have "slug" and "url" columns');
|
|
264
|
+
}
|
|
265
|
+
return lines.slice(1).map((line) => {
|
|
266
|
+
const cols = line.split(",").map((c) => c.trim());
|
|
267
|
+
const entry = {
|
|
268
|
+
slug: cols[slugIdx],
|
|
269
|
+
url: cols[urlIdx]
|
|
270
|
+
};
|
|
271
|
+
if (descIdx !== -1 && cols[descIdx]) entry.description = cols[descIdx];
|
|
272
|
+
if (expiresIdx !== -1 && cols[expiresIdx]) entry.expiresAt = cols[expiresIdx];
|
|
273
|
+
if (tagsIdx !== -1 && cols[tagsIdx])
|
|
274
|
+
entry.tags = cols[tagsIdx]?.split(";").map((t) => t.trim());
|
|
275
|
+
return entry;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async function importUrls(file, backend, opts) {
|
|
279
|
+
let content;
|
|
280
|
+
try {
|
|
281
|
+
const resolvedPath = resolve(file);
|
|
282
|
+
content = await readFile2(resolvedPath, "utf-8");
|
|
283
|
+
} catch {
|
|
284
|
+
error(`Cannot read file: ${file}`);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
const format = opts.format ?? (file.endsWith(".csv") ? "csv" : "json");
|
|
288
|
+
let entries;
|
|
289
|
+
try {
|
|
290
|
+
if (format === "csv") {
|
|
291
|
+
entries = parseCsv(content);
|
|
292
|
+
} else {
|
|
293
|
+
const parsed = JSON.parse(content);
|
|
294
|
+
entries = Array.isArray(parsed) ? parsed : parsed.urls ?? Object.values(parsed);
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
298
|
+
error(`Failed to parse ${format.toUpperCase()} file: ${msg}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
if (entries.length === 0) {
|
|
302
|
+
info("No entries found in import file.");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
info(`Importing ${entries.length} URL${entries.length === 1 ? "" : "s"}...`);
|
|
306
|
+
let imported = 0;
|
|
307
|
+
let skipped = 0;
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (!entry.slug || !entry.url) {
|
|
310
|
+
skipped++;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
await backend.create(entry.slug, entry.url, {
|
|
315
|
+
description: entry.description,
|
|
316
|
+
expiresAt: entry.expiresAt,
|
|
317
|
+
tags: entry.tags
|
|
318
|
+
});
|
|
319
|
+
imported++;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
322
|
+
console.log(` ${dim(`skip "${entry.slug}": ${msg}`)}`);
|
|
323
|
+
skipped++;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
success(`Imported ${imported} URL${imported === 1 ? "" : "s"}`);
|
|
327
|
+
if (skipped > 0) {
|
|
328
|
+
info(`${skipped} entr${skipped === 1 ? "y" : "ies"} skipped (conflicts or invalid)`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/commands/info.ts
|
|
333
|
+
import { appendUtm as appendUtm2, hasUtm as hasUtm2 } from "@clipr/core";
|
|
334
|
+
import pc2 from "picocolors";
|
|
335
|
+
async function info2(slug, backend) {
|
|
336
|
+
const entry = await backend.get(slug);
|
|
337
|
+
if (!entry) {
|
|
338
|
+
error(`Slug "${slug}" not found`);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
const baseUrl = await backend.getBaseUrl();
|
|
342
|
+
const shortUrl = baseUrl ? `${baseUrl}/${entry.slug}` : entry.slug;
|
|
343
|
+
const redirectUrl = hasUtm2(entry.utm) ? appendUtm2(entry.url, entry.utm) : entry.url;
|
|
344
|
+
console.log();
|
|
345
|
+
console.log(` ${pc2.bold("Slug:")} ${pc2.cyan(entry.slug)}`);
|
|
346
|
+
console.log(` ${pc2.bold("Short URL:")} ${shortUrl}`);
|
|
347
|
+
console.log(` ${pc2.bold("Target:")} ${entry.url}`);
|
|
348
|
+
if (redirectUrl !== entry.url) {
|
|
349
|
+
console.log(` ${pc2.bold("Redirect:")} ${dim(redirectUrl)}`);
|
|
350
|
+
}
|
|
351
|
+
if (entry.description) {
|
|
352
|
+
console.log(` ${pc2.bold("Note:")} ${entry.description}`);
|
|
353
|
+
}
|
|
354
|
+
console.log(` ${pc2.bold("Created:")} ${entry.createdAt}`);
|
|
355
|
+
if (entry.expiresAt) {
|
|
356
|
+
console.log(` ${pc2.bold("Expires:")} ${entry.expiresAt}`);
|
|
357
|
+
}
|
|
358
|
+
if (hasUtm2(entry.utm)) {
|
|
359
|
+
console.log(` ${pc2.bold("UTM:")}`);
|
|
360
|
+
for (const [key, value] of Object.entries(entry.utm)) {
|
|
361
|
+
if (value) console.log(` ${dim(key)}: ${value}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
console.log();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/commands/init.ts
|
|
368
|
+
import { existsSync as existsSync2 } from "fs";
|
|
369
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
370
|
+
import { DB_VERSION } from "@clipr/core";
|
|
371
|
+
async function init(dbPath, opts) {
|
|
372
|
+
if (existsSync2(dbPath) && !opts.force) {
|
|
373
|
+
error(`${dbPath} already exists. Use --force to overwrite.`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
const db = {
|
|
377
|
+
version: DB_VERSION,
|
|
378
|
+
counter: 0,
|
|
379
|
+
baseUrl: opts.baseUrl ?? "",
|
|
380
|
+
urls: {}
|
|
381
|
+
};
|
|
382
|
+
await writeFile4(dbPath, `${JSON.stringify(db, null, 2)}
|
|
383
|
+
`);
|
|
384
|
+
success(`Created ${dbPath}`);
|
|
385
|
+
if (opts.baseUrl) {
|
|
386
|
+
info(`Base URL set to ${dim(opts.baseUrl)}`);
|
|
387
|
+
} else {
|
|
388
|
+
info(`Set a base URL with: clipr config baseUrl https://your-domain.com`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/commands/list.ts
|
|
393
|
+
import pc3 from "picocolors";
|
|
394
|
+
async function list(backend) {
|
|
395
|
+
const entries = await backend.list();
|
|
396
|
+
if (entries.length === 0) {
|
|
397
|
+
info("No shortened URLs yet. Run `clipr shorten <url>` to create one.");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const baseUrl = await backend.getBaseUrl();
|
|
401
|
+
console.log(dim(`
|
|
402
|
+
${entries.length} shortened URL${entries.length === 1 ? "" : "s"}:
|
|
403
|
+
`));
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
const short = baseUrl ? `${baseUrl}/${entry.slug}` : entry.slug;
|
|
406
|
+
console.log(` ${pc3.bold(pc3.cyan(short))}`);
|
|
407
|
+
console.log(` \u2192 ${entry.url}`);
|
|
408
|
+
if (entry.description) {
|
|
409
|
+
console.log(` ${dim(entry.description)}`);
|
|
410
|
+
}
|
|
411
|
+
if (entry.expiresAt) {
|
|
412
|
+
console.log(` ${dim(`expires: ${entry.expiresAt}`)}`);
|
|
413
|
+
}
|
|
414
|
+
console.log();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/commands/qr.ts
|
|
419
|
+
import { writeFile as writeFile5 } from "fs/promises";
|
|
420
|
+
|
|
421
|
+
// src/utils/qr.ts
|
|
422
|
+
import QRCode from "qrcode";
|
|
423
|
+
async function generateQR(url, format = "svg", size = 256) {
|
|
424
|
+
if (format === "svg") {
|
|
425
|
+
return QRCode.toString(url, {
|
|
426
|
+
type: "svg",
|
|
427
|
+
width: size,
|
|
428
|
+
margin: 2
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return QRCode.toBuffer(url, {
|
|
432
|
+
type: "png",
|
|
433
|
+
width: size,
|
|
434
|
+
margin: 2
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/commands/qr.ts
|
|
439
|
+
async function qr(slug, backend, opts) {
|
|
440
|
+
const result = await backend.resolve(slug);
|
|
441
|
+
if (!result) {
|
|
442
|
+
error(`Slug "${slug}" not found`);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
const entries = await backend.list({ search: slug, limit: 1 });
|
|
446
|
+
const entry = entries.find((e) => e.slug === slug);
|
|
447
|
+
const urlToEncode = result.url;
|
|
448
|
+
const format = opts.format ?? "svg";
|
|
449
|
+
const size = opts.size ? parseInt(opts.size, 10) : 256;
|
|
450
|
+
if (Number.isNaN(size) || size < 64 || size > 4096) {
|
|
451
|
+
error("Size must be between 64 and 4096 pixels");
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const qrData = await generateQR(urlToEncode, format, size);
|
|
456
|
+
if (opts.output) {
|
|
457
|
+
await writeFile5(opts.output, qrData);
|
|
458
|
+
success(`QR code saved to ${opts.output}`);
|
|
459
|
+
} else {
|
|
460
|
+
if (typeof qrData === "string") {
|
|
461
|
+
console.log(qrData);
|
|
462
|
+
} else {
|
|
463
|
+
process.stdout.write(qrData);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (entry) {
|
|
467
|
+
info(`QR code for slug "${slug}" \u2192 ${entry.url}`);
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
471
|
+
error(`Failed to generate QR code: ${msg}`);
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/commands/shorten.ts
|
|
477
|
+
import {
|
|
478
|
+
generateRandomSlug,
|
|
479
|
+
hasUtm as hasUtm3,
|
|
480
|
+
normalizeSlug as normalizeSlug2,
|
|
481
|
+
validateSlug as validateSlug2,
|
|
482
|
+
validateUrl
|
|
483
|
+
} from "@clipr/core";
|
|
484
|
+
async function shorten(url, backend, opts) {
|
|
485
|
+
const urlResult = validateUrl(url);
|
|
486
|
+
if (!urlResult.valid) {
|
|
487
|
+
error(`Invalid URL: ${urlResult.reason}`);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
let slug;
|
|
491
|
+
if (opts.slug) {
|
|
492
|
+
slug = normalizeSlug2(opts.slug);
|
|
493
|
+
const slugResult = validateSlug2(slug);
|
|
494
|
+
if (!slugResult.valid) {
|
|
495
|
+
error(`Invalid slug: ${slugResult.reason}`);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
} else {
|
|
499
|
+
slug = generateRandomSlug();
|
|
500
|
+
while (await backend.has(slug)) {
|
|
501
|
+
slug = generateRandomSlug();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const utm = {};
|
|
505
|
+
if (opts.utm_source) utm.utm_source = opts.utm_source;
|
|
506
|
+
if (opts.utm_medium) utm.utm_medium = opts.utm_medium;
|
|
507
|
+
if (opts.utm_campaign) utm.utm_campaign = opts.utm_campaign;
|
|
508
|
+
if (opts.utm_term) utm.utm_term = opts.utm_term;
|
|
509
|
+
if (opts.utm_content) utm.utm_content = opts.utm_content;
|
|
510
|
+
const entry = {
|
|
511
|
+
slug,
|
|
512
|
+
url,
|
|
513
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
514
|
+
...opts.description && { description: opts.description },
|
|
515
|
+
...opts.expires && { expiresAt: new Date(opts.expires).toISOString() },
|
|
516
|
+
...hasUtm3(utm) && { utm }
|
|
517
|
+
};
|
|
518
|
+
await backend.set(entry);
|
|
519
|
+
const baseUrl = await backend.getBaseUrl();
|
|
520
|
+
const shortUrl = baseUrl ? `${baseUrl}/${slug}` : slug;
|
|
521
|
+
success(`Shortened ${dim(url)}`);
|
|
522
|
+
console.log(` ${shortUrl}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/commands/stats.ts
|
|
526
|
+
import pc4 from "picocolors";
|
|
527
|
+
async function stats(slug, backend, opts) {
|
|
528
|
+
try {
|
|
529
|
+
const data = await backend.getStats(slug);
|
|
530
|
+
if (data === null) {
|
|
531
|
+
error("Stats are not available in the current backend mode.");
|
|
532
|
+
info(
|
|
533
|
+
'Click analytics require the Workers API backend. Deploy with `clipr deploy` and set mode to "api".'
|
|
534
|
+
);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
if (opts.json) {
|
|
538
|
+
console.log(JSON.stringify(data, null, 2));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
console.log();
|
|
542
|
+
console.log(` ${pc4.bold("Stats for")} ${pc4.cyan(slug)}`);
|
|
543
|
+
console.log(` ${pc4.bold("Total clicks:")} ${data.total}`);
|
|
544
|
+
if (Object.keys(data.daily).length > 0) {
|
|
545
|
+
console.log();
|
|
546
|
+
console.log(` ${pc4.bold("Daily clicks:")}`);
|
|
547
|
+
const sorted = Object.entries(data.daily).sort(([a], [b]) => a.localeCompare(b));
|
|
548
|
+
for (const [date, count] of sorted) {
|
|
549
|
+
const bar = "\u2588".repeat(Math.min(count, 40));
|
|
550
|
+
console.log(` ${dim(date)} ${bar} ${count}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (Object.keys(data.geo).length > 0) {
|
|
554
|
+
console.log();
|
|
555
|
+
console.log(` ${pc4.bold("Top countries:")}`);
|
|
556
|
+
const sorted = Object.entries(data.geo).sort(([, a], [, b]) => b - a).slice(0, 10);
|
|
557
|
+
for (const [country, count] of sorted) {
|
|
558
|
+
console.log(` ${country}: ${count}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (Object.keys(data.referrer).length > 0) {
|
|
562
|
+
console.log();
|
|
563
|
+
console.log(` ${pc4.bold("Top referrers:")}`);
|
|
564
|
+
const sorted = Object.entries(data.referrer).sort(([, a], [, b]) => b - a).slice(0, 10);
|
|
565
|
+
for (const [ref, count] of sorted) {
|
|
566
|
+
console.log(` ${ref || dim("(direct)")}: ${count}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (Object.keys(data.device).length > 0) {
|
|
570
|
+
console.log();
|
|
571
|
+
console.log(` ${pc4.bold("Devices:")}`);
|
|
572
|
+
for (const [device, count] of Object.entries(data.device)) {
|
|
573
|
+
console.log(` ${device}: ${count}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
console.log();
|
|
577
|
+
} catch (err) {
|
|
578
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
579
|
+
error(msg);
|
|
580
|
+
process.exit(1);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// src/factory.ts
|
|
585
|
+
async function createBackend(config2) {
|
|
586
|
+
switch (config2.mode) {
|
|
587
|
+
case "github": {
|
|
588
|
+
if (!config2.github) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
"GitHub backend requires github config. Run `clipr config` to set owner, repo, branch, path, and token."
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
const { GitHubBackend } = await import("./github-PSGU4YYI.js");
|
|
594
|
+
return new GitHubBackend(config2.github, config2.baseUrl);
|
|
595
|
+
}
|
|
596
|
+
case "api": {
|
|
597
|
+
if (!config2.api) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
"API backend requires api config. Run `clipr config` to set baseUrl and token."
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const { ApiBackend } = await import("./api-KFVDRB2Q.js");
|
|
603
|
+
return new ApiBackend(config2.api, config2.baseUrl);
|
|
604
|
+
}
|
|
605
|
+
default: {
|
|
606
|
+
return new JsonBackendAdapter(config2.dbPath, config2.baseUrl);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/utils/context.ts
|
|
612
|
+
import { resolve as resolve2 } from "path";
|
|
613
|
+
import { DEFAULT_DB_PATH, JsonBackend } from "@clipr/core";
|
|
614
|
+
function createBackend2(dbPath) {
|
|
615
|
+
const resolved = resolve2(dbPath ?? DEFAULT_DB_PATH);
|
|
616
|
+
return new JsonBackend(resolved);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/index.ts
|
|
620
|
+
var program = new Command();
|
|
621
|
+
async function resolveBackend(globalOpts) {
|
|
622
|
+
if (globalOpts.db) {
|
|
623
|
+
const { JsonBackendAdapter: JsonBackendAdapter2 } = await import("./json-adapter-5YGDHNVJ.js");
|
|
624
|
+
const dbPath = resolve3(globalOpts.db);
|
|
625
|
+
return new JsonBackendAdapter2(dbPath, "");
|
|
626
|
+
}
|
|
627
|
+
const config2 = loadConfig();
|
|
628
|
+
return createBackend(config2);
|
|
629
|
+
}
|
|
630
|
+
program.name("clipr").description("Shorten, manage, and track URLs").version("0.0.1").option("--db <path>", "path to urls.json database file");
|
|
631
|
+
program.command("shorten").description("Shorten a URL").argument("<url>", "URL to shorten").option("-s, --slug <slug>", "custom slug (random if omitted)").option("-d, --description <text>", "description for the link").option("--utm-source <value>", "UTM source").option("--utm-medium <value>", "UTM medium").option("--utm-campaign <value>", "UTM campaign").option("--utm-term <value>", "UTM term").option("--utm-content <value>", "UTM content").option("--expires <date>", "expiration date (ISO 8601)").action(async (url, opts) => {
|
|
632
|
+
const backend = createBackend2(program.opts().db);
|
|
633
|
+
await shorten(url, backend, {
|
|
634
|
+
slug: opts.slug,
|
|
635
|
+
description: opts.description,
|
|
636
|
+
utm_source: opts.utmSource,
|
|
637
|
+
utm_medium: opts.utmMedium,
|
|
638
|
+
utm_campaign: opts.utmCampaign,
|
|
639
|
+
utm_term: opts.utmTerm,
|
|
640
|
+
utm_content: opts.utmContent,
|
|
641
|
+
expires: opts.expires
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
program.command("list").alias("ls").description("List all shortened URLs").action(async () => {
|
|
645
|
+
const backend = createBackend2(program.opts().db);
|
|
646
|
+
await list(backend);
|
|
647
|
+
});
|
|
648
|
+
program.command("delete").alias("rm").description("Delete a shortened URL").argument("<slug>", "slug to delete").action(async (slug) => {
|
|
649
|
+
const backend = createBackend2(program.opts().db);
|
|
650
|
+
await del(slug, backend);
|
|
651
|
+
});
|
|
652
|
+
program.command("info").description("Show details for a slug").argument("<slug>", "slug to inspect").action(async (slug) => {
|
|
653
|
+
const backend = createBackend2(program.opts().db);
|
|
654
|
+
await info2(slug, backend);
|
|
655
|
+
});
|
|
656
|
+
program.command("config").description("Get or set configuration").argument("<key>", "config key (e.g. baseUrl)").argument("[value]", "value to set (omit to read)").action(async (key, value) => {
|
|
657
|
+
const backend = createBackend2(program.opts().db);
|
|
658
|
+
await config(key, value, backend);
|
|
659
|
+
});
|
|
660
|
+
program.command("init").description("Initialize a new urls.json database").option("-b, --base-url <url>", "base URL for short links").option("-f, --force", "overwrite existing database").action(async (opts) => {
|
|
661
|
+
const dbPath = resolve3(program.opts().db ?? DEFAULT_DB_PATH2);
|
|
662
|
+
await init(dbPath, { baseUrl: opts.baseUrl, force: opts.force });
|
|
663
|
+
});
|
|
664
|
+
program.command("deploy").description("Deploy URLs to Cloudflare KV").requiredOption("--namespace-id <id>", "Cloudflare KV namespace ID").option("--preview", "deploy to preview KV namespace").action(async (opts) => {
|
|
665
|
+
const backend = createBackend2(program.opts().db);
|
|
666
|
+
await deploy(backend, {
|
|
667
|
+
namespaceId: opts.namespaceId,
|
|
668
|
+
preview: opts.preview
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
program.command("edit").description("Edit an existing shortened URL").argument("<slug>", "slug to edit").option("--url <url>", "new target URL").option("--slug <slug>", "new slug").option("--tags <tags>", "comma-separated tags").option("--expires <date>", "expiration date (ISO 8601)").option("--description <text>", "description").action(async (slug, opts) => {
|
|
672
|
+
const backend = await resolveBackend(program.opts());
|
|
673
|
+
await edit(slug, backend, opts);
|
|
674
|
+
});
|
|
675
|
+
program.command("stats").description("Show click analytics for a slug").argument("<slug>", "slug to inspect").option("--json", "output raw JSON").option("--period <period>", "time period (e.g. 7d, 30d)").action(async (slug, opts) => {
|
|
676
|
+
const backend = await resolveBackend(program.opts());
|
|
677
|
+
await stats(slug, backend, opts);
|
|
678
|
+
});
|
|
679
|
+
program.command("qr").description("Generate a QR code for a slug").argument("<slug>", "slug to generate QR for").option("-o, --output <file>", "output file path").option("-f, --format <format>", "output format (svg|png)", "svg").option("-s, --size <px>", "image size in pixels", "256").action(async (slug, opts) => {
|
|
680
|
+
const backend = await resolveBackend(program.opts());
|
|
681
|
+
await qr(slug, backend, opts);
|
|
682
|
+
});
|
|
683
|
+
program.command("import").description("Import URLs from a file").argument("<file>", "input file path (JSON or CSV)").option("-f, --format <format>", "file format (json|csv)").action(async (file, opts) => {
|
|
684
|
+
const backend = await resolveBackend(program.opts());
|
|
685
|
+
await importUrls(file, backend, opts);
|
|
686
|
+
});
|
|
687
|
+
program.command("export").description("Export all URLs to a file").option("-f, --format <format>", "output format (json|csv)", "json").option("-o, --output <file>", "output file path (stdout if omitted)").action(async (opts) => {
|
|
688
|
+
const backend = await resolveBackend(program.opts());
|
|
689
|
+
await exportUrls(backend, opts);
|
|
690
|
+
});
|
|
691
|
+
program.command("build").description("Build static HTML redirect pages").option("-i, --input <path>", "path to urls.json", "urls.json").option("-o, --output <dir>", "output directory", "dist").action(async (opts) => {
|
|
692
|
+
await build({ input: opts.input, output: opts.output });
|
|
693
|
+
});
|
|
694
|
+
program.parseAsync().catch((err) => {
|
|
695
|
+
error(err.message);
|
|
696
|
+
process.exit(1);
|
|
697
|
+
});
|
|
698
|
+
//# sourceMappingURL=index.js.map
|