onepage-cli 1.0.2 → 1.2.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 CHANGED
@@ -1,112 +1,102 @@
1
- # OnePage CLI(onepage-cli)
1
+ # onepage-cli
2
2
 
3
- 在终端用 git 风格指令生成、部署、管理你的 OnePage 静态网页。零依赖,Node 18+ 即可运行。
3
+ OnePage 平台命令行工具(`op` 命令)。在终端部署静态网页、管理博客文章/配置/主题。零依赖,Node 18+。
4
4
 
5
- ## 安装
5
+ - 服务端默认:`https://app.collegehub.cn`(可用环境变量 `OP_SERVER` 覆盖)
6
+ - 认证:Bearer Token,`op login` 获取,本地存于 `~/.onepage-cli.json`
7
+ - 响应格式:JSON `{ok, ...}`,401 表示登录失效需重新 `op login`
6
8
 
7
- ```bash
8
- npm install -g onepage-cli
9
- ```
10
-
11
- 安装后即可使用 `op` 命令(注意:若你同时安装了 1Password CLI,其命令也是 `op`,可通过 `npm exec -p onepage-cli -- op ...` 或全局别名区分)。
12
-
13
- ## 快速开始
14
-
15
- ```bash
16
- op login # 登录(输入邮箱 + 密码)
17
- op init # 在项目目录创建暂存区
18
- op add . # 把代码加入暂存区
19
- op push # 部署上线(交互式输入短链名、预览图、公开、SEO)
20
- ```
21
-
22
- ## 全部命令
9
+ ## 命令
23
10
 
24
11
  ### 账号
25
12
 
26
13
  | 命令 | 说明 |
27
14
  |---|---|
28
- | `op login` | 登录。新邮箱**自动注册**,用户名默认为邮箱 `@` 前面的字符(重名自动加随机后缀) |
15
+ | `op login` | 登录(输入邮箱+密码)。新邮箱自动注册,用户名默认邮箱 @ 前缀 |
29
16
  | `op whoami` | 查看当前登录账号 |
30
- | `op logout` | 退出登录(清除本地凭证) |
17
+ | `op logout` | 退出登录,清除本地凭证 |
31
18
 
32
- ### 部署流程
19
+ ### 静态站点
33
20
 
34
21
  | 命令 | 说明 |
35
22
  |---|---|
36
- | `op init` | 在当前目录创建代码暂存区 `.onepage/`,并生成**站点配置模板 `.onepage/preview.yml`** |
37
- | `op add [路径]` | 把文件/目录加入暂存区,默认当前目录 `.`(自动过滤不支持的类型与 node_modules 等) |
38
- | `op status` | 查看暂存区文件清单与大小 |
39
- | `op push` | 部署暂存区代码上线。**存在 `.onepage/preview.yml` 时直接按其配置部署(免交互)**;不存在时回退为交互式输入:短链名 → 预览图路径或 no → 是否公开 → 是否 SEO。短链名已存在且属于自己时为**覆盖更新** |
23
+ | `op init` | 当前目录创建暂存区 `.onepage/`(stage/ + preview.yml) |
24
+ | `op add [路径]` | 把文件/目录加入暂存区(默认当前目录) |
25
+ | `op status` | 查看暂存区文件 |
26
+ | `op push` | 部署上线。无 `.onepage/preview.yml` 时交互式输入短链名/预览图/公开/SEO;有则按配置免交互部署 |
27
+ | `op list` | 列出本账号所有站点 |
28
+ | `op clone <url>` | 拉取站点代码到本地(支持完整 URL 或短链名) |
29
+ | `op delete <name>` | 删除站点(需确认) |
30
+ | `op open <name>` | 浏览器打开站点 |
40
31
 
41
- ### preview.yml 站点配置(op init 自动生成)
32
+ `preview.yml` 字段:`path_name`(短链名)、`title`、`description`、`preview`(预览图路径,no 跳过)、`is_public`、`seo`。
42
33
 
43
- ```yaml
44
- # OnePage 站点配置 —— 填好后直接执行 op push,无需交互输入
45
- title: '' # 站点名称(留空则使用短链名)
46
- description: '' # 站点介绍
47
- path_name: '' # 短链名:字母/数字/连字符/下划线,<=64 字符(留空则使用目录名)
48
- preview: no # 预览图路径(本地图片文件,无则填 no)
49
- is_public: yes # 是否公开(yes/no)
50
- seo: yes # 是否 SEO 优化(yes/no)
51
- ```
52
-
53
- ### 站点管理
34
+ ### 博客(`op blog <子命令>`)
54
35
 
55
- | 命令 | 说明 |
36
+ | 子命令 | 说明 |
56
37
  |---|---|
