@flaredlink/cli 0.0.0-stage → 0.1.0

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/flared.js ADDED
@@ -0,0 +1,896 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { writeFile as writeFile2 } from "node:fs/promises";
5
+
6
+ // src/cli.ts
7
+ import { parseArgs } from "node:util";
8
+ import QRCode from "qrcode";
9
+
10
+ // ../contracts/src/analytics.ts
11
+ var clickEventMaxAgeMs = 48 * 60 * 60 * 1000;
12
+ var clickEventMaxSkewMs = 5 * 60 * 1000;
13
+ function isCount(value) {
14
+ return Number.isSafeInteger(value) && value >= 0;
15
+ }
16
+ function isRows(value, key) {
17
+ return Array.isArray(value) && value.every((row) => typeof row === "object" && row !== null && typeof row[key] === "string" && isCount(row.clicks));
18
+ }
19
+ function isLinkAnalytics(value) {
20
+ if (typeof value !== "object" || value === null)
21
+ return false;
22
+ const analytics = value;
23
+ return typeof analytics.linkId === "string" && typeof analytics.from === "string" && typeof analytics.to === "string" && isCount(analytics.total) && isRows(analytics.days, "day") && isRows(analytics.countries, "value") && isRows(analytics.devices, "value") && isRows(analytics.referrers, "value") && typeof analytics.asOf === "string";
24
+ }
25
+ function isUsage(value) {
26
+ if (typeof value !== "object" || value === null)
27
+ return false;
28
+ const usage = value;
29
+ return typeof usage.month === "string" && isCount(usage.clicks) && isCount(usage.clickLimit) && typeof usage.asOf === "string";
30
+ }
31
+
32
+ // ../contracts/src/errors.ts
33
+ var errorStatus = {
34
+ INVALID_INPUT: 422,
35
+ IDEMPOTENCY_KEY_REQUIRED: 422,
36
+ IDEMPOTENCY_KEY_REUSED: 409,
37
+ SLUG_TAKEN: 409,
38
+ DEFAULT_DOMAIN_UNAVAILABLE: 409,
39
+ DOMAIN_UNAVAILABLE: 422,
40
+ PLAN_LIMIT_REACHED: 403,
41
+ RATE_LIMITED: 429,
42
+ UNAUTHENTICATED: 401,
43
+ ORIGIN_REJECTED: 403,
44
+ INSUFFICIENT_SCOPE: 403,
45
+ REAUTH_REQUIRED: 403,
46
+ TOKEN_LIMIT_REACHED: 403,
47
+ NO_WORKSPACE: 403,
48
+ WORKSPACE_PENDING: 503,
49
+ NOT_FOUND: 404,
50
+ METHOD_NOT_ALLOWED: 405,
51
+ PAYLOAD_TOO_LARGE: 413,
52
+ UNSUPPORTED_MEDIA_TYPE: 415,
53
+ SERVICE_UNAVAILABLE: 503
54
+ };
55
+ function isErrorCode(value) {
56
+ return typeof value === "string" && Object.hasOwn(errorStatus, value);
57
+ }
58
+
59
+ // ../contracts/src/links.ts
60
+ function isRecord(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+ function isLink(value) {
64
+ if (!isRecord(value))
65
+ return false;
66
+ return typeof value.id === "string" && typeof value.domainId === "string" && typeof value.hostname === "string" && typeof value.slug === "string" && typeof value.shortUrl === "string" && typeof value.destination === "string" && (value.title === null || typeof value.title === "string") && typeof value.enabled === "boolean" && typeof value.createdAt === "string" && typeof value.updatedAt === "string";
67
+ }
68
+ function isListedLink(value) {
69
+ if (!isRecord(value) || !isLink(value))
70
+ return false;
71
+ const clicks = value.clicksLast30Days;
72
+ return clicks === null || Number.isSafeInteger(clicks) && clicks >= 0;
73
+ }
74
+ function isLinkPage(value) {
75
+ return isRecord(value) && Array.isArray(value.links) && value.links.every(isListedLink) && (value.nextCursor === null || typeof value.nextCursor === "string");
76
+ }
77
+
78
+ // ../contracts/src/tokens.ts
79
+ var tokenScopes = [
80
+ "links:read",
81
+ "links:write",
82
+ "analytics:read",
83
+ "domains:read",
84
+ "usage:read"
85
+ ];
86
+ var scopePresets = {
87
+ full: [...tokenScopes],
88
+ read: tokenScopes.filter((scope) => scope !== "links:write")
89
+ };
90
+ var tokenPrefix = "flr_";
91
+ function isTokenScope(value) {
92
+ return typeof value === "string" && tokenScopes.includes(value);
93
+ }
94
+ function nullableString(value) {
95
+ return value === null || typeof value === "string";
96
+ }
97
+ function isApiIdentity(value) {
98
+ if (typeof value !== "object" || value === null)
99
+ return false;
100
+ const identity = value;
101
+ if (!Array.isArray(identity.scopes) || !identity.scopes.every(isTokenScope))
102
+ return false;
103
+ if (identity.kind === "session")
104
+ return true;
105
+ if (identity.kind !== "token" || typeof identity.token !== "object" || identity.token === null)
106
+ return false;
107
+ const token = identity.token;
108
+ return typeof token.id === "string" && typeof token.name === "string" && typeof token.start === "string" && nullableString(token.expiresAt);
109
+ }
110
+
111
+ // ../client/src/client.ts
112
+ var defaultApiUrl = "https://api.flared.page/v1";
113
+
114
+ class FlaredApiError extends Error {
115
+ code;
116
+ status;
117
+ requestId;
118
+ retryAfterSeconds;
119
+ field;
120
+ constructor(code, message, status, requestId = null, retryAfterSeconds = null, field = null) {
121
+ super(message);
122
+ this.code = code;
123
+ this.status = status;
124
+ this.requestId = requestId;
125
+ this.retryAfterSeconds = retryAfterSeconds;
126
+ this.field = field;
127
+ this.name = "FlaredApiError";
128
+ }
129
+ }
130
+ var maxRetryWaitSeconds = 30;
131
+ function normalizeBaseUrl(value) {
132
+ let url;
133
+ try {
134
+ url = new URL(value);
135
+ } catch {
136
+ throw new Error("Use an absolute API URL, such as https://api.flared.page/v1.");
137
+ }
138
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
139
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
140
+ throw new Error("Use an HTTPS API URL. Plain HTTP is allowed only for localhost.");
141
+ if (url.username || url.password || url.search || url.hash)
142
+ throw new Error("Use an API URL without credentials, query, or fragment.");
143
+ return url.href.replace(/\/+$/, "");
144
+ }
145
+ function record(value) {
146
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
147
+ }
148
+ function retryAfter(response) {
149
+ const header = response.headers.get("retry-after");
150
+ const seconds = header === null || header.trim() === "" ? NaN : Number(header);
151
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds) : null;
152
+ }
153
+ async function failure(response) {
154
+ const body = record(await response.json().catch(() => null));
155
+ const error = record(body?.error);
156
+ const code = isErrorCode(error?.code) ? error.code : null;
157
+ return new FlaredApiError(code ?? (response.status >= 500 ? "SERVICE_UNAVAILABLE" : "INVALID_RESPONSE"), typeof error?.message === "string" ? error.message : `The API answered ${response.status}.`, response.status, typeof error?.requestId === "string" ? error.requestId : null, retryAfter(response), typeof error?.field === "string" ? error.field : null);
158
+ }
159
+ function invalid(status) {
160
+ return new FlaredApiError("INVALID_RESPONSE", "The API sent an unexpected response.", status);
161
+ }
162
+ function createClient(options) {
163
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
164
+ const send = options.fetch ?? fetch;
165
+ const retries = options.retries ?? 2;
166
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((done) => setTimeout(done, milliseconds)));
167
+ async function request(method, path, init) {
168
+ const headers = new Headers(init.headers);
169
+ headers.set("authorization", `Bearer ${options.token}`);
170
+ headers.set("accept", "application/json");
171
+ if (options.userAgent)
172
+ headers.set("user-agent", options.userAgent);
173
+ if (init.body !== undefined)
174
+ headers.set("content-type", "application/json");
175
+ for (let attempt = 0;; attempt += 1) {
176
+ let response;
177
+ try {
178
+ response = await send(`${baseUrl}${path}`, {
179
+ method,
180
+ headers,
181
+ body: init.body === undefined ? undefined : JSON.stringify(init.body)
182
+ });
183
+ } catch {
184
+ if (init.retry && attempt < retries) {
185
+ await sleep(1000 * 2 ** attempt);
186
+ continue;
187
+ }
188
+ throw new FlaredApiError("NETWORK_ERROR", "Could not reach the Flared API.", null);
189
+ }
190
+ if (response.ok) {
191
+ const body = await response.json().catch(() => {
192
+ return;
193
+ });
194
+ if (body === undefined)
195
+ throw invalid(response.status);
196
+ return { status: response.status, headers: response.headers, body };
197
+ }
198
+ const error = await failure(response);
199
+ const transient = response.status === 429 || response.status === 503;
200
+ const wait = error.retryAfterSeconds ?? 2 ** attempt;
201
+ if (init.retry && transient && attempt < retries && wait <= maxRetryWaitSeconds) {
202
+ await sleep(wait * 1000);
203
+ continue;
204
+ }
205
+ throw error;
206
+ }
207
+ }
208
+ const id = (value) => encodeURIComponent(value);
209
+ async function linkFrom(promise) {
210
+ const { status, body } = await promise;
211
+ const link = record(body)?.link;
212
+ if (!isLink(link))
213
+ throw invalid(status);
214
+ return link;
215
+ }
216
+ return {
217
+ async me() {
218
+ const { status, body } = await request("GET", "/me", { retry: true });
219
+ if (!isApiIdentity(body))
220
+ throw invalid(status);
221
+ return body;
222
+ },
223
+ async createLink({ idempotencyKey, ...input }) {
224
+ const key = idempotencyKey ?? crypto.randomUUID();
225
+ const response = await request("POST", "/links", {
226
+ body: input,
227
+ headers: { "idempotency-key": key },
228
+ retry: true
229
+ });
230
+ const link = record(response.body)?.link;
231
+ if (!isLink(link))
232
+ throw invalid(response.status);
233
+ return { link, replayed: response.headers.get("idempotent-replayed") === "true" };
234
+ },
235
+ async listLinks(query = {}) {
236
+ const params = new URLSearchParams;
237
+ if (query.limit !== undefined)
238
+ params.set("limit", String(query.limit));
239
+ if (query.cursor)
240
+ params.set("cursor", query.cursor);
241
+ if (query.search)
242
+ params.set("q", query.search);
243
+ const suffix = params.size ? `?${params}` : "";
244
+ const { status, body } = await request("GET", `/links${suffix}`, { retry: true });
245
+ if (!isLinkPage(body))
246
+ throw invalid(status);
247
+ return body;
248
+ },
249
+ getLink(linkId) {
250
+ return linkFrom(request("GET", `/links/${id(linkId)}`, { retry: true }));
251
+ },
252
+ updateLink(linkId, change) {
253
+ return linkFrom(request("PATCH", `/links/${id(linkId)}`, { body: change, retry: false }));
254
+ },
255
+ async getAnalytics(linkId, range = {}) {
256
+ const params = new URLSearchParams;
257
+ if (range.from)
258
+ params.set("from", range.from);
259
+ if (range.to)
260
+ params.set("to", range.to);
261
+ const suffix = params.size ? `?${params}` : "";
262
+ const { status, body } = await request("GET", `/links/${id(linkId)}/analytics${suffix}`, {
263
+ retry: true
264
+ });
265
+ const analytics = record(body)?.analytics;
266
+ if (!isLinkAnalytics(analytics))
267
+ throw invalid(status);
268
+ return analytics;
269
+ },
270
+ async getUsage() {
271
+ const { status, body } = await request("GET", "/usage", { retry: true });
272
+ const usage = record(body)?.usage;
273
+ if (!isUsage(usage))
274
+ throw invalid(status);
275
+ return usage;
276
+ }
277
+ };
278
+ }
279
+
280
+ // src/config.ts
281
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
282
+ import { homedir } from "node:os";
283
+ import { join } from "node:path";
284
+
285
+ // src/exit.ts
286
+ var exitCodes = {
287
+ ok: 0,
288
+ failure: 1,
289
+ usage: 2,
290
+ unauthenticated: 3,
291
+ forbidden: 4,
292
+ notFound: 5,
293
+ rejected: 6,
294
+ planLimit: 7,
295
+ rateLimited: 8,
296
+ unavailable: 9
297
+ };
298
+ function exitCodeFor(code) {
299
+ switch (code) {
300
+ case "UNAUTHENTICATED":
301
+ return exitCodes.unauthenticated;
302
+ case "INSUFFICIENT_SCOPE":
303
+ case "NO_WORKSPACE":
304
+ case "ORIGIN_REJECTED":
305
+ case "REAUTH_REQUIRED":
306
+ return exitCodes.forbidden;
307
+ case "NOT_FOUND":
308
+ return exitCodes.notFound;
309
+ case "INVALID_INPUT":
310
+ case "SLUG_TAKEN":
311
+ case "IDEMPOTENCY_KEY_REUSED":
312
+ case "IDEMPOTENCY_KEY_REQUIRED":
313
+ case "DEFAULT_DOMAIN_UNAVAILABLE":
314
+ case "DOMAIN_UNAVAILABLE":
315
+ return exitCodes.rejected;
316
+ case "PLAN_LIMIT_REACHED":
317
+ case "TOKEN_LIMIT_REACHED":
318
+ return exitCodes.planLimit;
319
+ case "RATE_LIMITED":
320
+ return exitCodes.rateLimited;
321
+ case "SERVICE_UNAVAILABLE":
322
+ case "WORKSPACE_PENDING":
323
+ case "NETWORK_ERROR":
324
+ return exitCodes.unavailable;
325
+ default:
326
+ return exitCodes.failure;
327
+ }
328
+ }
329
+
330
+ class UsageError extends Error {
331
+ }
332
+
333
+ class CliError extends Error {
334
+ exitCode;
335
+ code;
336
+ constructor(message, exitCode, code = "CLI_ERROR") {
337
+ super(message);
338
+ this.exitCode = exitCode;
339
+ this.code = code;
340
+ }
341
+ }
342
+
343
+ // src/config.ts
344
+ function configDir(env, platform = process.platform) {
345
+ if (env.FLARED_CONFIG_DIR)
346
+ return env.FLARED_CONFIG_DIR;
347
+ if (platform === "win32" && env.APPDATA)
348
+ return join(env.APPDATA, "flared");
349
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "flared");
350
+ }
351
+ async function readConfig(dir) {
352
+ let text;
353
+ try {
354
+ text = await readFile(join(dir, "config.json"), "utf8");
355
+ } catch (error) {
356
+ if (error.code === "ENOENT")
357
+ return {};
358
+ throw new CliError(`Cannot read ${join(dir, "config.json")}.`, exitCodes.failure);
359
+ }
360
+ let value;
361
+ try {
362
+ value = JSON.parse(text);
363
+ } catch {
364
+ value = null;
365
+ }
366
+ if (typeof value !== "object" || value === null)
367
+ throw new CliError(`${join(dir, "config.json")} is not valid. Run flared login again.`, exitCodes.failure);
368
+ const { apiUrl, token } = value;
369
+ return {
370
+ ...typeof apiUrl === "string" ? { apiUrl } : {},
371
+ ...typeof token === "string" ? { token } : {}
372
+ };
373
+ }
374
+ async function writeConfig(dir, config) {
375
+ await mkdir(dir, { recursive: true, mode: 448 });
376
+ await chmod(dir, 448).catch(() => {
377
+ return;
378
+ });
379
+ const file = join(dir, "config.json");
380
+ const temporary = `${file}.${process.pid}.tmp`;
381
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}
382
+ `, { mode: 384 });
383
+ await rename(temporary, file);
384
+ await chmod(file, 384).catch(() => {
385
+ return;
386
+ });
387
+ }
388
+ async function deleteConfig(dir) {
389
+ await rm(join(dir, "config.json"), { force: true });
390
+ }
391
+ function checkToken(token) {
392
+ const value = token.trim();
393
+ if (!value.startsWith(tokenPrefix) || value.length > 128 || /\s/.test(value))
394
+ throw new UsageError(`An API token starts with ${tokenPrefix}. Create one in Settings.`);
395
+ return value;
396
+ }
397
+ function resolveApiUrl(option, env, saved) {
398
+ const value = option ?? env.FLARED_API_URL ?? saved.apiUrl ?? defaultApiUrl;
399
+ try {
400
+ return normalizeBaseUrl(value);
401
+ } catch (error) {
402
+ throw new UsageError(error.message);
403
+ }
404
+ }
405
+ function resolveToken(env, saved) {
406
+ const token = env.FLARED_TOKEN || saved.token;
407
+ if (!token)
408
+ throw new CliError("Not signed in. Run flared login, or set FLARED_TOKEN.", exitCodes.unauthenticated, "UNAUTHENTICATED");
409
+ return token;
410
+ }
411
+
412
+ // src/output.ts
413
+ function table(rows) {
414
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
415
+ return rows.map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column])).join(" ")).join(`
416
+ `);
417
+ }
418
+ function shorten(text, length) {
419
+ return text.length > length ? `${text.slice(0, length - 1)}…` : text;
420
+ }
421
+ function utc(iso) {
422
+ return `${iso.slice(0, 16).replace("T", " ")} UTC`;
423
+ }
424
+ function linkTable(links) {
425
+ if (links.length === 0)
426
+ return "No links.";
427
+ return table([
428
+ ["SHORT URL", "CLICKS 30D", "STATUS", "DESTINATION"],
429
+ ...links.map((link) => [
430
+ link.shortUrl,
431
+ link.clicksLast30Days === null ? "-" : String(link.clicksLast30Days),
432
+ link.enabled ? "enabled" : "disabled",
433
+ shorten(link.destination, 60)
434
+ ])
435
+ ]);
436
+ }
437
+ function linkDetails(link) {
438
+ return table([
439
+ ["Short URL", link.shortUrl],
440
+ ["Destination", link.destination],
441
+ ["Title", link.title ?? "-"],
442
+ ["Status", link.enabled ? "enabled" : "disabled"],
443
+ ["Created", utc(link.createdAt)],
444
+ ["Updated", utc(link.updatedAt)],
445
+ ["ID", link.id]
446
+ ]);
447
+ }
448
+ function top(rows) {
449
+ if (rows.length === 0)
450
+ return "-";
451
+ return [...rows].sort((a, b) => b.clicks - a.clicks).slice(0, 5).map((row) => `${row.value} ${row.clicks}`).join(", ");
452
+ }
453
+ function analyticsReport(link, analytics) {
454
+ const clicks = analytics.total === 1 ? "1 click" : `${analytics.total} clicks`;
455
+ const days = analytics.days.filter((day) => day.clicks > 0);
456
+ return [
457
+ `${link.shortUrl}: ${clicks} from ${analytics.from} to ${analytics.to}`,
458
+ "",
459
+ days.length ? table([["DAY", "CLICKS"], ...days.map((day) => [day.day, String(day.clicks)])]) : "No clicks in this range.",
460
+ "",
461
+ table([
462
+ ["Countries", top(analytics.countries)],
463
+ ["Referrers", top(analytics.referrers)],
464
+ ["Devices", top(analytics.devices)],
465
+ ["As of", utc(analytics.asOf)]
466
+ ])
467
+ ].join(`
468
+ `);
469
+ }
470
+ function usageReport(usage) {
471
+ return `${usage.clicks} of ${usage.clickLimit} clicks recorded in ${usage.month} (as of ${utc(usage.asOf)}).`;
472
+ }
473
+ function identityReport(identity, apiUrl) {
474
+ const rows = identity.kind === "token" ? [
475
+ ["Token", `${identity.token.name} (${identity.token.start}…)`],
476
+ ["Expires", identity.token.expiresAt ? utc(identity.token.expiresAt) : "never"]
477
+ ] : [["Session", "browser session"]];
478
+ return table([...rows, ["Scopes", identity.scopes.join(", ")], ["API", apiUrl]]);
479
+ }
480
+
481
+ // src/version.ts
482
+ var cliVersion = "0.1.0";
483
+
484
+ // src/cli.ts
485
+ var optionTypes = {
486
+ json: { type: "boolean" },
487
+ "api-url": { type: "string" },
488
+ help: { type: "boolean", short: "h" },
489
+ version: { type: "boolean", short: "v" },
490
+ "token-stdin": { type: "boolean" },
491
+ slug: { type: "string" },
492
+ title: { type: "string" },
493
+ "clear-title": { type: "boolean" },
494
+ destination: { type: "string" },
495
+ "idempotency-key": { type: "string" },
496
+ search: { type: "string" },
497
+ limit: { type: "string" },
498
+ cursor: { type: "string" },
499
+ all: { type: "boolean" },
500
+ format: { type: "string" },
501
+ out: { type: "string" },
502
+ from: { type: "string" },
503
+ to: { type: "string" }
504
+ };
505
+ var uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
506
+ async function resolveLink(client, reference) {
507
+ if (uuid.test(reference))
508
+ return reference;
509
+ const page = await client.listLinks({ search: reference, limit: 100 });
510
+ const matches = page.links.filter((link) => link.slug === reference);
511
+ if (matches.length === 1)
512
+ return matches[0].id;
513
+ if (matches.length === 0)
514
+ throw new CliError(`No link with the slug "${reference}".`, exitCodes.notFound, "NOT_FOUND");
515
+ throw new UsageError(`Several links use the slug "${reference}". Use the link ID.`);
516
+ }
517
+ function limitOf(value) {
518
+ if (value === undefined)
519
+ return;
520
+ const limit = Number(value);
521
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
522
+ throw new UsageError("Use a --limit from 1 to 100.");
523
+ return limit;
524
+ }
525
+ var commands = {
526
+ login: {
527
+ usage: "flared login [--token-stdin] [--api-url URL]",
528
+ summary: "Save an API token after the API accepts it.",
529
+ options: ["token-stdin"],
530
+ args: 0,
531
+ async run({ options, io, json, print }) {
532
+ let token;
533
+ if (options["token-stdin"])
534
+ token = await io.readStdin();
535
+ else if (io.stdinIsTTY)
536
+ token = await io.readSecret("API token (from Settings): ");
537
+ else
538
+ throw new UsageError("Pass the token on standard input with --token-stdin.");
539
+ token = checkToken(token);
540
+ const dir = configDir(io.env);
541
+ const apiUrl = resolveApiUrl(options["api-url"], io.env, await readConfig(dir));
542
+ const identity = await createClient({
543
+ baseUrl: apiUrl,
544
+ token,
545
+ fetch: io.fetch,
546
+ userAgent: `flared-cli/${cliVersion}`
547
+ }).me();
548
+ await writeConfig(dir, { apiUrl, token });
549
+ if (io.env.FLARED_TOKEN && !json)
550
+ io.stderr("FLARED_TOKEN is set, so it still takes precedence over the saved token.");
551
+ print(`Signed in. The token is saved in ${dir}.
552
+
553
+ ${identityReport(identity, apiUrl)}`, {
554
+ apiUrl,
555
+ ...identity
556
+ });
557
+ }
558
+ },
559
+ logout: {
560
+ usage: "flared logout",
561
+ summary: "Delete the saved token. Revoke it in Settings to stop it working.",
562
+ options: [],
563
+ args: 0,
564
+ async run({ io, print }) {
565
+ const dir = configDir(io.env);
566
+ await deleteConfig(dir);
567
+ print("Removed the saved token. It keeps working until you revoke it in Settings.", {
568
+ ok: true
569
+ });
570
+ }
571
+ },
572
+ whoami: {
573
+ usage: "flared whoami [--api-url URL]",
574
+ summary: "Show the token in use, its scopes, and the API URL.",
575
+ options: [],
576
+ args: 0,
577
+ async run({ client, apiUrl, print }) {
578
+ const identity = await (await client()).me();
579
+ const url = await apiUrl();
580
+ print(identityReport(identity, url), { apiUrl: url, ...identity });
581
+ }
582
+ },
583
+ "link create": {
584
+ usage: "flared link create URL [--slug SLUG] [--title TITLE] [--idempotency-key KEY]",
585
+ summary: "Create a short link and print its URL.",
586
+ options: ["slug", "title", "idempotency-key"],
587
+ args: 1,
588
+ async run({ args, options, client, print }) {
589
+ const result = await (await client()).createLink({
590
+ destination: args[0],
591
+ ...options.slug ? { slug: options.slug } : {},
592
+ ...options.title ? { title: options.title } : {},
593
+ ...options["idempotency-key"] ? { idempotencyKey: options["idempotency-key"] } : {}
594
+ });
595
+ print(result.link.shortUrl, result);
596
+ }
597
+ },
598
+ "link list": {
599
+ usage: "flared link list [--search TEXT] [--limit N] [--cursor CURSOR] [--all]",
600
+ summary: "List links, newest first.",
601
+ options: ["search", "limit", "cursor", "all"],
602
+ args: 0,
603
+ async run({ options, client, print }) {
604
+ const api = await client();
605
+ const limit = limitOf(options.limit);
606
+ const query = { limit, search: options.search, cursor: options.cursor };
607
+ if (!options.all) {
608
+ const page = await api.listLinks(query);
609
+ const more = page.nextCursor ? `
610
+
611
+ More links: --cursor ${page.nextCursor}` : "";
612
+ return print(linkTable(page.links) + more, page);
613
+ }
614
+ const links = [];
615
+ let cursor = options.cursor;
616
+ for (let page = 0;page < 100; page += 1) {
617
+ const result = await api.listLinks({ ...query, limit: limit ?? 100, cursor });
618
+ links.push(...result.links);
619
+ if (!result.nextCursor)
620
+ break;
621
+ cursor = result.nextCursor;
622
+ }
623
+ print(linkTable(links), { links });
624
+ }
625
+ },
626
+ "link get": {
627
+ usage: "flared link get ID_OR_SLUG",
628
+ summary: "Show one link.",
629
+ options: [],
630
+ args: 1,
631
+ async run({ args, client, print }) {
632
+ const api = await client();
633
+ const link = await api.getLink(await resolveLink(api, args[0]));
634
+ print(linkDetails(link), { link });
635
+ }
636
+ },
637
+ "link update": {
638
+ usage: "flared link update ID_OR_SLUG [--destination URL] [--title TITLE | --clear-title]",
639
+ summary: "Change the destination or the title.",
640
+ options: ["destination", "title", "clear-title"],
641
+ args: 1,
642
+ async run({ args, options, client, print }) {
643
+ if (options.title !== undefined && options["clear-title"])
644
+ throw new UsageError("Use --title or --clear-title, not both.");
645
+ const change = {
646
+ ...options.destination !== undefined ? { destination: options.destination } : {},
647
+ ...options.title !== undefined ? { title: options.title } : {},
648
+ ...options["clear-title"] ? { title: null } : {}
649
+ };
650
+ if (Object.keys(change).length === 0)
651
+ throw new UsageError("Give --destination, --title, or --clear-title.");
652
+ const api = await client();
653
+ const link = await api.updateLink(await resolveLink(api, args[0]), change);
654
+ print(linkDetails(link), { link });
655
+ }
656
+ },
657
+ "link disable": {
658
+ usage: "flared link disable ID_OR_SLUG",
659
+ summary: "Stop a link from redirecting. Its slug stays reserved.",
660
+ options: [],
661
+ args: 1,
662
+ async run({ args, client, print }) {
663
+ const api = await client();
664
+ const link = await api.updateLink(await resolveLink(api, args[0]), { enabled: false });
665
+ print(`Disabled ${link.shortUrl}.`, { link });
666
+ }
667
+ },
668
+ "link enable": {
669
+ usage: "flared link enable ID_OR_SLUG",
670
+ summary: "Make a disabled link redirect again.",
671
+ options: [],
672
+ args: 1,
673
+ async run({ args, client, print }) {
674
+ const api = await client();
675
+ const link = await api.updateLink(await resolveLink(api, args[0]), { enabled: true });
676
+ print(`Enabled ${link.shortUrl}.`, { link });
677
+ }
678
+ },
679
+ "link qr": {
680
+ usage: "flared link qr ID_OR_SLUG [--format svg|png] [--out FILE]",
681
+ summary: "Make a QR code for the short URL. SVG goes to standard output without --out.",
682
+ options: ["format", "out"],
683
+ args: 1,
684
+ async run({ args, options, io, json, client, print }) {
685
+ const format = options.format ?? "svg";
686
+ if (format !== "svg" && format !== "png")
687
+ throw new UsageError("Use --format svg or png.");
688
+ if (format === "png" && !options.out)
689
+ throw new UsageError("A PNG needs --out FILE.");
690
+ if (json && !options.out)
691
+ throw new UsageError("With --json, give --out FILE.");
692
+ const api = await client();
693
+ const link = await api.getLink(await resolveLink(api, args[0]));
694
+ const data = format === "svg" ? await QRCode.toString(link.shortUrl, { type: "svg", margin: 2 }) : new Uint8Array(await QRCode.toBuffer(link.shortUrl, { type: "png", width: 512 }));
695
+ if (!options.out)
696
+ return io.stdout(String(data).trimEnd());
697
+ await io.writeFile(options.out, data);
698
+ print(`Saved the QR code for ${link.shortUrl} to ${options.out}.`, {
699
+ shortUrl: link.shortUrl,
700
+ format,
701
+ file: options.out
702
+ });
703
+ }
704
+ },
705
+ analytics: {
706
+ usage: "flared analytics ID_OR_SLUG [--from YYYY-MM-DD] [--to YYYY-MM-DD]",
707
+ summary: "Show clicks by day, country, referrer, and device.",
708
+ options: ["from", "to"],
709
+ args: 1,
710
+ async run({ args, options, client, print }) {
711
+ const api = await client();
712
+ const link = await api.getLink(await resolveLink(api, args[0]));
713
+ const analytics = await api.getAnalytics(link.id, { from: options.from, to: options.to });
714
+ print(analyticsReport(link, analytics), { analytics });
715
+ }
716
+ },
717
+ usage: {
718
+ usage: "flared usage",
719
+ summary: "Show recorded clicks this month and the allowance.",
720
+ options: [],
721
+ args: 0,
722
+ async run({ client, print }) {
723
+ const usage = await (await client()).getUsage();
724
+ print(usageReport(usage), { usage });
725
+ }
726
+ }
727
+ };
728
+ var globalOptions = "Options for every command: --json, --api-url URL, --help.";
729
+ function help() {
730
+ return [
731
+ `flared ${cliVersion}: create, edit, and measure Flared short links.`,
732
+ "",
733
+ ...Object.values(commands).map((command) => ` ${command.usage}
734
+ ${command.summary}`),
735
+ "",
736
+ globalOptions,
737
+ "Environment: FLARED_TOKEN (token), FLARED_API_URL (self-hosted API, such as https://links.example.com/v1).",
738
+ "Exit codes: 0 ok, 1 failure, 2 usage, 3 not signed in, 4 not allowed, 5 not found,",
739
+ "6 rejected input, 7 plan limit, 8 rate limited, 9 service unavailable.",
740
+ "Docs: https://flared.page/docs#cli"
741
+ ].join(`
742
+ `);
743
+ }
744
+ function findCommand(positionals) {
745
+ const [first, second, ...rest] = positionals;
746
+ if (first === "link") {
747
+ const name = `link ${second ?? ""}`;
748
+ const command2 = commands[name];
749
+ if (!command2)
750
+ throw new UsageError(`Unknown link command "${second ?? ""}".`);
751
+ return { name, command: command2, args: rest };
752
+ }
753
+ const command = first === undefined ? undefined : commands[first];
754
+ if (!command || first === undefined)
755
+ throw new UsageError(`Unknown command "${first ?? ""}".`);
756
+ return { name: first, command, args: positionals.slice(1) };
757
+ }
758
+ function report(io, json, code, message, requestId) {
759
+ if (json)
760
+ io.stderr(JSON.stringify({ error: { code, message, requestId } }));
761
+ else
762
+ io.stderr(`Error: ${message}${requestId ? ` (request ${requestId})` : ""}`);
763
+ }
764
+ async function run(argv, io) {
765
+ let json = argv.includes("--json");
766
+ try {
767
+ const parsed = parseArgs({
768
+ args: argv,
769
+ options: optionTypes,
770
+ allowPositionals: true,
771
+ strict: true
772
+ });
773
+ const options = parsed.values;
774
+ json = options.json === true;
775
+ if (options.version) {
776
+ io.stdout(cliVersion);
777
+ return exitCodes.ok;
778
+ }
779
+ if (parsed.positionals.length === 0) {
780
+ if (options.help) {
781
+ io.stdout(help());
782
+ return exitCodes.ok;
783
+ }
784
+ io.stderr(help());
785
+ return exitCodes.usage;
786
+ }
787
+ const { command, args } = findCommand(parsed.positionals);
788
+ if (options.help) {
789
+ io.stdout(`${command.usage}
790
+ ${command.summary}
791
+ ${globalOptions}`);
792
+ return exitCodes.ok;
793
+ }
794
+ const allowed = new Set(["json", "api-url", "help", ...command.options]);
795
+ const extra = Object.keys(options).find((name) => !allowed.has(name));
796
+ if (extra)
797
+ throw new UsageError(`--${extra} does not apply here. Usage: ${command.usage}`);
798
+ if (args.length !== command.args)
799
+ throw new UsageError(`Usage: ${command.usage}`);
800
+ let saved;
801
+ const config = async () => saved ??= await readConfig(configDir(io.env));
802
+ const apiUrl = async () => resolveApiUrl(options["api-url"], io.env, await config());
803
+ await command.run({
804
+ args,
805
+ options,
806
+ io,
807
+ json,
808
+ apiUrl,
809
+ async client() {
810
+ return createClient({
811
+ baseUrl: await apiUrl(),
812
+ token: resolveToken(io.env, await config()),
813
+ fetch: io.fetch,
814
+ userAgent: `flared-cli/${cliVersion}`
815
+ });
816
+ },
817
+ print(human, data) {
818
+ io.stdout(json ? JSON.stringify(data, null, 2) : human);
819
+ }
820
+ });
821
+ return exitCodes.ok;
822
+ } catch (error) {
823
+ if (error instanceof FlaredApiError) {
824
+ report(io, json, error.code, error.message, error.requestId);
825
+ return exitCodeFor(error.code);
826
+ }
827
+ if (error instanceof UsageError || error instanceof TypeError && "code" in error && String(error.code).startsWith("ERR_PARSE_ARGS")) {
828
+ report(io, json, "USAGE", error.message, null);
829
+ if (!json)
830
+ io.stderr("Run flared --help for the commands.");
831
+ return exitCodes.usage;
832
+ }
833
+ if (error instanceof CliError) {
834
+ report(io, json, error.code, error.message, null);
835
+ return error.exitCode;
836
+ }
837
+ report(io, json, "CLI_ERROR", "Something went wrong. Try again.", null);
838
+ return exitCodes.failure;
839
+ }
840
+ }
841
+
842
+ // src/prompt.ts
843
+ function promptSecret(question) {
844
+ const input = process.stdin;
845
+ return new Promise((resolve, reject) => {
846
+ let value = "";
847
+ process.stderr.write(question);
848
+ input.setRawMode(true);
849
+ input.resume();
850
+ input.setEncoding("utf8");
851
+ const done = (error) => {
852
+ input.setRawMode(false);
853
+ input.pause();
854
+ input.off("data", onData);
855
+ process.stderr.write(`
856
+ `);
857
+ if (error)
858
+ reject(error);
859
+ else
860
+ resolve(value);
861
+ };
862
+ const onData = (chunk) => {
863
+ for (const character of chunk) {
864
+ if (character === "\r" || character === `
865
+ `)
866
+ return done();
867
+ if (character === "\x03")
868
+ return done(new CliError("Cancelled.", exitCodes.usage, "CANCELLED"));
869
+ if (character === "" || character === "\b")
870
+ value = value.slice(0, -1);
871
+ else if (character >= " ")
872
+ value += character;
873
+ }
874
+ };
875
+ input.on("data", onData);
876
+ });
877
+ }
878
+ async function readAll(stream) {
879
+ let text = "";
880
+ for await (const chunk of stream)
881
+ text += chunk.toString();
882
+ return text;
883
+ }
884
+
885
+ // src/main.ts
886
+ process.exitCode = await run(process.argv.slice(2), {
887
+ stdout: (text) => process.stdout.write(`${text}
888
+ `),
889
+ stderr: (text) => process.stderr.write(`${text}
890
+ `),
891
+ env: process.env,
892
+ stdinIsTTY: process.stdin.isTTY === true,
893
+ readSecret: promptSecret,
894
+ readStdin: () => readAll(process.stdin),
895
+ writeFile: (path, data) => writeFile2(path, data)
896
+ });