peakurl 1.0.2 → 1.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.
Files changed (3) hide show
  1. package/README.md +55 -25
  2. package/bin/peakurl.js +978 -545
  3. package/package.json +13 -4
package/bin/peakurl.js CHANGED
@@ -4,14 +4,6 @@
4
4
  import { readFile as readFile3 } from "fs/promises";
5
5
  import { Command, CommanderError, InvalidArgumentError } from "commander";
6
6
 
7
- // src/commands/core.ts
8
- import { cwd } from "process";
9
-
10
- // src/config/store.ts
11
- import { chmod, mkdir, readFile, unlink, writeFile } from "fs/promises";
12
- import { dirname, join } from "path";
13
- import envPaths from "env-paths";
14
-
15
7
  // src/lib/errors.ts
16
8
  var CliError = class extends Error {
17
9
  exitCode;
@@ -33,159 +25,6 @@ function ensureCliError(error) {
33
25
  return new CliError("Unexpected error.");
34
26
  }
35
27
 
36
- // src/config/store.ts
37
- var CONFIG_FILENAME = "config.json";
38
- var STATE_FILENAME = "state.json";
39
- function getConfigPath() {
40
- const paths = envPaths("peakurl", { suffix: "" });
41
- const directory = process.platform === "darwin" ? paths.data : paths.config;
42
- return join(directory, CONFIG_FILENAME);
43
- }
44
- function getStatePath() {
45
- return join(envPaths("peakurl", { suffix: "" }).config, STATE_FILENAME);
46
- }
47
- async function ensureParentDir(filePath) {
48
- const directory = dirname(filePath);
49
- await mkdir(directory, { recursive: true, mode: 448 });
50
- return directory;
51
- }
52
- var ConfigStore = class {
53
- filePath;
54
- /**
55
- * Creates a config store bound to one on-disk file.
56
- *
57
- * @param filePath Optional override used by tests or advanced callers.
58
- */
59
- constructor(filePath = getConfigPath()) {
60
- this.filePath = filePath;
61
- }
62
- /**
63
- * Loads the stored credential set from disk.
64
- *
65
- * Missing files are treated as "not configured yet" instead of as hard
66
- * errors so first-run CLI flows remain clean.
67
- *
68
- * @returns Stored config or `null` when the file does not exist.
69
- * @throws {CliError} When the file exists but is unreadable or invalid.
70
- */
71
- async load() {
72
- try {
73
- const content = await readFile(this.filePath, "utf8");
74
- const parsed = JSON.parse(content);
75
- const apiBaseUrl = typeof parsed?.apiBaseUrl === "string" ? parsed.apiBaseUrl : typeof parsed?.baseUrl === "string" ? parsed.baseUrl : void 0;
76
- if (typeof apiBaseUrl !== "string" || typeof parsed?.apiKey !== "string") {
77
- throw new CliError(`Invalid config file: ${this.filePath}`);
78
- }
79
- return {
80
- apiBaseUrl,
81
- apiKey: parsed.apiKey
82
- };
83
- } catch (error) {
84
- if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code === "ENOENT") {
85
- return null;
86
- }
87
- if (error instanceof CliError) {
88
- throw error;
89
- }
90
- throw new CliError(
91
- `Could not read PeakURL config at ${this.filePath}.`,
92
- 1,
93
- {
94
- cause: error instanceof Error ? error : void 0
95
- }
96
- );
97
- }
98
- }
99
- /**
100
- * Persists one credential set to disk with restrictive file permissions.
101
- *
102
- * The chmod step is best-effort because Windows and some filesystems do not
103
- * expose POSIX permission bits in the same way as Unix-like systems.
104
- *
105
- * @param config Normalized credential set to write.
106
- */
107
- async save(config) {
108
- const directory = await ensureParentDir(this.filePath);
109
- await writeFile(this.filePath, `${JSON.stringify(config, null, 2)}
110
- `, {
111
- mode: 384
112
- });
113
- try {
114
- await chmod(directory, 448);
115
- await chmod(this.filePath, 384);
116
- } catch {
117
- }
118
- }
119
- /**
120
- * Removes the stored credential file.
121
- *
122
- * @returns `true` when a saved config file existed and was removed.
123
- * @throws {CliError} When the file exists but cannot be removed.
124
- */
125
- async clear() {
126
- try {
127
- await unlink(this.filePath);
128
- return true;
129
- } catch (error) {
130
- if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code === "ENOENT") {
131
- return false;
132
- }
133
- throw new CliError(
134
- `Could not remove PeakURL config at ${this.filePath}.`,
135
- 1,
136
- {
137
- cause: error instanceof Error ? error : void 0
138
- }
139
- );
140
- }
141
- }
142
- };
143
- var StateStore = class {
144
- filePath;
145
- /**
146
- * Creates a state store bound to one on-disk file.
147
- *
148
- * @param filePath Optional override used by tests or advanced callers.
149
- */
150
- constructor(filePath = getStatePath()) {
151
- this.filePath = filePath;
152
- }
153
- /**
154
- * Loads cached state from disk.
155
- *
156
- * Missing or invalid files are treated as empty state because the CLI can
157
- * always recompute update metadata on the next successful network check.
158
- *
159
- * @returns Parsed state object or an empty object.
160
- */
161
- async load() {
162
- try {
163
- const content = await readFile(this.filePath, "utf8");
164
- const parsed = JSON.parse(content);
165
- return parsed && typeof parsed === "object" ? parsed : {};
166
- } catch {
167
- return {};
168
- }
169
- }
170
- /**
171
- * Persists cached state to disk.
172
- *
173
- * @param state State payload to save.
174
- */
175
- async save(state) {
176
- const directory = await ensureParentDir(this.filePath);
177
- await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}
178
- `, {
179
- mode: 384
180
- });
181
- try {
182
- await chmod(directory, 448);
183
- await chmod(this.filePath, 384);
184
- } catch {
185
- }
186
- }
187
- };
188
-
189
28
  // src/lib/url.ts
190
29
  function validateHttpUrl(parsed, label) {
191
30
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -260,72 +99,289 @@ function normalizeWebhookUrl(value) {
260
99
  }
261
100
  }
262
101
 
263
- // src/lib/auth.ts
264
- var AUTH_REQUIRED_MESSAGE = "PeakURL credentials are not configured.";
265
- var EXAMPLE_BASE_URL = "https://example.com/api/v1";
266
- var EXAMPLE_API_KEY = "YOUR_API_KEY";
267
- function authRows(commandName) {
268
- const retryCommand = commandName ? `peakurl ${commandName}` : "peakurl whoami";
269
- const rows2 = [
270
- [
271
- "Save credentials",
272
- `peakurl login --base-url ${EXAMPLE_BASE_URL}
273
- --api-key ${EXAMPLE_API_KEY}`,
274
- "Regular use on this machine"
275
- ],
276
- [
277
- "Set environment variables",
278
- `PEAKURL_BASE_URL=${EXAMPLE_BASE_URL}
279
- PEAKURL_API_KEY=${EXAMPLE_API_KEY}`,
280
- "CI, scripts, or one-off use"
281
- ],
282
- ["Then run", retryCommand, "After completing one of the steps above"]
283
- ];
284
- return rows2;
102
+ // src/api/client.ts
103
+ function isApiResponse(value) {
104
+ return Boolean(
105
+ value && typeof value === "object" && "success" in value && "message" in value && "timestamp" in value
106
+ );
285
107
  }
286
- function getLoginConfig(input, env) {
287
- const apiBaseUrl = input.baseUrl?.trim() || env.PEAKURL_BASE_URL?.trim();
288
- const apiKey = input.apiKey?.trim() || env.PEAKURL_API_KEY?.trim();
289
- if (!apiBaseUrl || !apiKey) {
290
- throw new CliError(
291
- "Missing credentials. Provide --base-url and --api-key, or set PEAKURL_BASE_URL and PEAKURL_API_KEY."
292
- );
108
+ function networkError(apiBaseUrl, error) {
109
+ if (error instanceof Error && error.message) {
110
+ return `Could not reach PeakURL at ${apiBaseUrl}. ${error.message}`;
293
111
  }
294
- return {
295
- apiBaseUrl: getApiBaseUrl(apiBaseUrl),
296
- apiKey
297
- };
112
+ return `Could not reach PeakURL at ${apiBaseUrl}.`;
298
113
  }
299
- async function getAuthConfig(env, store = new ConfigStore()) {
300
- const saved = await store.load();
301
- const apiBaseUrl = env.PEAKURL_BASE_URL?.trim() || saved?.apiBaseUrl;
302
- const apiKey = env.PEAKURL_API_KEY?.trim() || saved?.apiKey;
303
- if (!apiBaseUrl || !apiKey) {
304
- throw new CliError(AUTH_REQUIRED_MESSAGE, 1, {
305
- kind: "auth_required"
306
- });
114
+ var ApiClient = class {
115
+ /**
116
+ * Creates a client bound to one resolved credential set.
117
+ *
118
+ * @param config Explicit API base URL plus bearer API key.
119
+ */
120
+ constructor(config) {
121
+ this.config = config;
122
+ }
123
+ config;
124
+ /**
125
+ * Loads the currently authenticated user.
126
+ *
127
+ * PeakURL accepts bearer API keys on `GET /users/me`, which is also the
128
+ * CLI login verification flow.
129
+ *
130
+ * @returns API response envelope containing the authenticated user.
131
+ */
132
+ whoami() {
133
+ return this.request("GET", "users/me");
134
+ }
135
+ /**
136
+ * Loads the current system status snapshot for the authenticated site.
137
+ *
138
+ * @returns API response envelope containing system status sections.
139
+ */
140
+ getStatus() {
141
+ return this.request("GET", "system/status");
142
+ }
143
+ /**
144
+ * Creates a short URL.
145
+ *
146
+ * @param payload Request body accepted by `POST /api/v1/urls`.
147
+ * @returns API response envelope containing the created link.
148
+ */
149
+ createUrl(payload) {
150
+ return this.request("POST", "urls", payload);
151
+ }
152
+ /**
153
+ * Lists short URLs with optional pagination and filtering.
154
+ *
155
+ * The current PeakURL app returns `{ items, meta }` under `data`, but the
156
+ * CLI keeps a slightly broader compatibility type for future-proofing.
157
+ *
158
+ * @param query Optional query-string values.
159
+ * @returns API response envelope containing list data.
160
+ */
161
+ listUrls(query) {
162
+ return this.request("GET", "urls", void 0, query);
163
+ }
164
+ /**
165
+ * Exports the full accessible link dataset for the authenticated user.
166
+ *
167
+ * @param query Optional search and sort values.
168
+ * @returns API response envelope containing the full export payload.
169
+ */
170
+ exportUrls(query) {
171
+ return this.request(
172
+ "GET",
173
+ "urls/export",
174
+ void 0,
175
+ query
176
+ );
177
+ }
178
+ /**
179
+ * Imports multiple short links in one bulk request.
180
+ *
181
+ * @param payload Request body accepted by `POST /api/v1/urls/bulk`.
182
+ * @returns API response envelope containing created rows plus row errors.
183
+ */
184
+ importUrls(payload) {
185
+ return this.request("POST", "urls/bulk", payload);
186
+ }
187
+ /**
188
+ * Loads a single short URL by identifier or alias.
189
+ *
190
+ * PeakURL resolves IDs, short codes, and aliases through the same route.
191
+ *
192
+ * @param idOrAlias Link identifier, short code, or alias.
193
+ * @returns API response envelope containing the resolved link.
194
+ */
195
+ getUrl(idOrAlias) {
196
+ return this.request(
197
+ "GET",
198
+ `urls/${encodeURIComponent(idOrAlias)}`
199
+ );
200
+ }
201
+ /**
202
+ * Deletes a short URL by its stable row ID.
203
+ *
204
+ * The current PeakURL backend delete route expects the row ID. The CLI can
205
+ * still accept an alias at the command layer by resolving it first.
206
+ *
207
+ * @param id Stable link row ID.
208
+ * @returns API response envelope containing the deletion result.
209
+ */
210
+ deleteUrl(id) {
211
+ return this.request(
212
+ "DELETE",
213
+ `urls/${encodeURIComponent(id)}`
214
+ );
215
+ }
216
+ /**
217
+ * Deletes multiple short URLs in a single bulk operation.
218
+ *
219
+ * @param ids Array of short URL IDs to delete.
220
+ * @returns API response envelope with deleted count.
221
+ */
222
+ deleteUrlsBulk(ids) {
223
+ return this.request("DELETE", "urls/bulk", { ids });
224
+ }
225
+ /**
226
+ * Deletes all accessible short URLs for the authenticated user.
227
+ *
228
+ * @returns API response envelope with deleted count.
229
+ */
230
+ clearUrls() {
231
+ return this.request("DELETE", "urls");
232
+ }
233
+ /**
234
+ * Empties all short links currently in trash.
235
+ *
236
+ * @returns API response envelope with deleted count.
237
+ */
238
+ emptyTrash() {
239
+ return this.request("DELETE", "urls/trash");
240
+ }
241
+ /**
242
+ * Lists audit log activity entries.
243
+ *
244
+ * @param query Optional query-string parameters for pagination or filters.
245
+ * @returns API response envelope containing activity items and meta.
246
+ */
247
+ listActivity(query) {
248
+ return this.request(
249
+ "GET",
250
+ "analytics/activity",
251
+ void 0,
252
+ query
253
+ );
254
+ }
255
+ /**
256
+ * Deletes a single audit log activity entry by its row ID.
257
+ *
258
+ * @param id Audit log row ID.
259
+ * @returns API response envelope confirming deletion.
260
+ */
261
+ deleteActivity(id) {
262
+ return this.request(
263
+ "DELETE",
264
+ `analytics/activity/${encodeURIComponent(id)}`
265
+ );
266
+ }
267
+ /**
268
+ * Deletes multiple audit log activity entries in bulk.
269
+ *
270
+ * @param ids Array of audit log row IDs.
271
+ * @returns API response envelope with deleted count.
272
+ */
273
+ deleteActivityBulk(ids) {
274
+ return this.request(
275
+ "DELETE",
276
+ "analytics/activity/bulk",
277
+ { ids }
278
+ );
279
+ }
280
+ /**
281
+ * Clears all audit log activity records.
282
+ *
283
+ * @returns API response envelope confirming all logs were deleted.
284
+ */
285
+ clearActivity() {
286
+ return this.request("DELETE", "analytics/activity");
287
+ }
288
+ /**
289
+ * Lists outbound webhooks for the authenticated user.
290
+ *
291
+ * @returns API response envelope containing webhook rows.
292
+ */
293
+ listWebhooks() {
294
+ return this.request("GET", "webhooks");
295
+ }
296
+ /**
297
+ * Creates one outbound webhook subscription.
298
+ *
299
+ * @param payload Request body accepted by `POST /api/v1/webhooks`.
300
+ * @returns API response envelope containing the created webhook.
301
+ */
302
+ createWebhook(payload) {
303
+ return this.request("POST", "webhooks", payload);
304
+ }
305
+ /**
306
+ * Deletes one webhook by its stable row ID.
307
+ *
308
+ * @param id Webhook identifier returned by the list/create endpoints.
309
+ * @returns API response envelope containing the deletion result.
310
+ */
311
+ deleteWebhook(id) {
312
+ return this.request(
313
+ "DELETE",
314
+ `webhooks/${encodeURIComponent(id)}`
315
+ );
316
+ }
317
+ /**
318
+ * Performs one authenticated API request and normalizes the response.
319
+ *
320
+ * @param method HTTP method to send.
321
+ * @param path Route path relative to `/api/v1`.
322
+ * @param body Optional JSON body.
323
+ * @param query Optional query-string values.
324
+ * @returns Parsed PeakURL response envelope.
325
+ * @throws {CliError} When the network request fails or the API returns an error.
326
+ */
327
+ async request(method, path, body, query) {
328
+ const url = buildApiUrl(this.config.apiBaseUrl, path, query);
329
+ let response;
330
+ try {
331
+ response = await fetch(url, {
332
+ method,
333
+ headers: {
334
+ Accept: "application/json",
335
+ Authorization: `Bearer ${this.config.apiKey}`,
336
+ ...body ? { "Content-Type": "application/json" } : {}
337
+ },
338
+ body: body ? JSON.stringify(body) : void 0
339
+ });
340
+ } catch (error) {
341
+ throw new CliError(networkError(this.config.apiBaseUrl, error), 1, {
342
+ cause: error instanceof Error ? error : void 0
343
+ });
344
+ }
345
+ const rawText = await response.text();
346
+ if (!rawText) {
347
+ if (!response.ok) {
348
+ throw new CliError(
349
+ `PeakURL request failed with HTTP ${response.status}.`
350
+ );
351
+ }
352
+ return {
353
+ success: true,
354
+ message: "Request completed.",
355
+ data: void 0,
356
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
357
+ };
358
+ }
359
+ let parsed;
360
+ try {
361
+ parsed = JSON.parse(rawText);
362
+ } catch {
363
+ if (!response.ok) {
364
+ throw new CliError(
365
+ `PeakURL request failed with HTTP ${response.status}.`
366
+ );
367
+ }
368
+ throw new CliError("PeakURL returned an invalid JSON response.");
369
+ }
370
+ if (!isApiResponse(parsed)) {
371
+ throw new CliError(
372
+ "PeakURL returned an unexpected response envelope."
373
+ );
374
+ }
375
+ if (!response.ok || !parsed.success) {
376
+ const statusCode = response.status === 401 ? 2 : 1;
377
+ throw new CliError(
378
+ parsed.message || `PeakURL request failed with HTTP ${response.status}.`,
379
+ statusCode
380
+ );
381
+ }
382
+ return parsed;
307
383
  }
308
- return {
309
- apiBaseUrl: getApiBaseUrl(apiBaseUrl),
310
- apiKey
311
- };
312
- }
313
-
314
- // src/lib/core.ts
315
- import { createHash } from "crypto";
316
- import {
317
- chmod as chmod2,
318
- copyFile,
319
- lstat,
320
- mkdir as mkdir2,
321
- mkdtemp,
322
- rm,
323
- writeFile as writeFile2
324
- } from "fs/promises";
325
- import { tmpdir } from "os";
326
- import { dirname as dirname2, join as join2, resolve } from "path";
327
- import { posix as pathPosix } from "path";
328
- import { inflateRawSync } from "zlib";
384
+ };
329
385
 
330
386
  // src/lib/output.ts
331
387
  function writeStdout(message = "") {
@@ -455,14 +511,329 @@ function formatTable(headers, rows2, target = "stdout") {
455
511
  )
456
512
  ].join("\n");
457
513
  }
458
- function formatDetailsTable(rows2, target = "stdout") {
459
- return formatTable(["Detail", "Information"], rows2, target);
514
+ function formatDetailsTable(rows2, target = "stdout") {
515
+ return formatTable(["Detail", "Information"], rows2, target);
516
+ }
517
+ function writeJson(value) {
518
+ writeStdout(JSON.stringify(value, null, 2));
519
+ }
520
+
521
+ // src/lib/activity.ts
522
+ var ACTIVITY_LIST_KEYS = [
523
+ "items",
524
+ "results",
525
+ "activities",
526
+ "history"
527
+ ];
528
+ function asObject(value) {
529
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
530
+ }
531
+ function asString(value) {
532
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
533
+ }
534
+ function asNumber(value) {
535
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
536
+ }
537
+ function truncate(value, maxLength) {
538
+ return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
539
+ }
540
+ function getActivityMeta(data) {
541
+ const record = asObject(data);
542
+ if (!record) {
543
+ return null;
544
+ }
545
+ const meta = asObject(record.meta);
546
+ if (meta) {
547
+ return {
548
+ page: asNumber(meta.page),
549
+ limit: asNumber(meta.limit),
550
+ totalItems: asNumber(meta.totalItems),
551
+ totalPages: asNumber(meta.totalPages)
552
+ };
553
+ }
554
+ return {
555
+ page: asNumber(record.page),
556
+ limit: asNumber(record.limit),
557
+ totalItems: asNumber(record.total),
558
+ totalPages: asNumber(record.totalPages)
559
+ };
560
+ }
561
+ function extractActivity(data) {
562
+ if (Array.isArray(data)) {
563
+ return data;
564
+ }
565
+ const record = asObject(data);
566
+ if (record) {
567
+ for (const key of ACTIVITY_LIST_KEYS) {
568
+ const value = record[key];
569
+ if (Array.isArray(value)) {
570
+ return value;
571
+ }
572
+ }
573
+ }
574
+ return [];
575
+ }
576
+ function formatActivityTable(items) {
577
+ if (items.length === 0) {
578
+ return "No activity logs found.";
579
+ }
580
+ const headers = [
581
+ "ID",
582
+ "Action",
583
+ "User",
584
+ "IP Address",
585
+ "Timestamp",
586
+ "Message"
587
+ ];
588
+ const rows2 = items.map((item) => [
589
+ truncate(asString(item.id) || "-", 18),
590
+ truncate(asString(item.type) || "-", 18),
591
+ truncate(
592
+ asString(item.userName) || asString(item.userEmail) || "-",
593
+ 18
594
+ ),
595
+ truncate(asString(item.ipAddress) || "-", 16),
596
+ truncate(asString(item.createdAt) || "-", 22),
597
+ truncate(asString(item.message) || "-", 40)
598
+ ]);
599
+ return formatTable(headers, rows2);
600
+ }
601
+ function formatActivitySummary(data, count) {
602
+ const meta = getActivityMeta(data);
603
+ if (!meta) {
604
+ return `${count} activity record${count === 1 ? "" : "s"} returned.`;
605
+ }
606
+ const total = meta.totalItems;
607
+ const page = meta.page;
608
+ const totalPages = meta.totalPages;
609
+ if (total !== void 0 && page !== void 0 && totalPages !== void 0) {
610
+ return `Page ${page} of ${totalPages}. ${total} total activity record${total === 1 ? "" : "s"}.`;
611
+ }
612
+ return `${count} activity record${count === 1 ? "" : "s"} returned.`;
613
+ }
614
+
615
+ // src/config/store.ts
616
+ import { chmod, mkdir, readFile, unlink, writeFile } from "fs/promises";
617
+ import { dirname, join } from "path";
618
+ import envPaths from "env-paths";
619
+ var CONFIG_FILENAME = "config.json";
620
+ var STATE_FILENAME = "state.json";
621
+ function getConfigPath() {
622
+ const paths = envPaths("peakurl", { suffix: "" });
623
+ const directory = process.platform === "darwin" ? paths.data : paths.config;
624
+ return join(directory, CONFIG_FILENAME);
625
+ }
626
+ function getStatePath() {
627
+ return join(envPaths("peakurl", { suffix: "" }).config, STATE_FILENAME);
628
+ }
629
+ async function ensureParentDir(filePath) {
630
+ const directory = dirname(filePath);
631
+ await mkdir(directory, { recursive: true, mode: 448 });
632
+ return directory;
633
+ }
634
+ var ConfigStore = class {
635
+ filePath;
636
+ /**
637
+ * Creates a config store bound to one on-disk file.
638
+ *
639
+ * @param filePath Optional override used by tests or advanced callers.
640
+ */
641
+ constructor(filePath = getConfigPath()) {
642
+ this.filePath = filePath;
643
+ }
644
+ /**
645
+ * Loads the stored credential set from disk.
646
+ *
647
+ * Missing files are treated as "not configured yet" instead of as hard
648
+ * errors so first-run CLI flows remain clean.
649
+ *
650
+ * @returns Stored config or `null` when the file does not exist.
651
+ * @throws {CliError} When the file exists but is unreadable or invalid.
652
+ */
653
+ async load() {
654
+ try {
655
+ const content = await readFile(this.filePath, "utf8");
656
+ const parsed = JSON.parse(content);
657
+ const apiBaseUrl = typeof parsed?.apiBaseUrl === "string" ? parsed.apiBaseUrl : typeof parsed?.baseUrl === "string" ? parsed.baseUrl : void 0;
658
+ if (typeof apiBaseUrl !== "string" || typeof parsed?.apiKey !== "string") {
659
+ throw new CliError(`Invalid config file: ${this.filePath}`);
660
+ }
661
+ return {
662
+ apiBaseUrl,
663
+ apiKey: parsed.apiKey
664
+ };
665
+ } catch (error) {
666
+ if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code === "ENOENT") {
667
+ return null;
668
+ }
669
+ if (error instanceof CliError) {
670
+ throw error;
671
+ }
672
+ throw new CliError(
673
+ `Could not read PeakURL config at ${this.filePath}.`,
674
+ 1,
675
+ {
676
+ cause: error instanceof Error ? error : void 0
677
+ }
678
+ );
679
+ }
680
+ }
681
+ /**
682
+ * Persists one credential set to disk with restrictive file permissions.
683
+ *
684
+ * The chmod step is best-effort because Windows and some filesystems do not
685
+ * expose POSIX permission bits in the same way as Unix-like systems.
686
+ *
687
+ * @param config Normalized credential set to write.
688
+ */
689
+ async save(config) {
690
+ const directory = await ensureParentDir(this.filePath);
691
+ await writeFile(this.filePath, `${JSON.stringify(config, null, 2)}
692
+ `, {
693
+ mode: 384
694
+ });
695
+ try {
696
+ await chmod(directory, 448);
697
+ await chmod(this.filePath, 384);
698
+ } catch {
699
+ }
700
+ }
701
+ /**
702
+ * Removes the stored credential file.
703
+ *
704
+ * @returns `true` when a saved config file existed and was removed.
705
+ * @throws {CliError} When the file exists but cannot be removed.
706
+ */
707
+ async clear() {
708
+ try {
709
+ await unlink(this.filePath);
710
+ return true;
711
+ } catch (error) {
712
+ if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code === "ENOENT") {
713
+ return false;
714
+ }
715
+ throw new CliError(
716
+ `Could not remove PeakURL config at ${this.filePath}.`,
717
+ 1,
718
+ {
719
+ cause: error instanceof Error ? error : void 0
720
+ }
721
+ );
722
+ }
723
+ }
724
+ };
725
+ var StateStore = class {
726
+ filePath;
727
+ /**
728
+ * Creates a state store bound to one on-disk file.
729
+ *
730
+ * @param filePath Optional override used by tests or advanced callers.
731
+ */
732
+ constructor(filePath = getStatePath()) {
733
+ this.filePath = filePath;
734
+ }
735
+ /**
736
+ * Loads cached state from disk.
737
+ *
738
+ * Missing or invalid files are treated as empty state because the CLI can
739
+ * always recompute update metadata on the next successful network check.
740
+ *
741
+ * @returns Parsed state object or an empty object.
742
+ */
743
+ async load() {
744
+ try {
745
+ const content = await readFile(this.filePath, "utf8");
746
+ const parsed = JSON.parse(content);
747
+ return parsed && typeof parsed === "object" ? parsed : {};
748
+ } catch {
749
+ return {};
750
+ }
751
+ }
752
+ /**
753
+ * Persists cached state to disk.
754
+ *
755
+ * @param state State payload to save.
756
+ */
757
+ async save(state) {
758
+ const directory = await ensureParentDir(this.filePath);
759
+ await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}
760
+ `, {
761
+ mode: 384
762
+ });
763
+ try {
764
+ await chmod(directory, 448);
765
+ await chmod(this.filePath, 384);
766
+ } catch {
767
+ }
768
+ }
769
+ };
770
+
771
+ // src/lib/auth.ts
772
+ var AUTH_REQUIRED_MESSAGE = "PeakURL credentials are not configured.";
773
+ var EXAMPLE_BASE_URL = "https://example.com/api/v1";
774
+ var EXAMPLE_API_KEY = "YOUR_API_KEY";
775
+ function authRows(commandName) {
776
+ const retryCommand = commandName ? `peakurl ${commandName}` : "peakurl whoami";
777
+ const rows2 = [
778
+ [
779
+ "Save credentials",
780
+ `peakurl login --base-url ${EXAMPLE_BASE_URL}
781
+ --api-key ${EXAMPLE_API_KEY}`,
782
+ "Regular use on this machine"
783
+ ],
784
+ [
785
+ "Set environment variables",
786
+ `PEAKURL_BASE_URL=${EXAMPLE_BASE_URL}
787
+ PEAKURL_API_KEY=${EXAMPLE_API_KEY}`,
788
+ "CI, scripts, or one-off use"
789
+ ],
790
+ ["Then run", retryCommand, "After completing one of the steps above"]
791
+ ];
792
+ return rows2;
793
+ }
794
+ function getLoginConfig(input, env) {
795
+ const apiBaseUrl = input.baseUrl?.trim() || env.PEAKURL_BASE_URL?.trim();
796
+ const apiKey = input.apiKey?.trim() || env.PEAKURL_API_KEY?.trim();
797
+ if (!apiBaseUrl || !apiKey) {
798
+ throw new CliError(
799
+ "Missing credentials. Provide --base-url and --api-key, or set PEAKURL_BASE_URL and PEAKURL_API_KEY."
800
+ );
801
+ }
802
+ return {
803
+ apiBaseUrl: getApiBaseUrl(apiBaseUrl),
804
+ apiKey
805
+ };
460
806
  }
