@inblog/cli 0.2.2 → 0.2.4

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 ADDED
@@ -0,0 +1,202 @@
1
+ # @inblog/cli
2
+
3
+ Command-line tool for managing [inblog.ai](https://inblog.ai) blog content. Create, publish, and manage blog posts, tags, authors, images, and more from your terminal.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @inblog/cli
9
+ ```
10
+
11
+ Requires Node.js 18+.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # Authenticate with your API key
17
+ inblog auth login
18
+
19
+ # Check current blog
20
+ inblog auth status
21
+
22
+ # List posts
23
+ inblog posts list
24
+
25
+ # Create and publish a post
26
+ inblog posts create --title "My Post" --slug "my-post" --content-file ./content.html
27
+ inblog posts publish <id>
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ ### Authentication
33
+
34
+ ```bash
35
+ inblog auth login # Login with API key
36
+ inblog auth whoami # Show current user/blog
37
+ inblog auth status # Check auth status
38
+ inblog auth logout # Remove saved credentials
39
+ ```
40
+
41
+ ### Blog Management
42
+
43
+ ```bash
44
+ inblog blogs me # Current blog info
45
+ inblog blogs list # List accessible blogs (OAuth)
46
+ inblog blogs switch [id] # Switch active blog
47
+ inblog blogs update # Update blog settings
48
+ ```
49
+
50
+ **Blog settings:**
51
+
52
+ ```bash
53
+ inblog blogs update --title "Blog Name" --description "About" --language ko
54
+ inblog blogs update --logo ./logo.png --favicon ./favicon.ico --og-image ./og.jpg
55
+ inblog blogs update --ga-id G-XXXXXXXXXX
56
+ ```
57
+
58
+ **Custom domain:**
59
+
60
+ ```bash
61
+ inblog blogs domain connect blog.example.com # Connect + DNS guide
62
+ inblog blogs domain status # Check verification
63
+ inblog blogs domain disconnect # Disconnect
64
+ ```
65
+
66
+ **Banner:**
67
+
68
+ ```bash
69
+ inblog blogs banner get
70
+ inblog blogs banner set --image ./banner.png --title "Title" --subtext "Subtitle"
71
+ inblog blogs banner remove
72
+ ```
73
+
74
+ ### Posts
75
+
76
+ ```bash
77
+ inblog posts list # List all posts
78
+ inblog posts list --published # Published only
79
+ inblog posts list --draft # Drafts only
80
+ inblog posts get <id> # Post details
81
+
82
+ inblog posts create \
83
+ --title "Title" \
84
+ --slug "url-slug" \
85
+ --image ./cover.jpg \
86
+ --content-file ./content.html
87
+
88
+ inblog posts update <id> --title "New Title" --image ./new-cover.jpg
89
+ inblog posts delete <id>
90
+
91
+ inblog posts publish <id> # Publish immediately
92
+ inblog posts unpublish <id> # Unpublish
93
+ inblog posts schedule <id> --at "2026-03-15T09:00:00+09:00"
94
+ ```
95
+
96
+ **Tags & Authors on posts:**
97
+
98
+ ```bash
99
+ inblog posts add-tags <id> --tag-ids 1,2,3
100
+ inblog posts remove-tag <postId> <tagId>
101
+ inblog posts add-authors <id> --author-ids uuid1,uuid2
102
+ inblog posts remove-author <postId> <authorId>
103
+ ```
104
+
105
+ ### Images
106
+
107
+ ```bash
108
+ # Upload images to CDN
109
+ inblog images upload ./photo1.jpg ./photo2.png
110
+ inblog images upload ./cover.jpg -b featured_image --json
111
+ ```
112
+
113
+ Local image files used with `--image` or `--content-file` are automatically uploaded to the CDN.
114
+
115
+ ### Tags
116
+
117
+ ```bash
118
+ inblog tags list
119
+ inblog tags create --name "Tag Name" --slug "tag-slug"
120
+ inblog tags update <id> --name "New Name"
121
+ inblog tags delete <id>
122
+ ```
123
+
124
+ ### Authors
125
+
126
+ ```bash
127
+ inblog authors list
128
+ inblog authors get <id>
129
+ inblog authors update <id> --name "Name" --avatar-url "https://..."
130
+ ```
131
+
132
+ ### Redirects
133
+
134
+ ```bash
135
+ inblog redirects list
136
+ inblog redirects create --from "/old" --to "/new" --type 308
137
+ inblog redirects update <id> --to "/newer"
138
+ inblog redirects delete <id>
139
+ ```
140
+
141
+ ### Forms
142
+
143
+ ```bash
144
+ inblog forms list
145
+ inblog forms get <id>
146
+ inblog form-responses list --form-id <id>
147
+ ```
148
+
149
+ ### Search Console
150
+
151
+ ```bash
152
+ inblog search-console connect # OAuth connect
153
+ inblog search-console status # Connection status
154
+ inblog search-console keywords --sort clicks --limit 20
155
+ inblog search-console pages --sort clicks --limit 20
156
+ ```
157
+
158
+ ### Analytics
159
+
160
+ ```bash
161
+ inblog analytics traffic --interval day # Blog traffic
162
+ inblog analytics posts --sort visits --limit 20 --include title
163
+ inblog analytics sources --limit 20 # Traffic sources
164
+ inblog analytics post <id> --interval day # Single post traffic
165
+ inblog analytics post <id> --sources # Single post sources
166
+ ```
167
+
168
+ ## Global Options
169
+
170
+ | Option | Description |
171
+ |--------|-------------|
172
+ | `--json` | Output as JSON |
173
+ | `--base-url <url>` | Custom API base URL |
174
+ | `--no-color` | Disable colored output |
175
+ | `--api-key <key>` | Use specific API key |
176
+
177
+ ## Image Handling
178
+
179
+ The CLI automatically handles image uploads:
180
+
181
+ - `--image ./cover.jpg` on `posts create/update` uploads the file to CDN
182
+ - `--content-file` scans HTML for local file paths and base64 data URIs, uploads them, and replaces with CDN URLs
183
+ - `--logo`, `--favicon`, `--og-image` on `blogs update` accept local files
184
+ - `inblog images upload` for standalone CDN uploads
185
+
186
+ **Note:** Do not embed base64 images directly in `content_html` API calls (causes 413 errors). Use `--content-file` or upload first with `inblog images upload`.
187
+
188
+ ## API Key
189
+
190
+ Get your API key from [inblog.ai dashboard](https://inblog.ai) > Settings > API Keys. Requires a Team plan or higher.
191
+
192
+ ## AI Skills
193
+
194
+ For AI-assisted blog management (Claude Code, Cursor, GitHub Copilot), install the companion package:
195
+
196
+ ```bash
197
+ npx @inblog/ai-skills
198
+ ```
199
+
200
+ ## License
201
+
202
+ MIT
@@ -1107,7 +1107,119 @@ function registerAuthCommands(program2) {
1107
1107
  }
1108
1108
 
1109
1109
  // src/commands/posts.ts
1110
- var fs3 = __toESM(require("fs"));
1110
+ var fs4 = __toESM(require("fs"));
1111
+
1112
+ // src/utils/upload.ts
1113
+ var import_node_fs = __toESM(require("fs"));
1114
+ var import_node_path = __toESM(require("path"));
1115
+ var import_node_crypto2 = require("crypto");
1116
+ var WORKER_URL = "https://upload.inblog.dev/";
1117
+ var MAX_FILE_SIZE = 10 * 1024 * 1024;
1118
+ var MIME_TYPES = {
1119
+ ".png": "image/png",
1120
+ ".jpg": "image/jpeg",
1121
+ ".jpeg": "image/jpeg",
1122
+ ".gif": "image/gif",
1123
+ ".webp": "image/webp",
1124
+ ".svg": "image/svg+xml",
1125
+ ".ico": "image/x-icon"
1126
+ };
1127
+ function isLocalPath(value) {
1128
+ if (value.startsWith("http://") || value.startsWith("https://")) return false;
1129
+ return import_node_fs.default.existsSync(value);
1130
+ }
1131
+ async function uploadImage(filePath, bucket) {
1132
+ const resolved = import_node_path.default.resolve(filePath);
1133
+ if (!import_node_fs.default.existsSync(resolved)) {
1134
+ throw new Error(`File not found: ${resolved}`);
1135
+ }
1136
+ const stat = import_node_fs.default.statSync(resolved);
1137
+ if (stat.size > MAX_FILE_SIZE) {
1138
+ throw new Error(`File size exceeds 10MB limit: ${(stat.size / 1024 / 1024).toFixed(1)}MB`);
1139
+ }
1140
+ const ext = import_node_path.default.extname(resolved).toLowerCase();
1141
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
1142
+ const fileKey = `${bucket}/${(/* @__PURE__ */ new Date()).toISOString()}-${(0, import_node_crypto2.randomUUID)()}`;
1143
+ const body = import_node_fs.default.readFileSync(resolved);
1144
+ const response = await fetch(`${WORKER_URL}?fileKey=${fileKey}`, {
1145
+ method: "POST",
1146
+ body,
1147
+ headers: { "Content-Type": contentType }
1148
+ });
1149
+ if (!response.ok) {
1150
+ throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
1151
+ }
1152
+ const data = await response.json();
1153
+ return data.publicUrl;
1154
+ }
1155
+ async function resolveImageUrl(value, bucket) {
1156
+ if (!isLocalPath(value)) return value;
1157
+ return uploadImage(value, bucket);
1158
+ }
1159
+ async function uploadBase64(dataUri, bucket) {
1160
+ const match = dataUri.match(/^data:([^;]+);base64,(.+)$/s);
1161
+ if (!match) throw new Error("Invalid data URI");
1162
+ const contentType = match[1];
1163
+ const body = Buffer.from(match[2], "base64");
1164
+ if (body.length > MAX_FILE_SIZE) {
1165
+ throw new Error(`Base64 image exceeds 10MB limit: ${(body.length / 1024 / 1024).toFixed(1)}MB`);
1166
+ }
1167
+ const fileKey = `${bucket}/${(/* @__PURE__ */ new Date()).toISOString()}-${(0, import_node_crypto2.randomUUID)()}`;
1168
+ const response = await fetch(`${WORKER_URL}?fileKey=${fileKey}`, {
1169
+ method: "POST",
1170
+ body,
1171
+ headers: { "Content-Type": contentType }
1172
+ });
1173
+ if (!response.ok) {
1174
+ throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
1175
+ }
1176
+ const data = await response.json();
1177
+ return data.publicUrl;
1178
+ }
1179
+ async function processContentImages(html) {
1180
+ const imgSrcRegex = /(<img\s[^>]*\bsrc=["'])([^"']+)(["'][^>]*>)/gi;
1181
+ const matches = [];
1182
+ let m;
1183
+ while ((m = imgSrcRegex.exec(html)) !== null) {
1184
+ matches.push({ full: m[0], prefix: m[1], src: m[2], suffix: m[3], index: m.index });
1185
+ }
1186
+ if (matches.length === 0) return { html, uploadCount: 0 };
1187
+ let uploadCount = 0;
1188
+ const replacements = /* @__PURE__ */ new Map();
1189
+ const concurrency = 5;
1190
+ for (let i = 0; i < matches.length; i += concurrency) {
1191
+ const batch = matches.slice(i, i + concurrency);
1192
+ const results = await Promise.allSettled(
1193
+ batch.map(async ({ src }) => {
1194
+ if (src.startsWith("https://source.inblog.dev/") || src.startsWith("https://image.inblog.dev/")) {
1195
+ return null;
1196
+ }
1197
+ if (src.startsWith("data:image/")) {
1198
+ const url = await uploadBase64(src, "post_image");
1199
+ return { src, url };
1200
+ }
1201
+ if (isLocalPath(src)) {
1202
+ const url = await uploadImage(src, "post_image");
1203
+ return { src, url };
1204
+ }
1205
+ return null;
1206
+ })
1207
+ );
1208
+ for (const result of results) {
1209
+ if (result.status === "fulfilled" && result.value) {
1210
+ replacements.set(result.value.src, result.value.url);
1211
+ uploadCount++;
1212
+ }
1213
+ }
1214
+ }
1215
+ let processed = html;
1216
+ for (const [src, url] of replacements) {
1217
+ processed = processed.split(src).join(url);
1218
+ }
1219
+ return { html: processed, uploadCount };
1220
+ }
1221
+
1222
+ // src/commands/posts.ts
1111
1223
  function formatPost(post) {
1112
1224
  return [
1113
1225
  ["ID", post.id],
@@ -1183,19 +1295,27 @@ Showing page ${meta.page ?? 1} (${data.length} of ${meta.total} posts)`);
1183
1295
  handleError(error, json);
