@code2rich/jpage 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.claude/settings.local.json +68 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +56 -0
  4. package/.github/workflows/ci.yml +43 -0
  5. package/CLAUDE.md +280 -0
  6. package/Dockerfile +44 -0
  7. package/LICENSE +21 -0
  8. package/README.md +433 -0
  9. package/README_EN.md +399 -0
  10. package/bin/args.js +64 -0
  11. package/bin/client.js +93 -0
  12. package/bin/commands/_shared.js +54 -0
  13. package/bin/commands/cat.js +23 -0
  14. package/bin/commands/ls.js +44 -0
  15. package/bin/commands/mv.js +20 -0
  16. package/bin/commands/rm.js +22 -0
  17. package/bin/commands/skills.js +70 -0
  18. package/bin/commands/star.js +23 -0
  19. package/bin/commands/tags.js +97 -0
  20. package/bin/commands/upload.js +84 -0
  21. package/bin/commands/url.js +25 -0
  22. package/bin/commands/whoami.js +29 -0
  23. package/bin/config.js +85 -0
  24. package/bin/jpage.js +168 -0
  25. package/build.js +112 -0
  26. package/docker-compose.yml +26 -0
  27. package/docs/api.md +438 -0
  28. package/docs/design/005-custom-modal.md +296 -0
  29. package/docs/design/013-file-version-history.md +324 -0
  30. package/docs/design/billing-system.md +600 -0
  31. package/docs/design/db-index-and-healthcheck.md +176 -0
  32. package/docs/design/loading-states.md +209 -0
  33. package/docs/virtual-hosting-feasibility.md +453 -0
  34. package/eslint.config.mjs +172 -0
  35. package/lib/auth-state.js +15 -0
  36. package/lib/categories.js +20 -0
  37. package/lib/crypto.js +85 -0
  38. package/lib/csp.js +66 -0
  39. package/lib/db.js +53 -0
  40. package/lib/dispatch.js +103 -0
  41. package/lib/fts.js +81 -0
  42. package/lib/middleware/auth.js +114 -0
  43. package/lib/middleware/files.js +42 -0
  44. package/lib/paths.js +9 -0
  45. package/lib/render-cache.js +48 -0
  46. package/lib/render.js +157 -0
  47. package/lib/templates.js +149 -0
  48. package/lib/util.js +66 -0
  49. package/lib/view-counts.js +59 -0
  50. package/lib/zip.js +192 -0
  51. package/logger.js +16 -0
  52. package/mailer.js +34 -0
  53. package/mcp/constants.js +16 -0
  54. package/mcp/resources.js +74 -0
  55. package/mcp/server.js +43 -0
  56. package/mcp/tools-categories.js +56 -0
  57. package/mcp/tools-content-templates.js +59 -0
  58. package/mcp/tools-files.js +245 -0
  59. package/mcp/tools-tags.js +41 -0
  60. package/mcp/tools-versions.js +57 -0
  61. package/mcp/transport.js +183 -0
  62. package/mcp/util.js +63 -0
  63. package/mcp-server.js +20 -0
  64. package/migrations/001_init_schema.js +25 -0
  65. package/migrations/002_add_share_key.js +33 -0
  66. package/migrations/003_add_roles_and_tokens.js +28 -0
  67. package/migrations/004_add_version_history.js +32 -0
  68. package/migrations/005_tags_starred_categories.js +49 -0
  69. package/migrations/006_zip_bundle.js +17 -0
  70. package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
  71. package/migrations/008_add_fts5.js +6 -0
  72. package/migrations/009_add_link_visits.js +20 -0
  73. package/migrations/010_add_templates_system.js +34 -0
  74. package/migrations/011_content_templates.js +233 -0
  75. package/migrations/012_add_email_and_verification.js +35 -0
  76. package/migrations/013_add_token_encrypted.js +14 -0
  77. package/migrations.js +65 -0
  78. package/package.json +63 -0
  79. package/public/css/style.css +2915 -0
  80. package/public/index.html +855 -0
  81. package/public/js/api.js +22 -0
  82. package/public/js/app.js +94 -0
  83. package/public/js/components/dialog.js +106 -0
  84. package/public/js/components/toast.js +13 -0
  85. package/public/js/pages/content-templates.js +330 -0
  86. package/public/js/pages/home.js +1903 -0
  87. package/public/js/pages/landing.js +158 -0
  88. package/public/js/pages/login.js +175 -0
  89. package/public/js/pages/preview.js +713 -0
  90. package/public/js/theme.js +44 -0
  91. package/public/js/utils.js +67 -0
  92. package/routes/admin.js +136 -0
  93. package/routes/auth.js +365 -0
  94. package/routes/categories.js +90 -0
  95. package/routes/content-templates.js +215 -0
  96. package/routes/files/_shared.js +112 -0
  97. package/routes/files/associations.js +94 -0
  98. package/routes/files/crud.js +139 -0
  99. package/routes/files/detail-serve.js +178 -0
  100. package/routes/files/index.js +38 -0
  101. package/routes/files/list.js +200 -0
  102. package/routes/files/overwrite.js +114 -0
  103. package/routes/files/upload.js +204 -0
  104. package/routes/files/versions.js +166 -0
  105. package/routes/files.js +16 -0
  106. package/routes/skills.js +93 -0
  107. package/routes/tags.js +65 -0
  108. package/routes/tokens.js +110 -0
  109. package/routes/users.js +120 -0
  110. package/server.js +372 -0
  111. package/skills/jpage-content-template/SKILL.md +98 -0
  112. package/skills/jpage-upload/SKILL.md +247 -0
  113. package/skills-registry.js +135 -0
  114. package/templates/academic.html +41 -0
  115. package/templates/dark-pro.html +41 -0
  116. package/templates/default.html +56 -0
  117. package/templates/github.html +67 -0
  118. package/test/browser-harness.js +125 -0
  119. package/test/dispatch-bench.js +74 -0
  120. package/test/helpers/setup.js +45 -0
  121. package/test/integration/admin.test.js +108 -0
  122. package/test/integration/auth.test.js +93 -0
  123. package/test/integration/categories.test.js +103 -0
  124. package/test/integration/cli.test.js +310 -0
  125. package/test/integration/content-templates.test.js +147 -0
  126. package/test/integration/files-security.test.js +248 -0
  127. package/test/integration/files.test.js +139 -0
  128. package/test/integration/share.test.js +79 -0
  129. package/test/integration/skills.test.js +104 -0
  130. package/test/integration/tags.test.js +84 -0
  131. package/test/integration/tokens.test.js +89 -0
  132. package/test/integration/users.test.js +138 -0
  133. package/test/mcp-harness.js +152 -0
  134. package/test/perf-bench.js +108 -0
  135. package/test/perf-harness.js +198 -0
  136. package/test/run-server.sh +15 -0
  137. package/test/unit/cli-args.test.js +88 -0
  138. package/test/unit/cli-config.test.js +89 -0
  139. package/test/unit/crypto.test.js +100 -0
  140. package/test/unit/fts.test.js +52 -0
  141. package/test/unit/render-cache.test.js +76 -0
  142. package/test/unit/util.test.js +81 -0
  143. package/test/unit/zip.test.js +164 -0
