peakurl 0.1.0 → 0.1.2

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -29
  3. package/bin/peakurl.js +486 -70
  4. package/package.json +17 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PeakURL Command-Line Interface and Abd Ur Rehman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
- # PeakURL CLI
1
+ # PeakURL - Command-Line Interface
2
2
 
3
- `peakurl` is the official command-line interface for PeakURL.
3
+ The official command-line interface for PeakURL.
4
4
 
5
- It gives you a fast way to create, inspect, list, and remove short links from the terminal while keeping default output readable for humans and optional `--json` output friendly for scripts.
5
+ Use `peakurl` to create short links, inspect existing links, and manage your PeakURL account from the terminal.
6
+
7
+ Learn more in the full CLI docs: <https://peakurl.org/docs/cli>
6
8
 
7
9
  ## Install
8
10
 
9
- Requirements: Node.js 20 or later.
11
+ Node.js 20 or later is required.
10
12
 
11
13
  ```bash
12
14
  npm install -g peakurl
@@ -14,22 +16,35 @@ npm install -g peakurl
14
16
 
15
17
  ## Quick Start
16
18
 
19
+ Sign in with your PeakURL API key:
20
+
21
+ ```bash
22
+ peakurl login \
23
+ --base-url https://peakurl.org \
24
+ --api-key 0123456789abcdef0123456789abcdef0123456789abcdef
25
+ ```
26
+
27
+ Create and review a short link:
28
+
17
29
  ```bash
18
- peakurl login --base-url https://peakurl.org --api-key 0123456789abcdef0123456789abcdef0123456789abcdef
19
- peakurl create https://example.com/articles/launch --alias launch --title "Launch Post"
30
+ peakurl create \
31
+ https://example.com/articles/launch \
32
+ --alias launch \
33
+ --title "Launch Post"
34
+
20
35
  peakurl list
21
36
  peakurl whoami
22
37
  ```
23
38
 
24
39
  ## Authentication
25
40
 
26
- The CLI uses PeakURL bearer API keys and validates them with `GET /api/v1/users/me` before storing them.
41
+ The CLI uses PeakURL bearer API keys and validates them against `GET /api/v1/users/me` before saving credentials.
27
42
 
28
- - `--base-url` accepts either the site root such as `https://peakurl.org` or the API base URL such as `https://peakurl.org/api/v1`
43
+ - `--base-url` accepts either the site root, such as `https://peakurl.org`, or the API base URL, such as `https://peakurl.org/api/v1`
29
44
  - API keys are opaque 48-character hex tokens
30
- - credentials are stored in the standard per-user config location for `peakurl`
45
+ - Credentials are stored in the standard per-user config location for `peakurl`
31
46
 
32
- For CI and automation, you can also use environment variables:
47
+ For CI or automation, you can also authenticate with environment variables:
33
48
 
34
49
  ```bash
35
50
  export PEAKURL_BASE_URL=https://peakurl.org
@@ -38,32 +53,33 @@ export PEAKURL_API_KEY=0123456789abcdef0123456789abcdef0123456789abcdef
38
53
 
39
54
  ## Commands
40
55
 
41
- | Command | Description |
42
- | ------------------------------ | ------------------------------------------- |
43
- | `peakurl login` | Validate and save your PeakURL credentials. |
44
- | `peakurl whoami` | Show the current authenticated account. |
45
- | `peakurl create <url>` | Create a new short link. |
46
- | `peakurl list` | List links in your account. |
47
- | `peakurl get <id-or-alias>` | Fetch a single link by ID or alias. |
48
- | `peakurl delete <id-or-alias>` | Delete a link by ID or alias. |
49
-
50
- ## Common Flags
51
-
52
- - `--json` prints machine-readable JSON output
53
- - `--quiet` minimizes output for scripts
56
+ | Command | Description |
57
+ | ------------------------------ | ---------------------------------------------------------- |
58
+ | `peakurl login` | Validate and save your PeakURL credentials. |
59
+ | `peakurl whoami` | Show the current authenticated account. |
60
+ | `peakurl create <url>` | Create a new short link. |
61
+ | `peakurl list` | List links in your account. |
62
+ | `peakurl get <id-or-alias>` | Fetch a single link by ID or alias. |
63
+ | `peakurl delete <id-or-alias>` | Delete a link by ID or alias. |
64
+ | `peakurl update` | Show the latest available CLI version and install command. |
54
65
 
55
66
  ## Examples
56
67
 
57
68
  Create a short link:
58
69
 
59
70
  ```bash
60
- peakurl create https://example.com --alias example --title "Example"
71
+ peakurl create \
72
+ https://example.com \
73
+ --alias example \
74
+ --title "Example"
61
75
  ```
62
76
 
63
77
  List links as JSON:
64
78
 
65
79
  ```bash
