peakurl 0.1.2 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +9 -0
  2. package/bin/peakurl.js +301 -231
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -34,6 +34,7 @@ peakurl create \
34
34
 
35
35
  peakurl list
36
36
  peakurl whoami
37
+ peakurl logout
37
38
  ```
38
39
 
39
40
  ## Authentication
@@ -43,6 +44,7 @@ The CLI uses PeakURL bearer API keys and validates them against `GET /api/v1/use
43
44
  - `--base-url` accepts either the site root, such as `https://peakurl.org`, or the API base URL, such as `https://peakurl.org/api/v1`
44
45
  - API keys are opaque 48-character hex tokens
45
46
  - Credentials are stored in the standard per-user config location for `peakurl`
47
+ - `peakurl logout` removes the saved config file, but shell environment variables still override auth if they are set
46
48
 
47
49
  For CI or automation, you can also authenticate with environment variables:
48
50
 
@@ -57,6 +59,7 @@ export PEAKURL_API_KEY=0123456789abcdef0123456789abcdef0123456789abcdef
57
59
  | ------------------------------ | ---------------------------------------------------------- |
58
60
  | `peakurl login` | Validate and save your PeakURL credentials. |
59
61
  | `peakurl whoami` | Show the current authenticated account. |
62
+ | `peakurl logout` | Remove saved local CLI credentials. |
60
63
  | `peakurl create <url>` | Create a new short link. |
61
64
  | `peakurl list` | List links in your account. |
62
65
  | `peakurl get <id-or-alias>` | Fetch a single link by ID or alias. |
@@ -88,6 +91,12 @@ Inspect a link:
88
91
  peakurl get example
89
92
  ```
90
93
 
94
+ Log out from saved local credentials:
95
+
96
+ ```bash
97
+ peakurl logout
98
+ ```
99
+
91
100
  Delete a link:
92
101
 
93
102
  ```bash
package/bin/peakurl.js CHANGED
@@ -7,13 +7,15 @@ import { Command, CommanderError, InvalidArgumentError } from "commander";
7
7
  // src/lib/errors.ts
8
8
  var CliError = class extends Error {
9
9
  exitCode;
10
+ kind;
10
11
  constructor(message, exitCode = 1, options) {
11
12
  super(message, options);
12
13
  this.name = "CliError";
13
14
  this.exitCode = exitCode;
15
+ this.kind = options?.kind;
14
16
  }
15
17
  };
