@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.
Files changed (3) hide show
  1. package/README.md +7 -9
  2. package/dist/index.js +222 -55
  3. 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
- return JSON.parse(raw);
59
- } catch {
60
- return {};
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), "utf8");
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 findPageOrThrow(state, pageId) {
247
- const page = state.pages.find((entry) => entry.id === pageId);
248
- if (!page) {
249
- throw new Error(`Page not found: ${pageId}`);
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 page;
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
- maybeOpenBrowser(started.authorizeUrl);
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 pageId = ctx.positionals[0];
369
- if (!pageId) {
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 = findPageOrThrow(state, pageId);
536
+ const page = findPageBySelectorOrThrow(state, pageSelector);
377
537
  if (ctx.json) {
378
538
  output(ctx.json, {
379
539
  pageId: page.id,
380
- title: page.title,
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 pageId = ctx.positionals[0];
548
+ const pageSelector = ctx.positionals[0];
389
549
  const filePath = ctx.positionals[1];
390
- if (!pageId || !filePath) {
391
- throw new Error("Usage: blankpage update <page-id> <markdown-file>");
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 = findPageOrThrow(state, pageId);
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 pageId = ctx.positionals[0];
412
- if (!pageId) {
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((page) => page.id !== pageId);
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: ${pageId}`);
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: ${pageId}`
591
+ { event: "delete_success", pageId: page.id },
592
+ `Deleted page: ${page.id}`
430
593
  );
431
594
  }
432
595
  async function commandShare(ctx) {
433
- const pageIdFlag = stringFlag(ctx.flags, "page-id");
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 (pageIdFlag) {
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 = findPageOrThrow(state, pageIdFlag);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blankdotpage/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "CLI for Blank Page",
5
5
  "type": "module",
6
6
  "files": [