461
- function writeJson(value) {
462
- writeStdout(JSON.stringify(value, null, 2));
807
+ async function getAuthConfig(env, store = new ConfigStore()) {
808
+ const saved = await store.load();
809
+ const apiBaseUrl = env.PEAKURL_BASE_URL?.trim() || saved?.apiBaseUrl;
810
+ const apiKey = env.PEAKURL_API_KEY?.trim() || saved?.apiKey;
811
+ if (!apiBaseUrl || !apiKey) {
812
+ throw new CliError(AUTH_REQUIRED_MESSAGE, 1, {
813
+ kind: "auth_required"
814
+ });
815
+ }
816
+ return {
817
+ apiBaseUrl: getApiBaseUrl(apiBaseUrl),
818
+ apiKey
819
+ };
463
820
  }
464
821
 
465
822
  // src/lib/core.ts
823
+ import { createHash } from "crypto";
824
+ import {
825
+ chmod as chmod2,
826
+ copyFile,
827
+ lstat,
828
+ mkdir as mkdir2,
829
+ mkdtemp,
830
+ rm,
831
+ writeFile as writeFile2
832
+ } from "fs/promises";
833
+ import { tmpdir } from "os";
834
+ import { dirname as dirname2, join as join2, resolve } from "path";
835
+ import { posix as pathPosix } from "path";
836
+ import { inflateRawSync } from "zlib";
466
837
  var DEFAULT_RELEASE_API_URL = "https://api.peakurl.org/v1/update";
467
838
  var DEFAULT_CORE_PACKAGE_URL = "https://peakurl.org/latest.zip";
468
839
  var EOCD_SIGNATURE = 101010256;
@@ -481,7 +852,7 @@ function getCorePackageUrl(env) {
481
852
  "package download"
482
853
  );
483
854
  }