66
- peakurl list --limit 10 --json
80
+ peakurl list \
81
+ --limit 10 \
82
+ --json
67
83
  ```
68
84
 
69
85
  Inspect a link:
@@ -80,9 +96,40 @@ peakurl delete example
80
96
 
81
97
  When `delete` receives an alias or short code, the CLI resolves it to the underlying PeakURL row ID before deleting it.
82
98
 
99
+ Check the latest available CLI version:
100
+
101
+ ```bash
102
+ peakurl update --check
103
+ ```
104
+
105
+ Show the recommended install command:
106
+
107
+ ```bash
108
+ peakurl update
109
+ ```
110
+
111
+ Install the latest version manually:
112
+
113
+ ```bash
114
+ npm install -g peakurl@latest
115
+ ```
116
+
117
+ Disable update notices in the current shell:
118
+
119
+ ```bash
120
+ export PEAKURL_DISABLE_UPDATE_CHECK=1
121
+ ```
122
+
123
+ ## Output
124
+
125
+ - Human-readable output is the default
126
+ - `--json` prints machine-readable JSON where supported
127
+ - `--quiet` minimizes output for scripts
128
+
83
129
  ## Links
84
130
 
85
- - Website: https://peakurl.org/
86
- - API docs: https://peakurl.org/docs/api
87
- - npm package: https://www.npmjs.com/package/peakurl
88
- - Issues: https://github.com/PeakURL/PeakURL-CLI/issues
131
+ - Website: <https://peakurl.org/>
132
+ - CLI docs: <https://peakurl.org/docs/cli>
133
+ - API docs: <https://peakurl.org/docs/api>
134
+ - npm package: <https://www.npmjs.com/package/peakurl>
135
+ - Issues: <https://github.com/PeakURL/PeakURL-CLI/issues>
package/bin/peakurl.js CHANGED
@@ -13,7 +13,7 @@ var CliError = class extends Error {
13
13
  this.exitCode = exitCode;
14
14
  }
15
15
  };
16
- function toCliError(error) {
16
+ function wrapCliError(error) {
17
17
  if (error instanceof CliError) {
18
18
  return error;
19
19
  }
@@ -24,6 +24,14 @@ function toCliError(error) {
24
24
  }
25
25
 
26
26
  // src/lib/url.ts
27
+ function validateHttpUrl(parsed, label) {
28
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
29
+ throw new CliError(`${label} must use http or https.`);
30
+ }
31
+ if (parsed.username || parsed.password) {
32
+ throw new CliError(`${label} must not include embedded credentials.`);
33
+ }
34
+ }
27
35
  function normalizeBaseUrl(value) {
28
36
  const input = value.trim();
29
37
  if (!input) {
@@ -35,9 +43,7 @@ function normalizeBaseUrl(value) {
35
43
  } catch {
36
44
  throw new CliError(`Invalid base URL: ${value}`);
37
45
  }
38
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
39
- throw new CliError("PeakURL base URLs must use http or https.");
40
- }
46
+ validateHttpUrl(parsed, "PeakURL base URL");
41
47
  parsed.hash = "";
42
48
  parsed.search = "";
43
49
  const pathname = parsed.pathname.replace(/\/+$/, "");
@@ -62,8 +68,13 @@ function normalizeDestinationUrl(value) {
62
68
  throw new CliError("A destination URL is required.");
63
69
  }
64
70
  try {
65
- return new URL(input).toString();
66
- } catch {
71
+ const parsed = new URL(input);
72
+ validateHttpUrl(parsed, "Destination URL");
73
+ return parsed.toString();
74
+ } catch (error) {
75
+ if (error instanceof CliError) {
76
+ throw error;
77
+ }
67
78
  throw new CliError(`Invalid destination URL: ${value}`);
68
79
  }
69
80
  }
@@ -234,9 +245,18 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises";
234
245
  import { dirname, join } from "path";
235
246
  import envPaths from "env-paths";
236
247
  var CONFIG_FILENAME = "config.json";
248
+ var STATE_FILENAME = "state.json";
237
249
  function defaultConfigPath() {
238
250
  return join(envPaths("peakurl", { suffix: "" }).config, CONFIG_FILENAME);
239
251
  }
252
+ function defaultStatePath() {
253
+ return join(envPaths("peakurl", { suffix: "" }).config, STATE_FILENAME);
254
+ }
255
+ async function prepareFileDirectory(filePath) {
256
+ const directory = dirname(filePath);
257
+ await mkdir(directory, { recursive: true, mode: 448 });
258
+ return directory;
259
+ }
240
260
  var ConfigStore = class {
241
261
  filePath;
242
262
  /**
@@ -292,8 +312,7 @@ var ConfigStore = class {
292
312
  * @param config Normalized credential set to write.
293
313
  */
294
314
  async save(config) {
295
- const directory = dirname(this.filePath);
296
- await mkdir(directory, { recursive: true, mode: 448 });
315
+ const directory = await prepareFileDirectory(this.filePath);
297
316
  await writeFile(this.filePath, `${JSON.stringify(config, null, 2)}