@@ -0,0 +1,20 @@
1
+ // mv 命令:修改文件名(original_name)。后端:PUT /api/files/:id { name }。
2
+
3
+ const { out } = require('./_shared');
4
+
5
+ async function run(client, args) {
6
+ const id = args.sub;
7
+ const newName = args.positional[2];
8
+ if (!id || !newName) {
9
+ const e = new Error('用法:jpage mv <id> <新文件名>');
10
+ e.name = 'UsageError';
11
+ throw e;
12
+ }
13
+ const body = { name: newName };
14
+ if (args.opts.public !== undefined) body.isPublic = true;
15
+ if (args.opts.private !== undefined) body.isPublic = false;
16
+ await client.put(`/api/files/${id}`, body);
17
+ out(`✓ 已更新 #${id}\n`);
18
+ }
19
+
20
+ module.exports = { run };
@@ -0,0 +1,22 @@
1
+ // rm 命令:删除文件。后端:DELETE /api/files/:id。不可撤销。
2
+
3
+ const { out, err } = require('./_shared');
4
+
5
+ async function run(client, args, { exit }) {
6
+ const id = args.sub;
7
+ if (!id) {
8
+ const e = new Error('用法:jpage rm <id>');
9
+ e.name = 'UsageError';
10
+ throw e;
11
+ }
12
+ // 简单防误删:--yes 才跳过确认(非交互环境默认会提示)
13
+ if (!args.opts.yes && process.stdin.isTTY) {
14
+ err(`将删除文件 #${id},此操作不可撤销。加 --yes 跳过确认。\n`);
15
+ (exit || ((c) => { process.exitCode = c; }))(1);
16
+ return;
17
+ }
18
+ await client.del(`/api/files/${id}`);
19
+ out(`✓ 已删除 #${id}\n`);
20
+ }
21
+
22
+ module.exports = { run };
@@ -0,0 +1,70 @@
1
+ // skills 命令:列出 / 查看 / 下载 Skill 包。
2
+ // 后端:GET /api/skills、GET /api/skills/:name、GET /api/skills/:name/download(zip 流)。
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { out } = require('./_shared');
7
+
8
+ async function run(client, args, { base }) {
9
+ const sub = args.sub; // ls / get / download
10
+ if (!sub || sub === 'ls' || sub === 'list') {
11
+ return listSkills(client);
12
+ }
13
+ const name = args.positional[2];
14
+ if (!name) {
15
+ const e = new Error('用法:jpage skills get <name> | jpage skills download <name>');
16
+ e.name = 'UsageError';
17
+ throw e;
18
+ }
19
+ if (sub === 'get') {
20
+ return getSkill(client, name);
21
+ }
22
+ if (sub === 'download') {
23
+ return downloadSkill(client, name, args, { base });
24
+ }
25
+ const e = new Error(`未知子命令:${sub}。支持:ls / get / download`);
26
+ e.name = 'UsageError';
27
+ throw e;
28
+ }
29
+
30
+ async function listSkills(client) {
31
+ const data = await client.get('/api/skills');
32
+ const skills = data.skills || [];
33
+ if (skills.length === 0) {
34
+ out('(无 Skill)\n');
35
+ return;
36
+ }
37
+ for (const s of skills) {
38
+ out(`${s.name} v${s.version || '-'} (${s.fileCount || 0} 文件)\n`);
39
+ if (s.description) {
40
+ out(` ${s.description}\n`);
41
+ }
42
+ }
43
+ }
44
+
45
+ async function getSkill(client, name) {
46
+ const s = await client.get(`/api/skills/${name}`);
47
+ out(`${s.title || s.name} v${s.version || '-'} 作者:${s.author || '-'}\n`);
48
+ if (s.description) out(`\n${s.description}\n`);
49
+ out(`\n文件(${s.fileCount || (s.files || []).length}):\n`);
50
+ for (const f of s.files || []) {
51
+ out(` ${f}\n`);
52
+ }
53
+ }
54
+
55
+ async function downloadSkill(client, name, args, _ctx) {
56
+ const res = await client.raw(`/api/skills/${name}/download`);
57
+ if (res.status < 200 || res.status >= 300) {
58
+ const text = await res.text().catch(() => '');
59
+ const e = new Error(`下载失败:HTTP ${res.status} ${text}`);
60
+ e.name = 'HttpError';
61
+ e.status = res.status;
62
+ throw e;
63
+ }
64
+ const buf = Buffer.from(await res.arrayBuffer());
65
+ const outFile = args.opts.out || `${name}.zip`;
66
+ fs.writeFileSync(path.resolve(outFile), buf);
67
+ out(`✓ 已下载 ${name} → ${outFile} (${buf.length} 字节)\n`);
68
+ }
69
+
70
+ module.exports = { run };
@@ -0,0 +1,23 @@
1
+ // star / unstar 命令:收藏 / 取消收藏。
2
+ // 后端:POST /api/files/:id/star、DELETE /api/files/:id/star。
3
+
4
+ const { out } = require('./_shared');
5
+
6
+ async function run(client, args) {
7
+ const id = args.sub;
8
+ if (!id) {
9
+ const e = new Error('用法:jpage star <id>');
10
+ e.name = 'UsageError';
11
+ throw e;
12
+ }
13
+ // cmd 本身就是 star 或 unstar
14
+ if (args.cmd === 'unstar') {
15
+ await client.del(`/api/files/${id}/star`);
16
+ out(`✓ 已取消收藏 #${id}\n`);
17
+ } else {
18
+ await client.post(`/api/files/${id}/star`);
19
+ out(`✓ 已收藏 #${id}\n`);
20
+ }
21
+ }
22
+
23
+ module.exports = { run };
@@ -0,0 +1,97 @@
1
+ // tags 命令:查看/添加/替换/清空文件的标签。
2
+ //
3
+ // 后端语义:PUT /api/files/:id/tags { tagIds:[] } 是「全量替换」,且只收 tag id(数字),
4
+ // 没有「按名字追加」端点。CLI 在客户端封装出更友好的命令:
5
+ //
6
+ // jpage tags <id> 列出当前标签
7
+ // jpage tags <id> add a,b,c 把这些标签追加到现有标签(缺失的标签自动创建)
8
+ // jpage tags <id> set a,b 全量替换为这些标签(缺失的自动创建)
9
+ // jpage tags <id> clear 清空标签
10
+ //
11
+ // 标签名 → id:GET /api/tags 拿全表按 name 精确匹配;缺失的 POST /api/tags {name} 建。
12
+
13
+ const { out } = require('./_shared');
14
+
15
+ async function run(client, args) {
16
+ const id = args.sub;
17
+ if (!id) {
18
+ const e = new Error('用法:jpage tags <id> [add|set|clear] [标签名,标签名,...]');
19
+ e.name = 'UsageError';
20
+ throw e;
21
+ }
22
+
23
+ const action = args.positional[2]; // add / set / clear,或空(查询)
24
+ const namesArg = args.positional[3];
25
+
26
+ // 无 action → 列出
27
+ if (!action) {
28
+ return listTags(client, id);
29
+ }
30
+
31
+ if (action === 'clear') {
32
+ await client.put(`/api/files/${id}/tags`, { tagIds: [] });
33
+ out(`✓ 已清空 #${id} 的标签\n`);
34
+ return;
35
+ }
36
+
37
+ if (action !== 'add' && action !== 'set') {
38
+ const e = new Error(`未知操作:${action}。支持:add / set / clear`);
39
+ e.name = 'UsageError';
40
+ throw e;
41
+ }
42
+
43
+ if (!namesArg) {
44
+ const e = new Error(`用法:jpage tags ${id} ${action} <标签名,标签名,...>`);
45
+ e.name = 'UsageError';
46
+ throw e;
47
+ }
48
+
49
+ const names = namesArg.split(',').map((s) => s.trim()).filter(Boolean);
50
+
51
+ // add:先拿现有标签,合并去重;set:直接用新的
52
+ let targetNames = names;
53
+ if (action === 'add') {
54
+ const file = await client.get(`/api/files/${id}`);
55
+ const existing = (file.tags || []).map((t) => t.name);
56
+ targetNames = [...new Set([...existing, ...names])];
57
+ }
58
+
59
+ // 名字 → id(缺失的创建)
60
+ const tagIds = await resolveOrCreateTagIds(client, targetNames);
61
+ await client.put(`/api/files/${id}/tags`, { tagIds });
62
+ out(
63
+ `✓ 已${action === 'add' ? '追加' : '设置'} #${id} 的标签:${targetNames.join(', ')}\n`
64
+ );
65
+ }
66
+
67
+ async function listTags(client, id) {
68
+ const file = await client.get(`/api/files/${id}`);
69
+ const tags = file.tags || [];
70
+ if (tags.length === 0) {
71
+ out(`文件 #${id} 没有标签\n`);
72
+ return;
73
+ }
74
+ out(`文件 #${id} 的标签:\n`);
75
+ for (const t of tags) {
76
+ out(` ${t.name} (#${t.id})\n`);
77
+ }
78
+ }
79
+
80
+ // 把标签名数组解析为 id 数组;缺失的标签通过 POST /api/tags 创建。
81
+ async function resolveOrCreateTagIds(client, names) {
82
+ const all = await client.get('/api/tags');
83
+ const byName = new Map((all.tags || []).map((t) => [t.name, t.id]));
84
+ const ids = [];
85
+ for (const name of names) {
86
+ if (byName.has(name)) {
87
+ ids.push(byName.get(name));
88
+ continue;
89
+ }
90
+ const created = await client.post('/api/tags', { name });
91
+ byName.set(name, created.id);
92
+ ids.push(created.id);
93
+ }
94
+ return ids;
95
+ }
96
+
97
+ module.exports = { run };
@@ -0,0 +1,84 @@
1
+ // upload 命令:上传本地文件到即页。
2
+ //
3
+ // 后端契约(routes/files/upload.js):
4
+ // POST /api/files/upload multipart,field=file,可选 isPublic=true
5
+ // (同名自动覆盖,含 version 备份)
6
+ // POST /api/files/:id/overwrite multipart,field=file(按 id 覆盖,仅 file 一个 field)
7
+ //
8
+ // 性能:用全局 FormData + Blob 构造 multipart,二进制流式,
9
+ // 不把内容 base64 塞进任何 token 流(这正是 CLI 相对 MCP upload_file 的核心优势)。
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { formatSize, out } = require('./_shared');
14
+
15
+ async function run(client, args, { base }) {
16
+ const filePath = args.sub; // 第一个位置参数(cmd=upload,sub=文件路径)
17
+ if (!filePath) {
18
+ throw new UsageError('用法:jpage upload <文件路径> [--public] [--overwrite ID]');
19
+ }
20
+
21
+ const abs = path.resolve(filePath);
22
+ let stat;
23
+ try {
24
+ stat = fs.statSync(abs);
25
+ } catch {
26
+ throw new UsageError(`文件不存在或无法读取:${filePath}`);
27
+ }
28
+ if (!stat.isFile()) {
29
+ throw new UsageError(`不是文件:${filePath}`);
30
+ }
31
+
32
+ const buf = fs.readFileSync(abs);
33
+ const name = path.basename(abs);
34
+ const blob = new Blob([buf]);
35
+ const overwriteId = args.opts.overwrite;
36
+
37
+ const form = new FormData();
38
+ form.append('file', blob, name);
39
+ // multipart 默认私有;--public 才公开(与 routes/files/upload.js:28 一致)
40
+ if (args.opts.public) form.append('isPublic', 'true');
41
+
42
+ let endpoint;
43
+ if (overwriteId) {
44
+ endpoint = `/api/files/${overwriteId}/overwrite`;
45
+ } else {
46
+ endpoint = '/api/files/upload';
47
+ }
48
+
49
+ const data = await client.postForm(endpoint, form);
50
+ printResult(data, base);
51
+ }
52
+
53
+ function printResult(data, base) {
54
+ // 批量上传(ZIP 含多个独立文件)
55
+ if (data.type === 'batch') {
56
+ out(`✓ 批量上传 ${data.count} 个文件\n`);
57
+ for (const f of data.files) {
58
+ const url = f.share_key ? `${base}/s/${f.share_key}` : '-';
59
+ out(` #${f.id} ${f.original_name} → ${url}\n`);
60
+ }
61
+ return;
62
+ }
63
+
64
+ // 单文件 / bundle
65
+ const url = data.share_key ? `${base}/s/${data.share_key}` : '-';
66
+ out(`✓ 上传成功 #${data.id} ${data.original_name} (${formatSize(data.size)})\n`);
67
+ if (data.overwritten) {
68
+ out(` 覆盖已有文件,版本 v${data.version}\n`);
69
+ }
70
+ if (data.is_bundle) {
71
+ out(` 网站包(bundle),入口:${data.entry_path || 'index.html'}\n`);
72
+ }
73
+ out(` 预览:${url}\n`);
74
+ }
75
+
76
+ // 命令层专用的用法错误(区别于 HTTP/网络错误),bin/jpage.js 据此打印帮助并退出 2
77
+ class UsageError extends Error {
78
+ constructor(message) {
79
+ super(message);
80
+ this.name = 'UsageError';
81
+ }
82
+ }
83
+
84
+ module.exports = { run, UsageError };
@@ -0,0 +1,25 @@
1
+ // url 命令:打印文件的公开预览短链。
2
+ // 后端:GET /api/files/:id(取 share_key,拼 /s/:key)。
3
+
4
+ const { shareUrl, out, err } = require('./_shared');
5
+
6
+ async function run(client, args, { base, exit }) {
7
+ const id = args.sub;
8
+ if (!id) {
9
+ const e = new Error('用法:jpage url <id>');
10
+ e.name = 'UsageError';
11
+ throw e;
12
+ }
13
+ const data = await client.get(`/api/files/${id}`);
14
+ const url = shareUrl(base, data);
15
+ if (!url) {
16
+ err(
17
+ `文件 #${id} 没有公开短链(私有文件)。用 \`jpage mv ${id} --public\` 设为公开后再获取链接。\n`
18
+ );
19
+ (exit || ((c) => { process.exitCode = c; }))(1);
20
+ return;
21
+ }
22
+ out(url + '\n');
23
+ }
24
+
25
+ module.exports = { run };
@@ -0,0 +1,29 @@
1
+ // whoami 命令:校验当前 token 是否有效(纯客户端,不动后端)。
2
+ //
3
+ // 后端没有 token→用户名的解析端点(GET /api/auth/me 只认 session,不认 Bearer)。
4
+ // 故用 GET /api/files?limit=1 探测:200=有效,401=无效。无法显示用户名。
5
+
6
+ const { out, err } = require('./_shared');
7
+
8
+ async function run(client, _args, { base, token, exit }) {
9
+ try {
10
+ await client.get('/api/files?limit=1');
11
+ out(`✓ token 有效,可访问 ${base}\n`);
12
+ if (token) {
13
+ // 只显示前缀,避免在终端泄露完整 token
14
+ const prefix = token.length > 8 ? token.slice(0, 8) + '…' : token;
15
+ out(` token:${prefix}\n`);
16
+ }
17
+ } catch (e) {
18
+ if (e.status === 401) {
19
+ err(
20
+ '✗ token 无效或未设置。用 --token <TOKEN>、JPAGE_TOKEN 环境变量、或 .env 的 MCP_TOKEN 提供。\n'
21
+ );
22
+ (exit || ((c) => { process.exitCode = c; }))(1);
23
+ return;
24
+ }
25
+ throw e;
26
+ }
27
+ }
28
+
29
+ module.exports = { run };
package/bin/config.js ADDED
@@ -0,0 +1,85 @@
1
+ // CLI 配置解析:token 与 base(服务地址)的来源优先级。
2
+ //
3
+ // 不引入 dotenv。.env 文件按 KEY=VALUE 简单行解析(兼容带引号、注释、空行)。
4
+ //
5
+ // 优先级(高 → 低):
6
+ // --token > JPAGE_TOKEN env > MCP_TOKEN env(环境 + .env 文件)
7
+ // --base > JPAGE_BASE env > 默认 http://localhost:8858
8
+ //
9
+ // 设计:让 jpage 能「无参跑通本地默认实例」,同时支持远程/CI 场景显式指定。
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const DEFAULT_BASE = 'http://localhost:8858';
15
+
16
+ // 解析 .env 文件为对象。仅支持最简语法:KEY=VALUE,值可带引号,# 开头为注释。
17
+ // 失败(文件不存在/读错)返回空对象,由调用方决定是否报错。
18
+ function parseEnvFile(filePath) {
19
+ const result = {};
20
+ let text;
21
+ try {
22
+ text = fs.readFileSync(filePath, 'utf-8');
23
+ } catch {
24
+ return result;
25
+ }
26
+ for (const rawLine of text.split(/\r?\n/)) {
27
+ const line = rawLine.trim();
28
+ if (!line || line.startsWith('#')) continue;
29
+ const eq = line.indexOf('=');
30
+ if (eq < 1) continue;
31
+ const key = line.slice(0, eq).trim();
32
+ let value = line.slice(eq + 1).trim();
33
+ // 去引号
34
+ if (
35
+ (value.startsWith('"') && value.endsWith('"')) ||
36
+ (value.startsWith("'") && value.endsWith("'"))
37
+ ) {
38
+ value = value.slice(1, -1);
39
+ }
40
+ result[key] = value;
41
+ }
42
+ return result;
43
+ }
44
+
45
+ // 从当前目录向上查找 .env(最多 5 层),合并所有命中文件。
46
+ // 这模仿 MCP/SKILL.md 里「项目根 .env」的惯例。
47
+ function loadEnvUp(startDir) {
48
+ const merged = {};
49
+ let dir = path.resolve(startDir);
50
+ for (let i = 0; i < 5; i++) {
51
+ const envPath = path.join(dir, '.env');
52
+ const parsed = parseEnvFile(envPath);
53
+ Object.assign(merged, parsed);
54
+ const parent = path.dirname(dir);
55
+ if (parent === dir) break;
56
+ dir = parent;
57
+ }
58
+ return merged;
59
+ }
60
+
61
+ /**
62
+ * 解析最终配置。
63
+ * @param {object} opts - 来自 args.parse 的 opts 对象(含 --token/--base 等)
64
+ * @param {object} [env] - 环境变量(默认 process.env,测试可注入)
65
+ * @param {string} [cwd] - .env 查找起点(默认 process.cwd())
66
+ * @returns {{token: string|null, base: string}}
67
+ */
68
+ function resolveConfig(opts, env = process.env, cwd = process.cwd()) {
69
+ const dotEnv = loadEnvUp(cwd);
70
+
71
+ const token =
72
+ opts.token ||
73
+ env.JPAGE_TOKEN ||
74
+ dotEnv.JPAGE_TOKEN ||
75
+ env.MCP_TOKEN ||
76
+ dotEnv.MCP_TOKEN ||
77
+ null;
78
+
79
+ const base = (opts.base || env.JPAGE_BASE || dotEnv.JPAGE_BASE || DEFAULT_BASE)
80
+ .replace(/\/+$/, ''); // 去尾部斜杠,避免拼接出 //
81
+
82
+ return { token, base };
83
+ }
84
+
85
+ module.exports = { resolveConfig, parseEnvFile, loadEnvUp, DEFAULT_BASE };
package/bin/jpage.js ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // jpage CLI 入口:解析 argv → 解析配置 → 创建 client → dispatch 到命令。
3
+ //
4
+ // 分层:
5
+ // args.js argv → {cmd, sub, opts, positional}
6
+ // config.js opts + env + .env → {token, base}
7
+ // client.js createClient({base, token, fetchImpl})
8
+ // commands/* 每个命令导出 async run(client, args, ctx)
9
+ //
10
+ // 错误处理集中在此:
11
+ // UsageError → 打印用法到 stderr,退出 2
12
+ // HttpError → 打印 API 错误到 stderr,401 额外提示 token,退出 1
13
+ // NetworkError→ 打印连接错误,退出 1
14
+ //
15
+ // 导出 run(argv, {fetchImpl}) 供测试调用(不依赖 process.argv、不退出进程)。
16
+
17
+ const { parse } = require('./args');
18
+ const { resolveConfig } = require('./config');
19
+ const { createClient, HttpError } = require('./client');
20
+ const { setIo, resetIo } = require('./commands/_shared');
21
+
22
+ const COMMANDS = {
23
+ upload: () => require('./commands/upload'),
24
+ ls: () => require('./commands/ls'),
25
+ cat: () => require('./commands/cat'),
26
+ url: () => require('./commands/url'),
27
+ mv: () => require('./commands/mv'),
28
+ rm: () => require('./commands/rm'),
29
+ star: () => require('./commands/star'),
30
+ unstar: () => require('./commands/star'),
31
+ tags: () => require('./commands/tags'),
32
+ skills: () => require('./commands/skills'),
33
+ whoami: () => require('./commands/whoami'),
34
+ };
35
+
36
+ const HELP = `jpage —— 即页命令行
37
+
38
+ 用法:
39
+ jpage <命令> [参数] [选项]
40
+
41
+ 命令:
42
+ upload <路径> [--public] [--overwrite ID] 上传文件(multipart,ZIP 自动判 bundle/batch)
43
+ ls [--page N --limit N --sort 字段 --order asc|desc --kw 词 --cat 分类 --tag 标签]
44
+ 列出文件
45
+ cat <id> 输出文件原始内容
46
+ url <id> 打印文件公开预览链接 /s/:key
47
+ mv <id> <新名> [--public|--private] 改名 / 改公开性
48
+ rm <id> [--yes] 删除文件(--yes 跳过确认)
49
+ star <id> / unstar <id> 收藏 / 取消收藏
50
+ tags <id> [add|set|clear] [名,名,...] 查看 / 追加 / 替换 / 清空标签
51
+ skills ls | get <名> | download <名> [--out 文件]
52
+ 列出 / 查看 / 下载 Skill
53
+ whoami 校验 token 是否有效
54
+
55
+ 通用选项:
56
+ --token <TOKEN> 鉴权 token(jp_ 用户 token 或 MCP_TOKEN)
57
+ --base <URL> 服务地址(默认 http://localhost:8858)
58
+ --help, -h 显示本帮助
59
+
60
+ token 优先级:--token > JPAGE_TOKEN 环境变量 > MCP_TOKEN 环境变量 > .env 里的同名变量
61
+ base 优先级:--base > JPAGE_BASE 环境变量 > 默认 http://localhost:8858
62
+
63
+ 示例:
64
+ jpage upload ./report.html --public
65
+ jpage upload ./site.zip --public
66
+ jpage upload ./x.html --overwrite 12
67
+ jpage ls --kw 季度 --limit 5
68
+ jpage cat 8
69
+ jpage tags 8 add 季度,财报
70
+ jpage skills download jpage-upload
71
+
72
+ 详细文档:https://github.com/code2rich/jpage`;
73
+
74
+ /**
75
+ * 运行 CLI。供入口和测试共用。
76
+ * @param {string[]} argv - 去掉 node/脚本名后的参数
77
+ * @param {object} [inject] - 测试注入:{ fetchImpl, env, cwd, stdout, stderr, exit }
78
+ * @returns {Promise<void>} 失败时设置 exit 状态(默认 process.exitCode,测试可注入)
79
+ */
80
+ async function run(argv, inject = {}) {
81
+ // 注入 I/O:默认走 process.stdout/stderr + process.exitCode。
82
+ // 测试注入 { stdout, stderr, exit } 用内存 sink + 计数器,避免污染 node:test 输出。
83
+ const stdout = inject.stdout || process.stdout;
84
+ const stderr = inject.stderr || process.stderr;
85
+ const exit = inject.exit || ((code) => { process.exitCode = code; });
86
+
87
+ // 把命令模块的 out()/err() 重定向到同一组流
88
+ setIo({ stdout, stderr });
89
+
90
+ const parsed = parse(argv);
91
+ const { cmd, opts } = parsed;
92
+
93
+ if (opts.version) {
94
+ const pkg = require('../package.json');
95
+ stdout.write(pkg.version + '\n');
96
+ return;
97
+ }
98
+
99
+ if (opts.help || opts.h || !cmd) {
100
+ stdout.write(HELP + '\n');
101
+ return;
102
+ }
103
+
104
+ if (!COMMANDS[cmd]) {
105
+ stderr.write(`未知命令:${cmd}\n\n${HELP}\n`);
106
+ exit(2);
107
+ return;
108
+ }
109
+
110
+ const { token, base } = resolveConfig(opts, inject.env, inject.cwd);
111
+ if (!token) {
112
+ stderr.write(
113
+ '未提供 token。用 --token <TOKEN>、JPAGE_TOKEN 环境变量、或 .env 的 MCP_TOKEN 设置。\n'
114
+ );
115
+ exit(2);
116
+ return;
117
+ }
118
+
119
+ const client = createClient({ base, token, fetchImpl: inject.fetchImpl });
120
+ const ctx = { base, token, exit };
121
+ const mod = COMMANDS[cmd]();
122
+
123
+ try {
124
+ await mod.run(client, parsed, ctx);
125
+ } catch (e) {
126
+ handleError(e, { stderr, exit });
127
+ }
128
+ }
129
+
130
+ function handleError(e, { stderr, exit }) {
131
+ if (e.name === 'UsageError') {
132
+ stderr.write(e.message + '\n');
133
+ exit(2);
134
+ return;
135
+ }
136
+ if (e instanceof HttpError) {
137
+ stderr.write(`✗ ${e.message}\n`);
138
+ if (e.status === 401) {
139
+ stderr.write(' token 无效或已失效。检查 --token / JPAGE_TOKEN / .env 的 MCP_TOKEN。\n');
140
+ }
141
+ if (e.status === 429) {
142
+ stderr.write(' 请求过于频繁(如上传 50 次/15 分钟)。稍后再试。\n');
143
+ }
144
+ exit(1);
145
+ return;
146
+ }
147
+ if (e.name === 'NetworkError') {
148
+ stderr.write(`✗ ${e.message}\n`);
149
+ exit(1);
150
+ return;
151
+ }
152
+ // 兜底
153
+ stderr.write(`✗ ${e.message || e}\n`);
154
+ exit(1);
155
+ }
156
+
157
+ // 直接执行(node bin/jpage.js ...)
158
+ if (require.main === module) {
159
+ const argv = process.argv.slice(2);
160
+ run(argv).catch((e) => {
161
+ process.stderr.write(`✗ ${e && e.message ? e.message : e}\n`);
162
+ process.exitCode = 1;
163
+ }).finally(() => {
164
+ resetIo();
165
+ });
166
+ }
167
+
168
+ module.exports = { run, HELP, COMMANDS, handleError };