484
- function asString(value) {
855
+ function asString2(value) {
485
856
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
486
857
  }
487
858
  function validateUrl(value, label) {
@@ -500,7 +871,7 @@ function validateUrl(value, label) {
500
871
  return parsed.toString();
501
872
  }
502
873
  function normalizeSha256(value) {
503
- const candidate = asString(value)?.toLowerCase();
874
+ const candidate = asString2(value)?.toLowerCase();
504
875
  if (!candidate || !/^[a-f0-9]{64}$/.test(candidate)) {
505
876
  throw new CliError(
506
877
  "PeakURL release metadata is missing a valid SHA-256 checksum."
@@ -718,13 +1089,13 @@ async function getCoreRelease(env) {
718
1089
  throw new CliError("PeakURL release metadata could not be loaded.");
719
1090
  }
720
1091
  const payload = await response.json();
721
- const version = asString(payload.version) || "latest";
1092
+ const version = asString2(payload.version) || "latest";
722
1093
  return {
723
1094
  version,
724
1095
  downloadUrl: getCorePackageUrl(env),
725
1096
  checksumSha256: normalizeSha256(payload.checksumSha256),
726
- releasedAt: asString(payload.releasedAt),
727
- releaseNotesUrl: asString(payload.releaseNotesUrl)
1097
+ releasedAt: asString2(payload.releasedAt),
1098
+ releaseNotesUrl: asString2(payload.releaseNotesUrl)
728
1099
  };
729
1100
  }
730
1101
  async function downloadCorePackage(release, targetPath, force = false) {
@@ -793,17 +1164,23 @@ var EXPORT_HEADERS = [
793
1164
  "created_at"
794
1165
  ];
795
1166
  function text(value) {
796
- return typeof value === "string" ? value : "";
1167
+ if (typeof value === "string") {
1168
+ return value;
1169
+ }
1170
+ if (typeof value === "number" || typeof value === "boolean") {
1171
+ return String(value);
1172
+ }
1173
+ return "";
797
1174
  }
798
1175
  function csvValue(value) {
799
- const content = value == null ? "" : String(value);
1176
+ const content = value === null || value === void 0 ? "" : text(value);
800
1177
  if (/[",\r\n]/.test(content)) {
801
1178
  return `"${content.replace(/"/g, '""')}"`;
802
1179
  }
803
1180
  return content;
804
1181
  }
805
1182
  function xmlValue(value) {
806
- return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1183
+ return text(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
807
1184
  }
808
1185
  function aliasValue(link) {
809
1186
  return text(link.alias) || text(link.shortCode);
@@ -1093,7 +1470,7 @@ function getImportFormat(filePath, value) {
1093
1470
  );
1094
1471
  }
1095
1472
  async function readImportRows(filePath, format) {
1096
- let textContent = "";
1473
+ let textContent;
1097
1474
  try {
1098
1475
  textContent = await readFile2(filePath, "utf8");
1099
1476
  } catch (error) {
@@ -1138,53 +1515,53 @@ function formatImportSummary(data) {
1138
1515
 
1139
1516
  // src/lib/links.ts
1140
1517
  var LIST_KEYS = ["urls", "items", "results"];
1141
- function asObject(value) {
1518
+ function asObject2(value) {
1142
1519
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1143
1520
  }
1144
- function asString2(value) {
1521
+ function asString3(value) {
1145
1522
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
1146
1523
  }
1147
- function asNumber(value) {
1524
+ function asNumber2(value) {
1148
1525
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1149
1526
  }
1150
1527
  function pickText(link, keys) {
1151
1528
  for (const key of keys) {
1152
- const value = asString2(link[key]);
1529
+ const value = asString3(link[key]);
1153
1530
  if (value) {
1154
1531
  return value;
1155
1532
  }
1156
1533
  }
1157
1534
  return void 0;
1158
1535
  }
1159
- function truncate(value, maxLength) {
1536
+ function truncate2(value, maxLength) {
1160
1537
  return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
1161
1538
  }
1162
1539
  function getListMeta(data) {
1163
- const record = asObject(data);
1540
+ const record = asObject2(data);
1164
1541
  if (!record) {
1165
1542
  return null;
1166
1543
  }
1167
- const meta = asObject(record.meta);
1544
+ const meta = asObject2(record.meta);
1168
1545
  if (meta) {
1169
1546
  return {
1170
- page: asNumber(meta.page),
1171
- limit: asNumber(meta.limit),
1172
- totalItems: asNumber(meta.totalItems),
1173
- totalPages: asNumber(meta.totalPages)
1547
+ page: asNumber2(meta.page),
1548
+ limit: asNumber2(meta.limit),
1549
+ totalItems: asNumber2(meta.totalItems),
1550
+ totalPages: asNumber2(meta.totalPages)
1174
1551
  };
1175
1552
  }
1176
1553
  return {
1177
- page: asNumber(record.page),
1178
- limit: asNumber(record.limit),
1179
- totalItems: asNumber(record.total),
1180
- totalPages: asNumber(record.totalPages)
1554
+ page: asNumber2(record.page),
1555
+ limit: asNumber2(record.limit),
1556
+ totalItems: asNumber2(record.total),
1557
+ totalPages: asNumber2(record.totalPages)
1181
1558
  };
1182
1559
  }
1183
1560
  function extractLinks(data) {
1184
1561
  if (Array.isArray(data)) {
1185
1562
  return data;
1186
1563
  }
1187
- const record = asObject(data);
1564
+ const record = asObject2(data);
1188
1565
  if (record) {
1189
1566
  for (const key of LIST_KEYS) {
1190
1567
  const value = record[key];
@@ -1221,14 +1598,14 @@ function formatLinkDetails(link) {
1221
1598
  ["Alias", getLinkAlias(link)],
1222
1599
  ["Short URL", getLinkShortUrl(link)],
1223
1600
  ["Destination", getLinkDestination(link)],
1224
- ["Title", asString2(link.title)],
1225
- ["Status", asString2(link.status)],
1601
+ ["Title", asString3(link.title)],
1602
+ ["Status", asString3(link.status)],
1226
1603
  [
1227
1604
  "Clicks",
1228
- asNumber(link.clicks) === void 0 ? void 0 : String(link.clicks)
1605
+ asNumber2(link.clicks) === void 0 ? void 0 : String(link.clicks)
1229
1606
  ],
1230
- ["Created", asString2(link.createdAt)],
1231
- ["Updated", asString2(link.updatedAt)]
1607
+ ["Created", asString3(link.createdAt)],
1608
+ ["Updated", asString3(link.updatedAt)]
1232
1609
  ].filter((entry) => Boolean(entry[1]));
1233
1610
  if (rows2.length === 0) {
1234
1611
  return "No link fields returned.";
@@ -1241,11 +1618,11 @@ function formatLinksTable(links) {
1241
1618
  }
1242
1619
  const headers = ["ID", "Alias", "Short URL", "Destination", "Status"];
1243
1620
  const rows2 = links.map((link) => [
1244
- truncate(getLinkId(link) || "-", 18),
1245
- truncate(getLinkAlias(link) || "-", 12),
1246
- truncate(getLinkShortUrl(link) || "-", 36),
1247
- truncate(getLinkDestination(link) || "-", 52),
1248
- truncate(asString2(link.status) || "-", 12)
1621
+ truncate2(getLinkId(link) || "-", 18),
1622
+ truncate2(getLinkAlias(link) || "-", 12),
1623
+ truncate2(getLinkShortUrl(link) || "-", 36),
1624
+ truncate2(getLinkDestination(link) || "-", 52),
1625
+ truncate2(asString3(link.status) || "-", 12)
1249
1626
  ]);
1250
1627
  return formatTable(headers, rows2);
1251
1628
  }
@@ -1527,6 +1904,33 @@ function locationRows(location) {
1527
1904
  row("Download command", text3(location.downloadCommand), 60)
1528
1905
  ]);
1529
1906
  }
1907
+ function cacheRows(cache) {
1908
+ if (!cache) {
1909
+ return [];
1910
+ }
1911
+ const redisEndpoint = cache.redis?.available || cache.redis?.configured ? `${text3(cache.redis.host) ?? "127.0.0.1"}:${formatCount(cache.redis.port) ?? "6379"}` : void 0;
1912
+ const redisStatus = cache.redis?.available ? cache.redis.serverVersion ? `Connected (v${cache.redis.serverVersion})` : "Connected" : cache.redis?.configured ? "Configured, unavailable" : void 0;
1913
+ const apcuStatus = cache.apcu?.available ? "Available" : cache.apcu?.extensionLoaded ? "Loaded, disabled" : cache.apcu !== void 0 && cache.apcu !== null ? "Missing" : void 0;
1914
+ return rows([
1915
+ row("Status", formatState(cache.status)),
1916
+ row("Enabled", yesNo(cache.enabled, "Enabled", "Disabled")),
1917
+ row("Active driver", text3(cache.activeDriver)),
1918
+ row("Configured driver", text3(cache.configuredDriver)),
1919
+ row("Cache size", formatSize(cache.sizeBytes)),
1920
+ row(
1921
+ cache.activeDriver === "redis" || cache.activeDriver === "apcu" ? "Cached items" : "Cached files",
1922
+ formatCount(cache.fileCount)
1923
+ ),
1924
+ row("Default TTL", formatSeconds(cache.defaultTtl)),
1925
+ row("Negative TTL", formatSeconds(cache.negativeTtl)),
1926
+ row("Cache directory", text3(cache.path), 60),
1927
+ row("Directory exists", yesNo(cache.directoryExists)),
1928
+ row("Directory writable", yesNo(cache.writable)),
1929
+ row("Redis server", redisEndpoint),
1930
+ row("Redis status", redisStatus),
1931
+ row("APCu extension", apcuStatus)
1932
+ ]);
1933
+ }
1530
1934
  function dataRows(data) {
1531
1935
  if (!data) {
1532
1936
  return [];
@@ -1575,6 +1979,7 @@ ${checks}` : void 0,
1575
1979
  section("Storage", storageRows(status2.storage)),
1576
1980
  section("Mail", mailRows(status2.mail)),
1577
1981
  section("Location", locationRows(status2.location)),
1982
+ section("Cache", cacheRows(status2.cache)),
1578
1983
  section("Data", dataRows(status2.data))
1579
1984
  ].filter((value) => Boolean(value));
1580
1985
  return sections.length > 0 ? sections.join("\n\n") : "No system status fields returned.";
@@ -1846,7 +2251,7 @@ function text5(value) {
1846
2251
  function textList(value) {
1847
2252
  return Array.isArray(value) ? value.map((item) => text5(item)).filter((item) => Boolean(item)) : [];
1848
2253
  }
1849
- function truncate2(value, maxLength) {
2254
+ function truncate3(value, maxLength) {
1850
2255
  return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
1851
2256
  }
1852
2257
  function parseWebhookEvents(value, previous = []) {
@@ -1885,287 +2290,179 @@ function formatWebhookEventsTable() {
1885
2290
  event.description
1886
2291
  ])
1887
2292
  );
1888
- }
1889
- function formatWebhooksTable(webhooks) {
1890
- if (webhooks.length === 0) {
1891
- return "No webhooks found.";
1892
- }
1893
- return formatTable(
1894
- ["ID", "URL", "Events", "Status", "Secret"],
1895
- webhooks.map((webhook) => [
1896
- truncate2(getWebhookId(webhook) || "-", 18),
1897
- truncate2(getWebhookUrl(webhook) || "-", 42),
1898
- truncate2(getWebhookEvents(webhook).join(", ") || "-", 30),
1899
- webhook.isActive === false ? "inactive" : "active",
1900
- truncate2(text5(webhook.secretHint) || "-", 18)
1901
- ])
1902
- );
1903
- }
1904
- function formatWebhookDetails(webhook) {
1905
- const rows2 = [
1906
- ["ID", getWebhookId(webhook)],
1907
- ["URL", getWebhookUrl(webhook)],
1908
- [
1909
- "Events",
1910
- getWebhookEvents(webhook).length > 0 ? getWebhookEvents(webhook).join(", ") : void 0
1911
- ],
1912
- ["Status", webhook.isActive === false ? "inactive" : "active"],
1913
- ["Secret", text5(webhook.secret)],
1914
- ["Secret Hint", text5(webhook.secretHint)],
1915
- ["Created", text5(webhook.createdAt)]
1916
- ].filter((entry) => Boolean(entry[1]));
1917
- if (rows2.length === 0) {
1918
- return "No webhook fields returned.";
1919
- }
1920
- return formatDetailsTable(rows2);
1921
- }
1922
- function formatWebhooksSummary(webhooks) {
1923
- return `${webhooks.length} webhook${webhooks.length === 1 ? "" : "s"} returned.`;
1924
- }
1925
-
1926
- // src/commands/core.ts
1927
- async function downloadCore(options) {
1928
- const release = await getCoreRelease(process.env);
1929
- const result = await downloadCorePackage(
1930
- release,
1931
- cwd(),
1932
- Boolean(options.force)
1933
- );
1934
- const responseBody = {
1935
- success: true,
1936
- message: "PeakURL downloaded.",
1937
- data: result,
1938
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1939
- };
1940
- if (options.json) {
1941
- writeJson(responseBody);
1942
- return;
1943
- }
1944
- if (options.quiet) {
1945
- writeStdout(result.path);
1946
- return;
1947
- }
1948
- writeStdout(successLine(responseBody.message));
1949
- writeStdout(formatCoreDownload(result));
1950
- }
1951
-
1952
- // src/commands/links.ts
1953
- import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
1954
- import { dirname as dirname3, resolve as resolve2 } from "path";
1955
-
1956
- // src/api/client.ts
1957
- function isApiResponse(value) {
1958
- return Boolean(
1959
- value && typeof value === "object" && "success" in value && "message" in value && "timestamp" in value
1960
- );
1961
- }
1962
- function networkError(apiBaseUrl, error) {
1963
- if (error instanceof Error && error.message) {
1964
- return `Could not reach PeakURL at ${apiBaseUrl}. ${error.message}`;
1965
- }
1966
- return `Could not reach PeakURL at ${apiBaseUrl}.`;
1967
- }
1968
- var ApiClient = class {
1969
- /**
1970
- * Creates a client bound to one resolved credential set.
1971
- *
1972
- * @param config Explicit API base URL plus bearer API key.
1973
- */
1974
- constructor(config) {
1975
- this.config = config;
1976
- }
1977
- config;
1978
- /**
1979
- * Loads the currently authenticated user.
1980
- *
1981
- * PeakURL accepts bearer API keys on `GET /users/me`, which is also the
1982
- * CLI login verification flow.
1983
- *
1984
- * @returns API response envelope containing the authenticated user.
1985
- */
1986
- whoami() {
1987
- return this.request("GET", "users/me");
1988
- }
1989
- /**
1990
- * Loads the current system status snapshot for the authenticated site.
1991
- *
1992
- * @returns API response envelope containing system status sections.
1993
- */
1994
- getStatus() {
1995
- return this.request("GET", "system/status");
1996
- }
1997
- /**
1998
- * Creates a short URL.
1999
- *
2000
- * @param payload Request body accepted by `POST /api/v1/urls`.
2001
- * @returns API response envelope containing the created link.
2002
- */
2003
- createUrl(payload) {
2004
- return this.request("POST", "urls", payload);
2005
- }
2006
- /**
2007
- * Lists short URLs with optional pagination and filtering.
2008
- *
2009
- * The current PeakURL app returns `{ items, meta }` under `data`, but the
2010
- * CLI keeps a slightly broader compatibility type for future-proofing.
2011
- *
2012
- * @param query Optional query-string values.
2013
- * @returns API response envelope containing list data.
2014
- */
2015
- listUrls(query) {
2016
- return this.request("GET", "urls", void 0, query);
2017
- }
2018
- /**
2019
- * Exports the full accessible link dataset for the authenticated user.
2020
- *
2021
- * @param query Optional search and sort values.
2022
- * @returns API response envelope containing the full export payload.
2023
- */
2024
- exportUrls(query) {
2025
- return this.request(
2026
- "GET",
2027
- "urls/export",
2028
- void 0,
2029
- query
2030
- );
2031
- }
2032
- /**
2033
- * Imports multiple short links in one bulk request.
2034
- *
2035
- * @param payload Request body accepted by `POST /api/v1/urls/bulk`.
2036
- * @returns API response envelope containing created rows plus row errors.
2037
- */
2038
- importUrls(payload) {
2039
- return this.request("POST", "urls/bulk", payload);
2040
- }
2041
- /**
2042
- * Loads a single short URL by identifier or alias.
2043
- *
2044
- * PeakURL resolves IDs, short codes, and aliases through the same route.
2045
- *
2046
- * @param idOrAlias Link identifier, short code, or alias.
2047
- * @returns API response envelope containing the resolved link.
2048
- */
2049
- getUrl(idOrAlias) {
2050
- return this.request(
2051
- "GET",
2052
- `urls/${encodeURIComponent(idOrAlias)}`
2053
- );
2293
+ }
2294
+ function formatWebhooksTable(webhooks) {
2295
+ if (webhooks.length === 0) {
2296
+ return "No webhooks found.";
2054
2297
  }
2055
- /**
2056
- * Deletes a short URL by its stable row ID.
2057
- *
2058
- * The current PeakURL backend delete route expects the row ID. The CLI can
2059
- * still accept an alias at the command layer by resolving it first.
2060
- *
2061
- * @param id Stable link row ID.
2062
- * @returns API response envelope containing the deletion result.
2063
- */
2064
- deleteUrl(id) {
2065
- return this.request(
2066
- "DELETE",
2067
- `urls/${encodeURIComponent(id)}`
2068
- );
2298
+ return formatTable(
2299
+ ["ID", "URL", "Events", "Status", "Secret"],
2300
+ webhooks.map((webhook) => [
2301
+ truncate3(getWebhookId(webhook) || "-", 18),
2302
+ truncate3(getWebhookUrl(webhook) || "-", 42),
2303
+ truncate3(getWebhookEvents(webhook).join(", ") || "-", 30),
2304
+ webhook.isActive === false ? "inactive" : "active",
2305
+ truncate3(text5(webhook.secretHint) || "-", 18)
2306
+ ])
2307
+ );
2308
+ }
2309
+ function formatWebhookDetails(webhook) {
2310
+ const rows2 = [
2311
+ ["ID", getWebhookId(webhook)],
2312
+ ["URL", getWebhookUrl(webhook)],
2313
+ [
2314
+ "Events",
2315
+ getWebhookEvents(webhook).length > 0 ? getWebhookEvents(webhook).join(", ") : void 0
2316
+ ],
2317
+ ["Status", webhook.isActive === false ? "inactive" : "active"],
2318
+ ["Secret", text5(webhook.secret)],
2319
+ ["Secret Hint", text5(webhook.secretHint)],
2320
+ ["Created", text5(webhook.createdAt)]
2321
+ ].filter((entry) => Boolean(entry[1]));
2322
+ if (rows2.length === 0) {
2323
+ return "No webhook fields returned.";
2069
2324
  }
2070
- /**
2071
- * Lists outbound webhooks for the authenticated user.
2072
- *
2073
- * @returns API response envelope containing webhook rows.
2074
- */
2075
- listWebhooks() {
2076
- return this.request("GET", "webhooks");
2325
+ return formatDetailsTable(rows2);
2326
+ }
2327
+ function formatWebhooksSummary(webhooks) {
2328
+ return `${webhooks.length} webhook${webhooks.length === 1 ? "" : "s"} returned.`;
2329
+ }
2330
+
2331
+ // src/commands/activity.ts
2332
+ async function listActivity(options) {
2333
+ const config = await getAuthConfig(process.env);
2334
+ const response = await new ApiClient(config).listActivity({
2335
+ page: options.page,
2336
+ limit: options.limit,
2337
+ search: options.search
2338
+ });
2339
+ const items = extractActivity(response.data);
2340
+ if (options.json) {
2341
+ writeJson(response);
2342
+ return;
2077
2343
  }
2078
- /**
2079
- * Creates one outbound webhook subscription.
2080
- *
2081
- * @param payload Request body accepted by `POST /api/v1/webhooks`.
2082
- * @returns API response envelope containing the created webhook.
2083
- */
2084
- createWebhook(payload) {
2085
- return this.request("POST", "webhooks", payload);
2344
+ if (options.quiet) {
2345
+ for (const item of items) {
2346
+ if (item.id) {
2347
+ writeStdout(String(item.id));
2348
+ }
2349
+ }
2350
+ return;
2086
2351
  }
2087
- /**
2088
- * Deletes one webhook by its stable row ID.
2089
- *
2090
- * @param id Webhook identifier returned by the list/create endpoints.
2091
- * @returns API response envelope containing the deletion result.
2092
- */
2093
- deleteWebhook(id) {
2094
- return this.request(
2095
- "DELETE",
2096
- `webhooks/${encodeURIComponent(id)}`
2352
+ writeStdout(successLine(response.message || "Activity loaded."));
2353
+ writeStdout(formatActivityTable(items));
2354
+ writeStdout(formatActivitySummary(response.data, items.length));
2355
+ }
2356
+ async function deleteActivity(identifiers, options) {
2357
+ const config = await getAuthConfig(process.env);
2358
+ const client = new ApiClient(config);
2359
+ if (options.all) {
2360
+ const response2 = await client.clearActivity();
2361
+ if (options.json) {
2362
+ writeJson(response2);
2363
+ return;
2364
+ }
2365
+ if (options.quiet) {
2366
+ return;
2367
+ }
2368
+ writeStdout(
2369
+ successLine(response2.message || "All activity logs deleted.")
2097
2370
  );
2371
+ return;
2098
2372
  }
2099
- /**
2100
- * Performs one authenticated API request and normalizes the response.
2101
- *
2102
- * @param method HTTP method to send.
2103
- * @param path Route path relative to `/api/v1`.
2104
- * @param body Optional JSON body.
2105
- * @param query Optional query-string values.
2106
- * @returns Parsed PeakURL response envelope.
2107
- * @throws {CliError} When the network request fails or the API returns an error.
2108
- */
2109
- async request(method, path, body, query) {
2110
- const url = buildApiUrl(this.config.apiBaseUrl, path, query);
2111
- let response;
2112
- try {
2113
- response = await fetch(url, {
2114
- method,
2115
- headers: {
2116
- Accept: "application/json",
2117
- Authorization: `Bearer ${this.config.apiKey}`,
2118
- ...body ? { "Content-Type": "application/json" } : {}
2119
- },
2120
- body: body ? JSON.stringify(body) : void 0
2121
- });
2122
- } catch (error) {
2123
- throw new CliError(networkError(this.config.apiBaseUrl, error), 1, {
2124
- cause: error instanceof Error ? error : void 0
2125
- });
2126
- }
2127
- const rawText = await response.text();
2128
- if (!rawText) {
2129
- if (!response.ok) {
2130
- throw new CliError(
2131
- `PeakURL request failed with HTTP ${response.status}.`
2132
- );
2373
+ const rawTargets = [];
2374
+ if (typeof identifiers === "string" && identifiers.trim()) {
2375
+ rawTargets.push(identifiers.trim());
2376
+ } else if (Array.isArray(identifiers)) {
2377
+ for (const item of identifiers) {
2378
+ if (typeof item === "string" && item.trim()) {
2379
+ rawTargets.push(item.trim());
2133
2380
  }
2134
- return {
2135
- success: true,
2136
- message: "Request completed.",
2137
- data: void 0,
2138
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2139
- };
2140
2381
  }
2141
- let parsed;
2142
- try {
2143
- parsed = JSON.parse(rawText);
2144
- } catch {
2145
- if (!response.ok) {
2146
- throw new CliError(
2147
- `PeakURL request failed with HTTP ${response.status}.`
2148
- );
2382
+ }
2383
+ if (options.ids) {
2384
+ for (const id of options.ids.split(",")) {
2385
+ const trimmed = id.trim();
2386
+ if (trimmed) {
2387
+ rawTargets.push(trimmed);
2149
2388
  }
2150
- throw new CliError("PeakURL returned an invalid JSON response.");
2151
2389
  }
2152
- if (!isApiResponse(parsed)) {
2153
- throw new CliError(
2154
- "PeakURL returned an unexpected response envelope."
2155
- );
2390
+ }
2391
+ const uniqueTargets = Array.from(new Set(rawTargets));
2392
+ if (uniqueTargets.length === 0) {
2393
+ throw new CliError(
2394
+ "Specify one or more activity IDs to delete, or use --all to delete all activity logs."
2395
+ );
2396
+ }
2397
+ if (uniqueTargets.length === 1) {
2398
+ const response2 = await client.deleteActivity(uniqueTargets[0]);
2399
+ if (options.json) {
2400
+ writeJson(response2);
2401
+ return;
2156
2402
  }
2157
- if (!response.ok || !parsed.success) {
2158
- const statusCode = response.status === 401 ? 2 : 1;
2159
- throw new CliError(
2160
- parsed.message || `PeakURL request failed with HTTP ${response.status}.`,
2161
- statusCode
2162
- );
2403
+ if (options.quiet) {
2404
+ return;
2163
2405
  }
2164
- return parsed;
2406
+ writeStdout(successLine(response2.message || "Activity log deleted."));
2407
+ return;
2165
2408
  }
2166
- };
2409
+ const response = await client.deleteActivityBulk(uniqueTargets);
2410
+ if (options.json) {
2411
+ writeJson(response);
2412
+ return;
2413
+ }
2414
+ if (options.quiet) {
2415
+ return;
2416
+ }
2417
+ writeStdout(
2418
+ successLine(
2419
+ response.message || `Deleted ${uniqueTargets.length} activity record${uniqueTargets.length === 1 ? "" : "s"}.`
2420
+ )
2421
+ );
2422
+ }
2423
+ async function clearActivity(options) {
2424
+ const config = await getAuthConfig(process.env);
2425
+ const response = await new ApiClient(config).clearActivity();
2426
+ if (options.json) {
2427
+ writeJson(response);
2428
+ return;
2429
+ }
2430
+ if (options.quiet) {
2431
+ return;
2432
+ }
2433
+ writeStdout(successLine(response.message || "All activity logs deleted."));
2434
+ }
2435
+
2436
+ // src/commands/core.ts
2437
+ import { cwd } from "process";
2438
+ async function downloadCore(options) {
2439
+ const release = await getCoreRelease(process.env);
2440
+ const result = await downloadCorePackage(
2441
+ release,
2442
+ cwd(),
2443
+ Boolean(options.force)
2444
+ );
2445
+ const responseBody = {
2446
+ success: true,
2447
+ message: "PeakURL downloaded.",
2448
+ data: result,
2449
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2450
+ };
2451
+ if (options.json) {
2452
+ writeJson(responseBody);
2453
+ return;
2454
+ }
2455
+ if (options.quiet) {
2456
+ writeStdout(result.path);
2457
+ return;
2458
+ }
2459
+ writeStdout(successLine(responseBody.message));
2460
+ writeStdout(formatCoreDownload(result));
2461
+ }
2167
2462
 
2168
2463
  // src/commands/links.ts
2464
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
2465
+ import { dirname as dirname3, resolve as resolve2 } from "path";
2169
2466
  function normalizeExpiresAt(value) {
2170
2467
  if (!value) {
2171
2468
  return void 0;
@@ -2308,17 +2605,93 @@ async function getLink(idOrAlias, options) {
2308
2605
  writeStdout(successLine(response.message));
2309
2606
  writeStdout(formatLinkDetails(response.data));
2310
2607
  }
2311
- async function deleteLink(idOrAlias, options) {
2608
+ async function deleteLink(identifiers, options) {
2312
2609
  const config = await getAuthConfig(process.env);
2313
2610
  const client = new ApiClient(config);
2314
- const lookupResponse = await client.getUrl(idOrAlias);
2315
- const resolvedId = getLinkId(lookupResponse.data);
2316
- if (!resolvedId) {
2611
+ if (options.all) {
2612
+ const response2 = await client.clearUrls();
2613
+ if (options.json) {
2614
+ writeJson(response2);
2615
+ return;
2616
+ }
2617
+ if (options.quiet) {
2618
+ return;
2619
+ }
2620
+ writeStdout(
2621
+ successLine(response2.message || "All short links deleted.")
2622
+ );
2623
+ return;
2624
+ }
2625
+ if (options.trash || options.emptyTrash) {
2626
+ const response2 = await client.emptyTrash();
2627
+ if (options.json) {
2628
+ writeJson(response2);
2629
+ return;
2630
+ }
2631
+ if (options.quiet) {
2632
+ return;
2633
+ }
2634
+ writeStdout(successLine(response2.message || "Trash emptied."));
2635
+ return;
2636
+ }
2637
+ const rawTargets = [];
2638
+ if (typeof identifiers === "string" && identifiers.trim()) {
2639
+ rawTargets.push(identifiers.trim());
2640
+ } else if (Array.isArray(identifiers)) {
2641
+ for (const item of identifiers) {
2642
+ if (typeof item === "string" && item.trim()) {
2643
+ rawTargets.push(item.trim());
2644
+ }
2645
+ }
2646
+ }
2647
+ if (options.ids) {
2648
+ for (const id of options.ids.split(",")) {
2649
+ const trimmed = id.trim();
2650
+ if (trimmed) {
2651
+ rawTargets.push(trimmed);
2652
+ }
2653
+ }
2654
+ }
2655
+ const uniqueTargets = Array.from(new Set(rawTargets));
2656
+ if (uniqueTargets.length === 0) {
2317
2657
  throw new CliError(
2318
- "PeakURL returned a link record without an ID, so the CLI cannot delete it safely."
2658
+ "Specify one or more link identifiers or aliases to delete, or use --all to delete all links."
2319
2659
  );
2320
2660
  }
2321
- const response = await client.deleteUrl(resolvedId);
2661
+ if (uniqueTargets.length === 1) {
2662
+ const lookupResponse = await client.getUrl(uniqueTargets[0]);
2663
+ const resolvedId = getLinkId(lookupResponse.data);
2664
+ if (!resolvedId) {
2665
+ throw new CliError(
2666
+ "PeakURL returned a link record without an ID, so the CLI cannot delete it safely."
2667
+ );
2668
+ }
2669
+ const response2 = await client.deleteUrl(resolvedId);
2670
+ if (options.json) {
2671
+ writeJson(response2);
2672
+ return;
2673
+ }
2674
+ if (options.quiet) {
2675
+ return;
2676
+ }
2677
+ writeStdout(successLine(response2.message));
2678
+ return;
2679
+ }
2680
+ const resolvedIds = [];
2681
+ for (const target of uniqueTargets) {
2682
+ try {
2683
+ const lookup = await client.getUrl(target);
2684
+ const id = getLinkId(lookup.data);
2685
+ if (id) {
2686
+ resolvedIds.push(id);
2687
+ } else {
2688
+ resolvedIds.push(target);
2689
+ }
2690
+ } catch {
2691
+ resolvedIds.push(target);
2692
+ }
2693
+ }
2694
+ const response = await client.deleteUrlsBulk(resolvedIds);
2322
2695
  if (options.json) {
2323
2696
  writeJson(response);
2324
2697
  return;
@@ -2326,7 +2699,11 @@ async function deleteLink(idOrAlias, options) {
2326
2699
  if (options.quiet) {
2327
2700
  return;
2328
2701
  }
2329
- writeStdout(successLine(response.message));
2702
+ writeStdout(
2703
+ successLine(
2704
+ response.message || `Deleted ${resolvedIds.length} short link${resolvedIds.length === 1 ? "" : "s"}.`
2705
+ )
2706
+ );
2330
2707
  }
2331
2708
 
2332
2709
  // src/commands/login.ts
@@ -2355,7 +2732,7 @@ async function login(options) {
2355
2732
  successLine(`Saved credentials for ${credentials.apiBaseUrl}.`)
2356
2733
  );
2357
2734
  writeStdout(`Authenticated as ${userLabel(response.data)}`);
2358
- writeStdout(userTable(response.data));
2735
+ writeStdout(userTable(response.data, credentials.apiBaseUrl));
2359
2736
  }
2360
2737
 
2361
2738
  // src/commands/logout.ts
@@ -2513,7 +2890,7 @@ async function deleteWebhook(id, options) {
2513
2890
  }
2514
2891
  writeStdout(successLine(response.message));
2515
2892
  }
2516
- async function listWebhookEvents(options) {
2893
+ function listWebhookEvents(options) {
2517
2894
  const response = {
2518
2895
  success: true,
2519
2896
  message: "Webhook events loaded.",
@@ -2584,7 +2961,7 @@ function getRetryCommandName(argv) {
2584
2961
  if (!first || first.startsWith("-")) {
2585
2962
  return void 0;
2586
2963
  }
2587
- if (first === "webhook" || first === "webhooks") {
2964
+ if (first === "webhook" || first === "webhooks" || first === "activity" || first === "activities") {
2588
2965
  const second = argv[3]?.trim();
2589
2966
  if (second && !second.startsWith("-")) {
2590
2967
  return `${first} ${second}`;
@@ -2595,7 +2972,7 @@ function getRetryCommandName(argv) {
2595
2972
  async function main() {
2596
2973
  const program = new Command();
2597
2974
  const version = await getCliVersion();
2598
- program.name("peakurl").description("Manage your PeakURL site from the terminal.").helpOption("-h, --help", "Show help").helpCommand("help [command]", "Show help for a command").version(version, "-V, --version", "Show CLI version").showHelpAfterError().showSuggestionAfterError().addHelpText(
2975
+ program.name("peakurl").description("Manage your PeakURL site from the terminal.").helpOption("-h, --help", "Show help").helpCommand("help [command]", "Show help for a command").version(version, "-v, --version", "Show CLI version").showHelpAfterError().showSuggestionAfterError().addHelpText(
2599
2976
  "after",
2600
2977
  `
2601
2978
  Get Started:
@@ -2710,8 +3087,57 @@ Run 'peakurl <command> --help' for command-specific flags and examples.`
2710
3087
  ["peakurl get docs", "peakurl get url_123 --json"]
2711
3088
  );
2712
3089
  addExamples(
2713
- program.command("delete").summary("Delete one short link").description("Delete a PeakURL short link by id or alias.").helpOption("-h, --help", "Show help").argument("<id-or-alias>", "Link identifier or alias").option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(deleteLink),
2714
- ["peakurl delete docs", "peakurl delete url_123 --quiet"]
3090
+ program.command("delete").summary("Delete short links").description(
3091
+ "Delete PeakURL short links by ID or alias, in bulk, or clear all links."
3092
+ ).helpOption("-h, --help", "Show help").argument("[id-or-alias...]", "Link identifier(s) or alias(es)").option("--all", "Delete all accessible short links").option(
3093
+ "--trash, --empty-trash",
3094
+ "Empty all short links currently in trash"
3095
+ ).option(
3096
+ "--ids <ids>",
3097
+ "Comma-separated list of link IDs to bulk delete"
3098
+ ).option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(deleteLink),
3099
+ [
3100
+ "peakurl delete docs",
3101
+ "peakurl delete docs pricing launch",
3102
+ "peakurl delete --ids url_1,url_2,url_3",
3103
+ "peakurl delete --empty-trash",
3104
+ "peakurl delete --all"
3105
+ ]
3106
+ );
3107
+ const activity = program.command("activity").alias("activities").summary("View and manage activity logs").helpOption("-h, --help", "Show help").description(
3108
+ "View audit log activity entries, delete specific records, or clear all history."
3109
+ );
3110
+ addExamples(activity, [
3111
+ "peakurl activity list",
3112
+ "peakurl activity delete act_123 act_456",
3113
+ "peakurl activity clear"
3114
+ ]);
3115
+ addExamples(
3116
+ activity.command("list", { isDefault: true }).summary("List activity logs").description("List audit log activity records.").helpOption("-h, --help", "Show help").option("--page <number>", "Page number", parseNumber("page")).option("--limit <number>", "Page size", parseNumber("limit")).option("--search <query>", "Search term").option("--json", "Print machine-readable output").option("--quiet", "Print only activity record IDs").action(listActivity),
3117
+ [
3118
+ "peakurl activity",
3119
+ "peakurl activity list",
3120
+ "peakurl activity list --limit 25 --page 1",
3121
+ "peakurl activity list --search delete --json"
3122
+ ]
3123
+ );
3124
+ addExamples(
3125
+ activity.command("delete").summary("Delete activity logs").description(
3126
+ "Delete one or more activity logs by ID, or clear all logs with --all."
3127
+ ).helpOption("-h, --help", "Show help").argument("[ids...]", "One or more activity log IDs to delete").option("--all", "Delete all activity log history").option(
3128
+ "--ids <ids>",
3129
+ "Comma-separated list of activity log IDs to delete"
3130
+ ).option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(deleteActivity),
3131
+ [
3132
+ "peakurl activity delete act_123",
3133
+ "peakurl activity delete act_123 act_456",
3134
+ "peakurl activity delete --ids act_1,act_2",
3135
+ "peakurl activity delete --all"
3136
+ ]
3137
+ );
3138
+ addExamples(
3139
+ activity.command("clear").summary("Clear all activity logs").description("Delete all audit log activity records permanently.").helpOption("-h, --help", "Show help").option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(clearActivity),
3140
+ ["peakurl activity clear", "peakurl activity clear --json"]
2715
3141
  );
2716
3142
  addExamples(
2717
3143
  program.command("update").summary("Check for CLI updates").description(
@@ -2719,18 +3145,25 @@ Run 'peakurl <command> --help' for command-specific flags and examples.`
2719
3145
  ).helpOption("-h, --help", "Show help").option(
2720
3146
  "--check",
2721
3147
  "Alias for checking update status without changing anything"
2722
- ).option("--json", "Print machine-readable output").option("--quiet", "Print minimal output").action((options) => checkUpdate(options, version)),
3148
+ ).option("--json", "Print machine-readable output").option("--quiet", "Print minimal output").action(
3149
+ (options) => checkUpdate(options, version)
3150
+ ),
2723
3151
  ["peakurl update", "peakurl update --check", "peakurl update --json"]
2724
3152
  );
2725
3153
  const webhook = program.command("webhook").alias("webhooks").summary("Manage webhooks").helpOption("-h, --help", "Show help").description("Manage outbound webhook integrations.");
2726
3154
  addExamples(webhook, [
3155
+ "peakurl webhook",
2727
3156
  "peakurl webhook list",
2728
3157
  "peakurl webhook create https://example.com/api/webhooks/peakurl --event link.clicked",
2729
3158
  "peakurl webhook events"
2730
3159
  ]);
2731
3160
  addExamples(
2732
- webhook.command("list").summary("List webhooks").description("List outbound webhooks.").helpOption("-h, --help", "Show help").option("--json", "Print machine-readable output").option("--quiet", "Print minimal webhook identifiers").action(listWebhooks),
2733
- ["peakurl webhook list", "peakurl webhook list --json"]
3161
+ webhook.command("list", { isDefault: true }).summary("List webhooks").description("List outbound webhooks.").helpOption("-h, --help", "Show help").option("--json", "Print machine-readable output").option("--quiet", "Print minimal webhook identifiers").action(listWebhooks),
3162
+ [
3163
+ "peakurl webhook",
3164
+ "peakurl webhook list",
3165
+ "peakurl webhook list --json"
3166
+ ]
2734
3167
  );
2735
3168
  addExamples(
2736
3169
  webhook.command("create").summary("Create a webhook").description("Create an outbound webhook.").helpOption("-h, --help", "Show help").argument("<url>", "Webhook endpoint URL").option(