1184
1296
  }
1185
1297
  });
1186
- posts.command("create").description("Create post (draft by default, use --published to publish)").requiredOption("-t, --title <title>", "Post title").option("-s, --slug <slug>", "Post slug").option("-d, --description <desc>", "Post description").option("--content <html>", "HTML content").option("--content-file <path>", "Read HTML content from file").option("--notion-url <url>", "Notion page URL").option("--published", "Publish immediately").option("--tag-ids <ids>", "Comma-separated tag IDs").option("--author-ids <ids>", "Comma-separated author IDs").option("--canonical-url <url>", "Canonical URL").option("--meta-title <title>", "Meta title").option("--meta-description <desc>", "Meta description").action(async function() {
1298
+ posts.command("create").description("Create post (draft by default, use --published to publish)").requiredOption("-t, --title <title>", "Post title").option("-s, --slug <slug>", "Post slug").option("-d, --description <desc>", "Post description").option("--content <html>", "HTML content").option("--content-file <path>", "Read HTML content from file").option("--image <path-or-url>", "Cover image (local file or URL)").option("--notion-url <url>", "Notion page URL").option("--published", "Publish immediately").option("--tag-ids <ids>", "Comma-separated tag IDs").option("--author-ids <ids>", "Comma-separated author IDs").option("--canonical-url <url>", "Canonical URL").option("--meta-title <title>", "Meta title").option("--meta-description <desc>", "Meta description").action(async function() {
1187
1299
  const json = isJsonMode(this);
1188
1300
  try {
1189
1301
  const opts = this.opts();
1190
1302
  const { posts: endpoint } = createClientFromCommand(this);
1191
1303
  let contentHtml = opts.content;
1192
1304
  if (opts.contentFile) {
1193
- contentHtml = fs3.readFileSync(opts.contentFile, "utf-8");
1305
+ contentHtml = fs4.readFileSync(opts.contentFile, "utf-8");
1306
+ }
1307
+ if (contentHtml) {
1308
+ const { html, uploadCount } = await processContentImages(contentHtml);
1309
+ contentHtml = html;
1310
+ if (uploadCount > 0 && !json) {
1311
+ printWarning(`Uploaded ${uploadCount} image(s) to CDN.`);
1312
+ }
1194
1313
  }
1195
1314
  const input = { title: opts.title };
1196
1315
  if (opts.slug) input.slug = opts.slug;
1197
1316
  if (opts.description) input.description = opts.description;
1198
1317
  if (contentHtml) input.content_html = contentHtml;
1318
+ if (opts.image) input.image = { url: await resolveImageUrl(opts.image, "featured_image") };
1199
1319
  if (opts.notionUrl) input.notion_url = opts.notionUrl;
1200
1320
  if (opts.published) input.published = true;
1201
1321
  if (opts.canonicalUrl) input.canonical_url = opts.canonicalUrl;
@@ -1214,20 +1334,28 @@ Showing page ${meta.page ?? 1} (${data.length} of ${meta.total} posts)`);
1214
1334
  handleError(error, json);
1215
1335
  }
1216
1336
  });
1217
- posts.command("update <id>").description("Update post fields (title, slug, content, SEO metadata)").option("-t, --title <title>", "Post title").option("-s, --slug <slug>", "Post slug").option("-d, --description <desc>", "Post description").option("--content <html>", "HTML content").option("--content-file <path>", "Read HTML content from file").option("--canonical-url <url>", "Canonical URL").option("--meta-title <title>", "Meta title").option("--meta-description <desc>", "Meta description").action(async function(id) {
1337
+ posts.command("update <id>").description("Update post fields (title, slug, content, SEO metadata)").option("-t, --title <title>", "Post title").option("-s, --slug <slug>", "Post slug").option("-d, --description <desc>", "Post description").option("--content <html>", "HTML content").option("--content-file <path>", "Read HTML content from file").option("--image <path-or-url>", "Cover image (local file or URL)").option("--canonical-url <url>", "Canonical URL").option("--meta-title <title>", "Meta title").option("--meta-description <desc>", "Meta description").action(async function(id) {
1218
1338
  const json = isJsonMode(this);
1219
1339
  try {
1220
1340
  const opts = this.opts();
1221
1341
  const { posts: endpoint } = createClientFromCommand(this);
1222
1342
  let contentHtml = opts.content;
1223
1343
  if (opts.contentFile) {
1224
- contentHtml = fs3.readFileSync(opts.contentFile, "utf-8");
1344
+ contentHtml = fs4.readFileSync(opts.contentFile, "utf-8");
1345
+ }
1346
+ if (contentHtml) {
1347
+ const { html, uploadCount } = await processContentImages(contentHtml);
1348
+ contentHtml = html;
1349
+ if (uploadCount > 0 && !json) {
1350
+ printWarning(`Uploaded ${uploadCount} image(s) to CDN.`);
1351
+ }
1225
1352
  }
1226
1353
  const input = {};
1227
1354
  if (opts.title) input.title = opts.title;
1228
1355
  if (opts.slug) input.slug = opts.slug;
1229
1356
  if (opts.description) input.description = opts.description;
1230
1357
  if (contentHtml) input.content_html = contentHtml;
1358
+ if (opts.image) input.image = { url: await resolveImageUrl(opts.image, "featured_image") };
1231
1359
  if (opts.canonicalUrl !== void 0) input.canonical_url = opts.canonicalUrl || null;
1232
1360
  if (opts.metaTitle !== void 0) input.meta_title = opts.metaTitle || null;
1233
1361
  if (opts.metaDescription !== void 0) input.meta_description = opts.metaDescription || null;
@@ -1672,54 +1800,6 @@ function getDnsProviderGuide(provider) {
1672
1800
  return DNS_PROVIDER_GUIDES[provider] || null;
1673
1801
  }
1674
1802
 
1675
- // src/utils/upload.ts
1676
- var import_node_fs = __toESM(require("fs"));
1677
- var import_node_path = __toESM(require("path"));
1678
- var import_node_crypto2 = require("crypto");
1679
- var WORKER_URL = "https://upload.inblog.dev/";
1680
- var MAX_FILE_SIZE = 10 * 1024 * 1024;
1681
- var MIME_TYPES = {
1682
- ".png": "image/png",
1683
- ".jpg": "image/jpeg",
1684
- ".jpeg": "image/jpeg",
1685
- ".gif": "image/gif",
1686
- ".webp": "image/webp",
1687
- ".svg": "image/svg+xml",
1688
- ".ico": "image/x-icon"
1689
- };
1690
- function isLocalPath(value) {
1691
- if (value.startsWith("http://") || value.startsWith("https://")) return false;
1692
- return import_node_fs.default.existsSync(value);
1693
- }
1694
- async function uploadImage(filePath, bucket) {
1695
- const resolved = import_node_path.default.resolve(filePath);
1696
- if (!import_node_fs.default.existsSync(resolved)) {
1697
- throw new Error(`File not found: ${resolved}`);
1698
- }
1699
- const stat = import_node_fs.default.statSync(resolved);
1700
- if (stat.size > MAX_FILE_SIZE) {
1701
- throw new Error(`File size exceeds 10MB limit: ${(stat.size / 1024 / 1024).toFixed(1)}MB`);
1702
- }
1703
- const ext = import_node_path.default.extname(resolved).toLowerCase();
1704
- const contentType = MIME_TYPES[ext] || "application/octet-stream";
1705
- const fileKey = `${bucket}/${(/* @__PURE__ */ new Date()).toISOString()}-${(0, import_node_crypto2.randomUUID)()}`;
1706
- const body = import_node_fs.default.readFileSync(resolved);
1707
- const response = await fetch(`${WORKER_URL}?fileKey=${fileKey}`, {
1708
- method: "POST",
1709
- body,
1710
- headers: { "Content-Type": contentType }
1711
- });
1712
- if (!response.ok) {
1713
- throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
1714
- }
1715
- const data = await response.json();
1716
- return data.publicUrl;
1717
- }
1718
- async function resolveImageUrl(value, bucket) {
1719
- if (!isLocalPath(value)) return value;
1720
- return uploadImage(value, bucket);
1721
- }
1722
-
1723
1803
  // src/commands/blogs.ts
1724
1804
  function registerBlogsCommands(program2) {
1725
1805
  const blogs = program2.command("blogs").description("Manage blogs \u2014 list, switch, view, and update blog settings");
@@ -2593,6 +2673,35 @@ function registerAnalyticsCommands(program2) {
2593
2673
  });
2594
2674
  }
2595
2675
 
2676
+ // src/commands/images.ts
2677
+ var VALID_BUCKETS = ["post_image", "featured_image", "logo", "favicon", "og_image", "banner", "avatar"];
2678
+ function registerImagesCommands(program2) {
2679
+ const images = program2.command("images").description("Upload images to inblog CDN");
2680
+ images.command("upload <file...>").description("Upload local image file(s) to inblog CDN").option("-b, --bucket <type>", "Image bucket (post_image, featured_image, logo, favicon, og_image, banner)", "post_image").action(async function(files) {
2681
+ const json = isJsonMode(this);
2682
+ try {
2683
+ const opts = this.opts();
2684
+ const bucket = opts.bucket;
2685
+ if (!VALID_BUCKETS.includes(bucket)) {
2686
+ throw new Error(`Invalid bucket: ${bucket}. Valid: ${VALID_BUCKETS.join(", ")}`);
2687
+ }
2688
+ const results = [];
2689
+ for (const file of files) {
2690
+ const url = await uploadImage(file, bucket);
2691
+ results.push({ file, url });
2692
+ if (!json) {
2693
+ printSuccess(`${file} \u2192 ${url}`);
2694
+ }
2695
+ }
2696
+ if (json) {
2697
+ printJson(results);
2698
+ }
2699
+ } catch (error) {
2700
+ handleError(error, json);
2701
+ }
2702
+ });
2703
+ }
2704
+
2596
2705
  // bin/inblog.ts
2597
2706
  var program = new import_commander.Command();
2598
2707
  program.name("inblog").description("CLI for managing inblog.ai blog content (posts, tags, authors, redirects, forms)").version("0.2.0").option("--json", "Output as JSON (for programmatic use)").option("--base-url <url>", "API base URL").option("--no-color", "Disable colored output");
@@ -2607,5 +2716,6 @@ registerFormResponsesCommands(program);
2607
2716
  registerConfigCommands(program);
2608
2717
  registerSearchConsoleCommands(program);
2609
2718
  registerAnalyticsCommands(program);
2719
+ registerImagesCommands(program);
2610
2720
  program.parse();
2611
2721
  //# sourceMappingURL=inblog.js.map