57
- | `op list` | 表格列出本账号所有站点(短链名/标题/公开/浏览/点赞/更新时间/URL) |
58
- | `op clone <url>` | 拉取站点代码到 `./<短链名>/`(支持完整 URL 或短链名;公开站点可拉,私有站点仅所有者) |
59
- | `op delete <短链名>` | 删除指定站点(需二次确认 yes/no) |
60
- | `op open <短链名>` | 在浏览器打开站点 |
61
-
62
- ## 支持的文件类型
63
-
64
- `html` `css` `js` `mjs` `jpg` `jpeg` `png` `gif` `svg` `webp` `ico` `json` `txt` `md` `woff` `woff2` `ttf` `eot` `otf` `mp3` `mp4` `webm` `ogg` `wav` `map`
65
-
66
- 暂存区自动跳过:`.onepage`、`node_modules`、`.git` 及隐藏文件。
38
+ | `init` | 创建博客工作区 `.onepage/blog/`(config.yml + posts/ + themes/) |
39
+ | `write <标题>` | 新建带 front-matter 的 Markdown 文章模板 |
40
+ | `push [文件] [--publish]` | 推送文章。`--publish` 直接发布(自动过 AI 审核),否则存草稿 |
41
+ | `pull [slug\|all] [-f]` | 拉取远端文章为 Markdown,`-f` 覆盖本地改动 |
42
+ | `list` | 远端文章列表 |
43
+ | `status` | 本地文章与远端对比 |
44
+ | `delete <slug> [-y]` | 删除文章 |
45
+ | `config [pull\|push]` | 拉取/推送博客配置(标题、介绍、主题及选项) |
46
+ | `categories add\|rename\|delete` | 分类管理 |
47
+ | `themes` | 主题列表 |
48
+ | `theme apply <key> [--opt k=v]` | 应用主题及选项(静态页重建) |
49
+ | `theme push <zip> [--overwrite]` | 上传主题包 |
50
+
51
+ ## 博客文章 front-matter 字段
67
52
 