298
317
  `, {
299
318
  mode: 384
@@ -305,6 +324,51 @@ var ConfigStore = class {
305
324
  }
306
325
  }
307
326
  };
327
+ var StateStore = class {
328
+ filePath;
329
+ /**
330
+ * Creates a state store bound to one on-disk file.
331
+ *
332
+ * @param filePath Optional override used by tests or advanced callers.
333
+ */
334
+ constructor(filePath = defaultStatePath()) {
335
+ this.filePath = filePath;
336
+ }
337
+ /**
338
+ * Loads cached state from disk.
339
+ *
340
+ * Missing or invalid files are treated as empty state because the CLI can
341
+ * always recompute update metadata on the next successful network check.
342
+ *
343
+ * @returns Parsed state object or an empty object.
344
+ */
345
+ async load() {
346
+ try {
347
+ const content = await readFile(this.filePath, "utf8");
348
+ const parsed = JSON.parse(content);
349
+ return parsed && typeof parsed === "object" ? parsed : {};
350
+ } catch {
351
+ return {};
352
+ }
353
+ }
354
+ /**
355
+ * Persists cached state to disk.
356
+ *
357
+ * @param state State payload to save.
358
+ */
359
+ async save(state) {
360
+ const directory = await prepareFileDirectory(this.filePath);
361
+ await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}
362
+ `, {
363
+ mode: 384
364
+ });
365
+ try {
366
+ await chmod(directory, 448);
367
+ await chmod(this.filePath, 384);
368
+ } catch {
369
+ }
370
+ }
371
+ };
308
372
 
309
373
  // src/lib/auth.ts
310
374
  function resolveLoginConfig(input, env) {
@@ -335,20 +399,126 @@ async function resolveStoredConfig(env, store = new ConfigStore()) {
335
399
  };
336
400
  }
337
401
 
402
+ // src/lib/output.ts
403
+ function writeStdout(message = "") {
404
+ process.stdout.write(`${message}
405
+ `);
406
+ }
407
+ function writeStderr(message = "") {
408
+ process.stderr.write(`${message}
409
+ `);
410
+ }
411
+ function writeNoticeBox(title, lines, target = "stderr") {
412
+ const contentLines = lines.length > 0 ? lines : [""];
413
+ const width = Math.max(
414
+ title.length,
415
+ ...contentLines.map((line) => line.length)
416
+ );
417
+ const stream = target === "stdout" ? process.stdout : process.stderr;
418
+ const useTuiBox = stream.isTTY;
419
+ const border = useTuiBox ? {
420
+ topLeft: "\u250C",
421
+ topRight: "\u2510",
422
+ bottomLeft: "\u2514",
423
+ bottomRight: "\u2518",
424
+ horizontal: "\u2500",
425
+ vertical: "\u2502",
426
+ separatorLeft: "\u251C",
427
+ separatorRight: "\u2524"
428
+ } : {
429
+ topLeft: "+",
430
+ topRight: "+",
431
+ bottomLeft: "+",
432
+ bottomRight: "+",
433
+ horizontal: "-",
434
+ vertical: "|",
435
+ separatorLeft: "+",
436
+ separatorRight: "+"
437
+ };
438
+ const topBorder = `${border.topLeft}${border.horizontal.repeat(width + 2)}${border.topRight}`;
439
+ const separator = `${border.separatorLeft}${border.horizontal.repeat(width + 2)}${border.separatorRight}`;
440
+ const bottomBorder = `${border.bottomLeft}${border.horizontal.repeat(width + 2)}${border.bottomRight}`;
441
+ const writeLine = target === "stdout" ? writeStdout : writeStderr;
442
+ writeLine(topBorder);
443
+ writeLine(`${border.vertical} ${title.padEnd(width)} ${border.vertical}`);
444
+ writeLine(separator);
445
+ for (const line of contentLines) {
446
+ writeLine(
447
+ `${border.vertical} ${line.padEnd(width)} ${border.vertical}`
448
+ );
449
+ }
450
+ writeLine(bottomBorder);
451
+ }
452
+ function formatTable(headers, rows, target = "stdout") {
453
+ const stream = target === "stdout" ? process.stdout : process.stderr;
454
+ const useTuiBox = stream.isTTY;
455
+ const border = useTuiBox ? {
456
+ topLeft: "\u250C",
457
+ topRight: "\u2510",
458
+ bottomLeft: "\u2514",
459
+ bottomRight: "\u2518",
460
+ horizontal: "\u2500",
461
+ vertical: "\u2502",
462
+ separatorLeft: "\u251C",
463
+ separatorRight: "\u2524",
464
+ topJunction: "\u252C",
465
+ middleJunction: "\u253C",
466
+ bottomJunction: "\u2534"
467
+ } : {
468
+ topLeft: "+",
469
+ topRight: "+",
470
+ bottomLeft: "+",
471
+ bottomRight: "+",
472
+ horizontal: "-",
473
+ vertical: "|",
474
+ separatorLeft: "+",
475
+ separatorRight: "+",
476
+ topJunction: "+",
477
+ middleJunction: "+",
478
+ bottomJunction: "+"
479
+ };
480
+ const widths = headers.map(
481
+ (header, index) => Math.max(
482
+ header.length,
483
+ ...rows.map((row) => (row[index] ?? "").length)
484
+ )
485
+ );
486
+ const formatTableBorder = (left, join2, right) => `${left}${widths.map((width) => border.horizontal.repeat(width + 2)).join(join2)}${right}`;
487
+ const formatTableRow = (cells) => `${border.vertical}${cells.map((cell, index) => ` ${(cell ?? "").padEnd(widths[index])} `).join(border.vertical)}${border.vertical}`;
488
+ return [
489
+ formatTableBorder(border.topLeft, border.topJunction, border.topRight),
490
+ formatTableRow(headers),
491
+ formatTableBorder(
492
+ border.separatorLeft,
493
+ border.middleJunction,
494
+ border.separatorRight
495
+ ),
496
+ ...rows.map(formatTableRow),
497
+ formatTableBorder(
498
+ border.bottomLeft,
499
+ border.bottomJunction,
500
+ border.bottomRight
501
+ )
502
+ ].join("\n");
503
+ }
504
+ function writeJson(value) {
505
+ writeStdout(JSON.stringify(value, null, 2));
506
+ }
507
+
338
508
  // src/lib/links.ts
