made-market 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +30 -2
- package/bin/made-market.mjs +301 -0
- package/lib/glyphs.mjs +25 -0
- package/lib/icon.mjs +110 -0
- package/lib/market.mjs +263 -0
- package/lib/pack.mjs +54 -0
- package/lib/plugin.mjs +169 -0
- package/lib/tar.mjs +78 -0
- package/lib/theme.mjs +142 -0
- package/package.json +46 -4
- package/registry/index.json +812 -0
- package/src/account.mjs +106 -0
- package/src/install.mjs +181 -0
- package/src/publish.mjs +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jobop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# made-market · 码得插件市场 CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
码得(made)公开插件市场的命令行工具。市场登记与分发主题、管控、agent 运行时三类包,本身不执行插件代码;包装到 `~/made` 后由桥接器自动扫描。
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx made-market list # 列出索引里的全部包
|
|
7
|
+
npx made-market search 日落 # 在包名、简介和标签里搜索
|
|
8
|
+
npx made-market info sunset # 看一个包的详情
|
|
9
|
+
npx made-market install sunset # 装到 ~/made/themes/,重启桥接器后生效
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 发布新包
|
|
13
|
+
|
|
14
|
+
发布需要先登录(下载安装永远匿名,无需账号):
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx made-market register <用户名>
|
|
18
|
+
npx made-market login <用户名>
|
|
19
|
+
npx made-market publish <包目录>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
会话有效期 90 天,凭证存放在 `~/made/credentials.json`(权限 0600)。
|
|
23
|
+
|
|
24
|
+
## 服务地址
|
|
25
|
+
|
|
26
|
+
- 默认索引:GitHub 上的 `registry/index.json`(离线时回落到包内自带快照)
|
|
27
|
+
- 自建市场:`--server` 指定市场服务地址,或设 `MADE_MARKET_REGISTRY` / `MADE_MARKET_SERVER` 环境变量
|
|
28
|
+
|
|
29
|
+
## 服务端
|
|
30
|
+
|
|
31
|
+
本仓库([github.com/jobop/made-market](https://github.com/jobop/made-market))同时包含市场服务端(`server/`)与站点(`site/`),它们**不随本 npm 包分发**;`cli/lib` 里的包格式核心库与仓库内服务端共用同一份源码。
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 码得插件市场的命令行入口 —— 供 `npx made-market` 直接使用。
|
|
4
|
+
*
|
|
5
|
+
* 市场只做包管理:列出、查询、下载、校验和落盘到一个固定目录,从不执行包代码。
|
|
6
|
+
* 包装到 <home>/plugins 或 <home>/themes(默认 ~/made),桥接器启动时自己扫。
|
|
7
|
+
*
|
|
8
|
+
* npx made-market list
|
|
9
|
+
* npx made-market info sunset
|
|
10
|
+
* npx made-market install sunset alarm-clock
|
|
11
|
+
* npx made-market install demo-echo --home /tmp/home --dry-run
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { ROOT } from '../lib/market.mjs';
|
|
17
|
+
import { install, loadRegistry, resolveHome, resolveRegistry } from '../src/install.mjs';
|
|
18
|
+
import { publish } from '../src/publish.mjs';
|
|
19
|
+
import { login, logout, register, resolvePassword, whoami } from '../src/account.mjs';
|
|
20
|
+
|
|
21
|
+
// 版本号读 CLI 自己的 package.json(cli/),发布后即 node_modules/made-market/package.json。
|
|
22
|
+
const CLI_PACKAGE_JSON = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
23
|
+
|
|
24
|
+
const KIND_LABELS = { theme: '主题', control: '管控', agent: 'agent 运行时' };
|
|
25
|
+
const KIND_ORDER = ['theme', 'control', 'agent'];
|
|
26
|
+
|
|
27
|
+
const USAGE = `码得 · 插件市场 (made-market)
|
|
28
|
+
|
|
29
|
+
用法
|
|
30
|
+
npx made-market <命令> [参数] [选项]
|
|
31
|
+
|
|
32
|
+
命令
|
|
33
|
+
list 列出索引里的全部包
|
|
34
|
+
search <关键词> 在包名、简介和标签里搜索
|
|
35
|
+
info <包 ID> 看一个包的详情:文件、校验和、安装位置、创建人
|
|
36
|
+
install <包 ID…> 装到包目录,重启桥接器后生效(匿名,无需登录)
|
|
37
|
+
publish <包目录> 把一个包目录上传到市场服务(需先登录)
|
|
38
|
+
register <用户名> 注册账号并登录(凭证存 <home>/credentials.json)
|
|
39
|
+
login <用户名> 登录市场服务(发布的前提;90 天会话)
|
|
40
|
+
whoami 看当前登录的账号
|
|
41
|
+
logout 退出登录(吊销服务端会话并删除本地凭证)
|
|
42
|
+
rebuild-from-store 管理命令:从存储 manifests 全量重建 PG 索引
|
|
43
|
+
|
|
44
|
+
选项
|
|
45
|
+
--home <目录> 包目录根,默认 $MADE_HOME 或 ~/made
|
|
46
|
+
--registry <路径|URL> 索引来源。默认优先 $MADE_MARKET_REGISTRY(如市场服务的索引),
|
|
47
|
+
不可用或未设置时回落到本包自带的索引(离线可用)
|
|
48
|
+
--server <URL> 市场服务地址(register/login/publish;默认 $MADE_MARKET_SERVER,
|
|
49
|
+
已登录时也可从凭证读取)
|
|
50
|
+
--token <t> 发布 token(publish;默认 $MADE_MARKET_TOKEN 或登录凭证)
|
|
51
|
+
--password <pw> 密码(register/login;缺省在 TTY 下不回显输入)
|
|
52
|
+
--kind theme|control|agent 只列某一类(list / search)
|
|
53
|
+
--json 以 JSON 输出
|
|
54
|
+
--force 目标目录已存在时覆盖
|
|
55
|
+
--dry-run 只校验并列出将要写入的文件
|
|
56
|
+
-h, --help 显示帮助
|
|
57
|
+
-v, --version 显示版本
|
|
58
|
+
|
|
59
|
+
装好的包在:
|
|
60
|
+
<home>/plugins/ 管控插件与 agent 运行时
|
|
61
|
+
<home>/themes/ 主题包
|
|
62
|
+
|
|
63
|
+
桥接器每次启动扫描这两个目录,不需要登记,也不需要和配置文件放在一起。`;
|
|
64
|
+
|
|
65
|
+
function parse(argv) {
|
|
66
|
+
const options = { command: '', args: [], home: '', registry: '', server: '', token: '', password: '', kind: '', json: false, force: false, dryRun: false, help: false, version: false };
|
|
67
|
+
for (let i = 0; i < argv.length; i++) {
|
|
68
|
+
const arg = argv[i];
|
|
69
|
+
if (arg === '--home') options.home = argv[++i] || '';
|
|
70
|
+
else if (arg === '--registry') options.registry = argv[++i] || '';
|
|
71
|
+
else if (arg === '--server') options.server = argv[++i] || '';
|
|
72
|
+
else if (arg === '--token') options.token = argv[++i] || '';
|
|
73
|
+
else if (arg === '--password') options.password = argv[++i] || '';
|
|
74
|
+
else if (arg === '--kind') options.kind = argv[++i] || '';
|
|
75
|
+
else if (arg === '--json') options.json = true;
|
|
76
|
+
else if (arg === '--force') options.force = true;
|
|
77
|
+
else if (arg === '--dry-run') options.dryRun = true;
|
|
78
|
+
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
79
|
+
else if (arg === '--version' || arg === '-v') options.version = true;
|
|
80
|
+
else if (arg.startsWith('-')) throw new Error(`未知参数 ${arg}`);
|
|
81
|
+
else if (!options.command) options.command = arg;
|
|
82
|
+
else options.args.push(arg);
|
|
83
|
+
}
|
|
84
|
+
return options;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const semverGreater = (a, b) => {
|
|
88
|
+
const left = String(a).split('.').map(Number);
|
|
89
|
+
const right = String(b).split('.').map(Number);
|
|
90
|
+
for (let i = 0; i < 3; i++) {
|
|
91
|
+
if ((left[i] || 0) !== (right[i] || 0)) return (left[i] || 0) > (right[i] || 0);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* API 模式安装:按包直查市场服务的 /api/packages/<id>,不下载全量索引。
|
|
98
|
+
* 大市场(索引几十 MB)时用这个,归档地址改写成服务上的绝对地址。
|
|
99
|
+
*/
|
|
100
|
+
async function resolveViaApi(ids, server) {
|
|
101
|
+
const base = String(server).replace(/\/+$/, '');
|
|
102
|
+
const packages = [];
|
|
103
|
+
for (const id of ids) {
|
|
104
|
+
const response = await fetch(`${base}/api/packages/${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(15000) });
|
|
105
|
+
if (!response.ok) throw new Error(`查询 ${id} 失败:HTTP ${response.status}`);
|
|
106
|
+
const versions = await response.json();
|
|
107
|
+
const latest = versions.reduce((best, item) => (!best || semverGreater(item.version, best.version) ? item : best), null);
|
|
108
|
+
if (!latest) throw new Error(`服务上没有包 ${id}`);
|
|
109
|
+
packages.push({ ...latest, download: latest.download.startsWith('http') ? latest.download : `${base}/${latest.download}` });
|
|
110
|
+
}
|
|
111
|
+
return { packages, base };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const human = (bytes) => (bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`);
|
|
115
|
+
const homeOf = (options) => resolveHome(options.home);
|
|
116
|
+
|
|
117
|
+
function installCommand(entry) {
|
|
118
|
+
return `npx made-market install ${entry.id}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function describe(entry) {
|
|
122
|
+
return {
|
|
123
|
+
id: entry.id,
|
|
124
|
+
kind: entry.kind,
|
|
125
|
+
version: entry.version,
|
|
126
|
+
name: entry.name,
|
|
127
|
+
summary: entry.summary,
|
|
128
|
+
author: entry.author,
|
|
129
|
+
license: entry.license,
|
|
130
|
+
tags: entry.tags,
|
|
131
|
+
target: entry.target,
|
|
132
|
+
dir: entry.dir,
|
|
133
|
+
createdBy: entry.createdBy || '',
|
|
134
|
+
publishedBy: entry.publishedBy || '',
|
|
135
|
+
sha256: entry.sha256,
|
|
136
|
+
archiveBytes: entry.archiveBytes,
|
|
137
|
+
files: entry.files,
|
|
138
|
+
install: installCommand(entry),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function listRows(index, options) {
|
|
143
|
+
const rows = options.kind ? index.packages.filter((entry) => entry.kind === options.kind) : index.packages;
|
|
144
|
+
const ordered = KIND_ORDER.flatMap((kind) => rows.filter((entry) => entry.kind === kind));
|
|
145
|
+
return ordered.length ? ordered : rows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function printTable(rows) {
|
|
149
|
+
if (!rows.length) {
|
|
150
|
+
console.log('没有匹配的包。');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const width = Math.max(...rows.map((entry) => entry.id.length), 2);
|
|
154
|
+
for (const entry of rows) {
|
|
155
|
+
console.log(`${entry.kind.padEnd(8)} ${entry.id.padEnd(width)} v${entry.version.padEnd(7)} ${human(entry.archiveBytes).padStart(9)} ${entry.name}`);
|
|
156
|
+
}
|
|
157
|
+
console.log(`\n共 ${rows.length} 个包。安装:npx made-market install <包 ID>`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function main() {
|
|
161
|
+
let options;
|
|
162
|
+
try {
|
|
163
|
+
options = parse(process.argv.slice(2));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error(error.message);
|
|
166
|
+
console.error(`\n${USAGE}`);
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.version) {
|
|
172
|
+
const pkg = JSON.parse(fs.readFileSync(CLI_PACKAGE_JSON, 'utf8'));
|
|
173
|
+
console.log(pkg.version);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (options.help || !options.command) {
|
|
177
|
+
console.log(USAGE);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 索引来源按需加载:--registry > $MADE_MARKET_REGISTRY > 包内自带(远端失败自动回落,离线可用)。
|
|
182
|
+
// rebuild-from-store 与 install --server(API 模式)不依赖全量索引。
|
|
183
|
+
const loadIndex = () => loadRegistry(resolveRegistry(options.registry));
|
|
184
|
+
|
|
185
|
+
if (options.command === 'list') {
|
|
186
|
+
const rows = listRows((await loadIndex()).index, options);
|
|
187
|
+
if (options.json) console.log(JSON.stringify(rows.map(describe), null, 2));
|
|
188
|
+
else printTable(rows);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options.command === 'search') {
|
|
193
|
+
const query = options.args.join(' ').trim().toLowerCase();
|
|
194
|
+
if (!query) throw new Error('search 需要一个关键词');
|
|
195
|
+
const rows = listRows((await loadIndex()).index, options).filter((entry) => [
|
|
196
|
+
entry.id, entry.name, entry.nameEn, entry.summary, entry.summaryEn, ...(entry.tags || []), ...(entry.highlights || []),
|
|
197
|
+
].filter(Boolean).some((field) => String(field).toLowerCase().includes(query)));
|
|
198
|
+
if (options.json) console.log(JSON.stringify(rows.map(describe), null, 2));
|
|
199
|
+
else printTable(rows);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (options.command === 'info') {
|
|
204
|
+
const id = options.args[0];
|
|
205
|
+
if (!id) throw new Error('info 需要一个包 ID');
|
|
206
|
+
const entry = (await loadIndex()).index.packages.find((item) => item.id === id);
|
|
207
|
+
if (!entry) throw new Error(`索引里没有包 ${id}`);
|
|
208
|
+
if (options.json) {
|
|
209
|
+
console.log(JSON.stringify(describe(entry), null, 2));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.log(`${entry.name} · ${entry.id} v${entry.version} · ${KIND_LABELS[entry.kind] || entry.kind}`);
|
|
213
|
+
console.log(`${entry.summary}`);
|
|
214
|
+
if (entry.description) console.log(`\n${entry.description}`);
|
|
215
|
+
console.log('\n信息');
|
|
216
|
+
console.log(` 作者 ${entry.author}`);
|
|
217
|
+
console.log(` 许可 ${entry.license}`);
|
|
218
|
+
console.log(` 创建人 ${entry.createdBy || '—'}`);
|
|
219
|
+
console.log(` 最近发布 ${entry.publishedBy || '—'}${entry.publishedAt ? ` · ${entry.publishedAt}` : ''}`);
|
|
220
|
+
console.log(` 标签 ${(entry.tags || []).join('、') || '—'}`);
|
|
221
|
+
console.log(` 安装到 ${homeOf(options)}/${entry.target}/${entry.dir}/`);
|
|
222
|
+
console.log(` 归档 ${human(entry.archiveBytes)} sha256 ${entry.sha256}`);
|
|
223
|
+
console.log('\n文件与校验和');
|
|
224
|
+
for (const file of entry.files || []) console.log(` ${file.path.padEnd(14)} ${human(file.size).padStart(9)} ${file.sha256.slice(0, 16)}`);
|
|
225
|
+
if (entry.highlights?.length) {
|
|
226
|
+
console.log('\n要点');
|
|
227
|
+
for (const item of entry.highlights) console.log(` · ${item}`);
|
|
228
|
+
}
|
|
229
|
+
console.log(`\n安装:${installCommand(entry)}`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (options.command === 'install') {
|
|
234
|
+
if (!options.args.length) throw new Error('install 需要至少一个包 ID');
|
|
235
|
+
// 配了 --server / $MADE_MARKET_SERVER:按包直查 API,不拉全量索引(大市场友好)。
|
|
236
|
+
if (options.server || process.env.MADE_MARKET_SERVER) {
|
|
237
|
+
const { packages, base } = await resolveViaApi(options.args, options.server || process.env.MADE_MARKET_SERVER);
|
|
238
|
+
await install(options.args, { home: options.home, registry: base, index: { packages }, force: options.force, dryRun: options.dryRun });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// 归档从索引所在的位置取:远端索引配远端归档,自带索引配包内归档,可离线。
|
|
242
|
+
const { source, index } = await loadIndex();
|
|
243
|
+
await install(options.args, { home: options.home, registry: source, index, force: options.force, dryRun: options.dryRun });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (options.command === 'publish') {
|
|
248
|
+
const dir = options.args[0];
|
|
249
|
+
if (!dir) throw new Error('publish 需要一个包目录');
|
|
250
|
+
await publish(dir, { server: options.server, token: options.token, home: options.home });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (options.command === 'register' || options.command === 'login') {
|
|
255
|
+
const name = options.args[0];
|
|
256
|
+
if (!name) throw new Error(`${options.command} 需要一个用户名`);
|
|
257
|
+
const server = options.server || process.env.MADE_MARKET_SERVER;
|
|
258
|
+
const password = await resolvePassword({ ...options, name });
|
|
259
|
+
const run = options.command === 'register' ? register : login;
|
|
260
|
+
const result = await run({ server, name, password, home: options.home });
|
|
261
|
+
const who = result.user || { name };
|
|
262
|
+
console.log(`${options.command === 'register' ? '注册' : '登录'}成功:${who.name}${who.displayName && who.displayName !== who.name ? `(${who.displayName})` : ''}`);
|
|
263
|
+
console.log(` 服务 ${server}`);
|
|
264
|
+
console.log(` 凭证已存 ${resolveHome(options.home)}/credentials.json(0600),publish 直接可用`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (options.command === 'whoami') {
|
|
269
|
+
const result = await whoami({ server: options.server, home: options.home, token: options.token });
|
|
270
|
+
console.log(`已登录:${result.user}${result.server ? ` @ ${result.server}` : ''}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (options.command === 'logout') {
|
|
275
|
+
await logout({ home: options.home, token: options.token });
|
|
276
|
+
console.log('已退出登录(服务端会话已吊销,本地凭证已删除)。');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (options.command === 'rebuild-from-store') {
|
|
281
|
+
const { createPool, initSchema, rebuildFromStore } = await import('../server/db.mjs');
|
|
282
|
+
const { createStore } = await import('../server/store.mjs');
|
|
283
|
+
if (!process.env.DATABASE_URL) throw new Error('rebuild-from-store 需要 DATABASE_URL 指向 PG 索引库');
|
|
284
|
+
const db = createPool(process.env.DATABASE_URL);
|
|
285
|
+
try {
|
|
286
|
+
await initSchema(db);
|
|
287
|
+
const count = await rebuildFromStore(db, createStore(), { log: (key) => console.log(` 同步 ${key}`) });
|
|
288
|
+
console.log(`索引重建完成:${count} 条版本事实。`);
|
|
289
|
+
} finally {
|
|
290
|
+
await db.close();
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw new Error(`未知命令 ${options.command}\n\n${USAGE}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
main().catch((error) => {
|
|
299
|
+
console.error(error.message);
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
});
|
package/lib/glyphs.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Site glyph names. `tools/build.mjs` writes them into `site/glyphs.js` so the
|
|
2
|
+
* page and the validator share one source of truth. Paths use a 24×24 viewBox. */
|
|
3
|
+
export const GLYPHS = {
|
|
4
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1"/>',
|
|
5
|
+
wave: '<path d="M2 13c2 0 3-3.5 5-3.5s3 3.5 5 3.5 3-3.5 5-3.5 3 3.5 5 3.5"/>',
|
|
6
|
+
leaf: '<path d="M20 4c0 8-5 12-11 12H5c0-7 5-12 11-12h4z"/><path d="M5 20c2-5 5-8 9-10"/>',
|
|
7
|
+
moon: '<path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z"/>',
|
|
8
|
+
bell: '<path d="M18 15V10a6 6 0 1 0-12 0v5l-2 3h16l-2-3z"/><path d="M10 21h4"/>',
|
|
9
|
+
clock: '<circle cx="12" cy="12" r="8"/><path d="M12 7v5l3.5 2"/>',
|
|
10
|
+
bolt: '<path d="M13 2 5 13h6l-1 9 8-11h-6l1-9z"/>',
|
|
11
|
+
link: '<path d="M9.5 14.5 14.5 9.5"/><path d="M10.5 6.5 13 4a4 4 0 0 1 6 6l-2.5 2.5"/><path d="M13.5 17.5 11 20a4 4 0 0 1-6-6l2.5-2.5"/>',
|
|
12
|
+
terminal: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M7 9l3 3-3 3M13 15h4"/>',
|
|
13
|
+
chat: '<path d="M20 12a7 7 0 0 1-7 7H8l-4 3v-5.5A7 7 0 0 1 6.5 5h6.5a7 7 0 0 1 7 7z"/>',
|
|
14
|
+
spark: '<path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2 2-6z"/>',
|
|
15
|
+
box: '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M4 7.5 12 12l8-4.5M12 12v9"/>',
|
|
16
|
+
shield: '<path d="M12 3l7 3v6c0 4-3 7.5-7 9-4-1.5-7-5-7-9V6l7-3z"/>',
|
|
17
|
+
palette: '<path d="M12 3a9 9 0 1 0 0 18c1.4 0 2-1 2-2s-.6-2-2-2h-1a2 2 0 0 1 0-4h6a4 4 0 0 0 4-4c0-3.5-4-6-9-6z"/><circle cx="8" cy="10" r="1.1"/><circle cx="12" cy="7.5" r="1.1"/><circle cx="16" cy="10" r="1.1"/>',
|
|
18
|
+
grid: '<rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/>',
|
|
19
|
+
mic: '<rect x="9" y="3" width="6" height="10" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
|
|
20
|
+
volume: '<path d="M11 5 6 9H3v6h3l5 4V5z"/><path d="M15 9a4 4 0 0 1 0 6M18 6.5a8 8 0 0 1 0 11"/>',
|
|
21
|
+
gift: '<rect x="3" y="9" width="18" height="12" rx="2"/><path d="M3 13h18M12 9v12"/><path d="M12 9S10 3 7 3.5 7 9 12 9zM12 9s2-6 5-5.5S17 9 12 9z"/>',
|
|
22
|
+
wand: '<path d="M4 20 15 9"/><path d="M17 3l1 2.5L20.5 6.5 18 8l-1 2.5L16 8 13.5 6.5 16 5.5 17 3z"/><path d="M5.5 5.5 6.5 8 9 9l-2.5 1L5.5 12.5 4.5 10 2 9l2.5-1L5.5 5.5z"/>',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const GLYPH_NAMES = Object.freeze(Object.keys(GLYPHS));
|
package/lib/icon.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `indexed4` agent-avatar protocol, kept byte-for-byte compatible with the
|
|
3
|
+
* bridge (`bridge/src/agent-icons.mjs` in the made repository).
|
|
4
|
+
*
|
|
5
|
+
* Pixels are row-major 4-bit palette indices, high nibble first, no row
|
|
6
|
+
* padding. There are no image URLs and no executable SVG on the wire, so the
|
|
7
|
+
* market can hand the same pixels to the web page and to the device.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_SIZE = 48;
|
|
10
|
+
export const MAX_JSON_BYTES = 2048;
|
|
11
|
+
const RGBA = /^[0-9a-f]{8}$/i;
|
|
12
|
+
const RGB = /^#[0-9a-f]{6}$/i;
|
|
13
|
+
const FIELDS = new Set(['format', 'width', 'height', 'palette', 'data', 'background', 'accent']);
|
|
14
|
+
const B64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
15
|
+
|
|
16
|
+
export function validateIcon(icon) {
|
|
17
|
+
if (!icon || typeof icon !== 'object' || Array.isArray(icon) || icon.format !== 'indexed4') {
|
|
18
|
+
throw new Error('图标必须为 indexed4 像素对象');
|
|
19
|
+
}
|
|
20
|
+
if (Object.keys(icon).some((key) => !FIELDS.has(key))) throw new Error('图标包含不支持的字段');
|
|
21
|
+
for (const key of ['width', 'height']) {
|
|
22
|
+
const value = icon[key];
|
|
23
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_SIZE) throw new Error(`图标尺寸必须为 1–${MAX_SIZE} 像素`);
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(icon.palette) || icon.palette.length < 1 || icon.palette.length > 16
|
|
26
|
+
|| icon.palette.some((color) => typeof color !== 'string' || !RGBA.test(color))) {
|
|
27
|
+
throw new Error('图标 palette 需要 1–16 个 RRGGBBAA 颜色');
|
|
28
|
+
}
|
|
29
|
+
if (!RGB.test(icon.background || '') || !RGB.test(icon.accent || '')) throw new Error('图标背景与强调色必须为 #RRGGBB');
|
|
30
|
+
const pixels = icon.width * icon.height;
|
|
31
|
+
const bytes = Math.ceil(pixels / 2);
|
|
32
|
+
if (typeof icon.data !== 'string' || icon.data.length !== Math.ceil(bytes / 3) * 4 || !B64.test(icon.data)) {
|
|
33
|
+
throw new Error('图标 data 的 Base64 长度或格式无效');
|
|
34
|
+
}
|
|
35
|
+
const packed = Buffer.from(icon.data, 'base64');
|
|
36
|
+
if (packed.length !== bytes || packed.toString('base64') !== icon.data) throw new Error('图标 data 必须使用标准 Base64 编码');
|
|
37
|
+
for (let i = 0; i < pixels; i++) {
|
|
38
|
+
const index = i % 2 ? packed[i >> 1] & 15 : packed[i >> 1] >> 4;
|
|
39
|
+
if (index >= icon.palette.length) throw new Error('图标像素引用了不存在的 palette 颜色');
|
|
40
|
+
}
|
|
41
|
+
if (pixels % 2 && (packed.at(-1) & 15)) throw new Error('图标最后一个未使用的低半字节必须为零');
|
|
42
|
+
if (Buffer.byteLength(JSON.stringify(icon)) > MAX_JSON_BYTES) throw new Error('图标数据超过 2 KB');
|
|
43
|
+
return icon;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeIcon(icon) {
|
|
47
|
+
return {
|
|
48
|
+
format: 'indexed4',
|
|
49
|
+
width: icon.width,
|
|
50
|
+
height: icon.height,
|
|
51
|
+
palette: icon.palette.map((color) => color.toLowerCase()),
|
|
52
|
+
data: icon.data,
|
|
53
|
+
background: icon.background.toLowerCase(),
|
|
54
|
+
accent: icon.accent.toLowerCase(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Draw rounded rectangles on a 42-unit canvas scaled to 48×48, exactly like the
|
|
62
|
+
* bridge renders its built-in animals, then quantise to an `indexed4` palette.
|
|
63
|
+
*
|
|
64
|
+
* @param {{background: string, accent: string, shapes: [number, number, number, number, string, number?][]}} art
|
|
65
|
+
*/
|
|
66
|
+
export function renderIcon(art) {
|
|
67
|
+
const size = MAX_SIZE;
|
|
68
|
+
const pixels = new Uint8Array(size * size * 4);
|
|
69
|
+
for (const [x, y, width, height, fill, round = 0] of art.shapes) {
|
|
70
|
+
const rx = Math.min(round, width / 2);
|
|
71
|
+
const ry = Math.min(round, height / 2);
|
|
72
|
+
const color = [1, 3, 5].map((offset) => parseInt(fill.slice(offset, offset + 2), 16));
|
|
73
|
+
for (let py = 0; py < size; py++) {
|
|
74
|
+
for (let px = 0; px < size; px++) {
|
|
75
|
+
const dx = ((px + 0.5) * 42) / size - x;
|
|
76
|
+
const dy = ((py + 0.5) * 42) / size - y;
|
|
77
|
+
if (dx < 0 || dy < 0 || dx >= width || dy >= height) continue;
|
|
78
|
+
const cx = Math.max(rx - dx, dx - (width - rx), 0);
|
|
79
|
+
const cy = Math.max(ry - dy, dy - (height - ry), 0);
|
|
80
|
+
if (cx && cy && (cx / rx) ** 2 + (cy / ry) ** 2 > 1) continue;
|
|
81
|
+
pixels.set([...color, 255], (py * size + px) * 4);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const order = [];
|
|
86
|
+
const palette = new Map();
|
|
87
|
+
for (let i = 0; i < size * size; i++) {
|
|
88
|
+
const rgba = pixels[i * 4 + 3] === 0 ? [0, 0, 0, 0] : [...pixels.subarray(i * 4, i * 4 + 4)];
|
|
89
|
+
const key = rgba.join(',');
|
|
90
|
+
if (!palette.has(key)) {
|
|
91
|
+
if (order.length === 16) throw new Error('图标超过 16 种颜色,请减少图形数量或颜色');
|
|
92
|
+
palette.set(key, order.length);
|
|
93
|
+
order.push(rgba);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const packed = Buffer.alloc(Math.ceil(size * size / 2));
|
|
97
|
+
for (let i = 0; i < size * size; i++) {
|
|
98
|
+
const rgba = pixels[i * 4 + 3] === 0 ? [0, 0, 0, 0] : [...pixels.subarray(i * 4, i * 4 + 4)];
|
|
99
|
+
packed[i >> 1] |= palette.get(rgba.join(',')) << (i % 2 ? 0 : 4);
|
|
100
|
+
}
|
|
101
|
+
return validateIcon(normalizeIcon({
|
|
102
|
+
format: 'indexed4',
|
|
103
|
+
width: size,
|
|
104
|
+
height: size,
|
|
105
|
+
palette: order.map((rgba) => rgba.map((value) => clamp(value, 0, 255).toString(16).padStart(2, '0')).join('')),
|
|
106
|
+
data: packed.toString('base64'),
|
|
107
|
+
background: art.background,
|
|
108
|
+
accent: art.accent,
|
|
109
|
+
}));
|
|
110
|
+
}
|