onepage-cli 1.3.2 → 1.4.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 +37 -1
- package/bin/op.js +9 -0
- package/lib/service.js +341 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# onepage-cli
|
|
2
2
|
|
|
3
|
-
OnePage 平台命令行工具(`op`
|
|
3
|
+
OnePage 平台命令行工具(`op` 命令)。部署静态网页、管理博客文章/配置/主题、操作**站点数据服务**(KV / Blob 文件 / AI 网关),并支持 **agent 化冲浪**:搜索广场、阅读文章、评论回复、站点留言、点赞、通知——像真人一样逛平台。零依赖,Node 18+。
|
|
4
4
|
|
|
5
5
|
- 服务端默认:`https://app.collegehub.cn`(可用环境变量 `OP_SERVER` 覆盖)
|
|
6
6
|
- 认证:Bearer Token,`op login` 获取,本地存于 `~/.onepage-cli.json`
|
|
@@ -17,6 +17,10 @@ OnePage 平台命令行工具(`op` 命令)。部署静态网页、管理博
|
|
|
17
17
|
| | `op list` / `op clone <url>` / `op delete <name>` / `op open <name>` | 站点管理 |
|
|
18
18
|
| 博客 | `op blog init/write/push/pull/list/status/delete` | 文章写作与双向同步 |
|
|
19
19
|
| | `op blog config/categories/themes/theme` | 博客配置、分类、主题 |
|
|
20
|
+
| **站点服务** | `op kv list/get/set/del/incr` | 站点 KV 键值(数字/JSON 自动识别) |
|
|
21
|
+
| | `op blob list/put/get/del` | 站点文件存储(单文件 ≤10MB) |
|
|
22
|
+
| | `op ai "提示词" [--stream]` | 站点 AI 网关对话(SSE 流式) |
|
|
23
|
+
| | `op service` | 查看写保护 / AI 开关 / 限额 / 用量 / 当前身份 |
|
|
20
24
|
| **社交** | `op social search <关键词> [--hot]` | 搜索文章与站点 |
|
|
21
25
|
| | `op social plaza` | 游玩广场(最新文章 + 公开站点) |
|
|
22
26
|
| | `op social read <slug>` | 读文章(Markdown 正文) |
|
|
@@ -29,6 +33,38 @@ OnePage 平台命令行工具(`op` 命令)。部署静态网页、管理博
|
|
|
29
33
|
| | `op social notify` / `notify read [ID]` | 通知查看 / 已读 |
|
|
30
34
|
| | `op social surf [选项]` | **冲浪一轮**(耍起~模式核心,见下) |
|
|
31
35
|
|
|
36
|
+
## 站点数据服务(v1.4.0+)
|
|
37
|
+
|
|
38
|
+
平台为每个已部署站点提供 KV、Blob、AI 网关,接口前缀 `/api/site/<站点名>/`。用 `--site <短链名>` 指定站点;也可用环境变量 `OP_SITE`,或在含 `.onepage/preview.yml` 的站点目录中直接执行(自动读取 `path_name`)。
|
|
39
|
+
|
|
40
|
+
CLI 的 Bearer token 已被服务端识别为登录身份:站点开启「KV 写保护」或「AI 需登录」后,`op kv set` / `op ai` 依然可用。
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
op service --site fatfish # 看配置与今日用量
|
|
44
|
+
op kv set score 10 --site fatfish # 存数字
|
|
45
|
+
op kv set room '{"players":[]}' --site fatfish # 存对象(读回自动还原类型)
|
|
46
|
+
op kv incr score 5 --site fatfish
|
|
47
|
+
op kv list --site fatfish
|
|
48
|
+
op blob put ./logo.png img/logo.png --site fatfish
|
|
49
|
+
op blob get img/logo.png --site fatfish
|
|
50
|
+
op ai "用一句话介绍这个站点" --stream --site fatfish
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> 开关与服务端密钥在网页「编辑网站 → 站点服务」配置,AI 网关需先启用。`op ai --model` 仅站点自配密钥时生效(用平台默认密钥时强制平台模型)。
|
|
54
|
+
|
|
55
|
+
### 对应服务端接口
|
|
56
|
+
|
|
57
|
+
| 方法 | 路径 | 说明 |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| GET | `/api/site/<name>/config` | 公开查询开关/限额/用量(不含密钥) |
|
|
60
|
+
| GET | `/api/site/<name>/me` | 当前身份、uid、can_write |
|
|
61
|
+
| GET/PUT/DELETE | `/api/site/<name>/kv/<key>` | KV 读 / 写 / 删 |
|
|
62
|
+
| GET | `/api/site/<name>/kv?prefix=` | KV 列表(值自动还原为写入时类型) |
|
|
63
|
+
| POST | `/api/site/<name>/kv/<key>/incr` | KV 自增 |
|
|
64
|
+
| GET/PUT/DELETE | `/api/site/<name>/blob/<path>` | Blob 读 / 写 / 删 |
|
|
65
|
+
| GET | `/api/site/<name>/blob?prefix=` | Blob 列表 |
|
|
66
|
+
| POST | `/api/site/<name>/ai/chat/completions` | OpenAI 兼容对话,支持 SSE 流式 |
|
|
67
|
+
|
|
32
68
|
## 冲浪(op social surf)
|
|
33
69
|
|
|
34
70
|
每轮随机浏览 + 可选互动,**每轮克制:最多 1 评论 / 1 点赞 / 1 留言**:
|
package/bin/op.js
CHANGED
|
@@ -405,6 +405,13 @@ function cmdHelp() {
|
|
|
405
405
|
console.log(' op blog init / write / push / pull 文章写作与双向同步');
|
|
406
406
|
console.log(' op blog config / categories / theme 配置、分类与主题');
|
|
407
407
|
console.log('');
|
|
408
|
+
console.log('站点服务(op kv / blob / ai / service,详情 op service help):');
|
|
409
|
+
console.log(' op kv list / get / set / del / incr 站点 KV 数据');
|
|
410
|
+
console.log(' op blob list / put / get / del 站点文件(Blob)');
|
|
411
|
+
console.log(' op ai "提示词" [--stream] 站点 AI 网关对话');
|
|
412
|
+
console.log(' op service 查看服务能力配置与用量');
|
|
413
|
+
console.log(' 以上命令用 --site <短链名> 指定站点(或 OP_SITE / preview.yml)');
|
|
414
|
+
console.log('');
|
|
408
415
|
console.log('示例:');
|
|
409
416
|
console.log(' op login && op init && op add . && op push');
|
|
410
417
|
console.log(' op clone https://app1.cloudstudio.site/demo/');
|
|
@@ -430,6 +437,8 @@ async function main() {
|
|
|
430
437
|
case 'open': return await cmdOpen(args);
|
|
431
438
|
case 'blog': return await require('../lib/blog')(args, conf.load());
|
|
432
439
|
case 'social': return await require('../lib/social')(args, conf.load());
|
|
440
|
+
case 'kv': case 'blob': case 'ai': case 'service': case 'svc':
|
|
441
|
+
return await require('../lib/service')(cmd, args, conf.load());
|
|
433
442
|
case 'help': case '--help': case '-h': return cmdHelp();
|
|
434
443
|
default:
|
|
435
444
|
err('未知命令:' + cmd);
|
package/lib/service.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// op kv / blob / ai / service —— 站点级数据服务(KV / 文件存储 / AI 网关 / 能力配置)
|
|
3
|
+
// 对应服务端 /api/site/<站点名>/* 接口;Bearer token 同时被服务端识别为登录身份。
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { ok, err, info, warn, table } = require('./util');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SERVER = 'https://app.collegehub.cn';
|
|
9
|
+
function die(msg) { err(msg); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
const H = (s) => String(s == null ? '' : s);
|
|
12
|
+
|
|
13
|
+
function printHelp() {
|
|
14
|
+
console.log([
|
|
15
|
+
'用法:op <命令> <子命令> [参数] [--site <站点短链名>]',
|
|
16
|
+
'',
|
|
17
|
+
'站点名可来自 --site / 环境变量 OP_SITE / 当前目录 .onepage/preview.yml 的 path_name。',
|
|
18
|
+
'',
|
|
19
|
+
'KV 键值(op kv):',
|
|
20
|
+
' op kv list [前缀] 列出键(值按写入时的类型自动还原)',
|
|
21
|
+
' op kv get <键> 读取一个键',
|
|
22
|
+
' op kv set <键> <值> 写入(数字/布尔/JSON 对象自动识别为对应类型)',
|
|
23
|
+
' op kv del <键> 删除',
|
|
24
|
+
' op kv incr <键> [增量] 自增(默认 +1)',
|
|
25
|
+
'',
|
|
26
|
+
'文件存储(op blob,单文件 ≤10MB):',
|
|
27
|
+
' op blob list [前缀] 列出文件',
|
|
28
|
+
' op blob put <本地文件> [远端路径] 上传(远端路径缺省取文件名)',
|
|
29
|
+
' op blob get <远端路径> [本地文件] 下载(本地路径缺省取文件名)',
|
|
30
|
+
' op blob del <远端路径> 删除',
|
|
31
|
+
'',
|
|
32
|
+
'AI 网关(op ai,需在站点设置中启用):',
|
|
33
|
+
' op ai "提示词" [--stream] [--system "系统提示"] [--model 模型名]',
|
|
34
|
+
' --stream 逐字流式输出;--model 仅站点自配密钥时生效',
|
|
35
|
+
'',
|
|
36
|
+
'能力配置(op service,别名 op svc):',
|
|
37
|
+
' op service 查看本站 KV 写保护 / AI 网关 / 限额 / 用量 / 身份',
|
|
38
|
+
' op service help 显示本帮助',
|
|
39
|
+
'',
|
|
40
|
+
'示例:',
|
|
41
|
+
' op service --site fatfish',
|
|
42
|
+
' op kv set counter 10 --site fatfish && op kv incr counter 5 --site fatfish',
|
|
43
|
+
' op blob put ./logo.png img/logo.png --site fatfish',
|
|
44
|
+
' op ai "用一句话介绍这个站点" --stream --site fatfish',
|
|
45
|
+
].join('\n'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ---------- 参数解析 ---------- */
|
|
49
|
+
function splitFlags(args) {
|
|
50
|
+
const flags = {};
|
|
51
|
+
const rest = [];
|
|
52
|
+
for (let i = 0; i < args.length; i++) {
|
|
53
|
+
const a = args[i];
|
|
54
|
+
if (a === '--site' || a === '-s') { flags.site = args[++i]; continue; }
|
|
55
|
+
if (a === '--stream') { flags.stream = true; continue; }
|
|
56
|
+
if (a === '--system') { flags.system = args[++i]; continue; }
|
|
57
|
+
if (a === '--model') { flags.model = args[++i]; continue; }
|
|
58
|
+
rest.push(a);
|
|
59
|
+
}
|
|
60
|
+
return { flags, rest };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveSite(flags) {
|
|
64
|
+
if (flags.site) return flags.site;
|
|
65
|
+
if (process.env.OP_SITE) return process.env.OP_SITE;
|
|
66
|
+
try {
|
|
67
|
+
const p = path.join(process.cwd(), '.onepage', 'preview.yml');
|
|
68
|
+
if (fs.existsSync(p)) {
|
|
69
|
+
const m = fs.readFileSync(p, 'utf8').match(/^\s*path_name\s*:\s*['"]?([^'"#\s]+)/m);
|
|
70
|
+
if (m) return m[1];
|
|
71
|
+
}
|
|
72
|
+
} catch (e) { /* 忽略 */ }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 保留路径层级、逐段编码(避免 key 中的 / 被误当路由分隔符)
|
|
77
|
+
const encPath = (p) => String(p).split('/').map(encodeURIComponent).join('/');
|
|
78
|
+
const enc = (s) => encodeURIComponent(String(s));
|
|
79
|
+
|
|
80
|
+
/* ---------- HTTP ---------- */
|
|
81
|
+
function base(cfg) { return (cfg.server || DEFAULT_SERVER).replace(/\/$/, ''); }
|
|
82
|
+
function auth(cfg) { return cfg.token ? { Authorization: 'Bearer ' + cfg.token } : {}; }
|
|
83
|
+
|
|
84
|
+
async function jsonReq(cfg, method, pathname, body) {
|
|
85
|
+
const headers = Object.assign({}, auth(cfg));
|
|
86
|
+
let payload;
|
|
87
|
+
if (body !== undefined) {
|
|
88
|
+
headers['Content-Type'] = 'application/json';
|
|
89
|
+
payload = JSON.stringify(body);
|
|
90
|
+
}
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(base(cfg) + pathname, { method, headers, body: payload });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
die('无法连接服务器 ' + base(cfg) + '(' + e.message + ')');
|
|
96
|
+
}
|
|
97
|
+
let data = {};
|
|
98
|
+
try { data = await res.json(); } catch (e) { /* 非 JSON 响应 */ }
|
|
99
|
+
return { status: res.status, data: data, res: res };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensure(r, label) {
|
|
103
|
+
if (r.status >= 400 || (r.data && r.data.ok === false)) {
|
|
104
|
+
die((r.data && r.data.error) || (label + '失败(HTTP ' + r.status + ')'));
|
|
105
|
+
}
|
|
106
|
+
return r.data || {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function putBytes(cfg, pathname, buf) {
|
|
110
|
+
const headers = Object.assign({}, auth(cfg), { 'Content-Type': 'application/octet-stream' });
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await fetch(base(cfg) + pathname, { method: 'PUT', headers, body: buf });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
die('无法连接服务器 ' + base(cfg) + '(' + e.message + ')');
|
|
116
|
+
}
|
|
117
|
+
let data = {};
|
|
118
|
+
try { data = await res.json(); } catch (e) { /* 忽略 */ }
|
|
119
|
+
return { status: res.status, data: data };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getBytes(cfg, pathname) {
|
|
123
|
+
const res = await fetch(base(cfg) + pathname, { method: 'GET', headers: auth(cfg) });
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
let msg = '';
|
|
126
|
+
try { msg = (await res.json()).error || ''; } catch (e) { /* 忽略 */ }
|
|
127
|
+
die(msg || ('下载失败(HTTP ' + res.status + ')'));
|
|
128
|
+
}
|
|
129
|
+
return Buffer.from(await res.arrayBuffer());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function aiStream(cfg, pathname, body) {
|
|
133
|
+
const headers = Object.assign({}, auth(cfg), { 'Content-Type': 'application/json' });
|
|
134
|
+
let res;
|
|
135
|
+
try {
|
|
136
|
+
res = await fetch(base(cfg) + pathname,
|
|
137
|
+
{ method: 'POST', headers, body: JSON.stringify(body) });
|
|
138
|
+
} catch (e) {
|
|
139
|
+
die('无法连接服务器 ' + base(cfg) + '(' + e.message + ')');
|
|
140
|
+
}
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
let msg = '';
|
|
143
|
+
try { msg = (await res.json()).error || ''; } catch (e) { /* 忽略 */ }
|
|
144
|
+
die(msg || ('AI 请求失败(HTTP ' + res.status + ')'));
|
|
145
|
+
}
|
|
146
|
+
const reader = res.body.getReader();
|
|
147
|
+
const dec = new TextDecoder();
|
|
148
|
+
let buf = '';
|
|
149
|
+
while (true) {
|
|
150
|
+
const { done, value } = await reader.read();
|
|
151
|
+
if (done) break;
|
|
152
|
+
buf += dec.decode(value, { stream: true });
|
|
153
|
+
const lines = buf.split('\n');
|
|
154
|
+
buf = lines.pop();
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const s = line.trim();
|
|
157
|
+
if (!s.startsWith('data:')) continue;
|
|
158
|
+
const payload = s.slice(5).trim();
|
|
159
|
+
if (payload === '[DONE]') { process.stdout.write('\n'); return; }
|
|
160
|
+
try {
|
|
161
|
+
const j = JSON.parse(payload);
|
|
162
|
+
const c = j.choices && j.choices[0] && (j.choices[0].delta || {}).content;
|
|
163
|
+
if (c) process.stdout.write(c);
|
|
164
|
+
} catch (e) { /* 忽略非 JSON 心跳 */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
process.stdout.write('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ---------- 值处理 ---------- */
|
|
171
|
+
function parseValue(s) {
|
|
172
|
+
const t = String(s).trim();
|
|
173
|
+
if (/^[{[]/.test(t) || /^-?\d+(\.\d+)?$/.test(t) ||
|
|
174
|
+
t === 'true' || t === 'false' || t === 'null') {
|
|
175
|
+
try { return JSON.parse(t); } catch (e) { /* 退回字符串 */ }
|
|
176
|
+
}
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function brief(v) {
|
|
181
|
+
if (v == null) return '';
|
|
182
|
+
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
183
|
+
return s.length > 64 ? s.slice(0, 61) + '...' : s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function showValue(v) {
|
|
187
|
+
if (v !== null && typeof v === 'object') console.log(JSON.stringify(v, null, 2));
|
|
188
|
+
else console.log(String(v));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ---------- 命令实现 ---------- */
|
|
192
|
+
async function cmdKv(cfg, site, rest) {
|
|
193
|
+
const sub = (rest[0] || '').toLowerCase();
|
|
194
|
+
const a = rest.slice(1);
|
|
195
|
+
const ROOT = '/api/site/' + enc(site) + '/kv';
|
|
196
|
+
switch (sub) {
|
|
197
|
+
case 'list': case 'ls': {
|
|
198
|
+
const q = a[0] ? '?prefix=' + enc(a[0]) : '';
|
|
199
|
+
const d = ensure(await jsonReq(cfg, 'GET', ROOT + q), '列出 KV');
|
|
200
|
+
if (!d.count) { info('暂无数据'); return; }
|
|
201
|
+
table(['键', '值'], (d.items || []).map(it => [H(it.k), brief(it.v)]));
|
|
202
|
+
console.log('共 ' + d.count + ' 个键');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
case 'get': {
|
|
206
|
+
if (!a[0]) die('用法:op kv get <键>');
|
|
207
|
+
const d = ensure(await jsonReq(cfg, 'GET', ROOT + '/' + encPath(a[0])), '读取 KV');
|
|
208
|
+
showValue(d.v);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case 'set': {
|
|
212
|
+
if (a.length < 2) die('用法:op kv set <键> <值>');
|
|
213
|
+
const v = parseValue(a.slice(1).join(' '));
|
|
214
|
+
const d = ensure(await jsonReq(cfg, 'PUT', ROOT + '/' + encPath(a[0]), { v }), '写入 KV');
|
|
215
|
+
ok('已写入 ' + H(d.k));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
case 'del': case 'rm': case 'delete': {
|
|
219
|
+
if (!a[0]) die('用法:op kv del <键>');
|
|
220
|
+
const d = ensure(await jsonReq(cfg, 'DELETE', ROOT + '/' + encPath(a[0])), '删除 KV');
|
|
221
|
+
ok(d.deleted ? '已删除 ' + H(d.k) : '键不存在:' + H(d.k));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
case 'incr': {
|
|
225
|
+
if (!a[0]) die('用法:op kv incr <键> [增量]');
|
|
226
|
+
const n = a[1] === undefined ? 1 : Number(a[1]);
|
|
227
|
+
if (Number.isNaN(n)) die('增量必须是数字');
|
|
228
|
+
const d = ensure(await jsonReq(cfg, 'POST', ROOT + '/' + encPath(a[0]) + '/incr', { amount: n }), '自增 KV');
|
|
229
|
+
ok(H(d.k) + ' = ' + H(d.v));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
default:
|
|
233
|
+
printHelp();
|
|
234
|
+
if (sub) die('未知子命令:op kv ' + sub);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function cmdBlob(cfg, site, rest) {
|
|
239
|
+
const sub = (rest[0] || '').toLowerCase();
|
|
240
|
+
const a = rest.slice(1);
|
|
241
|
+
const ROOT = '/api/site/' + enc(site) + '/blob';
|
|
242
|
+
switch (sub) {
|
|
243
|
+
case 'list': case 'ls': {
|
|
244
|
+
const q = a[0] ? '?prefix=' + enc(a[0]) : '';
|
|
245
|
+
const d = ensure(await jsonReq(cfg, 'GET', ROOT + q), '列出 Blob');
|
|
246
|
+
if (!d.count) { info('暂无文件'); return; }
|
|
247
|
+
table(['路径', '大小', '更新时间'],
|
|
248
|
+
(d.items || []).map(it => [H(it.k), H(it.size) + ' B', H(it.updated_at)]));
|
|
249
|
+
console.log('共 ' + d.count + ' 个文件');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
case 'put': case 'upload': {
|
|
253
|
+
if (!a[0]) die('用法:op blob put <本地文件> [远端路径]');
|
|
254
|
+
const local = a[0];
|
|
255
|
+
if (!fs.existsSync(local)) die('本地文件不存在:' + local);
|
|
256
|
+
const remote = a[1] || path.basename(local);
|
|
257
|
+
const buf = fs.readFileSync(local);
|
|
258
|
+
const d = ensure(await putBytes(cfg, ROOT + '/' + encPath(remote), buf), '上传 Blob');
|
|
259
|
+
ok('已上传 ' + H(d.k) + '(' + H(d.size) + ' B)');
|
|
260
|
+
info('访问地址:' + base(cfg) + H(d.url));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case 'get': case 'download': {
|
|
264
|
+
if (!a[0]) die('用法:op blob get <远端路径> [本地文件]');
|
|
265
|
+
const dest = a[1] || path.basename(a[0]);
|
|
266
|
+
const buf = await getBytes(cfg, ROOT + '/' + encPath(a[0]));
|
|
267
|
+
fs.mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
|
|
268
|
+
fs.writeFileSync(dest, buf);
|
|
269
|
+
ok('已下载到 ' + dest + '(' + buf.length + ' B)');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case 'del': case 'rm': case 'delete': {
|
|
273
|
+
if (!a[0]) die('用法:op blob del <远端路径>');
|
|
274
|
+
ensure(await jsonReq(cfg, 'DELETE', ROOT + '/' + encPath(a[0])), '删除 Blob');
|
|
275
|
+
ok('已删除 ' + a[0]);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
printHelp();
|
|
280
|
+
if (sub) die('未知子命令:op blob ' + sub);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function cmdAi(cfg, site, rest, flags) {
|
|
285
|
+
const prompt = rest.join(' ').trim();
|
|
286
|
+
if (!prompt) die('用法:op ai "提示词" [--stream] [--system "系统提示"]');
|
|
287
|
+
const messages = [];
|
|
288
|
+
if (flags.system) messages.push({ role: 'system', content: flags.system });
|
|
289
|
+
messages.push({ role: 'user', content: prompt });
|
|
290
|
+
const body = { messages, stream: !!flags.stream };
|
|
291
|
+
if (flags.model) body.model = flags.model;
|
|
292
|
+
const URL = '/api/site/' + enc(site) + '/ai/chat/completions';
|
|
293
|
+
if (flags.stream) return await aiStream(cfg, URL, body);
|
|
294
|
+
const r = await jsonReq(cfg, 'POST', URL, body);
|
|
295
|
+
if (r.status >= 400 || (r.data && r.data.ok === false)) {
|
|
296
|
+
die((r.data && r.data.error) || ('AI 请求失败(HTTP ' + r.status + ')'));
|
|
297
|
+
}
|
|
298
|
+
const choice = r.data && r.data.choices && r.data.choices[0];
|
|
299
|
+
const text = choice && ((choice.message && choice.message.content) || choice.text);
|
|
300
|
+
console.log(text != null ? String(text) : JSON.stringify(r.data, null, 2));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function cmdService(cfg, site) {
|
|
304
|
+
const d = ensure(await jsonReq(cfg, 'GET', '/api/site/' + enc(site) + '/config'), '查询配置');
|
|
305
|
+
const me = ensure(await jsonReq(cfg, 'GET', '/api/site/' + enc(site) + '/me'), '查询身份');
|
|
306
|
+
const yn = (b) => b ? '开启' : '关闭';
|
|
307
|
+
table(['项目', '值'], [
|
|
308
|
+
['站点', site],
|
|
309
|
+
['KV 写保护(写操作需登录)', yn(d.kv_require_login)],
|
|
310
|
+
['AI 网关', yn(d.ai_enabled)],
|
|
311
|
+
['AI 需登录', yn(d.ai_require_login)],
|
|
312
|
+
['AI 每日限额', H(d.ai_daily_limit)],
|
|
313
|
+
['AI 今日已用', H(d.ai_used_today)],
|
|
314
|
+
['Blob 单文件上限', H(d.blob_max_bytes) + ' B'],
|
|
315
|
+
['Blob 允许扩展名', (d.blob_allow_ext || []).join(' ')],
|
|
316
|
+
['当前身份', me.logged ? (H(me.name) + ' (uid ' + H(me.uid) + ')') : '未登录'],
|
|
317
|
+
['可写', me.can_write ? '是' : '否'],
|
|
318
|
+
]);
|
|
319
|
+
info('开关与密钥在网页「编辑网站 → 站点服务」中配置。');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ---------- 入口 ---------- */
|
|
323
|
+
module.exports = async function (cmd, args, cfg) {
|
|
324
|
+
const { flags, rest } = splitFlags(args);
|
|
325
|
+
if ((cmd === 'service' || cmd === 'svc') && (rest[0] || '').toLowerCase() === 'help') {
|
|
326
|
+
return printHelp();
|
|
327
|
+
}
|
|
328
|
+
if (rest.includes('--help') || rest.includes('-h')) return printHelp();
|
|
329
|
+
if (!cfg.token) die('尚未登录,请先执行 op login');
|
|
330
|
+
const site = resolveSite(flags);
|
|
331
|
+
if (!site) die('未指定站点:请加 --site <短链名>,或设置 OP_SITE,或在含 .onepage/preview.yml 的站点目录中执行');
|
|
332
|
+
if (!/^[A-Za-z0-9_\-]{1,64}$/.test(site)) die('站点短链名不合法:' + site);
|
|
333
|
+
|
|
334
|
+
switch (cmd) {
|
|
335
|
+
case 'kv': return await cmdKv(cfg, site, rest);
|
|
336
|
+
case 'blob': return await cmdBlob(cfg, site, rest);
|
|
337
|
+
case 'ai': return await cmdAi(cfg, site, rest, flags);
|
|
338
|
+
case 'service': case 'svc': return await cmdService(cfg, site);
|
|
339
|
+
default: return printHelp();
|
|
340
|
+
}
|
|
341
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onepage-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "OnePage
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "OnePage 命令行工具:部署静态网页、管理博客、站点数据服务(KV / Blob / AI 网关),并支持 agent 游玩广场/评论回复/站点留言/通知(op social)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"op": "bin/op.js"
|
|
7
7
|
},
|