339
509
  var LIST_KEYS = ["urls", "items", "results"];
340
- function asRecord(value) {
510
+ function objectValue(value) {
341
511
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
342
512
  }
343
- function readString(value) {
513
+ function stringValue(value) {
344
514
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
345
515
  }
346
- function readNumber(value) {
516
+ function numberValue(value) {
347
517
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
348
518
  }
349
- function firstString(link, keys) {
519
+ function pickString(link, keys) {
350
520
  for (const key of keys) {
351
- const value = readString(link[key]);
521
+ const value = stringValue(link[key]);
352
522
  if (value) {
353
523
  return value;
354
524
  }
@@ -359,31 +529,31 @@ function truncate(value, maxLength) {
359
529
  return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
360
530
  }
361
531
  function extractListMeta(data) {
362
- const record = asRecord(data);
532
+ const record = objectValue(data);
363
533
  if (!record) {
364
534
  return null;
365
535
  }
366
- const meta = asRecord(record.meta);
536
+ const meta = objectValue(record.meta);
367
537
  if (meta) {
368
538
  return {
369
- page: readNumber(meta.page),
370
- limit: readNumber(meta.limit),
371
- totalItems: readNumber(meta.totalItems),
372
- totalPages: readNumber(meta.totalPages)
539
+ page: numberValue(meta.page),
540
+ limit: numberValue(meta.limit),
541
+ totalItems: numberValue(meta.totalItems),
542
+ totalPages: numberValue(meta.totalPages)
373
543
  };
374
544
  }
375
545
  return {
376
- page: readNumber(record.page),
377
- limit: readNumber(record.limit),
378
- totalItems: readNumber(record.total),
379
- totalPages: readNumber(record.totalPages)
546
+ page: numberValue(record.page),
547
+ limit: numberValue(record.limit),
548
+ totalItems: numberValue(record.total),
549
+ totalPages: numberValue(record.totalPages)
380
550
  };
381
551
  }
382
552
  function extractLinks(data) {
383
553
  if (Array.isArray(data)) {
384
554
  return data;
385
555
  }
386
- const record = asRecord(data);
556
+ const record = objectValue(data);
387
557
  if (record) {
388
558
  for (const key of LIST_KEYS) {
389
559
  const value = record[key];
@@ -395,16 +565,16 @@ function extractLinks(data) {
395
565
  return [];
396
566
  }
397
567
  function getLinkId(link) {
398
- return firstString(link, ["id", "_id", "urlId"]) || (link.id !== void 0 ? String(link.id) : void 0);
568
+ return pickString(link, ["id", "_id", "urlId"]) || (link.id !== void 0 ? String(link.id) : void 0);
399
569
  }
400
570
  function getLinkAlias(link) {
401
- return firstString(link, ["alias", "shortCode", "slug", "code"]);
571
+ return pickString(link, ["alias", "shortCode", "slug", "code"]);
402
572
  }
403
573
  function getLinkShortUrl(link) {
404
- return firstString(link, ["shortUrl", "shortLink", "shortURL", "url"]);
574
+ return pickString(link, ["shortUrl", "shortLink", "shortURL", "url"]);
405
575
  }
406
576
  function getLinkDestination(link) {
407
- return firstString(link, [
577
+ return pickString(link, [
408
578
  "destinationUrl",
409
579
  "originalUrl",
410
580
  "targetUrl",
@@ -420,14 +590,14 @@ function formatLinkDetails(link) {
420
590
  ["Alias", getLinkAlias(link)],
421
591
  ["Short URL", getLinkShortUrl(link)],
422
592
  ["Destination", getLinkDestination(link)],
423
- ["Title", readString(link.title)],
424
- ["Status", readString(link.status)],
593
+ ["Title", stringValue(link.title)],
594
+ ["Status", stringValue(link.status)],
425
595
  [
426
596
  "Clicks",
427
- readNumber(link.clicks) === void 0 ? void 0 : String(link.clicks)
597
+ numberValue(link.clicks) === void 0 ? void 0 : String(link.clicks)
428
598
  ],
429
- ["Created", readString(link.createdAt)],
430
- ["Updated", readString(link.updatedAt)]
599
+ ["Created", stringValue(link.createdAt)],
600
+ ["Updated", stringValue(link.updatedAt)]
431
601
  ].filter((entry) => Boolean(entry[1]));
432
602
  return lines.map(([label, value]) => `${label}: ${value}`).join("\n");
433
603
  }
@@ -435,23 +605,15 @@ function formatLinksTable(links) {
435
605
  if (links.length === 0) {
436
606
  return "No links found.";
437
607
  }
438
- const headers = ["ID", "ALIAS", "SHORT URL", "DESTINATION", "STATUS"];
608
+ const headers = ["ID", "Alias", "Short URL", "Destination", "Status"];
439
609
  const rows = links.map((link) => [
440
610
  truncate(getLinkId(link) || "-", 18),
441
- truncate(getLinkAlias(link) || "-", 18),
611
+ truncate(getLinkAlias(link) || "-", 12),
442
612
  truncate(getLinkShortUrl(link) || "-", 36),
443
613
  truncate(getLinkDestination(link) || "-", 52),
444
- truncate(readString(link.status) || "-", 12)
614
+ truncate(stringValue(link.status) || "-", 12)
445
615
  ]);
446
- const widths = headers.map(
447
- (header, index) => Math.max(header.length, ...rows.map((row) => row[index].length))
448
- );
449
- const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
450
- return [
451
- renderRow(headers),
452
- renderRow(widths.map((width) => "-".repeat(width))),
453
- ...rows.map(renderRow)
454
- ].join("\n");
616
+ return formatTable(headers, rows);
455
617
  }
456
618
  function formatListSummary(data, count) {
457
619
  const meta = extractListMeta(data);
@@ -467,19 +629,6 @@ function formatListSummary(data, count) {
467
629
  return `${count} link${count === 1 ? "" : "s"} returned.`;
468
630
  }
469
631
 
470
- // src/lib/output.ts
471
- function writeStdout(message = "") {
472
- process.stdout.write(`${message}
473
- `);
474
- }
475
- function writeStderr(message = "") {
476
- process.stderr.write(`${message}
477
- `);
478
- }
479
- function writeJson(value) {
480
- writeStdout(JSON.stringify(value, null, 2));
481
- }
482
-
483
632
  // src/commands/create.ts
484
633
  function normalizeExpiresAt(value) {
485
634
  if (!value) {
@@ -585,22 +734,22 @@ async function listCommand(options) {
585
734
  }
586
735
 
587
736
  // src/lib/users.ts
588
- function readString2(value) {
737
+ function stringValue2(value) {
589
738
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
590
739
  }
591
740
  function getUserLabel(user) {
592
- const fullName = [readString2(user.firstName), readString2(user.lastName)].filter(Boolean).join(" ");
593
- return fullName || readString2(user.username) || readString2(user.email) || String(user.id ?? "unknown");
741
+ const fullName = [stringValue2(user.firstName), stringValue2(user.lastName)].filter(Boolean).join(" ");
742
+ return fullName || stringValue2(user.username) || stringValue2(user.email) || String(user.id ?? "unknown");
594
743
  }
595
744
  function getQuietUserValue(user) {
596
- return readString2(user.username) || readString2(user.email) || String(user.id ?? "");
745
+ return stringValue2(user.username) || stringValue2(user.email) || String(user.id ?? "");
597
746
  }
598
747
  function formatUserDetails(user) {
599
748
  const lines = [
600
749
  ["Name", getUserLabel(user)],
601
- ["Username", readString2(user.username)],
602
- ["Email", readString2(user.email)],
603
- ["Role", readString2(user.role)],
750
+ ["Username", stringValue2(user.username)],
751
+ ["Email", stringValue2(user.email)],
752
+ ["Role", stringValue2(user.role)],
604
753
  ["ID", user.id === void 0 ? void 0 : String(user.id)]
605
754
  ].filter((entry) => Boolean(entry[1]));
606
755
  return lines.map(([label, value]) => `${label}: ${value}`).join("\n");
@@ -612,7 +761,7 @@ async function loginCommand(options) {
612
761
  const client = new PeakUrlApiClient(credentials);
613
762
  const response = await client.whoami();
614
763
  await new ConfigStore().save(credentials);
615
- const payload = {
764
+ const responseBody = {
616
765
  success: true,
617
766
  message: `Saved credentials for ${credentials.baseUrl}.`,
618
767
  data: {
@@ -622,7 +771,7 @@ async function loginCommand(options) {
622
771
  timestamp: response.timestamp
623
772
  };
624
773
  if (options.json) {
625
- writeJson(payload);
774
+ writeJson(responseBody);
626
775
  return;
627
776
  }
628
777
  if (options.quiet) {
@@ -633,6 +782,256 @@ async function loginCommand(options) {
633
782
  writeStdout(formatUserDetails(response.data));
634
783
  }
635
784
 
785
+ // src/lib/update.ts
786
+ var PACKAGE_NAME = "peakurl";
787
+ var DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
788
+ var CACHE_TTL_MS = 1e3 * 60 * 60 * 12;
789
+ var NOTICE_TTL_MS = 1e3 * 60 * 60 * 24;
790
+ function parseTime(value) {
791
+ if (!value) {
792
+ return null;
793
+ }
794
+ const parsed = Date.parse(value);
795
+ return Number.isNaN(parsed) ? null : parsed;
796
+ }
797
+ function getRegistryBaseUrl(env) {
798
+ const candidate = env.PEAKURL_NPM_REGISTRY_URL?.trim();
799
+ if (!candidate) {
800
+ return DEFAULT_REGISTRY_URL;
801
+ }
802
+ try {
803
+ const parsed = new URL(candidate);
804
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password) {
805
+ return DEFAULT_REGISTRY_URL;
806
+ }
807
+ return candidate;
808
+ } catch {
809
+ return DEFAULT_REGISTRY_URL;
810
+ }
811
+ }
812
+ function normalizeRegistryUrl(registryUrl) {
813
+ return registryUrl.replace(/\/+$/, "");
814
+ }
815
+ function parseSemver(version) {
816
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
817
+ version
818
+ );
819
+ if (!match) {
820
+ return null;
821
+ }
822
+ return {
823
+ major: Number.parseInt(match[1], 10),
824
+ minor: Number.parseInt(match[2], 10),
825
+ patch: Number.parseInt(match[3], 10),
826
+ prerelease: match[4] ? match[4].split(".") : []
827
+ };
828
+ }
829
+ function comparePrerelease(left, right) {
830
+ if (left.length === 0 && right.length === 0) {
831
+ return 0;
832
+ }
833
+ if (left.length === 0) {
834
+ return 1;
835
+ }
836
+ if (right.length === 0) {
837
+ return -1;
838
+ }
839
+ const length = Math.max(left.length, right.length);
840
+ for (let index = 0; index < length; index += 1) {
841
+ const leftPart = left[index];
842
+ const rightPart = right[index];
843
+ if (leftPart === void 0) {
844
+ return -1;
845
+ }
846
+ if (rightPart === void 0) {
847
+ return 1;
848
+ }
849
+ const leftNumber = /^\d+$/.test(leftPart) ? Number.parseInt(leftPart, 10) : null;
850
+ const rightNumber = /^\d+$/.test(rightPart) ? Number.parseInt(rightPart, 10) : null;
851
+ if (leftNumber !== null && rightNumber !== null) {
852
+ if (leftNumber !== rightNumber) {
853
+ return leftNumber > rightNumber ? 1 : -1;
854
+ }
855
+ continue;
856
+ }
857
+ if (leftNumber !== null) {
858
+ return -1;
859
+ }
860
+ if (rightNumber !== null) {
861
+ return 1;
862
+ }
863
+ if (leftPart !== rightPart) {
864
+ return leftPart > rightPart ? 1 : -1;
865
+ }
866
+ }
867
+ return 0;
868
+ }
869
+ function compareSemver(left, right) {
870
+ const parsedLeft = parseSemver(left);
871
+ const parsedRight = parseSemver(right);
872
+ if (!parsedLeft || !parsedRight) {
873
+ return left.localeCompare(right, void 0, { numeric: true });
874
+ }
875
+ if (parsedLeft.major !== parsedRight.major) {
876
+ return parsedLeft.major > parsedRight.major ? 1 : -1;
877
+ }
878
+ if (parsedLeft.minor !== parsedRight.minor) {
879
+ return parsedLeft.minor > parsedRight.minor ? 1 : -1;
880
+ }
881
+ if (parsedLeft.patch !== parsedRight.patch) {
882
+ return parsedLeft.patch > parsedRight.patch ? 1 : -1;
883
+ }
884
+ return comparePrerelease(parsedLeft.prerelease, parsedRight.prerelease);
885
+ }
886
+ function getUpdateInstallCommand() {
887
+ return `npm install -g ${PACKAGE_NAME}@latest`;
888
+ }
889
+ async function fetchLatestPackageVersion(env) {
890
+ const registryUrl = normalizeRegistryUrl(getRegistryBaseUrl(env));
891
+ const url = `${registryUrl}/${PACKAGE_NAME}/latest`;
892
+ const controller = new AbortController();
893
+ const timeout = setTimeout(() => controller.abort(), 2e3);
894
+ try {
895
+ const response = await fetch(url, {
896
+ headers: {
897
+ accept: "application/json"
898
+ },
899
+ signal: controller.signal
900
+ });
901
+ if (!response.ok) {
902
+ return null;
903
+ }
904
+ const payload = await response.json();
905
+ return typeof payload.version === "string" ? payload.version : null;
906
+ } catch {
907
+ return null;
908
+ } finally {
909
+ clearTimeout(timeout);
910
+ }
911
+ }
912
+ async function getCachedUpdateState(store) {
913
+ const state = await store.load();
914
+ return state.update ?? {};
915
+ }
916
+ async function saveUpdateState(store, update) {
917
+ const state = await store.load();
918
+ await store.save({
919
+ ...state,
920
+ update
921
+ });
922
+ }
923
+ async function getLatestPackageVersion(env, options) {
924
+ const store = options?.store ?? new StateStore();
925
+ const updateState = await getCachedUpdateState(store);
926
+ const lastCheckedAt = parseTime(updateState.lastCheckedAt);
927
+ const now = Date.now();
928
+ if (!options?.forceRefresh && updateState.latestVersion && lastCheckedAt !== null && now - lastCheckedAt < CACHE_TTL_MS) {
929
+ return updateState.latestVersion;
930
+ }
931
+ const latestVersion = await fetchLatestPackageVersion(env);
932
+ if (!latestVersion) {
933
+ return updateState.latestVersion ?? null;
934
+ }
935
+ await saveUpdateState(store, {
936
+ ...updateState,
937
+ latestVersion,
938
+ lastCheckedAt: new Date(now).toISOString()
939
+ });
940
+ return latestVersion;
941
+ }
942
+ async function getUpdateStatus(currentVersion, env, options) {
943
+ const resolvedLatestVersion = await getLatestPackageVersion(env, {
944
+ forceRefresh: options?.forceRefresh,
945
+ store: options?.store
946
+ });
947
+ if (!resolvedLatestVersion && options?.forceRefresh) {
948
+ throw new CliError(
949
+ `Could not reach the npm registry to check the latest ${PACKAGE_NAME} version.`
950
+ );
951
+ }
952
+ const latestVersion = resolvedLatestVersion ?? currentVersion;
953
+ return {
954
+ currentVersion,
955
+ latestVersion,
956
+ isOutdated: compareSemver(currentVersion, latestVersion) < 0,
957
+ installCommand: getUpdateInstallCommand()
958
+ };
959
+ }
960
+ function writeUpdateNotice(status) {
961
+ writeNoticeBox("Update Available", [
962
+ `${PACKAGE_NAME} ${status.currentVersion} -> ${status.latestVersion}`,
963
+ `Run: ${status.installCommand}`
964
+ ]);
965
+ }
966
+ async function maybeShowUpdateNotice(options) {
967
+ if (options.env.PEAKURL_DISABLE_UPDATE_CHECK === "1" || options.commandName === "update" || options.options?.json || options.options?.quiet) {
968
+ return;
969
+ }
970
+ if (options.env.PEAKURL_FORCE_UPDATE_NOTICE !== "1" && !process.stderr.isTTY) {
971
+ return;
972
+ }
973
+ const store = new StateStore();
974
+ const updateState = await getCachedUpdateState(store);
975
+ const status = await getUpdateStatus(options.currentVersion, options.env, {
976
+ store
977
+ });
978
+ if (!status.isOutdated) {
979
+ return;
980
+ }
981
+ const lastNotifiedAt = parseTime(updateState.lastNotifiedAt);
982
+ const alreadyNotifiedForVersion = updateState.lastNotifiedVersion === status.latestVersion;
983
+ if (alreadyNotifiedForVersion && lastNotifiedAt !== null && Date.now() - lastNotifiedAt < NOTICE_TTL_MS) {
984
+ return;
985
+ }
986
+ await saveUpdateState(store, {
987
+ ...updateState,
988
+ lastNotifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
989
+ lastNotifiedVersion: status.latestVersion
990
+ });
991
+ writeUpdateNotice(status);
992
+ }
993
+
994
+ // src/commands/update.ts
995
+ async function updateCommand(options, currentVersion) {
996
+ const status = await getUpdateStatus(currentVersion, process.env, {
997
+ forceRefresh: true
998
+ });
999
+ const responseBody = {
1000
+ success: true,
1001
+ message: status.isOutdated ? `A newer PeakURL CLI version is available (${status.latestVersion}).` : `PeakURL CLI ${status.currentVersion} is up to date.`,
1002
+ data: {
1003
+ currentVersion: status.currentVersion,
1004
+ latestVersion: status.latestVersion,
1005
+ isOutdated: status.isOutdated,
1006
+ installCommand: status.installCommand,
1007
+ checkOnly: Boolean(options.check)
1008
+ },
1009
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1010
+ };
1011
+ if (options.json) {
1012
+ writeJson(responseBody);
1013
+ return;
1014
+ }
1015
+ if (!status.isOutdated) {
1016
+ if (!options.quiet) {
1017
+ writeStdout(`PeakURL CLI ${status.currentVersion} is up to date.`);
1018
+ }
1019
+ return;
1020
+ }
1021
+ if (options.quiet) {
1022
+ writeStdout(status.installCommand);
1023
+ return;
1024
+ }
1025
+ writeNoticeBox(
1026
+ "Update Available",
1027
+ [
1028
+ `peakurl ${status.currentVersion} -> ${status.latestVersion}`,
1029
+ `Run: ${status.installCommand}`
1030
+ ],
1031
+ "stdout"
1032
+ );
1033
+ }
1034
+
636
1035
  // src/commands/whoami.ts
637
1036
  async function whoamiCommand(options) {
638
1037
  const config = await resolveStoredConfig(process.env);
@@ -661,7 +1060,7 @@ function parsePositiveInteger(label) {
661
1060
  return parsed;
662
1061
  };
663
1062
  }
664
- async function readVersion() {
1063
+ async function getCliVersion() {
665
1064
  const packageJson = new URL("../package.json", import.meta.url);
666
1065
  const content = await readFile2(packageJson, "utf8");
667
1066
  const parsed = JSON.parse(content);
@@ -669,7 +1068,8 @@ async function readVersion() {
669
1068
  }
670
1069
  async function main() {
671
1070
  const program = new Command();
672
- program.name("peakurl").description("PeakURL command-line interface").version(await readVersion()).showHelpAfterError().showSuggestionAfterError().addHelpText(
1071
+ const version = await getCliVersion();
1072
+ program.name("peakurl").description("PeakURL command-line interface").version(version).showHelpAfterError().showSuggestionAfterError().addHelpText(
673
1073
  "after",
674
1074
  `
675
1075
  Examples:
@@ -677,9 +1077,19 @@ Examples:
677
1077
  peakurl whoami --json
678
1078
  peakurl create https://example.com --alias example
679
1079
  peakurl list --limit 10
1080
+ peakurl update --check
680
1081
  peakurl get example
681
1082
  peakurl delete example --quiet`
682
1083
  ).exitOverride();
1084
+ program.hook("preAction", async (_command, actionCommand) => {
1085
+ const options = actionCommand.optsWithGlobals();
1086
+ await maybeShowUpdateNotice({
1087
+ currentVersion: version,
1088
+ commandName: actionCommand.name(),
1089
+ options,
1090
+ env: process.env
1091
+ });
1092
+ });
683
1093
  program.command("login").description(
684
1094
  "Save PeakURL credentials after verifying them with GET /users/me."
685
1095
  ).option(
@@ -694,13 +1104,19 @@ Examples:
694
1104
  program.command("list").description("List PeakURL short links.").option("--page <number>", "Page number", parsePositiveInteger("page")).option("--limit <number>", "Page size", parsePositiveInteger("limit")).option("--search <query>", "Search term").option("--sort-by <field>", "Sort field").option("--sort-order <order>", "Sort order, for example asc or desc").option("--json", "Print machine-readable output").option("--quiet", "Print a minimal per-link value").action(listCommand);
695
1105
  program.command("get").description("Fetch a single PeakURL short link by id or alias.").argument("<id-or-alias>", "Link identifier or alias").option("--json", "Print machine-readable output").option("--quiet", "Print only the short URL").action(getCommand);
696
1106
  program.command("delete").description("Delete a PeakURL short link by id or alias.").argument("<id-or-alias>", "Link identifier or alias").option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(deleteCommand);
1107
+ program.command("update").description(
1108
+ "Check for a newer CLI version and print the npm command to install it."
1109
+ ).option(
1110
+ "--check",
1111
+ "Alias for checking update status without changing anything"
1112
+ ).option("--json", "Print machine-readable output").option("--quiet", "Print minimal output").action((options) => updateCommand(options, version));
697
1113
  try {
698
1114
  await program.parseAsync(process.argv);
699
1115
  } catch (error) {
700
1116
  if (error instanceof CommanderError) {
701
1117
  process.exit(error.exitCode);
702
1118
  }
703
- const cliError = toCliError(error);
1119
+ const cliError = wrapCliError(error);
704
1120
  writeStderr(cliError.message);
705
1121
  process.exit(cliError.exitCode);
706
1122
  }
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "peakurl",
3
- "version": "0.1.0",
4
- "description": "PeakURL command-line interface",
5
- "homepage": "https://peakurl.org/",
3
+ "version": "0.1.2",
4
+ "description": "Official CLI for creating, listing, and managing PeakURL short links from the terminal",
5
+ "homepage": "https://peakurl.org",
6
+ "bugs": {
7
+ "url": "https://github.com/PeakURL/PeakURL-CLI/issues"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "git+https://github.com/PeakURL/PeakURL-CLI.git"
9
12
  },
13
+ "keywords": [
14
+ "peakurl",
15
+ "url shortener",
16
+ "short links",
17
+ "link shortening",
18
+ "cli",
19
+ "command line",
20
+ "terminal",
21
+ "productivity"
22
+ ],
10
23
  "type": "module",
11
24
  "bin": {
12
25
  "peakurl": "bin/peakurl.js"
@@ -22,7 +35,7 @@
22
35
  "dev": "tsx src/index.ts",
23
36
  "format": "prettier --write .",
24
37
  "format:check": "prettier --check .",
25
- "test": "tsx --test test/*.test.ts",
38
+ "test": "npm run build && tsx --test test/*.test.ts",
26
39
  "typecheck": "tsc --noEmit",
27
40
  "prepare": "npm run build"
28
41
  },