peakurl 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/dist/index.js +708 -0
- package/package.json +30 -3
- package/index.js +0 -3
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# PeakURL CLI
|
|
2
|
+
|
|
3
|
+
`peakurl` is the official command-line interface for PeakURL.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g peakurl
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The npm package name is unscoped and the executable is also `peakurl`.
|
|
12
|
+
|
|
13
|
+
## Authentication
|
|
14
|
+
|
|
15
|
+
The CLI uses a PeakURL API key and validates it with `GET /api/v1/users/me` before storing it.
|
|
16
|
+
PeakURL API keys are opaque 48-character hex bearer tokens, for example `0123456789abcdef0123456789abcdef0123456789abcdef`.
|
|
17
|
+
The CLI accepts either the site root URL such as `https://peakurl.org` or the dashboard-exposed API base URL such as `https://peakurl.org/api/v1`.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
peakurl login --base-url https://peakurl.org --api-key 0123456789abcdef0123456789abcdef0123456789abcdef
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
You can also provide credentials through environment variables, which is useful in CI:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export PEAKURL_BASE_URL=https://peakurl.org
|
|
27
|
+
export PEAKURL_API_KEY=0123456789abcdef0123456789abcdef0123456789abcdef
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Stored credentials live in the OS-standard per-user config directory for `peakurl`.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
### `peakurl login`
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
peakurl login --base-url https://peakurl.org --api-key 0123456789abcdef0123456789abcdef0123456789abcdef
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### `peakurl whoami`
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
peakurl whoami
|
|
44
|
+
peakurl whoami --json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### `peakurl create <url>`
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
peakurl create https://example.com/articles/launch --alias launch --title "Launch Post"
|
|
51
|
+
peakurl create https://example.com --json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `peakurl list`
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
peakurl list
|
|
58
|
+
peakurl list --search launch --limit 10 --json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `peakurl get <id-or-alias>`
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
peakurl get launch
|
|
65
|
+
peakurl get 681f3b63e7e44c0a1e83aa11 --json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `peakurl delete <id-or-alias>`
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
peakurl delete launch
|
|
72
|
+
peakurl delete 681f3b63e7e44c0a1e83aa11 --quiet
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When you pass an alias or short code to `delete`, the CLI resolves it to the
|
|
76
|
+
stable PeakURL row ID first because the current backend delete route operates on
|
|
77
|
+
IDs.
|
|
78
|
+
|
|
79
|
+
## Flags
|
|
80
|
+
|
|
81
|
+
Common flags:
|
|
82
|
+
|
|
83
|
+
- `--json` prints machine-readable output.
|
|
84
|
+
- `--quiet` prints minimal output for scripts.
|
|
85
|
+
|
|
86
|
+
Create flags:
|
|
87
|
+
|
|
88
|
+
- `--alias`
|
|
89
|
+
- `--title`
|
|
90
|
+
- `--password`
|
|
91
|
+
- `--status`
|
|
92
|
+
- `--expires-at`
|
|
93
|
+
- `--utm-source`
|
|
94
|
+
- `--utm-medium`
|
|
95
|
+
- `--utm-campaign`
|
|
96
|
+
- `--utm-term`
|
|
97
|
+
- `--utm-content`
|
|
98
|
+
|
|
99
|
+
List flags:
|
|
100
|
+
|
|
101
|
+
- `--page`
|
|
102
|
+
- `--limit`
|
|
103
|
+
- `--search`
|
|
104
|
+
- `--sort-by`
|
|
105
|
+
- `--sort-order`
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm install
|
|
111
|
+
npm run typecheck
|
|
112
|
+
npm test
|
|
113
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
5
|
+
import { Command, CommanderError, InvalidArgumentError } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/lib/errors.ts
|
|
8
|
+
var CliError = class extends Error {
|
|
9
|
+
exitCode;
|
|
10
|
+
constructor(message, exitCode = 1, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.name = "CliError";
|
|
13
|
+
this.exitCode = exitCode;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function toCliError(error) {
|
|
17
|
+
if (error instanceof CliError) {
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
if (error instanceof Error) {
|
|
21
|
+
return new CliError(error.message, 1, { cause: error });
|
|
22
|
+
}
|
|
23
|
+
return new CliError("Unexpected error.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/lib/url.ts
|
|
27
|
+
function normalizeBaseUrl(value) {
|
|
28
|
+
const input = value.trim();
|
|
29
|
+
if (!input) {
|
|
30
|
+
throw new CliError("A PeakURL base URL is required.");
|
|
31
|
+
}
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = new URL(input);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new CliError(`Invalid base URL: ${value}`);
|
|
37
|
+
}
|
|
38
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
39
|
+
throw new CliError("PeakURL base URLs must use http or https.");
|
|
40
|
+
}
|
|
41
|
+
parsed.hash = "";
|
|
42
|
+
parsed.search = "";
|
|
43
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
44
|
+
const installPath = pathname.replace(/\/api\/v1$/i, "");
|
|
45
|
+
return installPath ? `${parsed.origin}${installPath}` : parsed.origin;
|
|
46
|
+
}
|
|
47
|
+
function buildApiUrl(baseUrl, path, query) {
|
|
48
|
+
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
|
49
|
+
const cleanPath = path.replace(/^\/+/, "");
|
|
50
|
+
const url = new URL(`api/v1/${cleanPath}`, `${cleanBaseUrl}/`);
|
|
51
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
52
|
+
if (value === void 0 || value === "") {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
url.searchParams.set(key, String(value));
|
|
56
|
+
}
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
59
|
+
function normalizeDestinationUrl(value) {
|
|
60
|
+
const input = value.trim();
|
|
61
|
+
if (!input) {
|
|
62
|
+
throw new CliError("A destination URL is required.");
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return new URL(input).toString();
|
|
66
|
+
} catch {
|
|
67
|
+
throw new CliError(`Invalid destination URL: ${value}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/api/client.ts
|
|
72
|
+
function isEnvelope(value) {
|
|
73
|
+
return Boolean(
|
|
74
|
+
value && typeof value === "object" && "success" in value && "message" in value && "timestamp" in value
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
function formatNetworkError(baseUrl, error) {
|
|
78
|
+
if (error instanceof Error && error.message) {
|
|
79
|
+
return `Could not reach PeakURL at ${baseUrl}. ${error.message}`;
|
|
80
|
+
}
|
|
81
|
+
return `Could not reach PeakURL at ${baseUrl}.`;
|
|
82
|
+
}
|
|
83
|
+
var PeakUrlApiClient = class {
|
|
84
|
+
/**
|
|
85
|
+
* Creates a client bound to one resolved credential set.
|
|
86
|
+
*
|
|
87
|
+
* @param config Normalized base URL plus bearer API key.
|
|
88
|
+
*/
|
|
89
|
+
constructor(config) {
|
|
90
|
+
this.config = config;
|
|
91
|
+
}
|
|
92
|
+
config;
|
|
93
|
+
/**
|
|
94
|
+
* Loads the currently authenticated user.
|
|
95
|
+
*
|
|
96
|
+
* PeakURL accepts bearer API keys on `GET /users/me`, which is also the
|
|
97
|
+
* CLI login verification flow.
|
|
98
|
+
*
|
|
99
|
+
* @returns API response envelope containing the authenticated user.
|
|
100
|
+
*/
|
|
101
|
+
whoami() {
|
|
102
|
+
return this.request("GET", "users/me");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Creates a short URL.
|
|
106
|
+
*
|
|
107
|
+
* @param payload Request body accepted by `POST /api/v1/urls`.
|
|
108
|
+
* @returns API response envelope containing the created link.
|
|
109
|
+
*/
|
|
110
|
+
createUrl(payload) {
|
|
111
|
+
return this.request("POST", "urls", payload);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Lists short URLs with optional pagination and filtering.
|
|
115
|
+
*
|
|
116
|
+
* The current PeakURL app returns `{ items, meta }` under `data`, but the
|
|
117
|
+
* CLI keeps a slightly broader compatibility type for future-proofing.
|
|
118
|
+
*
|
|
119
|
+
* @param query Optional query-string values.
|
|
120
|
+
* @returns API response envelope containing list data.
|
|
121
|
+
*/
|
|
122
|
+
listUrls(query) {
|
|
123
|
+
return this.request(
|
|
124
|
+
"GET",
|
|
125
|
+
"urls",
|
|
126
|
+
void 0,
|
|
127
|
+
query
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Loads a single short URL by identifier or alias.
|
|
132
|
+
*
|
|
133
|
+
* PeakURL resolves IDs, short codes, and aliases through the same route.
|
|
134
|
+
*
|
|
135
|
+
* @param idOrAlias Link identifier, short code, or alias.
|
|
136
|
+
* @returns API response envelope containing the resolved link.
|
|
137
|
+
*/
|
|
138
|
+
getUrl(idOrAlias) {
|
|
139
|
+
return this.request(
|
|
140
|
+
"GET",
|
|
141
|
+
`urls/${encodeURIComponent(idOrAlias)}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Deletes a short URL by its stable row ID.
|
|
146
|
+
*
|
|
147
|
+
* The current PeakURL backend delete route expects the row ID. The CLI can
|
|
148
|
+
* still accept an alias at the command layer by resolving it first.
|
|
149
|
+
*
|
|
150
|
+
* @param id Stable link row ID.
|
|
151
|
+
* @returns API response envelope containing the deletion result.
|
|
152
|
+
*/
|
|
153
|
+
deleteUrl(id) {
|
|
154
|
+
return this.request(
|
|
155
|
+
"DELETE",
|
|
156
|
+
`urls/${encodeURIComponent(id)}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Performs one authenticated API request and normalizes the response.
|
|
161
|
+
*
|
|
162
|
+
* @param method HTTP method to send.
|
|
163
|
+
* @param path Route path relative to `/api/v1`.
|
|
164
|
+
* @param body Optional JSON body.
|
|
165
|
+
* @param query Optional query-string values.
|
|
166
|
+
* @returns Parsed PeakURL response envelope.
|
|
167
|
+
* @throws {CliError} When the network request fails or the API returns an error.
|
|
168
|
+
*/
|
|
169
|
+
async request(method, path, body, query) {
|
|
170
|
+
const url = buildApiUrl(this.config.baseUrl, path, query);
|
|
171
|
+
let response;
|
|
172
|
+
try {
|
|
173
|
+
response = await fetch(url, {
|
|
174
|
+
method,
|
|
175
|
+
headers: {
|
|
176
|
+
Accept: "application/json",
|
|
177
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
178
|
+
...body ? { "Content-Type": "application/json" } : {}
|
|
179
|
+
},
|
|
180
|
+
body: body ? JSON.stringify(body) : void 0
|
|
181
|
+
});
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw new CliError(
|
|
184
|
+
formatNetworkError(this.config.baseUrl, error),
|
|
185
|
+
1,
|
|
186
|
+
{
|
|
187
|
+
cause: error instanceof Error ? error : void 0
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const rawText = await response.text();
|
|
192
|
+
if (!rawText) {
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
throw new CliError(
|
|
195
|
+
`PeakURL request failed with HTTP ${response.status}.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
success: true,
|
|
200
|
+
message: "Request completed.",
|
|
201
|
+
data: void 0,
|
|
202
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
let parsed;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(rawText);
|
|
208
|
+
} catch {
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new CliError(
|
|
211
|
+
`PeakURL request failed with HTTP ${response.status}.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
throw new CliError("PeakURL returned an invalid JSON response.");
|
|
215
|
+
}
|
|
216
|
+
if (!isEnvelope(parsed)) {
|
|
217
|
+
throw new CliError(
|
|
218
|
+
"PeakURL returned an unexpected response envelope."
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (!response.ok || !parsed.success) {
|
|
222
|
+
const statusCode = response.status === 401 ? 2 : 1;
|
|
223
|
+
throw new CliError(
|
|
224
|
+
parsed.message || `PeakURL request failed with HTTP ${response.status}.`,
|
|
225
|
+
statusCode
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return parsed;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// src/config/store.ts
|
|
233
|
+
import { chmod, mkdir, readFile, writeFile } from "fs/promises";
|
|
234
|
+
import { dirname, join } from "path";
|
|
235
|
+
import envPaths from "env-paths";
|
|
236
|
+
var CONFIG_FILENAME = "config.json";
|
|
237
|
+
function defaultConfigPath() {
|
|
238
|
+
return join(envPaths("peakurl", { suffix: "" }).config, CONFIG_FILENAME);
|
|
239
|
+
}
|
|
240
|
+
var ConfigStore = class {
|
|
241
|
+
filePath;
|
|
242
|
+
/**
|
|
243
|
+
* Creates a config store bound to one on-disk file.
|
|
244
|
+
*
|
|
245
|
+
* @param filePath Optional override used by tests or advanced callers.
|
|
246
|
+
*/
|
|
247
|
+
constructor(filePath = defaultConfigPath()) {
|
|
248
|
+
this.filePath = filePath;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Loads the stored credential set from disk.
|
|
252
|
+
*
|
|
253
|
+
* Missing files are treated as "not configured yet" instead of as hard
|
|
254
|
+
* errors so first-run CLI flows remain clean.
|
|
255
|
+
*
|
|
256
|
+
* @returns Stored config or `null` when the file does not exist.
|
|
257
|
+
* @throws {CliError} When the file exists but is unreadable or invalid.
|
|
258
|
+
*/
|
|
259
|
+
async load() {
|
|
260
|
+
try {
|
|
261
|
+
const content = await readFile(this.filePath, "utf8");
|
|
262
|
+
const parsed = JSON.parse(content);
|
|
263
|
+
if (typeof parsed.baseUrl !== "string" || typeof parsed.apiKey !== "string") {
|
|
264
|
+
throw new CliError(`Invalid config file: ${this.filePath}`);
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
baseUrl: parsed.baseUrl,
|
|
268
|
+
apiKey: parsed.apiKey
|
|
269
|
+
};
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code === "ENOENT") {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
if (error instanceof CliError) {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
throw new CliError(
|
|
278
|
+
`Could not read PeakURL config at ${this.filePath}.`,
|
|
279
|
+
1,
|
|
280
|
+
{
|
|
281
|
+
cause: error instanceof Error ? error : void 0
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Persists one credential set to disk with restrictive file permissions.
|
|
288
|
+
*
|
|
289
|
+
* The chmod step is best-effort because Windows and some filesystems do not
|
|
290
|
+
* expose POSIX permission bits in the same way as Unix-like systems.
|
|
291
|
+
*
|
|
292
|
+
* @param config Normalized credential set to write.
|
|
293
|
+
*/
|
|
294
|
+
async save(config) {
|
|
295
|
+
const directory = dirname(this.filePath);
|
|
296
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
297
|
+
await writeFile(this.filePath, `${JSON.stringify(config, null, 2)}
|
|
298
|
+
`, {
|
|
299
|
+
mode: 384
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
await chmod(directory, 448);
|
|
303
|
+
await chmod(this.filePath, 384);
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/lib/auth.ts
|
|
310
|
+
function resolveLoginConfig(input, env) {
|
|
311
|
+
const baseUrl = input.baseUrl?.trim() || env.PEAKURL_BASE_URL?.trim();
|
|
312
|
+
const apiKey = input.apiKey?.trim() || env.PEAKURL_API_KEY?.trim();
|
|
313
|
+
if (!baseUrl || !apiKey) {
|
|
314
|
+
throw new CliError(
|
|
315
|
+
"Missing credentials. Provide --base-url and --api-key, or set PEAKURL_BASE_URL and PEAKURL_API_KEY."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
320
|
+
apiKey
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async function resolveStoredConfig(env, store = new ConfigStore()) {
|
|
324
|
+
const saved = await store.load();
|
|
325
|
+
const baseUrl = env.PEAKURL_BASE_URL?.trim() || saved?.baseUrl;
|
|
326
|
+
const apiKey = env.PEAKURL_API_KEY?.trim() || saved?.apiKey;
|
|
327
|
+
if (!baseUrl || !apiKey) {
|
|
328
|
+
throw new CliError(
|
|
329
|
+
"PeakURL credentials are not configured. Run `peakurl login --base-url ... --api-key ...` or set PEAKURL_BASE_URL and PEAKURL_API_KEY."
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
334
|
+
apiKey
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/lib/links.ts
|
|
339
|
+
var LIST_KEYS = ["urls", "items", "results"];
|
|
340
|
+
function asRecord(value) {
|
|
341
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
342
|
+
}
|
|
343
|
+
function readString(value) {
|
|
344
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
345
|
+
}
|
|
346
|
+
function readNumber(value) {
|
|
347
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
348
|
+
}
|
|
349
|
+
function firstString(link, keys) {
|
|
350
|
+
for (const key of keys) {
|
|
351
|
+
const value = readString(link[key]);
|
|
352
|
+
if (value) {
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return void 0;
|
|
357
|
+
}
|
|
358
|
+
function truncate(value, maxLength) {
|
|
359
|
+
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
|
|
360
|
+
}
|
|
361
|
+
function extractListMeta(data) {
|
|
362
|
+
const record = asRecord(data);
|
|
363
|
+
if (!record) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const meta = asRecord(record.meta);
|
|
367
|
+
if (meta) {
|
|
368
|
+
return {
|
|
369
|
+
page: readNumber(meta.page),
|
|
370
|
+
limit: readNumber(meta.limit),
|
|
371
|
+
totalItems: readNumber(meta.totalItems),
|
|
372
|
+
totalPages: readNumber(meta.totalPages)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
page: readNumber(record.page),
|
|
377
|
+
limit: readNumber(record.limit),
|
|
378
|
+
totalItems: readNumber(record.total),
|
|
379
|
+
totalPages: readNumber(record.totalPages)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function extractLinks(data) {
|
|
383
|
+
if (Array.isArray(data)) {
|
|
384
|
+
return data;
|
|
385
|
+
}
|
|
386
|
+
const record = asRecord(data);
|
|
387
|
+
if (record) {
|
|
388
|
+
for (const key of LIST_KEYS) {
|
|
389
|
+
const value = record[key];
|
|
390
|
+
if (Array.isArray(value)) {
|
|
391
|
+
return value;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
function getLinkId(link) {
|
|
398
|
+
return firstString(link, ["id", "_id", "urlId"]) || (link.id !== void 0 ? String(link.id) : void 0);
|
|
399
|
+
}
|
|
400
|
+
function getLinkAlias(link) {
|
|
401
|
+
return firstString(link, ["alias", "shortCode", "slug", "code"]);
|
|
402
|
+
}
|
|
403
|
+
function getLinkShortUrl(link) {
|
|
404
|
+
return firstString(link, ["shortUrl", "shortLink", "shortURL", "url"]);
|
|
405
|
+
}
|
|
406
|
+
function getLinkDestination(link) {
|
|
407
|
+
return firstString(link, [
|
|
408
|
+
"destinationUrl",
|
|
409
|
+
"originalUrl",
|
|
410
|
+
"targetUrl",
|
|
411
|
+
"destination"
|
|
412
|
+
]);
|
|
413
|
+
}
|
|
414
|
+
function getQuietLinkValue(link) {
|
|
415
|
+
return getLinkShortUrl(link) || getLinkAlias(link) || getLinkId(link) || "";
|
|
416
|
+
}
|
|
417
|
+
function formatLinkDetails(link) {
|
|
418
|
+
const lines = [
|
|
419
|
+
["ID", getLinkId(link)],
|
|
420
|
+
["Alias", getLinkAlias(link)],
|
|
421
|
+
["Short URL", getLinkShortUrl(link)],
|
|
422
|
+
["Destination", getLinkDestination(link)],
|
|
423
|
+
["Title", readString(link.title)],
|
|
424
|
+
["Status", readString(link.status)],
|
|
425
|
+
[
|
|
426
|
+
"Clicks",
|
|
427
|
+
readNumber(link.clicks) === void 0 ? void 0 : String(link.clicks)
|
|
428
|
+
],
|
|
429
|
+
["Created", readString(link.createdAt)],
|
|
430
|
+
["Updated", readString(link.updatedAt)]
|
|
431
|
+
].filter((entry) => Boolean(entry[1]));
|
|
432
|
+
return lines.map(([label, value]) => `${label}: ${value}`).join("\n");
|
|
433
|
+
}
|
|
434
|
+
function formatLinksTable(links) {
|
|
435
|
+
if (links.length === 0) {
|
|
436
|
+
return "No links found.";
|
|
437
|
+
}
|
|
438
|
+
const headers = ["ID", "ALIAS", "SHORT URL", "DESTINATION", "STATUS"];
|
|
439
|
+
const rows = links.map((link) => [
|
|
440
|
+
truncate(getLinkId(link) || "-", 18),
|
|
441
|
+
truncate(getLinkAlias(link) || "-", 18),
|
|
442
|
+
truncate(getLinkShortUrl(link) || "-", 36),
|
|
443
|
+
truncate(getLinkDestination(link) || "-", 52),
|
|
444
|
+
truncate(readString(link.status) || "-", 12)
|
|
445
|
+
]);
|
|
446
|
+
const widths = headers.map(
|
|
447
|
+
(header, index) => Math.max(header.length, ...rows.map((row) => row[index].length))
|
|
448
|
+
);
|
|
449
|
+
const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
450
|
+
return [
|
|
451
|
+
renderRow(headers),
|
|
452
|
+
renderRow(widths.map((width) => "-".repeat(width))),
|
|
453
|
+
...rows.map(renderRow)
|
|
454
|
+
].join("\n");
|
|
455
|
+
}
|
|
456
|
+
function formatListSummary(data, count) {
|
|
457
|
+
const meta = extractListMeta(data);
|
|
458
|
+
if (!meta) {
|
|
459
|
+
return `${count} link${count === 1 ? "" : "s"} returned.`;
|
|
460
|
+
}
|
|
461
|
+
const total = meta.totalItems;
|
|
462
|
+
const page = meta.page;
|
|
463
|
+
const totalPages = meta.totalPages;
|
|
464
|
+
if (total !== void 0 && page !== void 0 && totalPages !== void 0) {
|
|
465
|
+
return `Page ${page} of ${totalPages}. ${total} total link${total === 1 ? "" : "s"}.`;
|
|
466
|
+
}
|
|
467
|
+
return `${count} link${count === 1 ? "" : "s"} returned.`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/lib/output.ts
|
|
471
|
+
function writeStdout(message = "") {
|
|
472
|
+
process.stdout.write(`${message}
|
|
473
|
+
`);
|
|
474
|
+
}
|
|
475
|
+
function writeStderr(message = "") {
|
|
476
|
+
process.stderr.write(`${message}
|
|
477
|
+
`);
|
|
478
|
+
}
|
|
479
|
+
function writeJson(value) {
|
|
480
|
+
writeStdout(JSON.stringify(value, null, 2));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/commands/create.ts
|
|
484
|
+
function normalizeExpiresAt(value) {
|
|
485
|
+
if (!value) {
|
|
486
|
+
return void 0;
|
|
487
|
+
}
|
|
488
|
+
if (Number.isNaN(Date.parse(value))) {
|
|
489
|
+
throw new CliError(`Invalid expiration timestamp: ${value}`);
|
|
490
|
+
}
|
|
491
|
+
return value;
|
|
492
|
+
}
|
|
493
|
+
async function createCommand(destinationUrl, options) {
|
|
494
|
+
const config = await resolveStoredConfig(process.env);
|
|
495
|
+
const response = await new PeakUrlApiClient(config).createUrl({
|
|
496
|
+
destinationUrl: normalizeDestinationUrl(destinationUrl),
|
|
497
|
+
...options.alias ? { alias: options.alias } : {},
|
|
498
|
+
...options.title ? { title: options.title } : {},
|
|
499
|
+
...options.password ? { password: options.password } : {},
|
|
500
|
+
...options.status ? { status: options.status } : {},
|
|
501
|
+
...options.expiresAt ? { expiresAt: normalizeExpiresAt(options.expiresAt) } : {},
|
|
502
|
+
...options.utmSource ? { utmSource: options.utmSource } : {},
|
|
503
|
+
...options.utmMedium ? { utmMedium: options.utmMedium } : {},
|
|
504
|
+
...options.utmCampaign ? { utmCampaign: options.utmCampaign } : {},
|
|
505
|
+
...options.utmTerm ? { utmTerm: options.utmTerm } : {},
|
|
506
|
+
...options.utmContent ? { utmContent: options.utmContent } : {}
|
|
507
|
+
});
|
|
508
|
+
if (options.json) {
|
|
509
|
+
writeJson(response);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (options.quiet) {
|
|
513
|
+
writeStdout(getQuietLinkValue(response.data));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
writeStdout(response.message);
|
|
517
|
+
writeStdout(formatLinkDetails(response.data));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/commands/delete.ts
|
|
521
|
+
async function deleteCommand(idOrAlias, options) {
|
|
522
|
+
const config = await resolveStoredConfig(process.env);
|
|
523
|
+
const client = new PeakUrlApiClient(config);
|
|
524
|
+
const lookupResponse = await client.getUrl(idOrAlias);
|
|
525
|
+
const resolvedId = getLinkId(lookupResponse.data);
|
|
526
|
+
if (!resolvedId) {
|
|
527
|
+
throw new CliError(
|
|
528
|
+
"PeakURL returned a link record without an ID, so the CLI cannot delete it safely."
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
const response = await client.deleteUrl(resolvedId);
|
|
532
|
+
if (options.json) {
|
|
533
|
+
writeJson(response);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (options.quiet) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
writeStdout(response.message);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/commands/get.ts
|
|
543
|
+
async function getCommand(idOrAlias, options) {
|
|
544
|
+
const config = await resolveStoredConfig(process.env);
|
|
545
|
+
const response = await new PeakUrlApiClient(config).getUrl(idOrAlias);
|
|
546
|
+
if (options.json) {
|
|
547
|
+
writeJson(response);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (options.quiet) {
|
|
551
|
+
writeStdout(getQuietLinkValue(response.data));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
writeStdout(response.message);
|
|
555
|
+
writeStdout(formatLinkDetails(response.data));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/commands/list.ts
|
|
559
|
+
async function listCommand(options) {
|
|
560
|
+
const config = await resolveStoredConfig(process.env);
|
|
561
|
+
const response = await new PeakUrlApiClient(config).listUrls({
|
|
562
|
+
page: options.page,
|
|
563
|
+
limit: options.limit,
|
|
564
|
+
search: options.search,
|
|
565
|
+
sortBy: options.sortBy,
|
|
566
|
+
sortOrder: options.sortOrder
|
|
567
|
+
});
|
|
568
|
+
const links = extractLinks(response.data);
|
|
569
|
+
if (options.json) {
|
|
570
|
+
writeJson(response);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (options.quiet) {
|
|
574
|
+
for (const link of links) {
|
|
575
|
+
const value = getQuietLinkValue(link);
|
|
576
|
+
if (value) {
|
|
577
|
+
writeStdout(value);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
writeStdout(response.message);
|
|
583
|
+
writeStdout(formatLinksTable(links));
|
|
584
|
+
writeStdout(formatListSummary(response.data, links.length));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/lib/users.ts
|
|
588
|
+
function readString2(value) {
|
|
589
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
590
|
+
}
|
|
591
|
+
function getUserLabel(user) {
|
|
592
|
+
const fullName = [readString2(user.firstName), readString2(user.lastName)].filter(Boolean).join(" ");
|
|
593
|
+
return fullName || readString2(user.username) || readString2(user.email) || String(user.id ?? "unknown");
|
|
594
|
+
}
|
|
595
|
+
function getQuietUserValue(user) {
|
|
596
|
+
return readString2(user.username) || readString2(user.email) || String(user.id ?? "");
|
|
597
|
+
}
|
|
598
|
+
function formatUserDetails(user) {
|
|
599
|
+
const lines = [
|
|
600
|
+
["Name", getUserLabel(user)],
|
|
601
|
+
["Username", readString2(user.username)],
|
|
602
|
+
["Email", readString2(user.email)],
|
|
603
|
+
["Role", readString2(user.role)],
|
|
604
|
+
["ID", user.id === void 0 ? void 0 : String(user.id)]
|
|
605
|
+
].filter((entry) => Boolean(entry[1]));
|
|
606
|
+
return lines.map(([label, value]) => `${label}: ${value}`).join("\n");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/commands/login.ts
|
|
610
|
+
async function loginCommand(options) {
|
|
611
|
+
const credentials = resolveLoginConfig(options, process.env);
|
|
612
|
+
const client = new PeakUrlApiClient(credentials);
|
|
613
|
+
const response = await client.whoami();
|
|
614
|
+
await new ConfigStore().save(credentials);
|
|
615
|
+
const payload = {
|
|
616
|
+
success: true,
|
|
617
|
+
message: `Saved credentials for ${credentials.baseUrl}.`,
|
|
618
|
+
data: {
|
|
619
|
+
baseUrl: credentials.baseUrl,
|
|
620
|
+
user: response.data
|
|
621
|
+
},
|
|
622
|
+
timestamp: response.timestamp
|
|
623
|
+
};
|
|
624
|
+
if (options.json) {
|
|
625
|
+
writeJson(payload);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (options.quiet) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
writeStdout(`Saved credentials for ${credentials.baseUrl}`);
|
|
632
|
+
writeStdout(`Authenticated as ${getUserLabel(response.data)}`);
|
|
633
|
+
writeStdout(formatUserDetails(response.data));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/commands/whoami.ts
|
|
637
|
+
async function whoamiCommand(options) {
|
|
638
|
+
const config = await resolveStoredConfig(process.env);
|
|
639
|
+
const response = await new PeakUrlApiClient(config).whoami();
|
|
640
|
+
if (options.json) {
|
|
641
|
+
writeJson(response);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (options.quiet) {
|
|
645
|
+
writeStdout(getQuietUserValue(response.data));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
writeStdout(response.message);
|
|
649
|
+
writeStdout(formatUserDetails(response.data));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/index.ts
|
|
653
|
+
function parsePositiveInteger(label) {
|
|
654
|
+
return (value) => {
|
|
655
|
+
const parsed = Number.parseInt(value, 10);
|
|
656
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
657
|
+
throw new InvalidArgumentError(
|
|
658
|
+
`${label} must be a positive integer.`
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return parsed;
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
async function readVersion() {
|
|
665
|
+
const packageJson = new URL("../package.json", import.meta.url);
|
|
666
|
+
const content = await readFile2(packageJson, "utf8");
|
|
667
|
+
const parsed = JSON.parse(content);
|
|
668
|
+
return parsed.version || "0.0.0";
|
|
669
|
+
}
|
|
670
|
+
async function main() {
|
|
671
|
+
const program = new Command();
|
|
672
|
+
program.name("peakurl").description("PeakURL command-line interface").version(await readVersion()).showHelpAfterError().showSuggestionAfterError().addHelpText(
|
|
673
|
+
"after",
|
|
674
|
+
`
|
|
675
|
+
Examples:
|
|
676
|
+
peakurl login --base-url https://peakurl.org --api-key 0123456789abcdef0123456789abcdef0123456789abcdef
|
|
677
|
+
peakurl whoami --json
|
|
678
|
+
peakurl create https://example.com --alias example
|
|
679
|
+
peakurl list --limit 10
|
|
680
|
+
peakurl get example
|
|
681
|
+
peakurl delete example --quiet`
|
|
682
|
+
).exitOverride();
|
|
683
|
+
program.command("login").description(
|
|
684
|
+
"Save PeakURL credentials after verifying them with GET /users/me."
|
|
685
|
+
).option(
|
|
686
|
+
"--base-url <url>",
|
|
687
|
+
"PeakURL base URL, for example https://peakurl.org"
|
|
688
|
+
).option("--api-key <token>", "PeakURL API key to store").option("--json", "Print machine-readable output").option("--quiet", "Suppress success output").action(loginCommand);
|
|
689
|
+
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);
|
|
690
|
+
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(
|
|
691
|
+
"--status <status>",
|
|
692
|
+
"Link status, for example active or paused"
|
|
693
|
+
).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);
|
|
694
|
+
program.command("list").description("List PeakURL short links.").option("--page <number>", "Page number", parsePositiveInteger("page")).option("--limit <number>", "Page size", parsePositiveInteger("limit")).option("--search <query>", "Search term").option("--sort-by <field>", "Sort field").option("--sort-order <order>", "Sort order, for example asc or desc").option("--json", "Print machine-readable output").option("--quiet", "Print a minimal per-link value").action(listCommand);
|
|
695
|
+
program.command("get").description("Fetch a single PeakURL short link by id or alias.").argument("<id-or-alias>", "Link identifier or alias").option("--json", "Print machine-readable output").option("--quiet", "Print only the short URL").action(getCommand);
|
|
696
|
+
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);
|
|
697
|
+
try {
|
|
698
|
+
await program.parseAsync(process.argv);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof CommanderError) {
|
|
701
|
+
process.exit(error.exitCode);
|
|
702
|
+
}
|
|
703
|
+
const cliError = toCliError(error);
|
|
704
|
+
writeStderr(cliError.message);
|
|
705
|
+
process.exit(cliError.exitCode);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
void main();
|
package/package.json
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peakurl",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "PeakURL
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "PeakURL command-line interface",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"bin": {
|
|
6
|
-
"peakurl": "
|
|
7
|
+
"peakurl": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup src/index.ts --format esm --target node20 --clean",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"format": "prettier --write .",
|
|
19
|
+
"format:check": "prettier --check .",
|
|
20
|
+
"test": "tsx --test test/**/*.test.ts",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"prepare": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^14.0.0",
|
|
26
|
+
"env-paths": "^3.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^24.0.0",
|
|
30
|
+
"prettier": "^3.6.2",
|
|
31
|
+
"tsup": "^8.5.0",
|
|
32
|
+
"tsx": "^4.20.6",
|
|
33
|
+
"typescript": "^5.9.3"
|
|
7
34
|
},
|
|
8
35
|
"license": "MIT"
|
|
9
36
|
}
|
package/index.js
DELETED