16
- function wrapCliError(error) {
18
+ function ensureCliError(error) {
17
19
  if (error instanceof CliError) {
18
20
  return error;
19
21
  }
@@ -80,18 +82,18 @@ function normalizeDestinationUrl(value) {
80
82
  }
81
83
 
82
84
  // src/api/client.ts
83
- function isEnvelope(value) {
85
+ function isApiResponse(value) {
84
86
  return Boolean(
85
87
  value && typeof value === "object" && "success" in value && "message" in value && "timestamp" in value
86
88
  );
87
89
  }
88
- function formatNetworkError(baseUrl, error) {
90
+ function networkError(baseUrl, error) {
89
91
  if (error instanceof Error && error.message) {
90
92
  return `Could not reach PeakURL at ${baseUrl}. ${error.message}`;
91
93
  }
92
94
  return `Could not reach PeakURL at ${baseUrl}.`;
93
95
  }
94
- var PeakUrlApiClient = class {
96
+ var ApiClient = class {
95
97
  /**
96
98
  * Creates a client bound to one resolved credential set.
97
99
  *
@@ -131,12 +133,7 @@ var PeakUrlApiClient = class {
131
133
  * @returns API response envelope containing list data.
132
134
  */
133
135
  listUrls(query) {
134
- return this.request(
135
- "GET",
136
- "urls",
137
- void 0,
138
- query
139
- );
136
+ return this.request("GET", "urls", void 0, query);
140
137
  }
141
138
  /**
142
139
  * Loads a single short URL by identifier or alias.
@@ -191,13 +188,9 @@ var PeakUrlApiClient = class {
191
188
  body: body ? JSON.stringify(body) : void 0
192
189
  });
193
190
  } catch (error) {
194
- throw new CliError(
195
- formatNetworkError(this.config.baseUrl, error),
196
- 1,
197
- {
198
- cause: error instanceof Error ? error : void 0
199
- }
200
- );
191
+ throw new CliError(networkError(this.config.baseUrl, error), 1, {
192
+ cause: error instanceof Error ? error : void 0
193
+ });
201
194
  }
202
195
  const rawText = await response.text();
203
196
  if (!rawText) {
@@ -224,7 +217,7 @@ var PeakUrlApiClient = class {
224
217
  }
225
218
  throw new CliError("PeakURL returned an invalid JSON response.");
226
219
  }
227
- if (!isEnvelope(parsed)) {
220
+ if (!isApiResponse(parsed)) {
228
221
  throw new CliError(
229
222
  "PeakURL returned an unexpected response envelope."
230
223
  );
@@ -241,18 +234,18 @@ var PeakUrlApiClient = class {
241
234
  };
242
235
 
243
236
  // src/config/store.ts
244
- import { chmod, mkdir, readFile, writeFile } from "fs/promises";
237
+ import { chmod, mkdir, readFile, unlink, writeFile } from "fs/promises";
245
238
  import { dirname, join } from "path";
246
239
  import envPaths from "env-paths";
247
240
  var CONFIG_FILENAME = "config.json";
248
241
  var STATE_FILENAME = "state.json";
249
- function defaultConfigPath() {
242
+ function getConfigPath() {
250
243
  return join(envPaths("peakurl", { suffix: "" }).config, CONFIG_FILENAME);
251
244
  }
252
- function defaultStatePath() {
245
+ function getStatePath() {
253
246
  return join(envPaths("peakurl", { suffix: "" }).config, STATE_FILENAME);
254
247
  }
255
- async function prepareFileDirectory(filePath) {
248
+ async function ensureParentDir(filePath) {
256
249
  const directory = dirname(filePath);
257
250
  await mkdir(directory, { recursive: true, mode: 448 });
258
251
  return directory;
@@ -264,7 +257,7 @@ var ConfigStore = class {
264
257
  *
265
258
  * @param filePath Optional override used by tests or advanced callers.
266
259
  */
267
- constructor(filePath = defaultConfigPath()) {
260
+ constructor(filePath = getConfigPath()) {
268
261
  this.filePath = filePath;
269
262
  }
270
263
  /**
@@ -312,7 +305,7 @@ var ConfigStore = class {
312
305
  * @param config Normalized credential set to write.
313
306
  */
314
307
  async save(config) {
315
- const directory = await prepareFileDirectory(this.filePath);
308
+ const directory = await ensureParentDir(this.filePath);
316
309
  await writeFile(this.filePath, `${JSON.stringify(config, null, 2)}
317
310
  `, {
318
311
  mode: 384
@@ -323,6 +316,29 @@ var ConfigStore = class {
323
316
  } catch {
324
317
  }
325
318
  }
319
+ /**
320
+ * Removes the stored credential file.
321
+ *
322
+ * @returns `true` when a saved config file existed and was removed.
323
+ * @throws {CliError} When the file exists but cannot be removed.
324
+ */
325
+ async clear() {
326
+ try {
327
+ await unlink(this.filePath);
328
+ return true;
329
+ } catch (error) {
330
+ if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code === "ENOENT") {
331
+ return false;
332
+ }
333
+ throw new CliError(
334
+ `Could not remove PeakURL config at ${this.filePath}.`,
335
+ 1,
336
+ {
337
+ cause: error instanceof Error ? error : void 0
338
+ }
339
+ );
340
+ }
341
+ }
326
342
  };
327
343
  var StateStore = class {
328
344
  filePath;
@@ -331,7 +347,7 @@ var StateStore = class {
331
347
  *
332
348
  * @param filePath Optional override used by tests or advanced callers.
333
349
  */
334
- constructor(filePath = defaultStatePath()) {
350
+ constructor(filePath = getStatePath()) {
335
351
  this.filePath = filePath;
336
352
  }
337
353
  /**
@@ -357,7 +373,7 @@ var StateStore = class {
357
373
  * @param state State payload to save.
358
374
  */
359
375
  async save(state) {
360
- const directory = await prepareFileDirectory(this.filePath);
376
+ const directory = await ensureParentDir(this.filePath);
361
377
  await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}
362
378
  `, {
363
379
  mode: 384
@@ -371,7 +387,16 @@ var StateStore = class {
371
387
  };
372
388
 
373
389
  // src/lib/auth.ts
374
- function resolveLoginConfig(input, env) {
390
+ var AUTH_REQUIRED_MESSAGE = "PeakURL credentials are not configured.";
391
+ function authRows() {
392
+ return [
393
+ ["Reason", AUTH_REQUIRED_MESSAGE],
394
+ ["Command", "peakurl login"],
395
+ ["Flags", "--base-url <url> --api-key <key>"],
396
+ ["Env", "PEAKURL_BASE_URL, PEAKURL_API_KEY"]
397
+ ];
398
+ }
399
+ function getLoginConfig(input, env) {
375
400
  const baseUrl = input.baseUrl?.trim() || env.PEAKURL_BASE_URL?.trim();
376
401
  const apiKey = input.apiKey?.trim() || env.PEAKURL_API_KEY?.trim();
377
402
  if (!baseUrl || !apiKey) {
@@ -384,14 +409,14 @@ function resolveLoginConfig(input, env) {
384
409
  apiKey
385
410
  };
386
411
  }
387
- async function resolveStoredConfig(env, store = new ConfigStore()) {
412
+ async function getAuthConfig(env, store = new ConfigStore()) {
388
413
  const saved = await store.load();
389
414
  const baseUrl = env.PEAKURL_BASE_URL?.trim() || saved?.baseUrl;
390
415
  const apiKey = env.PEAKURL_API_KEY?.trim() || saved?.apiKey;
391
416
  if (!baseUrl || !apiKey) {
392
- throw new CliError(
393
- "PeakURL credentials are not configured. Run `peakurl login --base-url ... --api-key ...` or set PEAKURL_BASE_URL and PEAKURL_API_KEY."
394
- );
417
+ throw new CliError(AUTH_REQUIRED_MESSAGE, 1, {
418
+ kind: "auth_required"
419
+ });
395
420
  }
396
421
  return {
397
422
  baseUrl: normalizeBaseUrl(baseUrl),
@@ -507,18 +532,18 @@ function writeJson(value) {
507
532
 
508
533
  // src/lib/links.ts
509
534
  var LIST_KEYS = ["urls", "items", "results"];
510
- function objectValue(value) {
535
+ function asObject(value) {
511
536
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
512
537
  }
513
- function stringValue(value) {
538
+ function asString(value) {
514
539
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
515
540
  }
516
- function numberValue(value) {
541
+ function asNumber(value) {
517
542
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
518
543
  }
519
- function pickString(link, keys) {
544
+ function pickText(link, keys) {
520
545
  for (const key of keys) {
521
- const value = stringValue(link[key]);
546
+ const value = asString(link[key]);
522
547
  if (value) {
523
548
  return value;
524
549
  }
@@ -528,32 +553,32 @@ function pickString(link, keys) {
528
553
  function truncate(value, maxLength) {
529
554
  return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
530
555
  }
531
- function extractListMeta(data) {
532
- const record = objectValue(data);
556
+ function getListMeta(data) {
557
+ const record = asObject(data);
533
558
  if (!record) {
534
559
  return null;
535
560
  }
536
- const meta = objectValue(record.meta);
561
+ const meta = asObject(record.meta);
537
562
  if (meta) {
538
563
  return {
539
- page: numberValue(meta.page),
540
- limit: numberValue(meta.limit),
541
- totalItems: numberValue(meta.totalItems),
542
- totalPages: numberValue(meta.totalPages)
564
+ page: asNumber(meta.page),
565
+ limit: asNumber(meta.limit),
566
+ totalItems: asNumber(meta.totalItems),
567
+ totalPages: asNumber(meta.totalPages)
543
568
  };
544
569
  }
545
570
  return {
546
- page: numberValue(record.page),
547
- limit: numberValue(record.limit),
548
- totalItems: numberValue(record.total),
549
- totalPages: numberValue(record.totalPages)
571
+ page: asNumber(record.page),
572
+ limit: asNumber(record.limit),
573
+ totalItems: asNumber(record.total),
574
+ totalPages: asNumber(record.totalPages)
550
575
  };
551
576
  }
552
577
  function extractLinks(data) {
553
578
  if (Array.isArray(data)) {
554
579
  return data;
555
580
  }
556
- const record = objectValue(data);
581
+ const record = asObject(data);
557
582
  if (record) {
558
583
  for (const key of LIST_KEYS) {
559
584
  const value = record[key];
@@ -565,16 +590,16 @@ function extractLinks(data) {
565
590
  return [];
566
591
  }
567
592
  function getLinkId(link) {
568
- return pickString(link, ["id", "_id", "urlId"]) || (link.id !== void 0 ? String(link.id) : void 0);
593
+ return pickText(link, ["id", "_id", "urlId"]) || (link.id !== void 0 ? String(link.id) : void 0);
569
594
  }
570
595
  function getLinkAlias(link) {
571
- return pickString(link, ["alias", "shortCode", "slug", "code"]);
596
+ return pickText(link, ["alias", "shortCode", "slug", "code"]);
572
597
  }
573
598
  function getLinkShortUrl(link) {
574
- return pickString(link, ["shortUrl", "shortLink", "shortURL", "url"]);
599
+ return pickText(link, ["shortUrl", "shortLink", "shortURL", "url"]);
575
600
  }
576
601
  function getLinkDestination(link) {
577
- return pickString(link, [
602
+ return pickText(link, [
578
603
  "destinationUrl",
579
604
  "originalUrl",
580
605
  "targetUrl",
@@ -590,14 +615,14 @@ function formatLinkDetails(link) {
590
615
  ["Alias", getLinkAlias(link)],
591
616
  ["Short URL", getLinkShortUrl(link)],
592
617
  ["Destination", getLinkDestination(link)],
593
- ["Title", stringValue(link.title)],
594
- ["Status", stringValue(link.status)],
618
+ ["Title", asString(link.title)],
619
+ ["Status", asString(link.status)],
595
620
  [
596
621
  "Clicks",
597
- numberValue(link.clicks) === void 0 ? void 0 : String(link.clicks)
622
+ asNumber(link.clicks) === void 0 ? void 0 : String(link.clicks)
598
623
  ],
599
- ["Created", stringValue(link.createdAt)],
600
- ["Updated", stringValue(link.updatedAt)]
624
+ ["Created", asString(link.createdAt)],
625
+ ["Updated", asString(link.updatedAt)]
601
626
  ].filter((entry) => Boolean(entry[1]));
602
627
  return lines.map(([label, value]) => `${label}: ${value}`).join("\n");
603
628
  }
@@ -611,12 +636,12 @@ function formatLinksTable(links) {
611
636
  truncate(getLinkAlias(link) || "-", 12),
612
637
  truncate(getLinkShortUrl(link) || "-", 36),
613
638
  truncate(getLinkDestination(link) || "-", 52),
614
- truncate(stringValue(link.status) || "-", 12)
639
+ truncate(asString(link.status) || "-", 12)
615
640
  ]);
616
641
  return formatTable(headers, rows);
617
642
  }
618
643
  function formatListSummary(data, count) {
619
- const meta = extractListMeta(data);
644
+ const meta = getListMeta(data);
620
645
  if (!meta) {
621
646
  return `${count} link${count === 1 ? "" : "s"} returned.`;
622
647
  }
@@ -629,159 +654,6 @@ function formatListSummary(data, count) {
629
654
  return `${count} link${count === 1 ? "" : "s"} returned.`;
630
655
  }
631
656
 
632
- // src/commands/create.ts
633
- function normalizeExpiresAt(value) {
634
- if (!value) {
635
- return void 0;
636
- }
637
- if (Number.isNaN(Date.parse(value))) {
638
- throw new CliError(`Invalid expiration timestamp: ${value}`);
639
- }
640
- return value;
641
- }
642
- async function createCommand(destinationUrl, options) {
643
- const config = await resolveStoredConfig(process.env);
644
- const response = await new PeakUrlApiClient(config).createUrl({
645
- destinationUrl: normalizeDestinationUrl(destinationUrl),
646
- ...options.alias ? { alias: options.alias } : {},
647
- ...options.title ? { title: options.title } : {},
648
- ...options.password ? { password: options.password } : {},
649
- ...options.status ? { status: options.status } : {},
650
- ...options.expiresAt ? { expiresAt: normalizeExpiresAt(options.expiresAt) } : {},
651
- ...options.utmSource ? { utmSource: options.utmSource } : {},
652
- ...options.utmMedium ? { utmMedium: options.utmMedium } : {},
653
- ...options.utmCampaign ? { utmCampaign: options.utmCampaign } : {},
654
- ...options.utmTerm ? { utmTerm: options.utmTerm } : {},
655
- ...options.utmContent ? { utmContent: options.utmContent } : {}
656
- });
657
- if (options.json) {
658
- writeJson(response);
659
- return;
660
- }
661
- if (options.quiet) {
662
- writeStdout(getQuietLinkValue(response.data));
663
- return;
664
- }
665
- writeStdout(response.message);
666
- writeStdout(formatLinkDetails(response.data));
667
- }
668
-
669
- // src/commands/delete.ts
670
- async function deleteCommand(idOrAlias, options) {
671
- const config = await resolveStoredConfig(process.env);
672
- const client = new PeakUrlApiClient(config);
673
- const lookupResponse = await client.getUrl(idOrAlias);
674
- const resolvedId = getLinkId(lookupResponse.data);
675
- if (!resolvedId) {
676
- throw new CliError(
677
- "PeakURL returned a link record without an ID, so the CLI cannot delete it safely."
678
- );
679
- }
680
- const response = await client.deleteUrl(resolvedId);
681
- if (options.json) {
682
- writeJson(response);
683
- return;
684
- }
685
- if (options.quiet) {
686
- return;
687
- }
688
- writeStdout(response.message);
689
- }
690
-
691
- // src/commands/get.ts
692
- async function getCommand(idOrAlias, options) {
693
- const config = await resolveStoredConfig(process.env);
694
- const response = await new PeakUrlApiClient(config).getUrl(idOrAlias);
695
- if (options.json) {
696
- writeJson(response);
697
- return;
698
- }
699
- if (options.quiet) {
700
- writeStdout(getQuietLinkValue(response.data));
701
- return;
702
- }
703
- writeStdout(response.message);
704
- writeStdout(formatLinkDetails(response.data));
705
- }
706
-
707
- // src/commands/list.ts
708
- async function listCommand(options) {
709
- const config = await resolveStoredConfig(process.env);
710
- const response = await new PeakUrlApiClient(config).listUrls({
711
- page: options.page,
712
- limit: options.limit,
713
- search: options.search,
714
- sortBy: options.sortBy,
715
- sortOrder: options.sortOrder
716
- });
717
- const links = extractLinks(response.data);
718
- if (options.json) {
719
- writeJson(response);
720
- return;
721
- }
722
- if (options.quiet) {
723
- for (const link of links) {
724
- const value = getQuietLinkValue(link);
725
- if (value) {
726
- writeStdout(value);
727
- }
728
- }
729
- return;
730
- }
731
- writeStdout(response.message);
732
- writeStdout(formatLinksTable(links));
733
- writeStdout(formatListSummary(response.data, links.length));
734
- }
735
-
736
- // src/lib/users.ts
737
- function stringValue2(value) {
738
- return typeof value === "string" && value.trim() ? value.trim() : void 0;
739
- }
740
- function getUserLabel(user) {
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");
743
- }
744
- function getQuietUserValue(user) {
745
- return stringValue2(user.username) || stringValue2(user.email) || String(user.id ?? "");
746
- }
747
- function formatUserDetails(user) {
748
- const lines = [
749
- ["Name", getUserLabel(user)],
750
- ["Username", stringValue2(user.username)],
751
- ["Email", stringValue2(user.email)],
752
- ["Role", stringValue2(user.role)],
753
- ["ID", user.id === void 0 ? void 0 : String(user.id)]
754
- ].filter((entry) => Boolean(entry[1]));
755
- return lines.map(([label, value]) => `${label}: ${value}`).join("\n");
756
- }
757
-
758
- // src/commands/login.ts
759
- async function loginCommand(options) {
760
- const credentials = resolveLoginConfig(options, process.env);
761
- const client = new PeakUrlApiClient(credentials);
762
- const response = await client.whoami();
763
- await new ConfigStore().save(credentials);
764
- const responseBody = {
765
- success: true,
766
- message: `Saved credentials for ${credentials.baseUrl}.`,
767
- data: {
768
- baseUrl: credentials.baseUrl,
769
- user: response.data
770
- },
771
- timestamp: response.timestamp
772
- };
773
- if (options.json) {
774
- writeJson(responseBody);
775
- return;
776
- }
777
- if (options.quiet) {
778
- return;
779
- }
780
- writeStdout(`Saved credentials for ${credentials.baseUrl}`);
781
- writeStdout(`Authenticated as ${getUserLabel(response.data)}`);
782
- writeStdout(formatUserDetails(response.data));
783
- }
784
-
785
657
  // src/lib/update.ts
786
658
  var PACKAGE_NAME = "peakurl";
787
659
  var DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
@@ -886,7 +758,7 @@ function compareSemver(left, right) {
886
758
  function getUpdateInstallCommand() {
887
759
  return `npm install -g ${PACKAGE_NAME}@latest`;
888
760
  }
889
- async function fetchLatestPackageVersion(env) {
761
+ async function fetchLatestVersion(env) {
890
762
  const registryUrl = normalizeRegistryUrl(getRegistryBaseUrl(env));
891
763
  const url = `${registryUrl}/${PACKAGE_NAME}/latest`;
892
764
  const controller = new AbortController();
@@ -909,7 +781,7 @@ async function fetchLatestPackageVersion(env) {
909
781
  clearTimeout(timeout);
910
782
  }
911
783
  }
912
- async function getCachedUpdateState(store) {
784
+ async function getUpdateState(store) {
913
785
  const state = await store.load();
914
786
  return state.update ?? {};
915
787
  }
@@ -920,15 +792,15 @@ async function saveUpdateState(store, update) {
920
792
  update
921
793
  });
922
794
  }
923
- async function getLatestPackageVersion(env, options) {
795
+ async function loadLatestVersion(env, options) {
924
796
  const store = options?.store ?? new StateStore();
925
- const updateState = await getCachedUpdateState(store);
797
+ const updateState = await getUpdateState(store);
926
798
  const lastCheckedAt = parseTime(updateState.lastCheckedAt);
927
799
  const now = Date.now();
928
800
  if (!options?.forceRefresh && updateState.latestVersion && lastCheckedAt !== null && now - lastCheckedAt < CACHE_TTL_MS) {
929
801
  return updateState.latestVersion;
930
802
  }
931
- const latestVersion = await fetchLatestPackageVersion(env);
803
+ const latestVersion = await fetchLatestVersion(env);
932
804
  if (!latestVersion) {
933
805
  return updateState.latestVersion ?? null;
934
806
  }
@@ -940,7 +812,7 @@ async function getLatestPackageVersion(env, options) {
940
812
  return latestVersion;
941
813
  }
942
814
  async function getUpdateStatus(currentVersion, env, options) {
943
- const resolvedLatestVersion = await getLatestPackageVersion(env, {
815
+ const resolvedLatestVersion = await loadLatestVersion(env, {
944
816
  forceRefresh: options?.forceRefresh,
945
817
  store: options?.store
946
818
  });
@@ -957,13 +829,13 @@ async function getUpdateStatus(currentVersion, env, options) {
957
829
  installCommand: getUpdateInstallCommand()
958
830
  };
959
831
  }
960
- function writeUpdateNotice(status) {
832
+ function showUpdateNotice(status) {
961
833
  writeNoticeBox("Update Available", [
962
834
  `${PACKAGE_NAME} ${status.currentVersion} -> ${status.latestVersion}`,
963
835
  `Run: ${status.installCommand}`
964
836
  ]);
965
837
  }
966
- async function maybeShowUpdateNotice(options) {
838
+ async function checkUpdates(options) {
967
839
  if (options.env.PEAKURL_DISABLE_UPDATE_CHECK === "1" || options.commandName === "update" || options.options?.json || options.options?.quiet) {
968
840
  return;
969
841
  }
@@ -971,7 +843,7 @@ async function maybeShowUpdateNotice(options) {
971
843
  return;
972
844
  }
973
845
  const store = new StateStore();
974
- const updateState = await getCachedUpdateState(store);
846
+ const updateState = await getUpdateState(store);
975
847
  const status = await getUpdateStatus(options.currentVersion, options.env, {
976
848
  store
977
849
  });
@@ -988,7 +860,198 @@ async function maybeShowUpdateNotice(options) {
988
860
  lastNotifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
989
861
  lastNotifiedVersion: status.latestVersion
990
862
  });
991
- writeUpdateNotice(status);
863
+ showUpdateNotice(status);
864
+ }
865
+
866
+ // src/lib/users.ts
867
+ function text(value) {
868
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
869
+ }
870
+ function userLabel(user) {
871
+ const fullName = [text(user.firstName), text(user.lastName)].filter((value) => Boolean(value)).join(" ");
872
+ return fullName || text(user.username) || text(user.email) || String(user.id ?? "unknown");
873
+ }
874
+ function userValue(user) {
875
+ return text(user.username) || text(user.email) || String(user.id ?? "");
876
+ }
877
+ function userTable(user) {
878
+ const rows = [
879
+ ["Name", userLabel(user)],
880
+ ["Username", text(user.username)],
881
+ ["Email", text(user.email)],
882
+ ["Role", text(user.role)],
883
+ ["ID", user.id === void 0 ? void 0 : String(user.id)]
884
+ ].filter((entry) => Boolean(entry[1]));
885
+ if (rows.length === 0) {
886
+ return "No user fields returned.";
887
+ }
888
+ return formatTable(["Field", "Value"], rows);
889
+ }
890
+
891
+ // src/commands/create.ts
892
+ function normalizeExpiresAt(value) {
893
+ if (!value) {
894
+ return void 0;
895
+ }
896
+ if (Number.isNaN(Date.parse(value))) {
897
+ throw new CliError(`Invalid expiration timestamp: ${value}`);
898
+ }
899
+ return value;
900
+ }
901
+ async function createCommand(destinationUrl, options) {
902
+ const config = await getAuthConfig(process.env);
903
+ const response = await new ApiClient(config).createUrl({
904
+ destinationUrl: normalizeDestinationUrl(destinationUrl),
905
+ ...options.alias ? { alias: options.alias } : {},
906
+ ...options.title ? { title: options.title } : {},
907
+ ...options.password ? { password: options.password } : {},
908
+ ...options.status ? { status: options.status } : {},
909
+ ...options.expiresAt ? { expiresAt: normalizeExpiresAt(options.expiresAt) } : {},
910
+ ...options.utmSource ? { utmSource: options.utmSource } : {},
911
+ ...options.utmMedium ? { utmMedium: options.utmMedium } : {},
912
+ ...options.utmCampaign ? { utmCampaign: options.utmCampaign } : {},
913
+ ...options.utmTerm ? { utmTerm: options.utmTerm } : {},
914
+ ...options.utmContent ? { utmContent: options.utmContent } : {}
915
+ });
916
+ if (options.json) {
917
+ writeJson(response);
918
+ return;
919
+ }
920
+ if (options.quiet) {
921
+ writeStdout(getQuietLinkValue(response.data));
922
+ return;
923
+ }
924
+ writeStdout(response.message);
925
+ writeStdout(formatLinkDetails(response.data));
926
+ }
927
+
928
+ // src/commands/delete.ts
929
+ async function deleteCommand(idOrAlias, options) {
930
+ const config = await getAuthConfig(process.env);
931
+ const client = new ApiClient(config);
932
+ const lookupResponse = await client.getUrl(idOrAlias);
933
+ const resolvedId = getLinkId(lookupResponse.data);
934
+ if (!resolvedId) {
935
+ throw new CliError(
936
+ "PeakURL returned a link record without an ID, so the CLI cannot delete it safely."
937
+ );
938
+ }
939
+ const response = await client.deleteUrl(resolvedId);
940
+ if (options.json) {
941
+ writeJson(response);
942
+ return;
943
+ }
944
+ if (options.quiet) {
945
+ return;
946
+ }
947
+ writeStdout(response.message);
948
+ }
949
+
950
+ // src/commands/get.ts
951
+ async function getCommand(idOrAlias, options) {
952
+ const config = await getAuthConfig(process.env);
953
+ const response = await new ApiClient(config).getUrl(idOrAlias);
954
+ if (options.json) {
955
+ writeJson(response);
956
+ return;
957
+ }
958
+ if (options.quiet) {
959
+ writeStdout(getQuietLinkValue(response.data));
960
+ return;
961
+ }
962
+ writeStdout(response.message);
963
+ writeStdout(formatLinkDetails(response.data));
964
+ }
965
+
966
+ // src/commands/list.ts
967
+ async function listCommand(options) {
968
+ const config = await getAuthConfig(process.env);
969
+ const response = await new ApiClient(config).listUrls({
970
+ page: options.page,
971
+ limit: options.limit,
972
+ search: options.search,
973
+ sortBy: options.sortBy,
974
+ sortOrder: options.sortOrder
975
+ });
976
+ const links = extractLinks(response.data);
977
+ if (options.json) {
978
+ writeJson(response);
979
+ return;
980
+ }
981
+ if (options.quiet) {
982
+ for (const link of links) {
983
+ const value = getQuietLinkValue(link);
984
+ if (value) {
985
+ writeStdout(value);
986
+ }
987
+ }
988
+ return;
989
+ }
990
+ writeStdout(response.message);
991
+ writeStdout(formatLinksTable(links));
992
+ writeStdout(formatListSummary(response.data, links.length));
993
+ }
994
+
995
+ // src/commands/login.ts
996
+ async function loginCommand(options) {
997
+ const credentials = getLoginConfig(options, process.env);
998
+ const client = new ApiClient(credentials);
999
+ const response = await client.whoami();
1000
+ await new ConfigStore().save(credentials);
1001
+ const responseBody = {
1002
+ success: true,
1003
+ message: `Saved credentials for ${credentials.baseUrl}.`,
1004
+ data: {
1005
+ baseUrl: credentials.baseUrl,
1006
+ user: response.data
1007
+ },
1008
+ timestamp: response.timestamp
1009
+ };
1010
+ if (options.json) {
1011
+ writeJson(responseBody);
1012
+ return;
1013
+ }
1014
+ if (options.quiet) {
1015
+ return;
1016
+ }
1017
+ writeStdout(`Saved credentials for ${credentials.baseUrl}`);
1018
+ writeStdout(`Authenticated as ${userLabel(response.data)}`);
1019
+ writeStdout(userTable(response.data));
1020
+ }
1021
+
1022
+ // src/commands/logout.ts
1023
+ function hasEnvConfig(env) {
1024
+ return Boolean(env.PEAKURL_BASE_URL?.trim() || env.PEAKURL_API_KEY?.trim());
1025
+ }
1026
+ async function logoutCommand(options) {
1027
+ const store = new ConfigStore();
1028
+ const saved = await store.load();
1029
+ const removed = await store.clear();
1030
+ const envConfig = hasEnvConfig(process.env);
1031
+ const message = removed && saved?.baseUrl ? `Logged out. Removed saved credentials for ${saved.baseUrl}.` : removed ? "Logged out. Removed saved PeakURL credentials." : "Already logged out. No saved PeakURL credentials were found.";
1032
+ const responseBody = {
1033
+ success: true,
1034
+ message,
1035
+ data: {
1036
+ removed,
1037
+ baseUrl: saved?.baseUrl,
1038
+ envCredentialsActive: envConfig
1039
+ },
1040
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1041
+ };
1042
+ if (options.json) {
1043
+ writeJson(responseBody);
1044
+ return;
1045
+ }
1046
+ if (options.quiet) {
1047
+ return;
1048
+ }
1049
+ writeStdout(message);
1050
+ if (envConfig) {
1051
+ writeStdout(
1052
+ "Environment credentials in PEAKURL_BASE_URL or PEAKURL_API_KEY still apply in this shell."
1053
+ );
1054
+ }
992
1055
  }
993
1056
 
994
1057
  // src/commands/update.ts
@@ -1034,22 +1097,22 @@ async function updateCommand(options, currentVersion) {
1034
1097
 
1035
1098
  // src/commands/whoami.ts
1036
1099
  async function whoamiCommand(options) {
1037
- const config = await resolveStoredConfig(process.env);
1038
- const response = await new PeakUrlApiClient(config).whoami();
1100
+ const config = await getAuthConfig(process.env);
1101
+ const response = await new ApiClient(config).whoami();
1039
1102
  if (options.json) {
1040
1103
  writeJson(response);
1041
1104
  return;
1042
1105
  }
1043
1106
  if (options.quiet) {
1044
- writeStdout(getQuietUserValue(response.data));
1107
+ writeStdout(userValue(response.data));
1045
1108
  return;
1046
1109
  }
1047
1110
  writeStdout(response.message);
1048
- writeStdout(formatUserDetails(response.data));
1111
+ writeStdout(userTable(response.data));
1049
1112
  }
1050
1113
 
1051
1114
  // src/index.ts
1052
- function parsePositiveInteger(label) {
1115
+ function parseNumber(label) {
1053
1116
  return (value) => {
1054
1117
  const parsed = Number.parseInt(value, 10);
1055
1118
  if (!Number.isInteger(parsed) || parsed <= 0) {
@@ -1075,6 +1138,7 @@ async function main() {
1075
1138
  Examples:
1076
1139
  peakurl login --base-url https://peakurl.org --api-key 0123456789abcdef0123456789abcdef0123456789abcdef
1077
1140
  peakurl whoami --json
1141
+ peakurl logout
1078
1142
  peakurl create https://example.com --alias example
1079
1143
  peakurl list --limit 10
1080
1144
  peakurl update --check
@@ -1083,7 +1147,7 @@ Examples:
1083
1147
  ).exitOverride();
1084
1148
  program.hook("preAction", async (_command, actionCommand) => {
1085
1149
  const options = actionCommand.optsWithGlobals();
1086
- await maybeShowUpdateNotice({
1150
+ await checkUpdates({
1087
1151
  currentVersion: version,
1088
1152
  commandName: actionCommand.name(),
1089
1153
  options,
@@ -1097,11 +1161,12 @@ Examples:
1097
1161
  "PeakURL base URL, for example https://peakurl.org"
1098
1162
  ).option("--api-key <token>", "PeakURL API key to store").option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(loginCommand);
1099
1163
  program.command("whoami").description("Show the current authenticated PeakURL user.").option("--json", "Print machine-readable output").option("--quiet", "Print a minimal identity value").action(whoamiCommand);
1164
+ program.command("logout").description("Remove saved PeakURL credentials from this device.").option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(logoutCommand);
1100
1165
  program.command("create").description("Create a PeakURL short link.").argument("<url>", "Destination URL to shorten").option("--alias <alias>", "Custom alias for the short link").option("--title <title>", "Title to store with the short link").option("--password <password>", "Password-protect the short link").option(
1101
1166
  "--status <status>",
1102
1167
  "Link status, for example active or paused"
1103
1168
  ).option("--expires-at <iso>", "Expiration timestamp in ISO-8601 format").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("--json", "Print machine-readable output").option("--quiet", "Print only the created short URL").action(createCommand);
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);
1169
+ program.command("list").description("List PeakURL short links.").option("--page <number>", "Page number", parseNumber("page")).option("--limit <number>", "Page size", parseNumber("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);
1105
1170
  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);
1106
1171
  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
1172
  program.command("update").description(
@@ -1116,8 +1181,13 @@ Examples:
1116
1181
  if (error instanceof CommanderError) {
1117
1182
  process.exit(error.exitCode);
1118
1183
  }
1119
- const cliError = wrapCliError(error);
1120
- writeStderr(cliError.message);
1184
+ const cliError = ensureCliError(error);
1185
+ if (cliError.kind === "auth_required") {
1186
+ writeStderr("Not logged in.");
1187
+ writeStderr(formatTable(["Field", "Value"], authRows(), "stderr"));
1188
+ } else {
1189
+ writeStderr(cliError.message);
1190
+ }
1121
1191
  process.exit(cliError.exitCode);
1122
1192
  }
1123
1193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peakurl",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Official CLI for creating, listing, and managing PeakURL short links from the terminal",
5
5
  "homepage": "https://peakurl.org",
6
6
  "bugs": {