@blankdotpage/cli 0.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 +40 -0
  2. package/dist/index.js +602 -0
  3. package/package.json +28 -0
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @blankdotpage/cli
2
+
3
+ Simple command-line interface for Blank Page.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g @blankdotpage/cli
9
+ ```
10
+
11
+ This installs both command aliases:
12
+
13
+ - `blankpage`
14
+ - `bp`
15
+
16
+ ## Default endpoint
17
+
18
+ By default, CLI commands target:
19
+
20
+ - `https://blank.page`
21
+
22
+ Override target endpoint with either:
23
+
24
+ - `--app-url https://your-preview.fly.dev`
25
+ - `BLANKPAGE_CLI_APP_URL=https://your-preview.fly.dev`
26
+ - `BLANKPAGE_APP_URL=https://your-preview.fly.dev`
27
+
28
+ ## Commands
29
+
30
+ ```bash
31
+ blankpage login [--app-url <url>] [--no-browser]
32
+ blankpage whoami
33
+ blankpage create <file.md>
34
+ blankpage view <page-id>
35
+ blankpage update <page-id> <file.md>
36
+ blankpage delete <page-id>
37
+ blankpage share <file.md> [--title "..."]
38
+ blankpage share --page-id <page-id> [--title "..."]
39
+ blankpage unshare <link-id-or-url>
40
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import fs2 from "node:fs/promises";
5
+ import os2 from "node:os";
6
+ import { spawn } from "node:child_process";
7
+ import { URL } from "node:url";
8
+
9
+ // src/lib/args.ts
10
+ function parseArgs(argv) {
11
+ const booleanFlags = /* @__PURE__ */ new Set(["json", "no-browser", "help", "h"]);
12
+ let command = null;
13
+ const positionals = [];
14
+ const flags = {};
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const token = argv[i];
17
+ if (token.startsWith("--")) {
18
+ const key = token.slice(2);
19
+ const next = argv[i + 1];
20
+ if (!booleanFlags.has(key) && next && !next.startsWith("--")) {
21
+ flags[key] = next;
22
+ i++;
23
+ } else {
24
+ flags[key] = true;
25
+ }
26
+ continue;
27
+ }
28
+ if (!command) {
29
+ command = token;
30
+ continue;
31
+ }
32
+ positionals.push(token);
33
+ }
34
+ return { command, positionals, flags };
35
+ }
36
+ function stringFlag(flags, key) {
37
+ const value = flags[key];
38
+ return typeof value === "string" ? value : void 0;
39
+ }
40
+ function booleanFlag(flags, key) {
41
+ return flags[key] === true;
42
+ }
43
+
44
+ // src/lib/config.ts
45
+ import os from "node:os";
46
+ import path from "node:path";
47
+ import fs from "node:fs/promises";
48
+ function resolveConfigPath() {
49
+ if (process.env.BLANKPAGE_CLI_CONFIG_PATH) {
50
+ return process.env.BLANKPAGE_CLI_CONFIG_PATH;
51
+ }
52
+ return path.join(os.homedir(), ".blankpage", "cli.json");
53
+ }
54
+ async function loadConfig() {
55
+ const configPath = resolveConfigPath();
56
+ try {
57
+ const raw = await fs.readFile(configPath, "utf8");
58
+ return JSON.parse(raw);
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+ async function saveConfig(config) {
64
+ const configPath = resolveConfigPath();
65
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
66
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
67
+ }
68
+
69
+ // src/lib/http.ts
70
+ var ApiError = class extends Error {
71
+ status;
72
+ body;
73
+ constructor({
74
+ status,
75
+ body,
76
+ message
77
+ }) {
78
+ super(message);
79
+ this.status = status;
80
+ this.body = body;
81
+ }
82
+ };
83
+ function buildUrl(appUrl, endpoint) {
84
+ if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
85
+ return endpoint;
86
+ }
87
+ const base = appUrl.endsWith("/") ? appUrl.slice(0, -1) : appUrl;
88
+ const path2 = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
89
+ return `${base}${path2}`;
90
+ }
91
+ async function parseBody(response) {
92
+ const contentType = response.headers.get("content-type") ?? "";
93
+ if (contentType.includes("application/json")) {
94
+ return response.json();
95
+ }
96
+ return response.text();
97
+ }
98
+ var ApiClient = class {
99
+ appUrl;
100
+ token;
101
+ constructor({ appUrl, token }) {
102
+ this.appUrl = appUrl;
103
+ this.token = token;
104
+ }
105
+ async request({
106
+ method,
107
+ endpoint,
108
+ body
109
+ }) {
110
+ const response = await fetch(buildUrl(this.appUrl, endpoint), {
111
+ method,
112
+ headers: {
113
+ ...this.token ? { Authorization: `Bearer ${this.token}` } : {},
114
+ ...body ? { "Content-Type": "application/json" } : {}
115
+ },
116
+ body: body ? JSON.stringify(body) : void 0
117
+ });
118
+ const parsed = await parseBody(response);
119
+ if (!response.ok) {
120
+ const fallback = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `Request failed with status ${response.status}`;
121
+ throw new ApiError({
122
+ status: response.status,
123
+ body: parsed,
124
+ message: fallback
125
+ });
126
+ }
127
+ return parsed;
128
+ }
129
+ get(endpoint) {
130
+ return this.request({ method: "GET", endpoint });
131
+ }
132
+ post(endpoint, body) {
133
+ return this.request({ method: "POST", endpoint, body });
134
+ }
135
+ delete(endpoint) {
136
+ return this.request({ method: "DELETE", endpoint });
137
+ }
138
+ };
139
+
140
+ // src/lib/output.ts
141
+ function printJson(payload) {
142
+ process.stdout.write(`${JSON.stringify(payload)}
143
+ `);
144
+ }
145
+ function printText(message) {
146
+ process.stdout.write(`${message}
147
+ `);
148
+ }
149
+ function printError(message) {
150
+ process.stderr.write(`${message}
151
+ `);
152
+ }
153
+
154
+ // src/lib/state.ts
155
+ import { randomBytes } from "node:crypto";
156
+ async function fetchRemoteState(client) {
157
+ const response = await client.get("/api/sync?lastUpdatedAt=0");
158
+ if ("status" in response) {
159
+ if (response.status !== "success") {
160
+ throw new Error(`Unexpected sync response status: ${response.status}`);
161
+ }
162
+ if (response.state === null) {
163
+ return {
164
+ version: 2,
165
+ lastUpdatedAt: 0,
166
+ pages: []
167
+ };
168
+ }
169
+ return response.state;
170
+ }
171
+ if (response.state === null) {
172
+ return {
173
+ version: 2,
174
+ lastUpdatedAt: 0,
175
+ pages: []
176
+ };
177
+ }
178
+ return response.state;
179
+ }
180
+ async function pushRemoteState(client, state) {
181
+ const response = await client.post("/api/sync", { state });
182
+ if (response.status !== "success") {
183
+ throw new Error("User mismatch while syncing state");
184
+ }
185
+ }
186
+ function generatePageId() {
187
+ const alphabet = "0123456789abc";
188
+ const bytes = randomBytes(10);
189
+ let id = "";
190
+ for (let i = 0; i < bytes.length; i++) {
191
+ id += alphabet[bytes[i] % alphabet.length];
192
+ }
193
+ return id;
194
+ }
195
+
196
+ // src/index.ts
197
+ function getDefaultAppUrl() {
198
+ return process.env.BLANKPAGE_CLI_APP_URL || process.env.BLANKPAGE_APP_URL || "https://blank.page";
199
+ }
200
+ function resolveAppUrl({
201
+ appUrlFlag,
202
+ configAppUrl
203
+ }) {
204
+ return appUrlFlag || configAppUrl || getDefaultAppUrl();
205
+ }
206
+ function requireToken(token) {
207
+ if (!token) {
208
+ throw new Error("Not logged in. Run `blankpage login` first.");
209
+ }
210
+ return token;
211
+ }
212
+ function sleep(ms) {
213
+ return new Promise((resolve) => setTimeout(resolve, ms));
214
+ }
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
+ function output(json, payload, text) {
233
+ if (json) {
234
+ printJson(payload);
235
+ return;
236
+ }
237
+ if (text) {
238
+ printText(text);
239
+ return;
240
+ }
241
+ printText(String(payload));
242
+ }
243
+ async function readMarkdownFile(filePath) {
244
+ return fs2.readFile(filePath, "utf8");
245
+ }
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}`);
250
+ }
251
+ return page;
252
+ }
253
+ async function commandLogin(ctx) {
254
+ const noBrowser = booleanFlag(ctx.flags, "no-browser");
255
+ const timeoutSeconds = Number(
256
+ stringFlag(ctx.flags, "timeout-seconds") ?? "300"
257
+ );
258
+ const clientName = stringFlag(ctx.flags, "client-name") ?? `blankpage-cli@${os2.hostname()}`;
259
+ const client = new ApiClient({ appUrl: ctx.appUrl });
260
+ const started = await client.post("/api/cli/auth/start", {
261
+ clientName
262
+ });
263
+ output(
264
+ ctx.json,
265
+ {
266
+ event: "login_started",
267
+ authorizeUrl: started.authorizeUrl,
268
+ challengeId: started.challengeId,
269
+ challengeSecret: started.challengeSecret
270
+ },
271
+ `Open this URL to authorize: ${started.authorizeUrl}`
272
+ );
273
+ if (!noBrowser) {
274
+ maybeOpenBrowser(started.authorizeUrl);
275
+ }
276
+ const deadline = Date.now() + timeoutSeconds * 1e3;
277
+ while (Date.now() < deadline) {
278
+ try {
279
+ const poll = await client.post(`/api/cli/auth/poll/${started.challengeId}`, {
280
+ challengeSecret: started.challengeSecret,
281
+ tokenName: clientName
282
+ });
283
+ if (poll.status === "pending") {
284
+ await sleep(started.pollIntervalMs);
285
+ continue;
286
+ }
287
+ await saveConfig({
288
+ appUrl: ctx.appUrl,
289
+ token: poll.token,
290
+ tokenExpiresAt: poll.expiresAt
291
+ });
292
+ output(
293
+ ctx.json,
294
+ {
295
+ event: "login_success",
296
+ tokenExpiresAt: poll.expiresAt
297
+ },
298
+ "Login successful. CLI token saved."
299
+ );
300
+ return;
301
+ } catch (error) {
302
+ if (error instanceof ApiError) {
303
+ const status = error.body && typeof error.body === "object" && "status" in error.body && typeof error.body.status === "string" ? error.body.status : void 0;
304
+ if (status === "pending") {
305
+ await sleep(started.pollIntervalMs);
306
+ continue;
307
+ }
308
+ if (status === "consumed") {
309
+ throw new Error(
310
+ "This challenge was already consumed. Run `blankpage login` again."
311
+ );
312
+ }
313
+ if (status === "expired") {
314
+ throw new Error(
315
+ "Login challenge expired. Run `blankpage login` again."
316
+ );
317
+ }
318
+ if (status === "invalid") {
319
+ throw new Error(
320
+ "Invalid login challenge. Run `blankpage login` again."
321
+ );
322
+ }
323
+ }
324
+ throw error;
325
+ }
326
+ }
327
+ throw new Error("Login timed out before authorization was completed.");
328
+ }
329
+ async function commandWhoAmI(ctx) {
330
+ const config = await loadConfig();
331
+ const token = requireToken(config.token);
332
+ const client = new ApiClient({ appUrl: ctx.appUrl, token });
333
+ const whoami = await client.get("/api/cli/whoami");
334
+ output(
335
+ ctx.json,
336
+ whoami,
337
+ `${whoami.email}${whoami.handle ? ` (@${whoami.handle})` : ""}`
338
+ );
339
+ }
340
+ async function commandCreate(ctx) {
341
+ const filePath = ctx.positionals[0];
342
+ if (!filePath) {
343
+ throw new Error("Usage: blankpage create <markdown-file>");
344
+ }
345
+ const config = await loadConfig();
346
+ const token = requireToken(config.token);
347
+ const client = new ApiClient({ appUrl: ctx.appUrl, token });
348
+ const content = await readMarkdownFile(filePath);
349
+ const state = await fetchRemoteState(client);
350
+ const now = Date.now();
351
+ const pageId = generatePageId();
352
+ state.pages.push({
353
+ id: pageId,
354
+ title: null,
355
+ content,
356
+ lastUpdatedAt: now
357
+ });
358
+ state.lastUpdatedAt = now;
359
+ state.version = state.version || 2;
360
+ await pushRemoteState(client, state);
361
+ output(
362
+ ctx.json,
363
+ { event: "create_success", pageId },
364
+ `Created page: ${pageId}`
365
+ );
366
+ }
367
+ async function commandView(ctx) {
368
+ const pageId = ctx.positionals[0];
369
+ if (!pageId) {
370
+ throw new Error("Usage: blankpage view <page-id>");
371
+ }
372
+ const config = await loadConfig();
373
+ const token = requireToken(config.token);
374
+ const client = new ApiClient({ appUrl: ctx.appUrl, token });
375
+ const state = await fetchRemoteState(client);
376
+ const page = findPageOrThrow(state, pageId);
377
+ if (ctx.json) {
378
+ output(ctx.json, {
379
+ pageId: page.id,
380
+ title: page.title,
381
+ content: page.content
382
+ });
383
+ return;
384
+ }
385
+ printText(page.content);
386
+ }
387
+ async function commandUpdate(ctx) {
388
+ const pageId = ctx.positionals[0];
389
+ const filePath = ctx.positionals[1];
390
+ if (!pageId || !filePath) {
391
+ throw new Error("Usage: blankpage update <page-id> <markdown-file>");
392
+ }
393
+ const config = await loadConfig();
394
+ const token = requireToken(config.token);
395
+ const client = new ApiClient({ appUrl: ctx.appUrl, token });
396
+ const content = await readMarkdownFile(filePath);
397
+ const state = await fetchRemoteState(client);
398
+ const page = findPageOrThrow(state, pageId);
399
+ const now = Date.now();
400
+ page.content = content;
401
+ page.lastUpdatedAt = now;
402
+ state.lastUpdatedAt = now;
403
+ await pushRemoteState(client, state);
404
+ output(
405
+ ctx.json,
406
+ { event: "update_success", pageId: page.id },
407
+ `Updated page: ${page.id}`
408
+ );
409
+ }
410
+ async function commandDelete(ctx) {
411
+ const pageId = ctx.positionals[0];
412
+ if (!pageId) {
413
+ throw new Error("Usage: blankpage delete <page-id>");
414
+ }
415
+ const config = await loadConfig();
416
+ const token = requireToken(config.token);
417
+ const client = new ApiClient({ appUrl: ctx.appUrl, token });
418
+ const state = await fetchRemoteState(client);
419
+ const before = state.pages.length;
420
+ state.pages = state.pages.filter((page) => page.id !== pageId);
421
+ if (state.pages.length === before) {
422
+ throw new Error(`Page not found: ${pageId}`);
423
+ }
424
+ state.lastUpdatedAt = Date.now();
425
+ await pushRemoteState(client, state);
426
+ output(
427
+ ctx.json,
428
+ { event: "delete_success", pageId },
429
+ `Deleted page: ${pageId}`
430
+ );
431
+ }
432
+ async function commandShare(ctx) {
433
+ const pageIdFlag = stringFlag(ctx.flags, "page-id");
434
+ const title = stringFlag(ctx.flags, "title");
435
+ const config = await loadConfig();
436
+ const token = requireToken(config.token);
437
+ const client = new ApiClient({ appUrl: ctx.appUrl, token });
438
+ const state = await fetchRemoteState(client);
439
+ let pageId;
440
+ let content;
441
+ if (pageIdFlag) {
442
+ if (ctx.positionals.length > 0) {
443
+ throw new Error(
444
+ "When --page-id is used, do not pass a markdown file path."
445
+ );
446
+ }
447
+ const page = findPageOrThrow(state, pageIdFlag);
448
+ pageId = page.id;
449
+ content = page.content;
450
+ } else {
451
+ const filePath = ctx.positionals[0];
452
+ if (!filePath) {
453
+ throw new Error(
454
+ "Usage: blankpage share <markdown-file> [--title ...] or blankpage share --page-id <id>"
455
+ );
456
+ }
457
+ content = await readMarkdownFile(filePath);
458
+ pageId = generatePageId();
459
+ const now = Date.now();
460
+ state.pages.push({
461
+ id: pageId,
462
+ title: null,
463
+ content,
464
+ lastUpdatedAt: now
465
+ });
466
+ state.lastUpdatedAt = now;
467
+ await pushRemoteState(client, state);
468
+ }
469
+ const link = await client.post("/api/link", {
470
+ content,
471
+ title,
472
+ pageId,
473
+ linkId: null
474
+ });
475
+ const whoami = await client.get("/api/cli/whoami");
476
+ const url = whoami.handle && link.slug ? `${ctx.appUrl}/@${whoami.handle}/${link.slug}` : `${ctx.appUrl}/@${link.linkId}`;
477
+ output(
478
+ ctx.json,
479
+ {
480
+ event: "share_success",
481
+ pageId: link.pageId,
482
+ linkId: link.linkId,
483
+ url
484
+ },
485
+ `Shared! Here's your link: ${url}
486
+ Use \`blankpage unshare <id-or-url>\` to delete it.`
487
+ );
488
+ }
489
+ function parseShareInput(input) {
490
+ try {
491
+ const parsed = new URL(input);
492
+ const segments = parsed.pathname.split("/").filter(Boolean);
493
+ if (segments.length === 1 && segments[0].startsWith("@")) {
494
+ return { linkId: segments[0].slice(1) };
495
+ }
496
+ if (segments.length === 2 && segments[0].startsWith("@")) {
497
+ return { slug: segments[1] };
498
+ }
499
+ } catch {
500
+ }
501
+ if (input.startsWith("@")) {
502
+ return { linkId: input.slice(1) };
503
+ }
504
+ return { linkId: input };
505
+ }
506
+ async function commandUnshare(ctx) {
507
+ const input = ctx.positionals[0];
508
+ if (!input) {
509
+ throw new Error("Usage: blankpage unshare <link-id-or-url>");
510
+ }
511
+ const config = await loadConfig();
512
+ const token = requireToken(config.token);
513
+ const client = new ApiClient({ appUrl: ctx.appUrl, token });
514
+ const parsed = parseShareInput(input);
515
+ let linkId = parsed.linkId;
516
+ if (!linkId && parsed.slug) {
517
+ const links = await client.get("/api/shared-links");
518
+ const found = links.sharedLinks.find((link) => link.slug === parsed.slug);
519
+ if (!found) {
520
+ throw new Error(`Shared link not found for slug: ${parsed.slug}`);
521
+ }
522
+ linkId = found.id;
523
+ }
524
+ if (!linkId) {
525
+ throw new Error("Unable to resolve shared link id.");
526
+ }
527
+ await client.delete(`/api/link/${linkId}`);
528
+ output(
529
+ ctx.json,
530
+ { event: "unshare_success", linkId },
531
+ `Unshared link: ${linkId}`
532
+ );
533
+ }
534
+ function printHelp() {
535
+ printText(`blankpage CLI
536
+
537
+ Commands:
538
+ blankpage login [--app-url <url>] [--no-browser] [--json]
539
+ blankpage whoami [--app-url <url>] [--json]
540
+ 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]
544
+ blankpage share <markdown-file> [--title "..."] [--app-url <url>] [--json]
545
+ blankpage share --page-id <page-id> [--title "..."] [--app-url <url>] [--json]
546
+ blankpage unshare <link-id-or-url> [--app-url <url>] [--json]
547
+ `);
548
+ }
549
+ async function main() {
550
+ const args = parseArgs(process.argv.slice(2));
551
+ const config = await loadConfig();
552
+ const appUrl = resolveAppUrl({
553
+ appUrlFlag: stringFlag(args.flags, "app-url"),
554
+ configAppUrl: config.appUrl
555
+ });
556
+ const json = booleanFlag(args.flags, "json");
557
+ const ctx = {
558
+ appUrl,
559
+ json,
560
+ flags: args.flags,
561
+ positionals: args.positionals
562
+ };
563
+ switch (args.command) {
564
+ case "login":
565
+ await commandLogin(ctx);
566
+ return;
567
+ case "whoami":
568
+ await commandWhoAmI(ctx);
569
+ return;
570
+ case "create":
571
+ await commandCreate(ctx);
572
+ return;
573
+ case "view":
574
+ await commandView(ctx);
575
+ return;
576
+ case "update":
577
+ await commandUpdate(ctx);
578
+ return;
579
+ case "delete":
580
+ await commandDelete(ctx);
581
+ return;
582
+ case "share":
583
+ await commandShare(ctx);
584
+ return;
585
+ case "unshare":
586
+ await commandUnshare(ctx);
587
+ return;
588
+ case "help":
589
+ case "--help":
590
+ case "-h":
591
+ case null:
592
+ printHelp();
593
+ return;
594
+ default:
595
+ throw new Error(`Unknown command: ${args.command}`);
596
+ }
597
+ }
598
+ main().catch((error) => {
599
+ const message = error instanceof Error ? error.message : String(error);
600
+ printError(message);
601
+ process.exitCode = 1;
602
+ });
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@blankdotpage/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Blank Page",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "README.md"
9
+ ],
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "bin": {
17
+ "blankpage": "./dist/index.js",
18
+ "bp": "./dist/index.js"
19
+ },
20
+ "scripts": {
21
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js",
22
+ "prepack": "npm run build",
23
+ "test": "tsx --test tests/*.test.ts"
24
+ },
25
+ "devDependencies": {
26
+ "esbuild": "^0.25.10"
27
+ }
28
+ }