68
- ## 配置
69
-
70
- 登录凭证保存在 `~/.onepage-cli.json`(权限 600):
71
-
72
- ```json
73
- {
74
- "server": "https://app.collegehub.cn",
75
- "token": "...",
76
- "username": "...",
77
- "email": "..."
78
- }
79
- ```
80
-
81
- 默认服务器为 `https://app.collegehub.cn`;如需指向其他地址,设置环境变量 `OP_SERVER=https://xxx` 后重新 `op login`。
82
-
83
- ## 典型工作流
84
-
85
- ```bash
86
- # 首次
87
- op login
88
- mkdir my-site && cd my-site
89
- # ... 编写 index.html 等文件 ...
90
- op init && op add . && op push
91
- # → 短链名 [my-site]:
92
- # → 预览图路径(no 跳过): no
93
- # → 是否公开(yes/no): yes
94
- # → 是否 SEO 优化(yes/no): yes
95
- # ✓ 部署完成:https://app.collegehub.cn/my-site/
96
-
97
- # 更新:改完代码再 add + push(短链名不变即覆盖更新)
98
- op add . && op push
99
-
100
- # 备份/迁移
101
- op clone https://app.collegehub.cn/my-site/
53
+ ```yaml
54
+ ---
55
+ title: 标题
56
+ slug: url-slug # 留空由服务端按标题生成并保证唯一
57
+ status: draft # draft=草稿 / published=发布
58
+ category: 分类名
59
+ description: 摘要
60
+ cover: # 封面图(1.2.0+)
61
+ # 本地路径(相对 md 文件)→ push 时自动上传
62
+ # http(s) URL → 自动下载转存
63
+ # 留空 → 清除封面
64
+ ---
65
+ 正文支持 Markdown。
102
66
  ```
103
67
 
104
- ## 安全说明
105
-
106
- - 密码仅在登录/注册时通过 HTTPS 提交一次,本地只保存服务端签发的 token
107
- - token 与账号绑定,`op logout` 即可作废本地凭证
108
- - 站点文件由服务端校验扩展名与相对路径(禁止 `..` 越权)
109
-
110
- ## License
111
-
112
- MIT
68
+ - `cover` 仅支持 jpg/jpeg/png/gif/webp,服务端自动压缩为 WebP 存入资源库
69
+ - 本地封面成功上传后,CLI 自动把该字段改写为线上 URL,避免重复上传
70
+ - 封面同时用作文章分享卡片图(og:image)
71
+
72
+ ## 服务端 API(本工具调用)
73
+
74
+ 认证方式:请求头 `Authorization: Bearer <token>`。token 通过 `POST /api/cli/login`(body: `{email, password}`)获取,返回 `{token, username, email}`。
75
+
76
+ | 方法 | 路径 | 说明 |
77
+ |---|---|---|
78
+ | POST | `/api/cli/deploy` | 部署站点(multipart:文件 + path_name/title/description/preview/is_public/seo) |
79
+ | GET | `/api/cli/sites` | 站点列表 |
80
+ | DELETE | `/api/cli/site/<name>` | 删除站点 |
81
+ | GET | `/api/cli/download/<name>` | 打包下载站点代码 |
82
+ | GET/POST | `/api/cli/blog/config` | 博客配置读取/保存(site_title/description/theme/options) |
83
+ | GET | `/api/cli/blog/posts` | 文章列表(含 cover 字段) |
84
+ | POST | `/api/cli/blog/posts` | 创建/更新文章。body: `{slug?, title?, markdown, description?, category?, status?, publish?, cover_asset_id?, cover_remove?}` |
85
+ | GET | `/api/cli/blog/post/<slug>` | 文章详情(含 markdown 与 cover) |
86
+ | DELETE | `/api/cli/blog/post/<slug>` | 删除文章 |
87
+ | POST | `/api/cli/blog/post/<slug>/publish` | 发布文章 |
88
+ | POST | `/api/cli/blog/assets` | 上传图片资源(multipart `file`),返回 `{asset_id, url}`,自动压缩 WebP |
89
+ | GET/POST | `/api/cli/blog/categories` | 分类查询/管理(action: add/rename/delete) |
90
+ | GET | `/api/cli/blog/themes` | 主题列表 |
91
+ | POST | `/api/cli/blog/theme/apply` | 应用主题(body: `{theme, options}`) |
92
+ | POST | `/api/cli/blog/theme/import` | 导入主题包(multipart zip) |
93
+
94
+ ## 注意事项
95
+
96
+ 1. 需要 Node 18+(使用原生 fetch / FormData / Blob,无第三方依赖)。
97
+ 2. 命令名 `op` 与 1Password CLI 重名,冲突时用 `npm exec -p onepage-cli -- op ...` 或设置别名。
98
+ 3. 暂存区/博客工作区均在项目目录的 `.onepage/` 下,可加入 .gitignore。
99
+ 4. `--publish` 发布会触发 AI 内容审核与静态页重建,属异步操作。
100
+ 5. 文章 slug 服务端保证唯一;重复 push 同一 slug 为更新。
101
+ 6. 本地凭证文件 `~/.onepage-cli.json` 权限 600,含 Bearer token,勿泄露。
102
+ 7. 所有写操作(发布/删除/配置/主题)都会即时同步线上并对已发布页面生效。
package/bin/op.js CHANGED
@@ -235,6 +235,9 @@ async function cmdPush() {
235
235
  if (!files.some((f) => f.rel.toLowerCase() === 'index.html')) {
236
236
  die('缺少 index.html(网站入口文件),无法部署');
237
237
  }
238
+ if (files.some((f) => f.rel.split('/').pop().toLowerCase() === 'theme.json')) {
239
+ die('检测到 theme.json:这是博客主题包,请改用 op blog theme push <主题包.zip> 上传(不要解压后当静态站部署)');
240
+ }
238
241
 
239
242
  // 默认短链名:目录名
240
243
  const dirName = path.basename(process.cwd()).replace(/[^A-Za-z0-9_\-]/g, '-').toLowerCase() || 'site';
@@ -392,6 +395,10 @@ function cmdHelp() {
392
395
  console.log(' op delete <name> 删除指定站点');
393
396
  console.log(' op open <name> 在浏览器打开站点');
394
397
  console.log('');
398
+ console.log('博客(op blog <子命令>,详情 op blog help):');
399
+ console.log(' op blog init / write / push / pull 文章写作与双向同步');
400
+ console.log(' op blog config / categories / theme 配置、分类与主题');
401
+ console.log('');
395
402
  console.log('示例:');
396
403
  console.log(' op login && op init && op add . && op push');
397
404
  console.log(' op clone https://app1.cloudstudio.site/demo/');
@@ -415,6 +422,7 @@ async function main() {
415
422
  case 'list': case 'ls': return await cmdList();
416
423
  case 'delete': case 'rm': return await cmdDelete(args);
417
424
  case 'open': return await cmdOpen(args);
425
+ case 'blog': return await require('../lib/blog')(args, conf.load());
418
426
  case 'help': case '--help': case '-h': return cmdHelp();
419
427
  default:
420
428
  err('未知命令:' + cmd);
package/lib/blog.js ADDED
@@ -0,0 +1,446 @@
1
+ 'use strict';
2
+ // op blog —— 博客模块命令组(文章 Markdown 双向同步 / 配置 / 分类 / 主题)
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { api } = require('./api');
7
+ const { ok, err, info, warn, table } = require('./util');
8
+
9
+ const BLOG_DIR = path.join('.onepage', 'blog');
10
+ const CFG_YML = path.join(BLOG_DIR, 'config.yml');
11
+ const POSTS_DIR = path.join(BLOG_DIR, 'posts');
12
+ const THEMES_DIR = path.join(BLOG_DIR, 'themes');
13
+
14
+ function die(msg) { err(msg); process.exit(1); }
15
+
16
+ /* ---------- 极简扁平 YAML(与 op.js preview.yml 同一套约定) ---------- */
17
+ function parseSimpleYaml(text) {
18
+ const out = {};
19
+ for (let line of String(text).split(/\r?\n/)) {
20
+ line = line.trim();
21
+ if (!line || line.startsWith('#')) continue;
22
+ const i = line.indexOf(':');
23
+ if (i < 1) continue;
24
+ const k = line.slice(0, i).trim();
25
+ let v = line.slice(i + 1).trim();
26
+ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
27
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);
28
+ out[k] = v;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /* ---------- front-matter ---------- */
34
+ function parseFrontMatter(text) {
35
+ const m = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
36
+ if (!m) return { meta: {}, body: String(text) };
37
+ return { meta: parseSimpleYaml(m[1]), body: m[2] };
38
+ }
39
+
40
+ function buildFrontMatter(meta, body) {
41
+ const lines = ['---'];
42
+ for (const [k, v] of Object.entries(meta)) {
43
+ if (v === '' || v == null) continue;
44
+ lines.push(`${k}: ${v}`);
45
+ }
46
+ lines.push('---', '');
47
+ return lines.join('\n') + '\n' + body;
48
+ }
49
+
50
+ function slugifyTitle(t) {
51
+ const s = String(t || '').trim().toLowerCase()
52
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
53
+ return s || ('post-' + new Date().toISOString().slice(0, 10).replace(/-/g, ''));
54
+ }
55
+
56
+ function requireAuth(cfg) {
57
+ if (!cfg.token) die('尚未登录,请先执行 op login');
58
+ }
59
+
60
+ async function authed(cfg, method, pathname, body) {
61
+ const r = await api(cfg, method, pathname, body);
62
+ if (r.status === 401) die('登录已失效,请重新执行 op login');
63
+ return r;
64
+ }
65
+
66
+ function listLocalPosts() {
67
+ if (!fs.existsSync(POSTS_DIR)) return [];
68
+ return fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith('.md')).map((f) => {
69
+ const full = path.join(POSTS_DIR, f);
70
+ const { meta, body } = parseFrontMatter(fs.readFileSync(full, 'utf8'));
71
+ return { file: f, path: full, meta, body };
72
+ });
73
+ }
74
+
75
+ /* ---------- init ---------- */
76
+ function cmdInit() {
77
+ if (fs.existsSync(BLOG_DIR)) {
78
+ warn('博客工作区已存在(' + BLOG_DIR + ')');
79
+ return;
80
+ }
81
+ fs.mkdirSync(POSTS_DIR, { recursive: true });
82
+ fs.mkdirSync(THEMES_DIR, { recursive: true });
83
+ fs.writeFileSync(CFG_YML, [
84
+ '# OnePage 博客配置 —— 编辑后执行 op blog config push 应用到线上',
85
+ '# 值一律写在冒号后面;theme 留空表示不改动当前主题',
86
+ "site_title: '' # 博客名称",
87
+ "description: '' # 博客介绍(显示在首页与分享卡片)",
88
+ "theme: '' # 主题 key(如 firefly / paper / illusion…)",
89
+ '# 主题选项(按所选主题的配置项填写,一行一项,前缀 option.):',
90
+ '# option.accent: \'#0969da\'',
91
+ ].join('\n') + '\n');
92
+ fs.writeFileSync(path.join(POSTS_DIR, 'hello-world.md'), [
93
+ '---',
94
+ "title: 你好,世界",
95
+ 'slug: hello-world',
96
+ 'status: draft # draft=草稿 published=发布',
97
+ "category: ''",
98
+ "description: ''",
99
+ '---',
100
+ '',
101
+ '这是第一篇文章的正文,支持 **Markdown** 与表格:',
102
+ '',
103
+ '| 表头A | 表头B |',
104
+ '| --- | --- |',
105
+ '| 1 | 2 |',
106
+ '',
107
+ ].join('\n'));
108
+ ok('已创建博客工作区 .onepage/blog/(config.yml + posts/ + themes/)');
109
+ console.log(' 1. op blog write 文章标题 新建一篇文章');
110
+ console.log(' 2. op blog push --publish 发布全部文章(自动过 AI 审核)');
111
+ console.log(' 3. op blog config push 应用配置/主题修改');
112
+ }
113
+
114
+ /* ---------- config ---------- */
115
+ async function cmdConfig(args, cfg) {
116
+ const sub = args[0] || 'pull';
117
+ if (sub === 'pull') {
118
+ const r = await authed(cfg, 'GET', '/api/cli/blog/config');
119
+ const c = r.data.config || {};
120
+ const lines = [
121
+ '# OnePage 博客配置(op blog config pull 生成)',
122
+ "site_title: '" + String(c.site_title || '').replace(/'/g, "''") + "'",
123
+ "description: '" + String(c.description || '').replace(/'/g, "''") + "'",
124
+ "theme: '" + String(c.theme || '') + "'",
125
+ ];
126
+ for (const [k, v] of Object.entries(c.options || {})) {
127
+ lines.push("option." + k + ": '" + String(v).replace(/'/g, "''") + "'");
128
+ }
129
+ fs.writeFileSync(CFG_YML, lines.join('\n') + '\n');
130
+ ok('已拉取线上配置 → ' + CFG_YML);
131
+ return;
132
+ }
133
+ if (sub === 'push') {
134
+ if (!fs.existsSync(CFG_YML)) die('未找到 ' + CFG_YML + '(先执行 op blog init 或 op blog config pull)');
135
+ const y = parseSimpleYaml(fs.readFileSync(CFG_YML, 'utf8'));
136
+ const body = {};
137
+ if (y.site_title !== undefined) body.site_title = y.site_title;
138
+ if (y.description !== undefined) body.description = y.description;
139
+ const options = {};
140
+ for (const [k, v] of Object.entries(y)) {
141
+ if (k.startsWith('option.')) options[k.slice(7)] = v;
142
+ }
143
+ if (y.theme) { body.theme = y.theme; if (Object.keys(options).length) body.options = options; }
144
+ const r = await authed(cfg, 'POST', '/api/cli/blog/config', body);
145
+ if (!r.data.ok) die(r.data.error || '保存失败');
146
+ ok('配置已应用' + (body.theme ? '(主题:' + body.theme + ',静态页已重建)' : ''));
147
+ return;
148
+ }
149
+ die('未知子命令:config ' + sub + '(可用:pull / push)');
150
+ }
151
+
152
+ /* ---------- write ---------- */
153
+ function cmdWrite(args) {
154
+ const title = args.filter((a) => !a.startsWith('--')).join(' ') || '未命名文章';
155
+ if (!fs.existsSync(POSTS_DIR)) die('未找到博客工作区,请先执行 op blog init');
156
+ const slug = slugifyTitle(title);
157
+ let file = path.join(POSTS_DIR, slug + '.md');
158
+ let n = 2;
159
+ while (fs.existsSync(file)) file = path.join(POSTS_DIR, slug + '-' + n++ + '.md');
160
+ fs.writeFileSync(file, buildFrontMatter({
161
+ title, slug: path.basename(file, '.md'),
162
+ status: 'draft', category: '', description: '', cover: '',
163
+ }, '正文从这里开始…\n'));
164
+ ok('已创建 ' + file);
165
+ console.log(' 写完后执行 op blog push ' + path.basename(file) + ' --publish 发布');
166
+ }
167
+
168
+ /* ---------- list / status ---------- */
169
+ async function cmdList(cfg) {
170
+ const r = await authed(cfg, 'GET', '/api/cli/blog/posts');
171
+ const posts = r.data.posts || [];
172
+ if (!posts.length) { info('博客还没有文章'); return; }
173
+ table(['SLUG', '状态', '分类', '更新时间'], posts.map((p) => [
174
+ p.slug, p.status === 'published' ? '已发布' : '草稿', p.category || '-', p.updated_at || p.date,
175
+ ]));
176
+ info('共 ' + posts.length + ' 篇');
177
+ }
178
+
179
+ async function cmdStatus(cfg) {
180
+ const r = await authed(cfg, 'GET', '/api/cli/blog/posts');
181
+ const remote = new Set((r.data.posts || []).map((p) => p.slug));
182
+ const local = listLocalPosts();
183
+ if (!local.length) { info('本地 posts/ 没有文章'); return; }
184
+ table(['本地文件', 'slug', '状态', '远端'], local.map((l) => [
185
+ l.file, l.meta.slug || path.basename(l.file, '.md'),
186
+ l.meta.status === 'published' ? '已发布' : '草稿',
187
+ remote.has(l.meta.slug || path.basename(l.file, '.md')) ? '已存在(更新)' : '新文章',
188
+ ]));
189
+ info('执行 op blog push 上传变更;--publish 同时发布');
190
+ }
191
+
192
+
193
+ /* ---------- 封面(cover)---------- */
194
+ function mimeFromName(name) {
195
+ const e = path.extname(name).toLowerCase();
196
+ return { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
197
+ '.gif': 'image/gif', '.webp': 'image/webp' }[e] || '';
198
+ }
199
+
200
+ async function uploadCoverFile(cfg, filePath) {
201
+ const mime = mimeFromName(filePath);
202
+ if (!mime) throw new Error('封面仅支持 jpg/jpeg/png/gif/webp:' + filePath);
203
+ const form = new FormData();
204
+ form.append('file', new Blob([fs.readFileSync(filePath)], { type: mime }), path.basename(filePath));
205
+ const res = await fetch(cfg.server + '/api/cli/blog/assets', {
206
+ method: 'POST', headers: { Authorization: 'Bearer ' + cfg.token }, body: form,
207
+ });
208
+ const data = await res.json().catch(() => ({}));
209
+ if (res.status === 401) die('登录已失效,请重新执行 op login');
210
+ if (!data.ok) throw new Error(data.error || '封面上传失败');
211
+ return data;
212
+ }
213
+
214
+ // 解析 front-matter 的 cover:空→清除;http(s) URL→下载后上传;否则视为本地路径
215
+ async function resolveCover(cfg, coverVal, mdFile) {
216
+ const cv = String(coverVal || '').trim();
217
+ if (!cv) return { cover_remove: true };
218
+ if (/^https?:\/\//i.test(cv)) {
219
+ const res = await fetch(cv);
220
+ if (!res.ok) throw new Error('封面 URL 无法下载:' + cv);
221
+ const buf = Buffer.from(await res.arrayBuffer());
222
+ const name = path.basename(new URL(cv).pathname) || 'cover.jpg';
223
+ const tmp = path.join(os.tmpdir(), 'op-cover-' + Date.now() + '-' + name.replace(/[^\w.-]/g, '_'));
224
+ fs.writeFileSync(tmp, buf);
225
+ try {
226
+ const up = await uploadCoverFile(cfg, tmp);
227
+ return { cover_asset_id: up.asset_id, cover_url_final: up.url };
228
+ } finally { try { fs.unlinkSync(tmp); } catch (e) {} }
229
+ }
230
+ const local = path.isAbsolute(cv) ? cv : path.join(path.dirname(path.resolve(mdFile)), cv);
231
+ if (!fs.existsSync(local)) throw new Error('封面文件不存在:' + cv);
232
+ const up = await uploadCoverFile(cfg, local);
233
+ return { cover_asset_id: up.asset_id, cover_url_final: up.url };
234
+ }
235
+
236
+ /* ---------- push ---------- */
237
+ async function cmdPush(args, cfg) {
238
+ const publishFlag = args.includes('--publish');
239
+ const targets = args.filter((a) => !a.startsWith('--'));
240
+ let files;
241
+ if (targets.length) {
242
+ files = targets.map((t) => (t.endsWith('.md') ? t : t + '.md'))
243
+ .map((t) => (fs.existsSync(t) ? t : path.join(POSTS_DIR, t)));
244
+ for (const f of files) if (!fs.existsSync(f)) die('文件不存在:' + f);
245
+ } else {
246
+ files = listLocalPosts().map((l) => l.path);
247
+ if (!files.length) { info('posts/ 没有可推送的文章'); return; }
248
+ }
249
+ let published = 0, saved = 0;
250
+ for (const f of files) {
251
+ const { meta, body } = parseFrontMatter(fs.readFileSync(f, 'utf8'));
252
+ if (!body.trim()) { warn('跳过空文件:' + f); continue; }
253
+ const publish = publishFlag || meta.status === 'published';
254
+ // 封面:front-matter 的 cover 字段(留空=清除封面;本地路径或 http URL)
255
+ let coverInfo = null;
256
+ if (meta.cover !== undefined) {
257
+ try { coverInfo = await resolveCover(cfg, meta.cover, f); }
258
+ catch (e) { warn('封面处理失败:' + e.message + ' ← ' + f); }
259
+ }
260
+ const r = await authed(cfg, 'POST', '/api/cli/blog/posts', {
261
+ slug: meta.slug || path.basename(f, '.md'),
262
+ title: meta.title || '',
263
+ markdown: body,
264
+ description: meta.description || '',
265
+ category: meta.category || '',
266
+ publish,
267
+ ...(coverInfo || {}),
268
+ });
269
+ if (!r.data.ok) { warn((r.data.error || '推送失败') + ' ← ' + f); continue; }
270
+ if (publish) published++; else saved++;
271
+ console.log((publish ? ' ✓ 已发布' : ' ✓ 已保存草稿') + ' ' + r.data.slug +
272
+ (r.data.created ? '(新建)' : '(更新)') +
273
+ (r.data.cover ? '(封面已设置)' : (coverInfo && coverInfo.cover_remove ? '(封面已清除)' : '')));
274
+ // 本地文件路径上传成功后,把 cover 改写为线上 URL,避免下次 push 重复上传
275
+ if (r.data.ok && coverInfo && coverInfo.cover_url_final && !/^https?:/i.test(String(meta.cover || ''))) {
276
+ const abs = new URL(coverInfo.cover_url_final, cfg.server + '/').href;
277
+ const updated = fs.readFileSync(f, 'utf8').replace(/^(cover:\s*).*$/m, (m0, p1) => p1 + abs);
278
+ fs.writeFileSync(f, updated);
279
+ }
280
+ }
281
+ if (published) info(published + ' 篇已发布,将自动进行 AI 内容审核');
282
+ if (saved) info(saved + ' 篇存为草稿,发布执行 op blog push <文件> --publish 或到网页后台');
283
+ }
284
+
285
+ /* ---------- pull ---------- */
286
+ async function cmdPull(args, cfg) {
287
+ const force = args.includes('-f') || args.includes('--force');
288
+ const targets = args.filter((a) => !a.startsWith('-'));
289
+ if (!fs.existsSync(POSTS_DIR)) die('未找到博客工作区,请先执行 op blog init');
290
+ let slugs;
291
+ if (!targets.length || targets[0] === 'all') {
292
+ const r = await authed(cfg, 'GET', '/api/cli/blog/posts');
293
+ slugs = (r.data.posts || []).map((p) => p.slug);
294
+ if (!slugs.length) { info('远端还没有文章'); return; }
295
+ info('拉取全部 ' + slugs.length + ' 篇');
296
+ } else {
297
+ slugs = targets;
298
+ }
299
+ let done = 0, skipped = 0;
300
+ for (const slug of slugs) {
301
+ const r = await authed(cfg, 'GET', '/api/cli/blog/post/' + encodeURIComponent(slug));
302
+ if (!r.data.ok) { warn(r.data.error); continue; }
303
+ const p = r.data.post;
304
+ let md = p.markdown || '';
305
+ // 服务端导出首行为 "# 标题",标题已在 front-matter 中,去掉避免重复
306
+ md = md.replace(new RegExp('^# ' + p.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\r?\\n'), '');
307
+ const file = path.join(POSTS_DIR, p.slug + '.md');
308
+ const content = buildFrontMatter({
309
+ title: p.title, slug: p.slug, status: p.status,
310
+ category: p.category || '', description: p.description || '',
311
+ cover: p.cover ? new URL(p.cover, cfg.server + '/').href : '',
312
+ }, md);
313
+ if (fs.existsSync(file) && !force && fs.readFileSync(file, 'utf8') !== content) {
314
+ warn('本地有改动,跳过(用 -f 覆盖):' + p.slug + '.md');
315
+ skipped++;
316
+ continue;
317
+ }
318
+ fs.writeFileSync(file, content);
319
+ console.log(' ✓ ' + p.slug + '.md' + (p.status === 'draft' ? '(草稿)' : ''));
320
+ done++;
321
+ }
322
+ info('拉取 ' + done + ' 篇' + (skipped ? ',跳过 ' + skipped + ' 篇(本地有改动)' : ''));
323
+ }
324
+
325
+ /* ---------- delete ---------- */
326
+ async function cmdDelete(args, cfg) {
327
+ const slug = args.filter((a) => !a.startsWith('-'))[0];
328
+ if (!slug) die('用法:op blog delete <slug> [-y]');
329
+ const { ask } = require('./prompt');
330
+ if (!args.includes('-y') && !args.includes('--yes')) {
331
+ const ans = await ask('确认删除文章「' + slug + '」?(y/N) ');
332
+ if (ans.trim().toLowerCase() !== 'y') { info('已取消'); return; }
333
+ }
334
+ const r = await authed(cfg, 'DELETE', '/api/cli/blog/post/' + encodeURIComponent(slug));
335
+ if (!r.data.ok) die(r.data.error || '删除失败');
336
+ ok('已删除:' + slug);
337
+ }
338
+
339
+ /* ---------- categories ---------- */
340
+ async function cmdCategories(args, cfg) {
341
+ const sub = args[0];
342
+ if (sub === 'add' || sub === 'rename' || sub === 'delete') {
343
+ const body = { action: sub, name: args[1] || '' };
344
+ if (sub === 'rename') body.new_name = args[2] || '';
345
+ const r = await authed(cfg, 'POST', '/api/cli/blog/categories', body);
346
+ if (!r.data.ok) die(r.data.error || '操作失败');
347
+ ok({ add: '已创建分类:', rename: '已重命名为:', delete: '已删除分类:' }[sub] +
348
+ (sub === 'rename' ? body.new_name : body.name));
349
+ return;
350
+ }
351
+ const r = await authed(cfg, 'GET', '/api/cli/blog/categories');
352
+ const cats = r.data.categories || [];
353
+ if (!cats.length) { info('还没有分类(op blog categories add <名称>)'); return; }
354
+ table(['分类', '文章数'], cats.map((c) => [c.name, String(c.count)]));
355
+ }
356
+
357
+ /* ---------- themes ---------- */
358
+ async function cmdThemes(cfg) {
359
+ const r = await authed(cfg, 'GET', '/api/cli/blog/themes');
360
+ const themes = r.data.themes || [];
361
+ table(['KEY', '名称', '当前'], themes.map((t) => [t.key, t.name, t.current ? '✓' : '']));
362
+ info('应用:op blog theme apply <key> [--opt 选项名=值 ...]');
363
+ }
364
+
365
+ async function cmdThemeApply(args, cfg) {
366
+ const key = args.filter((a) => !a.startsWith('--'))[0];
367
+ if (!key) die('用法:op blog theme apply <key> [--opt 选项名=值 ...]');
368
+ const options = {};
369
+ const rest = args.filter((a) => !a.startsWith('--'));
370
+ for (const a of args) {
371
+ const m = a.match(/^--opt(?:ion)?[= ](.+?)=(.+)$/);
372
+ if (m) options[m[1]] = m[2];
373
+ }
374
+ if (rest[1]) options._hint = rest[1]; // 容错:位置参数不进 options
375
+ delete options._hint;
376
+ const r = await authed(cfg, 'POST', '/api/cli/blog/theme/apply', { theme: key, options });
377
+ if (!r.data.ok) die(r.data.error || '应用失败');
378
+ ok('主题已应用:' + key + '(静态页已重建)');
379
+ }
380
+
381
+ async function cmdThemePush(args, cfg) {
382
+ const zipPath = args.filter((a) => !a.startsWith('-'))[0];
383
+ if (!zipPath) die('用法:op blog theme push <主题包.zip> [--overwrite]');
384
+ if (!fs.existsSync(zipPath)) die('文件不存在:' + zipPath);
385
+ const form = new FormData();
386
+ form.append('file', new Blob([fs.readFileSync(zipPath)]), path.basename(zipPath));
387
+ if (args.includes('--overwrite')) form.append('overwrite', '1');
388
+ const res = await fetch(cfg.server + '/api/cli/blog/theme/import', {
389
+ method: 'POST',
390
+ headers: { Authorization: 'Bearer ' + cfg.token },
391
+ body: form,
392
+ });
393
+ const data = await res.json().catch(() => ({}));
394
+ if (res.status === 401) die('登录已失效,请重新执行 op login');
395
+ if (!data.ok) die(data.error || '导入失败');
396
+ ok('主题「' + data.key + '」导入成功,静态页已重建');
397
+ }
398
+
399
+ /* ---------- help ---------- */
400
+ function cmdHelp() {
401
+ console.log([
402
+ '',
403
+ '用法:op blog <子命令> [参数]',
404
+ '',
405
+ ' init 创建博客工作区 .onepage/blog/(config.yml + posts/ + themes/)',
406
+ ' write <标题> 新建带 front-matter 的 Markdown 文章模板',
407
+ ' status 本地文章与远端对比',
408
+ ' push [文件] [--publish] 推送文章(--publish 直接发布,自动过 AI 审核;front-matter 支持 cover 封面)',
409
+ ' pull [slug|all] [-f] 拉取远端文章为 Markdown(-f 覆盖本地改动)',
410
+ ' list 远端文章列表',
411
+ ' delete <slug> [-y] 删除文章',
412
+ ' config [pull|push] 拉取/推送博客配置(标题、介绍、主题及选项)',
413
+ ' categories [add|rename|delete ...] 分类管理',
414
+ ' themes 主题列表',
415
+ ' theme apply <key> [--opt k=v] 应用主题',
416
+ ' theme push <zip> [--overwrite] 上传主题包',
417
+ '',
418
+ ].join('\n'));
419
+ }
420
+
421
+ /* ---------- 入口 ---------- */
422
+ module.exports = async function (args, cfg) {
423
+ const sub = args[0] || 'help';
424
+ const rest = args.slice(1);
425
+ switch (sub) {
426
+ case 'init': return cmdInit();
427
+ case 'write': case 'new': return cmdWrite(rest);
428
+ case 'status': return await cmdStatus(cfg);
429
+ case 'push': return await cmdPush(rest, cfg);
430
+ case 'pull': return await cmdPull(rest, cfg);
431
+ case 'list': case 'ls': return await cmdList(cfg);
432
+ case 'delete': case 'rm': return await cmdDelete(rest, cfg);
433
+ case 'config': return await cmdConfig(rest, cfg);
434
+ case 'categories': case 'cat': return await cmdCategories(rest, cfg);
435
+ case 'themes': return await cmdThemes(cfg);
436
+ case 'theme': {
437
+ const action = rest[0];
438
+ if (action === 'apply') return await cmdThemeApply(rest.slice(1), cfg);
439
+ if (action === 'push' || action === 'import') return await cmdThemePush(rest.slice(1), cfg);
440
+ die('未知子命令:theme ' + (action || '') + '(可用:apply / push)');
441
+ }
442
+ case 'help': case '--help': case '-h': return cmdHelp();
443
+ default:
444
+ die('未知子命令:blog ' + sub + '(op blog help 查看用法)');
445
+ }
446
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "onepage-cli",
3
- "version": "1.0.2",
4
- "description": "OnePage 命令行工具:在终端生成、部署、管理你的静态网页(git 风格指令 op login / init / add / push / clone / list / delete)",
3
+ "version": "1.2.0",
4
+ "description": "OnePage 命令行工具:在终端部署管理静态网页与博客(op 静态站指令 + op blog 文章/配置/主题指令)",
5
5
  "bin": {
6
6
  "op": "bin/op.js"
7
7
  },
@@ -27,4 +27,4 @@
27
27
  "type": "url",
28
28
  "url": "https://app1.cloudstudio.site"
29
29
  }
30
- }
30
+ }