@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.
- package/README.md +40 -0
- package/dist/index.js +602 -0
- 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
|
+
}
|