@blankdotpage/cli 0.1.1 → 0.1.3
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 +7 -9
- package/dist/index.js +222 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,21 +19,19 @@ By default, CLI commands target:
|
|
|
19
19
|
|
|
20
20
|
- `https://blank.page`
|
|
21
21
|
|
|
22
|
-
Override target endpoint with either:
|
|
23
|
-
|
|
24
|
-
- `--app-url https://your-preview.fly.dev`
|
|
25
|
-
- `BLANKPAGE_APP_URL=https://your-preview.fly.dev`
|
|
26
|
-
|
|
27
22
|
## Commands
|
|
28
23
|
|
|
29
24
|
```bash
|
|
30
25
|
blankpage login [--app-url <url>] [--no-browser]
|
|
31
26
|
blankpage whoami
|
|
27
|
+
blankpage list
|
|
32
28
|
blankpage create <file.md>
|
|
33
|
-
blankpage view <page-id>
|
|
34
|
-
blankpage update <page-id> <file.md>
|
|
35
|
-
blankpage delete <page-id>
|
|
29
|
+
blankpage view <page-id-or-title>
|
|
30
|
+
blankpage update <page-id-or-title> <file.md>
|
|
31
|
+
blankpage delete <page-id-or-title>
|
|
36
32
|
blankpage share <file.md> [--title "..."]
|
|
37
|
-
blankpage share --page-id <page-id> [--title "..."]
|
|
33
|
+
blankpage share --page-id <page-id-or-title> [--title "..."]
|
|
38
34
|
blankpage unshare <link-id-or-url>
|
|
39
35
|
```
|
|
36
|
+
|
|
37
|
+
Commands that accept page ids also accept page titles when the title is unique.
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import fs2 from "node:fs/promises";
|
|
5
5
|
import os2 from "node:os";
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
6
|
import { URL } from "node:url";
|
|
8
7
|
|
|
9
8
|
// src/lib/args.ts
|
|
@@ -41,10 +40,67 @@ function booleanFlag(flags, key) {
|
|
|
41
40
|
return flags[key] === true;
|
|
42
41
|
}
|
|
43
42
|
|
|
43
|
+
// src/lib/browser.ts
|
|
44
|
+
import {
|
|
45
|
+
spawn
|
|
46
|
+
} from "node:child_process";
|
|
47
|
+
function getLaunchCommand(platform, url) {
|
|
48
|
+
if (platform === "darwin") {
|
|
49
|
+
return { command: "open", args: [url] };
|
|
50
|
+
}
|
|
51
|
+
if (platform === "linux") {
|
|
52
|
+
return { command: "xdg-open", args: [url] };
|
|
53
|
+
}
|
|
54
|
+
if (platform === "win32") {
|
|
55
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function toError(error) {
|
|
60
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
61
|
+
}
|
|
62
|
+
function openBrowser(url, options = {}) {
|
|
63
|
+
const platform = options.platform ?? process.platform;
|
|
64
|
+
const launch = getLaunchCommand(platform, url);
|
|
65
|
+
if (!launch) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const spawnCommand = options.spawnCommand ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions));
|
|
69
|
+
const onError = options.onError ?? (() => {
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
const child = spawnCommand(launch.command, launch.args, {
|
|
73
|
+
stdio: "ignore",
|
|
74
|
+
detached: true
|
|
75
|
+
});
|
|
76
|
+
child.once("error", (error) => {
|
|
77
|
+
onError(toError(error));
|
|
78
|
+
});
|
|
79
|
+
child.unref();
|
|
80
|
+
return true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
onError(toError(error));
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
44
87
|
// src/lib/config.ts
|
|
45
88
|
import os from "node:os";
|
|
46
89
|
import path from "node:path";
|
|
47
90
|
import fs from "node:fs/promises";
|
|
91
|
+
function isObject(value) {
|
|
92
|
+
return typeof value === "object" && value !== null;
|
|
93
|
+
}
|
|
94
|
+
function readOptionalString(input, key) {
|
|
95
|
+
const value = input[key];
|
|
96
|
+
if (value === void 0) {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
if (typeof value !== "string") {
|
|
100
|
+
throw new Error(`Invalid CLI config value for "${key}"`);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
48
104
|
function resolveConfigPath() {
|
|
49
105
|
if (process.env.BLANKPAGE_CLI_CONFIG_PATH) {
|
|
50
106
|
return process.env.BLANKPAGE_CLI_CONFIG_PATH;
|
|
@@ -55,15 +111,39 @@ async function loadConfig() {
|
|
|
55
111
|
const configPath = resolveConfigPath();
|
|
56
112
|
try {
|
|
57
113
|
const raw = await fs.readFile(configPath, "utf8");
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Failed to parse CLI config file at ${configPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!isObject(parsed)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Invalid CLI config file at ${configPath}: expected an object`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
appUrl: readOptionalString(parsed, "appUrl"),
|
|
129
|
+
token: readOptionalString(parsed, "token"),
|
|
130
|
+
tokenExpiresAt: readOptionalString(parsed, "tokenExpiresAt")
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code === "ENOENT") {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
61
137
|
}
|
|
62
138
|
}
|
|
63
139
|
async function saveConfig(config) {
|
|
64
140
|
const configPath = resolveConfigPath();
|
|
65
|
-
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
66
|
-
await fs.writeFile(configPath, JSON.stringify(config, null, 2),
|
|
141
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 448 });
|
|
142
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), {
|
|
143
|
+
encoding: "utf8",
|
|
144
|
+
mode: 384
|
|
145
|
+
});
|
|
146
|
+
await fs.chmod(configPath, 384);
|
|
67
147
|
}
|
|
68
148
|
|
|
69
149
|
// src/lib/http.ts
|
|
@@ -212,23 +292,6 @@ function requireToken(token) {
|
|
|
212
292
|
function sleep(ms) {
|
|
213
293
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
214
294
|
}
|
|
215
|
-
function maybeOpenBrowser(url) {
|
|
216
|
-
const platform = process.platform;
|
|
217
|
-
if (platform === "darwin") {
|
|
218
|
-
spawn("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
if (platform === "linux") {
|
|
222
|
-
spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
if (platform === "win32") {
|
|
226
|
-
spawn("cmd", ["/c", "start", "", url], {
|
|
227
|
-
stdio: "ignore",
|
|
228
|
-
detached: true
|
|
229
|
-
}).unref();
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
295
|
function output(json, payload, text) {
|
|
233
296
|
if (json) {
|
|
234
297
|
printJson(payload);
|
|
@@ -243,12 +306,95 @@ function output(json, payload, text) {
|
|
|
243
306
|
async function readMarkdownFile(filePath) {
|
|
244
307
|
return fs2.readFile(filePath, "utf8");
|
|
245
308
|
}
|
|
246
|
-
function
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
309
|
+
function inferTitleFromMarkdown(content) {
|
|
310
|
+
const lines = content.split(/\r?\n/);
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
const trimmed = line.trim();
|
|
313
|
+
if (!trimmed) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const headingMatch = trimmed.match(/^#{1,6}\s+(.+)$/);
|
|
317
|
+
if (headingMatch) {
|
|
318
|
+
const heading = headingMatch[1]?.trim();
|
|
319
|
+
if (heading) {
|
|
320
|
+
return heading;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return trimmed;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
function getPageTitle(page) {
|
|
328
|
+
const explicit = page.title?.trim();
|
|
329
|
+
if (explicit) {
|
|
330
|
+
return explicit;
|
|
331
|
+
}
|
|
332
|
+
const inferred = inferTitleFromMarkdown(page.content);
|
|
333
|
+
if (inferred) {
|
|
334
|
+
return inferred;
|
|
335
|
+
}
|
|
336
|
+
return "(untitled)";
|
|
337
|
+
}
|
|
338
|
+
function normalizeSelector(value) {
|
|
339
|
+
return value.trim().toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
function findPageBySelectorOrThrow(state, selector) {
|
|
342
|
+
const exactIdMatch = state.pages.find((entry) => entry.id === selector);
|
|
343
|
+
if (exactIdMatch) {
|
|
344
|
+
return exactIdMatch;
|
|
345
|
+
}
|
|
346
|
+
const normalizedSelector = normalizeSelector(selector);
|
|
347
|
+
const titleMatches = state.pages.filter(
|
|
348
|
+
(entry) => normalizeSelector(getPageTitle(entry)) === normalizedSelector
|
|
349
|
+
);
|
|
350
|
+
if (titleMatches.length === 1) {
|
|
351
|
+
return titleMatches[0];
|
|
352
|
+
}
|
|
353
|
+
if (titleMatches.length > 1) {
|
|
354
|
+
const matchesPreview = titleMatches.slice(0, 5).map((entry) => `${entry.id} (${getPageTitle(entry)})`).join(", ");
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Multiple pages match "${selector}". Use page id instead: ${matchesPreview}`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
throw new Error(`Page not found: ${selector}`);
|
|
360
|
+
}
|
|
361
|
+
function toListedPages(state) {
|
|
362
|
+
return [...state.pages].map((page) => ({
|
|
363
|
+
id: page.id,
|
|
364
|
+
title: getPageTitle(page),
|
|
365
|
+
lastUpdatedAt: page.lastUpdatedAt
|
|
366
|
+
})).sort((left, right) => right.lastUpdatedAt - left.lastUpdatedAt);
|
|
367
|
+
}
|
|
368
|
+
function truncateForTable(value, maxLength) {
|
|
369
|
+
if (value.length <= maxLength) {
|
|
370
|
+
return value;
|
|
371
|
+
}
|
|
372
|
+
if (maxLength <= 3) {
|
|
373
|
+
return value.slice(0, maxLength);
|
|
374
|
+
}
|
|
375
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
376
|
+
}
|
|
377
|
+
function formatPagesTable(pages) {
|
|
378
|
+
if (pages.length === 0) {
|
|
379
|
+
return "No pages found.";
|
|
380
|
+
}
|
|
381
|
+
const idWidth = Math.max(
|
|
382
|
+
"PAGE ID".length,
|
|
383
|
+
...pages.map((page) => page.id.length)
|
|
384
|
+
);
|
|
385
|
+
const titleWidth = Math.min(
|
|
386
|
+
48,
|
|
387
|
+
Math.max("TITLE".length, ...pages.map((page) => page.title.length))
|
|
388
|
+
);
|
|
389
|
+
const lines = [
|
|
390
|
+
`${"PAGE ID".padEnd(idWidth)} ${"TITLE".padEnd(titleWidth)} UPDATED`
|
|
391
|
+
];
|
|
392
|
+
for (const page of pages) {
|
|
393
|
+
lines.push(
|
|
394
|
+
`${page.id.padEnd(idWidth)} ${truncateForTable(page.title, titleWidth).padEnd(titleWidth)} ${new Date(page.lastUpdatedAt).toISOString()}`
|
|
395
|
+
);
|
|
250
396
|
}
|
|
251
|
-
return
|
|
397
|
+
return lines.join("\n");
|
|
252
398
|
}
|
|
253
399
|
async function commandLogin(ctx) {
|
|
254
400
|
const noBrowser = booleanFlag(ctx.flags, "no-browser");
|
|
@@ -265,13 +411,16 @@ async function commandLogin(ctx) {
|
|
|
265
411
|
{
|
|
266
412
|
event: "login_started",
|
|
267
413
|
authorizeUrl: started.authorizeUrl,
|
|
268
|
-
challengeId: started.challengeId
|
|
269
|
-
challengeSecret: started.challengeSecret
|
|
414
|
+
challengeId: started.challengeId
|
|
270
415
|
},
|
|
271
416
|
`Open this URL to authorize: ${started.authorizeUrl}`
|
|
272
417
|
);
|
|
273
418
|
if (!noBrowser) {
|
|
274
|
-
|
|
419
|
+
openBrowser(started.authorizeUrl, {
|
|
420
|
+
onError: (error) => {
|
|
421
|
+
printError(`Could not open browser automatically: ${error.message}`);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
275
424
|
}
|
|
276
425
|
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
277
426
|
while (Date.now() < deadline) {
|
|
@@ -337,6 +486,17 @@ async function commandWhoAmI(ctx) {
|
|
|
337
486
|
`${whoami.email}${whoami.handle ? ` (@${whoami.handle})` : ""}`
|
|
338
487
|
);
|
|
339
488
|
}
|
|
489
|
+
async function commandList(ctx) {
|
|
490
|
+
if (ctx.positionals.length > 0) {
|
|
491
|
+
throw new Error("Usage: blankpage list");
|
|
492
|
+
}
|
|
493
|
+
const config = await loadConfig();
|
|
494
|
+
const token = requireToken(config.token);
|
|
495
|
+
const client = new ApiClient({ appUrl: ctx.appUrl, token });
|
|
496
|
+
const state = await fetchRemoteState(client);
|
|
497
|
+
const pages = toListedPages(state);
|
|
498
|
+
output(ctx.json, { pages }, formatPagesTable(pages));
|
|
499
|
+
}
|
|
340
500
|
async function commandCreate(ctx) {
|
|
341
501
|
const filePath = ctx.positionals[0];
|
|
342
502
|
if (!filePath) {
|
|
@@ -365,19 +525,19 @@ async function commandCreate(ctx) {
|
|
|
365
525
|
);
|
|
366
526
|
}
|
|
367
527
|
async function commandView(ctx) {
|
|
368
|
-
const
|
|
369
|
-
if (!
|
|
370
|
-
throw new Error("Usage: blankpage view <page-id>");
|
|
528
|
+
const pageSelector = ctx.positionals[0];
|
|
529
|
+
if (!pageSelector) {
|
|
530
|
+
throw new Error("Usage: blankpage view <page-id-or-title>");
|
|
371
531
|
}
|
|
372
532
|
const config = await loadConfig();
|
|
373
533
|
const token = requireToken(config.token);
|
|
374
534
|
const client = new ApiClient({ appUrl: ctx.appUrl, token });
|
|
375
535
|
const state = await fetchRemoteState(client);
|
|
376
|
-
const page =
|
|
536
|
+
const page = findPageBySelectorOrThrow(state, pageSelector);
|
|
377
537
|
if (ctx.json) {
|
|
378
538
|
output(ctx.json, {
|
|
379
539
|
pageId: page.id,
|
|
380
|
-
title: page
|
|
540
|
+
title: getPageTitle(page),
|
|
381
541
|
content: page.content
|
|
382
542
|
});
|
|
383
543
|
return;
|
|
@@ -385,17 +545,19 @@ async function commandView(ctx) {
|
|
|
385
545
|
printText(page.content);
|
|
386
546
|
}
|
|
387
547
|
async function commandUpdate(ctx) {
|
|
388
|
-
const
|
|
548
|
+
const pageSelector = ctx.positionals[0];
|
|
389
549
|
const filePath = ctx.positionals[1];
|
|
390
|
-
if (!
|
|
391
|
-
throw new Error(
|
|
550
|
+
if (!pageSelector || !filePath) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
"Usage: blankpage update <page-id-or-title> <markdown-file>"
|
|
553
|
+
);
|
|
392
554
|
}
|
|
393
555
|
const config = await loadConfig();
|
|
394
556
|
const token = requireToken(config.token);
|
|
395
557
|
const client = new ApiClient({ appUrl: ctx.appUrl, token });
|
|
396
558
|
const content = await readMarkdownFile(filePath);
|
|
397
559
|
const state = await fetchRemoteState(client);
|
|
398
|
-
const page =
|
|
560
|
+
const page = findPageBySelectorOrThrow(state, pageSelector);
|
|
399
561
|
const now = Date.now();
|
|
400
562
|
page.content = content;
|
|
401
563
|
page.lastUpdatedAt = now;
|
|
@@ -408,29 +570,30 @@ async function commandUpdate(ctx) {
|
|
|
408
570
|
);
|
|
409
571
|
}
|
|
410
572
|
async function commandDelete(ctx) {
|
|
411
|
-
const
|
|
412
|
-
if (!
|
|
413
|
-
throw new Error("Usage: blankpage delete <page-id>");
|
|
573
|
+
const pageSelector = ctx.positionals[0];
|
|
574
|
+
if (!pageSelector) {
|
|
575
|
+
throw new Error("Usage: blankpage delete <page-id-or-title>");
|
|
414
576
|
}
|
|
415
577
|
const config = await loadConfig();
|
|
416
578
|
const token = requireToken(config.token);
|
|
417
579
|
const client = new ApiClient({ appUrl: ctx.appUrl, token });
|
|
418
580
|
const state = await fetchRemoteState(client);
|
|
581
|
+
const page = findPageBySelectorOrThrow(state, pageSelector);
|
|
419
582
|
const before = state.pages.length;
|
|
420
|
-
state.pages = state.pages.filter((
|
|
583
|
+
state.pages = state.pages.filter((entry) => entry.id !== page.id);
|
|
421
584
|
if (state.pages.length === before) {
|
|
422
|
-
throw new Error(`Page not found: ${
|
|
585
|
+
throw new Error(`Page not found: ${pageSelector}`);
|
|
423
586
|
}
|
|
424
587
|
state.lastUpdatedAt = Date.now();
|
|
425
588
|
await pushRemoteState(client, state);
|
|
426
589
|
output(
|
|
427
590
|
ctx.json,
|
|
428
|
-
{ event: "delete_success", pageId },
|
|
429
|
-
`Deleted page: ${
|
|
591
|
+
{ event: "delete_success", pageId: page.id },
|
|
592
|
+
`Deleted page: ${page.id}`
|
|
430
593
|
);
|
|
431
594
|
}
|
|
432
595
|
async function commandShare(ctx) {
|
|
433
|
-
const
|
|
596
|
+
const pageSelectorFlag = stringFlag(ctx.flags, "page-id");
|
|
434
597
|
const title = stringFlag(ctx.flags, "title");
|
|
435
598
|
const config = await loadConfig();
|
|
436
599
|
const token = requireToken(config.token);
|
|
@@ -438,20 +601,20 @@ async function commandShare(ctx) {
|
|
|
438
601
|
const state = await fetchRemoteState(client);
|
|
439
602
|
let pageId;
|
|
440
603
|
let content;
|
|
441
|
-
if (
|
|
604
|
+
if (pageSelectorFlag) {
|
|
442
605
|
if (ctx.positionals.length > 0) {
|
|
443
606
|
throw new Error(
|
|
444
607
|
"When --page-id is used, do not pass a markdown file path."
|
|
445
608
|
);
|
|
446
609
|
}
|
|
447
|
-
const page =
|
|
610
|
+
const page = findPageBySelectorOrThrow(state, pageSelectorFlag);
|
|
448
611
|
pageId = page.id;
|
|
449
612
|
content = page.content;
|
|
450
613
|
} else {
|
|
451
614
|
const filePath = ctx.positionals[0];
|
|
452
615
|
if (!filePath) {
|
|
453
616
|
throw new Error(
|
|
454
|
-
"Usage: blankpage share <markdown-file> [--title ...] or blankpage share --page-id <id>"
|
|
617
|
+
"Usage: blankpage share <markdown-file> [--title ...] or blankpage share --page-id <page-id-or-title>"
|
|
455
618
|
);
|
|
456
619
|
}
|
|
457
620
|
content = await readMarkdownFile(filePath);
|
|
@@ -537,12 +700,13 @@ function printHelp() {
|
|
|
537
700
|
Commands:
|
|
538
701
|
blankpage login [--app-url <url>] [--no-browser] [--json]
|
|
539
702
|
blankpage whoami [--app-url <url>] [--json]
|
|
703
|
+
blankpage list [--app-url <url>] [--json]
|
|
540
704
|
blankpage create <markdown-file> [--app-url <url>] [--json]
|
|
541
|
-
blankpage update <page-id> <markdown-file> [--app-url <url>] [--json]
|
|
542
|
-
blankpage view <page-id> [--app-url <url>] [--json]
|
|
543
|
-
blankpage delete <page-id> [--app-url <url>] [--json]
|
|
705
|
+
blankpage update <page-id-or-title> <markdown-file> [--app-url <url>] [--json]
|
|
706
|
+
blankpage view <page-id-or-title> [--app-url <url>] [--json]
|
|
707
|
+
blankpage delete <page-id-or-title> [--app-url <url>] [--json]
|
|
544
708
|
blankpage share <markdown-file> [--title "..."] [--app-url <url>] [--json]
|
|
545
|
-
blankpage share --page-id <page-id> [--title "..."] [--app-url <url>] [--json]
|
|
709
|
+
blankpage share --page-id <page-id-or-title> [--title "..."] [--app-url <url>] [--json]
|
|
546
710
|
blankpage unshare <link-id-or-url> [--app-url <url>] [--json]
|
|
547
711
|
`);
|
|
548
712
|
}
|
|
@@ -567,6 +731,9 @@ async function main() {
|
|
|
567
731
|
case "whoami":
|
|
568
732
|
await commandWhoAmI(ctx);
|
|
569
733
|
return;
|
|
734
|
+
case "list":
|
|
735
|
+
await commandList(ctx);
|
|
736
|
+
return;
|
|
570
737
|
case "create":
|
|
571
738
|
await commandCreate(ctx);
|
|
572
739
|
return;
|