@bulolo/hermes-link 0.2.1 → 0.2.2

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,103 +1,120 @@
1
1
  # @bulolo/hermes-link
2
2
 
3
- Local companion service and CLI for connecting [Hermes Agent](https://github.com/nousresearch/hermes-agent) to your devices through zhiji.
3
+ 本地伴随服务,为 [Hermes Agent](https://github.com/nousresearch/hermes-agent) 提供移动端接入能力,支持局域网直连。
4
4
 
5
- ## Requirements
5
+ ## 环境要求
6
6
 
7
7
  - Node.js >= 20
8
- - A running [Hermes Agent](https://github.com/nousresearch/hermes-agent) installation
8
+ - 已安装并运行的 [Hermes Agent](https://github.com/nousresearch/hermes-agent)
9
9
 
10
- ## Installation
10
+ ## 安装
11
11
 
12
12
  ```bash
13
- npm install -g @bulolo/hermes-link
13
+ npm install -g @bulolo/hermes-link --registry https://registry.npmjs.org
14
14
  ```
15
15
 
16
- ## Quick Start
16
+ ## 快速开始
17
17
 
18
18
  ```bash
19
- # Start the background daemon
19
+ # 启动后台服务
20
20
  hermeslink start
21
21
 
22
- # Check status
22
+ # 生成配对二维码(手机扫码连接)
23
+ hermeslink pair
24
+
25
+ # 查看状态
23
26
  hermeslink status
24
27
 
25
- # View logs
28
+ # 查看日志
26
29
  hermeslink logs
27
30
  ```
28
31
 
29
- ## Commands
30
-
31
- | Command | Description |
32
- |---------|-------------|
33
- | `hermeslink start` | Start the background daemon |
34
- | `hermeslink stop` | Stop the daemon |
35
- | `hermeslink restart` | Restart the daemon |
36
- | `hermeslink status` | Show daemon and service status |
37
- | `hermeslink pair` | Generate a pairing URL for your device |
38
- | `hermeslink config get` | Show current configuration |
39
- | `hermeslink config set <key> <value>` | Set a configuration value |
40
- | `hermeslink autostart` | Show autostart status |
41
- | `hermeslink autostart enable` | Enable autostart on login |
42
- | `hermeslink autostart disable` | Disable autostart |
43
- | `hermeslink logs` | Show recent log entries |
44
- | `hermeslink logs --gateway` | Show Hermes gateway logs |
45
- | `hermeslink version` | Print version |
46
-
47
- ## Configuration
32
+ ## 配对流程
33
+
34
+ ```
35
+ 1. 运行 hermeslink pair
36
+
37
+
38
+ 终端显示二维码
39
+
40
+
41
+ 手机 App 扫码
42
+
43
+
44
+ 直连本地服务 (局域网 / 127.0.0.1)
45
+ ```
46
+
47
+ 配对完成后,手机 App 获得访问令牌,后续请求直接连接本地服务,无需经过外部服务器。
48
+
49
+ ## 命令一览
50
+
51
+ | 命令 | 说明 |
52
+ |------|------|
53
+ | `hermeslink start` | 启动后台守护进程 |
54
+ | `hermeslink stop` | 停止守护进程 |
55
+ | `hermeslink restart` | 重启守护进程 |
56
+ | `hermeslink status` | 查看运行状态 |
57
+ | `hermeslink pair` | 生成配对二维码 |
58
+ | `hermeslink config get` | 查看当前配置 |
59
+ | `hermeslink config set <key> <value>` | 修改配置 |
60
+ | `hermeslink autostart enable` | 开机自启 |
61
+ | `hermeslink autostart disable` | 关闭自启 |
62
+ | `hermeslink logs` | 查看日志 |
63
+ | `hermeslink logs --gateway` | 查看 Hermes 网关日志 |
64
+ | `hermeslink version` | 查看版本 |
65
+
66
+ ## 配置
48
67
 
49
68
  ```bash
50
- hermeslink config set port 52379 # Change listen port
51
- hermeslink config set lan-host 192.168.1.10 # Set LAN IP manually
52
- hermeslink config set language zh-CN # Set language (auto/en/zh-CN)
53
- hermeslink config set log-level debug # Set log level (debug/info/warn/error)
69
+ hermeslink config set port 52379 # 修改监听端口
70
+ hermeslink config set lan-host 192.168.1.10 # 手动指定局域网 IP
71
+ hermeslink config set language zh-CN # 语言 (auto/en/zh-CN)
72
+ hermeslink config set log-level debug # 日志级别 (debug/info/warn/error)
54
73
  ```
55
74
 
56
- Config is stored at `~/.hermeslink/config.json`.
75
+ 配置文件位于 `~/.hermeslink/config.json`。
57
76
 
58
- ## How It Works
77
+ ## 工作原理
59
78
 
60
79
  ```
61
- Hermes App (mobile)
62
-
63
-
64
- hermes-relay.catwiki.ai ──────────────────┐
65
- WebSocket tunnel
66
- hermeslink (this service)
67
-
68
-
69
- Hermes Agent (local)
70
- localhost:8642
80
+ 手机 App
81
+
82
+ └──→ hermeslink (本地, 端口 52379)
83
+
84
+ └──→ Hermes Agent (localhost:8642)
71
85
  ```
72
86
 
73
- `hermeslink` runs a local HTTP server (default port `52379`) and maintains a persistent WebSocket connection to the relay server. The mobile app connects through the relay and proxies requests to your local Hermes Agent.
87
+ `hermeslink` 在本地运行一个 HTTP 服务,手机 App 通过局域网直接访问。对话、文件、指令均在本地处理,数据不经过外部服务器。
74
88
 
75
- ## Runtime Files
89
+ ## 运行时文件
76
90
 
77
- All runtime files are stored in `~/.hermeslink/`:
91
+ 所有文件存储于 `~/.hermeslink/`:
78
92
 
79
- | Path | Description |
80
- |------|-------------|
81
- | `config.json` | User configuration |
82
- | `identity.json` | Device identity (ed25519 keypair) |
83
- | `link.db` | SQLite database (stats, usage) |
84
- | `logs/` | Log files |
85
- | `daemon.pid` | Daemon PID file |
93
+ | 路径 | 说明 |
94
+ |------|------|
95
+ | `config.json` | 用户配置 |
96
+ | `identity.json` | 设备身份(ed25519 密钥对)|
97
+ | `credentials.json` | 已配对设备的访问令牌 |
98
+ | `conversations/` | 对话数据 |
99
+ | `blobs/` | 文件附件 |
100
+ | `pairing/` | 配对会话 |
101
+ | `link.db` | SQLite 数据库(统计信息)|
102
+ | `logs/` | 日志文件 |
86
103
 
87
- ## Environment Variables
104
+ ## 环境变量
88
105
 
89
- | Variable | Description |
90
- |----------|-------------|
91
- | `HERMESLINK_HOME` | Override runtime directory (default `~/.hermeslink`) |
92
- | `HERMESLINK_LOG_LEVEL` | Override log level |
93
- | `HERMESLINK_LANG` | Override language |
94
- | `HERMES_BIN` | Path to `hermes` binary (default: `hermes`) |
106
+ | 变量 | 说明 |
107
+ |------|------|
108
+ | `HERMESLINK_HOME` | 覆盖运行时目录(默认 `~/.hermeslink`)|
109
+ | `HERMESLINK_LOG_LEVEL` | 覆盖日志级别 |
110
+ | `HERMESLINK_LANG` | 覆盖语言 |
111
+ | `HERMES_BIN` | `hermes` 二进制路径(默认 `hermes`)|
95
112
 
96
- ## Autostart
113
+ ## 开机自启
97
114
 
98
- On macOS, autostart is managed via launchd (`~/Library/LaunchAgents/com.hermes.link.plist`).
99
- On Linux, via systemd user service or XDG autostart.
100
- On Windows, via the Startup folder.
115
+ - **macOS**:通过 launchd(`~/Library/LaunchAgents/com.hermes.link.plist`)
116
+ - **Linux**:通过 systemd 用户服务或 XDG autostart
117
+ - **Windows**:通过 Startup 文件夹
101
118
 
102
119
  ```bash
103
120
  hermeslink autostart enable
@@ -800,9 +800,6 @@ async function dismissUpdate(paths) {
800
800
  await updateLinkState({ updateDismissedAt: (/* @__PURE__ */ new Date()).toISOString() }, runtimePaths);
801
801
  }
802
802
 
803
- // src/http/auth.ts
804
- import { createPublicKey, verify } from "crypto";
805
-
806
803
  // src/security/credentials.ts
807
804
  import { randomBytes, randomUUID as randomUUID2, timingSafeEqual, createHash } from "crypto";
808
805
  var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
@@ -1075,133 +1072,50 @@ async function renameDeviceById(deviceId, label, paths = resolveRuntimePaths())
1075
1072
  return formatDeviceListItem(device);
1076
1073
  }
1077
1074
 
1078
- // src/identity/identity.ts
1079
- import { generateKeyPairSync, randomUUID as randomUUID3, sign } from "crypto";
1080
- import { chmod as chmod2, mkdir as mkdir4 } from "fs/promises";
1081
- import { z } from "zod";
1082
- var linkIdentitySchema = z.object({
1083
- install_id: z.string().min(1),
1084
- link_id: z.string().min(1).nullable().optional(),
1085
- public_key_pem: z.string().min(1),
1086
- private_key_pem: z.string().min(1),
1087
- created_at: z.string().min(1),
1088
- updated_at: z.string().min(1)
1089
- });
1090
- async function loadIdentity(paths = resolveRuntimePaths()) {
1091
- const value = await readJsonFile(paths.identityFile);
1092
- if (value === null) {
1093
- return null;
1094
- }
1095
- return linkIdentitySchema.parse(value);
1096
- }
1097
- async function ensureIdentity(paths = resolveRuntimePaths()) {
1098
- const existing = await loadIdentity(paths);
1099
- if (existing) {
1100
- return existing;
1101
- }
1102
- await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
1103
- await chmod2(paths.homeDir, 448).catch(() => void 0);
1104
- const { publicKey, privateKey } = generateKeyPairSync("ed25519");
1105
- const now = (/* @__PURE__ */ new Date()).toISOString();
1106
- const identity = {
1107
- install_id: `install_${randomUUID3().replaceAll("-", "")}`,
1108
- link_id: null,
1109
- public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
1110
- private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
1111
- created_at: now,
1112
- updated_at: now
1113
- };
1114
- await writeJsonFile(paths.identityFile, identity);
1115
- return identity;
1075
+ // src/security/app-connect-token.ts
1076
+ import crypto from "crypto";
1077
+ import path6 from "path";
1078
+ var TOKENS_FILE = "app-connect-tokens.json";
1079
+ var TOKEN_EXPIRY_MS = 5 * 60 * 1e3;
1080
+ function tokensFilePath(paths) {
1081
+ return path6.join(paths.homeDir, TOKENS_FILE);
1082
+ }
1083
+ async function readTokens(paths) {
1084
+ const raw = await readJsonFile(tokensFilePath(paths));
1085
+ if (!Array.isArray(raw)) return [];
1086
+ const now = /* @__PURE__ */ new Date();
1087
+ return raw.filter(isValidToken).filter((t) => new Date(t.expiresAt) > now);
1116
1088
  }
1117
- async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
1118
- const identity = await ensureIdentity(paths);
1119
- const next = {
1120
- ...identity,
1121
- link_id: linkId,
1122
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
1123
- };
1124
- await writeJsonFile(paths.identityFile, next);
1125
- return next;
1089
+ function isValidToken(value) {
1090
+ if (!value || typeof value !== "object") return false;
1091
+ const t = value;
1092
+ return typeof t.token === "string" && typeof t.createdAt === "string" && typeof t.expiresAt === "string";
1126
1093
  }
1127
- function signRelayNonce(identity, nonce) {
1128
- return signIdentityPayload(identity, nonce);
1094
+ async function saveTokens(tokens, paths) {
1095
+ await writeJsonFile(tokensFilePath(paths), tokens);
1129
1096
  }
1130
- function signIdentityPayload(identity, payload) {
1131
- const signature = sign(null, Buffer.from(payload, "utf8"), identity.private_key_pem);
1132
- return signature.toString("base64url");
1133
- }
1134
-
1135
- // src/config/config.ts
1136
- var defaultLinkConfig = {
1137
- port: LINK_DEFAULT_PORT,
1138
- lanHost: null,
1139
- serverBaseUrl: "https://hermes-server.catwiki.ai",
1140
- relayBaseUrl: "https://hermes-relay.catwiki.ai",
1141
- appConnectTokenIssuer: "https://hermes-server.catwiki.ai",
1142
- appConnectTokenAudience: "hermes-link",
1143
- language: "auto",
1144
- logLevel: "warn"
1145
- };
1146
- async function loadConfig(paths = resolveRuntimePaths()) {
1147
- const existing = await readJsonFile(paths.configFile);
1148
- const language = normalizeConfiguredLanguage(existing?.language);
1149
- const lanHost = normalizeLanHost(existing?.lanHost);
1150
- const logLevel = normalizeLogLevel(existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL);
1151
- return {
1152
- ...defaultLinkConfig,
1153
- ...existing ?? {},
1154
- language,
1155
- lanHost,
1156
- logLevel
1157
- };
1158
- }
1159
- async function saveConfig(patch, paths = resolveRuntimePaths()) {
1160
- const current = await loadConfig(paths);
1161
- const next = {
1162
- ...current,
1163
- ...patch,
1164
- logLevel: patch.logLevel === void 0 ? current.logLevel : normalizeLogLevel(patch.logLevel)
1097
+ async function generateAppConnectToken(paths) {
1098
+ const runtimePaths = paths ?? resolveRuntimePaths();
1099
+ const now = /* @__PURE__ */ new Date();
1100
+ const token = {
1101
+ token: crypto.randomBytes(32).toString("base64url"),
1102
+ createdAt: now.toISOString(),
1103
+ usedAt: null,
1104
+ expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS).toISOString()
1165
1105
  };
1166
- await writeJsonFile(paths.configFile, next);
1167
- return next;
1168
- }
1169
- function normalizeConfiguredLanguage(language) {
1170
- if (language === "zh-CN" || language === "en" || language === "auto") {
1171
- return language;
1172
- }
1173
- return defaultLinkConfig.language;
1174
- }
1175
- function normalizeLogLevel(level) {
1176
- if (level === "debug" || level === "info" || level === "warn" || level === "error") {
1177
- return level;
1178
- }
1179
- return defaultLinkConfig.logLevel;
1180
- }
1181
- function normalizeLanHost(value) {
1182
- if (value === null || value === void 0) {
1183
- return null;
1184
- }
1185
- if (typeof value !== "string") {
1186
- return null;
1187
- }
1188
- const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
1189
- if (!host) {
1190
- return null;
1191
- }
1192
- if (!isUsableLanIpv4(host)) {
1193
- return null;
1194
- }
1195
- return host;
1106
+ const tokens = await readTokens(runtimePaths);
1107
+ tokens.push(token);
1108
+ await saveTokens(tokens, runtimePaths);
1109
+ return token;
1196
1110
  }
1197
- function isUsableLanIpv4(value) {
1198
- const parts = value.split(".").map((part) => Number.parseInt(part, 10));
1199
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
1200
- return false;
1201
- }
1202
- const [first, second, , fourth] = parts;
1203
- const privateRange = first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
1204
- return privateRange && fourth !== 0 && fourth !== 255;
1111
+ async function consumeAppConnectToken(tokenValue, paths) {
1112
+ const runtimePaths = paths ?? resolveRuntimePaths();
1113
+ const tokens = await readTokens(runtimePaths);
1114
+ const index = tokens.findIndex((t) => t.token === tokenValue && !t.usedAt);
1115
+ if (index === -1) return null;
1116
+ tokens[index].usedAt = (/* @__PURE__ */ new Date()).toISOString();
1117
+ await saveTokens(tokens, runtimePaths);
1118
+ return tokens[index];
1205
1119
  }
1206
1120
 
1207
1121
  // src/core/errors.ts
@@ -1216,7 +1130,6 @@ var LinkHttpError = class extends Error {
1216
1130
  };
1217
1131
 
1218
1132
  // src/http/auth.ts
1219
- var cachedJwks = null;
1220
1133
  async function authenticateRequest(ctx, paths = resolveRuntimePaths()) {
1221
1134
  const token = readBearerToken(ctx.get("authorization"));
1222
1135
  if (!token) {
@@ -1229,21 +1142,11 @@ async function authenticateRequest(ctx, paths = resolveRuntimePaths()) {
1229
1142
  if (token.startsWith("hpat_")) {
1230
1143
  throw new LinkHttpError(401, "device_access_token_invalid", "Device access token is invalid or expired");
1231
1144
  }
1232
- const [identity, config] = await Promise.all([loadRequiredIdentity(paths), loadConfig(paths)]);
1233
- const claims = await verifyAppConnectToken(token, { config, linkId: identity.link_id ?? null });
1234
- return {
1235
- kind: "app-connect",
1236
- accountId: typeof claims.sub === "string" ? claims.sub : null,
1237
- scopes: normalizeScopes(claims.scope),
1238
- appInstanceId: normalizeAppInstanceId2(claims.app_instance_id)
1239
- };
1240
- }
1241
- async function loadRequiredIdentity(paths) {
1242
- const identity = await loadIdentity(paths);
1243
- if (!identity?.link_id) {
1244
- throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
1145
+ const localToken = await consumeAppConnectToken(token, paths);
1146
+ if (localToken) {
1147
+ return { kind: "app-connect", accountId: null, scopes: [], appInstanceId: null };
1245
1148
  }
1246
- return identity;
1149
+ throw new LinkHttpError(401, "auth_invalid", "Token is invalid or expired");
1247
1150
  }
1248
1151
  function readBearerToken(value) {
1249
1152
  const trimmed = value.trim();
@@ -1251,67 +1154,6 @@ function readBearerToken(value) {
1251
1154
  const token = trimmed.slice(7).trim();
1252
1155
  return token || null;
1253
1156
  }
1254
- function normalizeScopes(value) {
1255
- if (Array.isArray(value)) return value.filter((i) => typeof i === "string").map((i) => i.trim()).filter(Boolean);
1256
- if (typeof value === "string") return value.split(/\s+/u).map((i) => i.trim()).filter(Boolean);
1257
- return [];
1258
- }
1259
- function normalizeAppInstanceId2(value) {
1260
- if (typeof value !== "string") return null;
1261
- const trimmed = value.trim();
1262
- return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(trimmed) ? trimmed : null;
1263
- }
1264
- async function verifyAppConnectToken(token, options) {
1265
- const segments = token.split(".");
1266
- if (segments.length !== 3) {
1267
- throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token is malformed");
1268
- }
1269
- const [encodedHeader, encodedPayload, encodedSignature] = segments;
1270
- const header = decodeJwtPart(encodedHeader);
1271
- const payload = decodeJwtPart(encodedPayload);
1272
- if (header.alg !== "ES256" || header.typ !== "JWT") {
1273
- throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token algorithm is unsupported");
1274
- }
1275
- if (payload.token_type !== "hermes_app_connect" || payload.iss !== options.config.appConnectTokenIssuer || payload.aud !== options.config.appConnectTokenAudience || payload.link_id !== options.linkId || !Number.isFinite(payload.exp) || payload.exp <= Math.floor(Date.now() / 1e3)) {
1276
- throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token claims are invalid");
1277
- }
1278
- const jwks = await getJwks(options.config.serverBaseUrl);
1279
- const key = jwks.find((k) => k.kid === header.kid) ?? jwks[0];
1280
- if (!key) {
1281
- throw new LinkHttpError(503, "app_connect_jwks_unavailable", "App connect token key is unavailable");
1282
- }
1283
- const publicKey = createPublicKey({ key, format: "jwk" });
1284
- const ok = verify(
1285
- "sha256",
1286
- Buffer.from(`${encodedHeader}.${encodedPayload}`),
1287
- { key: publicKey, dsaEncoding: "ieee-p1363" },
1288
- Buffer.from(base64UrlToBase64(encodedSignature), "base64")
1289
- );
1290
- if (!ok) {
1291
- throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token signature is invalid");
1292
- }
1293
- return payload;
1294
- }
1295
- async function getJwks(serverBaseUrl) {
1296
- if (cachedJwks && cachedJwks.expiresAt > Date.now()) return cachedJwks.keys;
1297
- const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}/api/v1/app-connect/jwks.json`, {
1298
- headers: { accept: "application/json" }
1299
- });
1300
- if (!response.ok) {
1301
- throw new LinkHttpError(503, "app_connect_jwks_unavailable", "Unable to load app connect JWKS");
1302
- }
1303
- const payload = await response.json();
1304
- const keys = Array.isArray(payload.keys) ? payload.keys : [];
1305
- cachedJwks = { keys, expiresAt: Date.now() + 5 * 60 * 1e3 };
1306
- return keys;
1307
- }
1308
- function decodeJwtPart(value) {
1309
- return JSON.parse(Buffer.from(base64UrlToBase64(value), "base64").toString("utf8"));
1310
- }
1311
- function base64UrlToBase64(value) {
1312
- const n = value.replace(/-/g, "+").replace(/_/g, "/");
1313
- return n + "=".repeat((4 - n.length % 4) % 4);
1314
- }
1315
1157
  function readAppInstanceIdHeader(ctx) {
1316
1158
  const value = ctx.get("x-hermes-app-instance-id").trim();
1317
1159
  return /^appi_[A-Za-z0-9_-]{16,96}$/u.test(value) ? value : null;
@@ -1516,6 +1358,135 @@ function createStatisticsRouter(options) {
1516
1358
  // src/http/routes/bootstrap.ts
1517
1359
  import Router3 from "@koa/router";
1518
1360
 
1361
+ // src/identity/identity.ts
1362
+ import { generateKeyPairSync, randomUUID as randomUUID3, sign } from "crypto";
1363
+ import { chmod as chmod2, mkdir as mkdir4 } from "fs/promises";
1364
+ import { z } from "zod";
1365
+ var linkIdentitySchema = z.object({
1366
+ install_id: z.string().min(1),
1367
+ link_id: z.string().min(1).nullable().optional(),
1368
+ public_key_pem: z.string().min(1),
1369
+ private_key_pem: z.string().min(1),
1370
+ created_at: z.string().min(1),
1371
+ updated_at: z.string().min(1)
1372
+ });
1373
+ async function loadIdentity(paths = resolveRuntimePaths()) {
1374
+ const value = await readJsonFile(paths.identityFile);
1375
+ if (value === null) {
1376
+ return null;
1377
+ }
1378
+ return linkIdentitySchema.parse(value);
1379
+ }
1380
+ async function ensureIdentity(paths = resolveRuntimePaths()) {
1381
+ const existing = await loadIdentity(paths);
1382
+ if (existing) {
1383
+ return existing;
1384
+ }
1385
+ await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
1386
+ await chmod2(paths.homeDir, 448).catch(() => void 0);
1387
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
1388
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1389
+ const identity = {
1390
+ install_id: `install_${randomUUID3().replaceAll("-", "")}`,
1391
+ link_id: null,
1392
+ public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
1393
+ private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
1394
+ created_at: now,
1395
+ updated_at: now
1396
+ };
1397
+ await writeJsonFile(paths.identityFile, identity);
1398
+ return identity;
1399
+ }
1400
+ async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
1401
+ const identity = await ensureIdentity(paths);
1402
+ const next = {
1403
+ ...identity,
1404
+ link_id: linkId,
1405
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1406
+ };
1407
+ await writeJsonFile(paths.identityFile, next);
1408
+ return next;
1409
+ }
1410
+ function signRelayNonce(identity, nonce) {
1411
+ return signIdentityPayload(identity, nonce);
1412
+ }
1413
+ function signIdentityPayload(identity, payload) {
1414
+ const signature = sign(null, Buffer.from(payload, "utf8"), identity.private_key_pem);
1415
+ return signature.toString("base64url");
1416
+ }
1417
+
1418
+ // src/config/config.ts
1419
+ var defaultLinkConfig = {
1420
+ port: LINK_DEFAULT_PORT,
1421
+ lanHost: null,
1422
+ serverBaseUrl: "https://hermes-server.catwiki.ai",
1423
+ relayBaseUrl: "https://hermes-relay.catwiki.ai",
1424
+ appConnectTokenIssuer: "https://hermes-server.catwiki.ai",
1425
+ appConnectTokenAudience: "hermes-link",
1426
+ language: "auto",
1427
+ logLevel: "warn"
1428
+ };
1429
+ async function loadConfig(paths = resolveRuntimePaths()) {
1430
+ const existing = await readJsonFile(paths.configFile);
1431
+ const language = normalizeConfiguredLanguage(existing?.language);
1432
+ const lanHost = normalizeLanHost(existing?.lanHost);
1433
+ const logLevel = normalizeLogLevel(existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL);
1434
+ return {
1435
+ ...defaultLinkConfig,
1436
+ ...existing ?? {},
1437
+ language,
1438
+ lanHost,
1439
+ logLevel
1440
+ };
1441
+ }
1442
+ async function saveConfig(patch, paths = resolveRuntimePaths()) {
1443
+ const current = await loadConfig(paths);
1444
+ const next = {
1445
+ ...current,
1446
+ ...patch,
1447
+ logLevel: patch.logLevel === void 0 ? current.logLevel : normalizeLogLevel(patch.logLevel)
1448
+ };
1449
+ await writeJsonFile(paths.configFile, next);
1450
+ return next;
1451
+ }
1452
+ function normalizeConfiguredLanguage(language) {
1453
+ if (language === "zh-CN" || language === "en" || language === "auto") {
1454
+ return language;
1455
+ }
1456
+ return defaultLinkConfig.language;
1457
+ }
1458
+ function normalizeLogLevel(level) {
1459
+ if (level === "debug" || level === "info" || level === "warn" || level === "error") {
1460
+ return level;
1461
+ }
1462
+ return defaultLinkConfig.logLevel;
1463
+ }
1464
+ function normalizeLanHost(value) {
1465
+ if (value === null || value === void 0) {
1466
+ return null;
1467
+ }
1468
+ if (typeof value !== "string") {
1469
+ return null;
1470
+ }
1471
+ const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
1472
+ if (!host) {
1473
+ return null;
1474
+ }
1475
+ if (!isUsableLanIpv4(host)) {
1476
+ return null;
1477
+ }
1478
+ return host;
1479
+ }
1480
+ function isUsableLanIpv4(value) {
1481
+ const parts = value.split(".").map((part) => Number.parseInt(part, 10));
1482
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
1483
+ return false;
1484
+ }
1485
+ const [first, second, , fourth] = parts;
1486
+ const privateRange = first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
1487
+ return privateRange && fourth !== 0 && fourth !== 255;
1488
+ }
1489
+
1519
1490
  // src/network/topology.ts
1520
1491
  import os5 from "os";
1521
1492
  var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
@@ -2240,7 +2211,7 @@ function createConversationsRouter(options) {
2240
2211
 
2241
2212
  // src/http/routes/pairing.ts
2242
2213
  import Router7 from "@koa/router";
2243
- import path6 from "path";
2214
+ import path7 from "path";
2244
2215
  function readString4(body, ...keys) {
2245
2216
  if (!body || typeof body !== "object") return null;
2246
2217
  for (const key of keys) {
@@ -2267,10 +2238,10 @@ async function readJsonBody4(req) {
2267
2238
  });
2268
2239
  }
2269
2240
  function pairingSessionPath(sessionId, paths) {
2270
- return path6.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
2241
+ return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
2271
2242
  }
2272
2243
  function pairingClaimPath(sessionId, paths) {
2273
- return path6.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
2244
+ return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
2274
2245
  }
2275
2246
  async function readPairingSession(sessionId, paths) {
2276
2247
  const record = await readJsonFile(pairingSessionPath(sessionId, paths));
@@ -2315,35 +2286,18 @@ function createPairingRouter(options) {
2315
2286
  if (!sessionId || !claimToken) {
2316
2287
  throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
2317
2288
  }
2318
- const [identity, config, localSession] = await Promise.all([
2289
+ const [identity, localSession] = await Promise.all([
2319
2290
  loadIdentity(paths),
2320
- loadConfig(paths),
2321
2291
  readPairingSession(sessionId, paths)
2322
2292
  ]);
2323
2293
  if (!identity?.link_id) throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
2324
2294
  if (!localSession) throw new LinkHttpError(404, "pairing_session_not_found", "Pairing session was not found");
2325
2295
  if (isPairingSessionExpired(localSession)) throw new LinkHttpError(404, "pairing_session_expired", "Pairing session has expired");
2326
2296
  if (localSession.link_id !== identity.link_id) throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
2327
- let verified;
2328
- try {
2329
- const resp = await fetch(`${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/link-pairings/${sessionId}/claim/verify`, {
2330
- method: "POST",
2331
- headers: { accept: "application/json", "content-type": "application/json" },
2332
- body: JSON.stringify({ claim_token: claimToken, app_instance_id: readString4(body, "app_instance_id", "appInstanceId") ?? void 0 })
2333
- });
2334
- if (!resp.ok) {
2335
- const errBody = await resp.json().catch(() => ({}));
2336
- throw new LinkHttpError(resp.status, "pairing_claim_verify_failed", String(errBody.message ?? "Pairing claim verification failed"));
2337
- }
2338
- verified = await resp.json();
2339
- } catch (err) {
2340
- if (err instanceof LinkHttpError) throw err;
2341
- throw new LinkHttpError(503, "pairing_server_unreachable", `Unable to verify pairing claim: ${err.message}`);
2297
+ if (localSession.code !== claimToken) {
2298
+ throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim token does not match");
2342
2299
  }
2343
- if (verified.ok !== true || verified.linkId !== identity.link_id) {
2344
- throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
2345
- }
2346
- const appInstanceId = typeof verified.appInstanceId === "string" ? verified.appInstanceId : null;
2300
+ const appInstanceId = readString4(body, "app_instance_id", "appInstanceId");
2347
2301
  const deviceLabel = readString4(body, "device_label", "deviceLabel") ?? "HermesPilot App";
2348
2302
  const devicePlatform = readString4(body, "device_platform", "devicePlatform") ?? "unknown";
2349
2303
  const session = await createDeviceSession(
@@ -2601,9 +2555,9 @@ function openSqliteDatabase(filePath, options = {}) {
2601
2555
 
2602
2556
  // src/storage/link-database.ts
2603
2557
  import { mkdir as mkdir5 } from "fs/promises";
2604
- import path7 from "path";
2558
+ import path8 from "path";
2605
2559
  async function initLinkDatabase(paths) {
2606
- await mkdir5(path7.dirname(paths.databaseFile), { recursive: true, mode: 448 });
2560
+ await mkdir5(path8.dirname(paths.databaseFile), { recursive: true, mode: 448 });
2607
2561
  const db = openDb(paths);
2608
2562
  try {
2609
2563
  db.exec(`
@@ -2699,35 +2653,35 @@ function openDb(paths) {
2699
2653
 
2700
2654
  // src/conversations/service.ts
2701
2655
  import { EventEmitter as EventEmitter2 } from "events";
2702
- import crypto3 from "crypto";
2656
+ import crypto4 from "crypto";
2703
2657
 
2704
2658
  // src/conversations/store.ts
2705
2659
  import { mkdir as mkdir6, readFile as readFile4, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2706
- import path8 from "path";
2707
- import crypto from "crypto";
2660
+ import path9 from "path";
2661
+ import crypto2 from "crypto";
2708
2662
  function createConversationId() {
2709
- return `conv_${crypto.randomUUID().replaceAll("-", "")}`;
2663
+ return `conv_${crypto2.randomUUID().replaceAll("-", "")}`;
2710
2664
  }
2711
2665
  function createMessageId() {
2712
- return `msg_${crypto.randomUUID().replaceAll("-", "")}`;
2666
+ return `msg_${crypto2.randomUUID().replaceAll("-", "")}`;
2713
2667
  }
2714
2668
  function createRunId() {
2715
- return `run_${crypto.randomUUID().replaceAll("-", "")}`;
2669
+ return `run_${crypto2.randomUUID().replaceAll("-", "")}`;
2716
2670
  }
2717
2671
  function conversationDir(paths, conversationId) {
2718
- return path8.join(paths.conversationsDir, conversationId);
2672
+ return path9.join(paths.conversationsDir, conversationId);
2719
2673
  }
2720
2674
  function manifestPath(paths, conversationId) {
2721
- return path8.join(conversationDir(paths, conversationId), "manifest.json");
2675
+ return path9.join(conversationDir(paths, conversationId), "manifest.json");
2722
2676
  }
2723
2677
  function snapshotPath(paths, conversationId) {
2724
- return path8.join(conversationDir(paths, conversationId), "snapshot.json");
2678
+ return path9.join(conversationDir(paths, conversationId), "snapshot.json");
2725
2679
  }
2726
2680
  function eventsPath(paths, conversationId) {
2727
- return path8.join(conversationDir(paths, conversationId), "events.json");
2681
+ return path9.join(conversationDir(paths, conversationId), "events.json");
2728
2682
  }
2729
2683
  function blobPath(paths, blobId) {
2730
- return path8.join(paths.blobsDir, blobId);
2684
+ return path9.join(paths.blobsDir, blobId);
2731
2685
  }
2732
2686
  async function readJson(filePath, fallback) {
2733
2687
  try {
@@ -2738,7 +2692,7 @@ async function readJson(filePath, fallback) {
2738
2692
  }
2739
2693
  }
2740
2694
  async function writeJson(filePath, data) {
2741
- await mkdir6(path8.dirname(filePath), { recursive: true, mode: 448 });
2695
+ await mkdir6(path9.dirname(filePath), { recursive: true, mode: 448 });
2742
2696
  await writeFile2(filePath, JSON.stringify(data), { mode: 384 });
2743
2697
  }
2744
2698
  function assertValidConversationId(id) {
@@ -2795,8 +2749,8 @@ async function listConversationIds(paths) {
2795
2749
 
2796
2750
  // src/conversations/blobs.ts
2797
2751
  import { mkdir as mkdir7, readFile as readFile5, rm as rm5, writeFile as writeFile3 } from "fs/promises";
2798
- import path9 from "path";
2799
- import crypto2 from "crypto";
2752
+ import path10 from "path";
2753
+ import crypto3 from "crypto";
2800
2754
  var MAX_BLOB_UPLOAD_BYTES2 = 50 * 1024 * 1024;
2801
2755
  function blobManifestPath(paths, blobId) {
2802
2756
  return `${blobPath(paths, blobId)}.json`;
@@ -2804,7 +2758,7 @@ function blobManifestPath(paths, blobId) {
2804
2758
  function normalizeMime(mime, filename) {
2805
2759
  if (mime && mime !== "application/octet-stream") return mime;
2806
2760
  if (filename) {
2807
- const ext = path9.extname(filename).toLowerCase();
2761
+ const ext = path10.extname(filename).toLowerCase();
2808
2762
  const mimeMap = {
2809
2763
  ".jpg": "image/jpeg",
2810
2764
  ".jpeg": "image/jpeg",
@@ -2824,7 +2778,7 @@ function normalizeMime(mime, filename) {
2824
2778
  }
2825
2779
  function sanitizeFilename(filename, fallback) {
2826
2780
  if (!filename) return fallback;
2827
- return path9.basename(filename).replace(/[^\w.\-]/gu, "_").slice(0, 255) || fallback;
2781
+ return path10.basename(filename).replace(/[^\w.\-]/gu, "_").slice(0, 255) || fallback;
2828
2782
  }
2829
2783
  async function writeBlob(paths, conversationId, input) {
2830
2784
  assertValidConversationId(conversationId);
@@ -2834,9 +2788,9 @@ async function writeBlob(paths, conversationId, input) {
2834
2788
  if (input.bytes.byteLength > MAX_BLOB_UPLOAD_BYTES2) {
2835
2789
  throw new LinkHttpError(413, "blob_too_large", "Blob is too large");
2836
2790
  }
2837
- const id = `blob_${crypto2.randomUUID().replaceAll("-", "")}`;
2791
+ const id = `blob_${crypto3.randomUUID().replaceAll("-", "")}`;
2838
2792
  const filePath = blobPath(paths, id);
2839
- await mkdir7(path9.dirname(filePath), { recursive: true, mode: 448 });
2793
+ await mkdir7(path10.dirname(filePath), { recursive: true, mode: 448 });
2840
2794
  await writeFile3(filePath, input.bytes, { mode: 384 });
2841
2795
  const blob = {
2842
2796
  id,
@@ -3209,7 +3163,7 @@ var ConversationService = class extends EventEmitter2 {
3209
3163
  return { deleted_count: count };
3210
3164
  }
3211
3165
  async prepareClearAllConversationPlan() {
3212
- const planId = `plan_${crypto3.randomUUID().replaceAll("-", "")}`;
3166
+ const planId = `plan_${crypto4.randomUUID().replaceAll("-", "")}`;
3213
3167
  const ids = await listConversationIds(this.paths);
3214
3168
  const activeIds = [];
3215
3169
  for (const id of ids) {
@@ -3287,7 +3241,7 @@ import { promisify as promisify3 } from "util";
3287
3241
 
3288
3242
  // src/hermes/config.ts
3289
3243
  import os6 from "os";
3290
- import path10 from "path";
3244
+ import path11 from "path";
3291
3245
  import YAML from "yaml";
3292
3246
  var PROFILE_PERMISSION_TOOLSETS = [
3293
3247
  {
@@ -3417,19 +3371,19 @@ var profileApiServerPortAssignmentQueue = Promise.resolve();
3417
3371
  function resolveHermesProfilesDir() {
3418
3372
  const hermesHome = process.env.HERMES_HOME?.trim();
3419
3373
  if (hermesHome) {
3420
- return path10.join(
3421
- resolveDefaultHermesRoot(path10.resolve(hermesHome)),
3374
+ return path11.join(
3375
+ resolveDefaultHermesRoot(path11.resolve(hermesHome)),
3422
3376
  "profiles"
3423
3377
  );
3424
3378
  }
3425
- return path10.join(os6.homedir(), ".hermes", "profiles");
3379
+ return path11.join(os6.homedir(), ".hermes", "profiles");
3426
3380
  }
3427
3381
  function isValidProfileName(value) {
3428
3382
  return typeof value === "string" && /^[a-zA-Z0-9._-]{1,64}$/.test(value);
3429
3383
  }
3430
3384
  function resolveDefaultHermesRoot(hermesHome) {
3431
- if (path10.basename(path10.dirname(hermesHome)) === "profiles") {
3432
- return path10.dirname(path10.dirname(hermesHome));
3385
+ if (path11.basename(path11.dirname(hermesHome)) === "profiles") {
3386
+ return path11.dirname(path11.dirname(hermesHome));
3433
3387
  }
3434
3388
  return hermesHome;
3435
3389
  }
@@ -3641,7 +3595,6 @@ export {
3641
3595
  LINK_VERSION,
3642
3596
  DAEMON_LOG_FILE,
3643
3597
  resolveRuntimePaths,
3644
- readJsonFile,
3645
3598
  writeJsonFile,
3646
3599
  loadConfig,
3647
3600
  saveConfig,
@@ -3658,5 +3611,6 @@ export {
3658
3611
  readRecentLogEntries,
3659
3612
  readRecentGatewayLogEntries,
3660
3613
  bootstrapWithRelay,
3614
+ generateAppConnectToken,
3661
3615
  startLinkService
3662
3616
  };
package/dist/cli/index.js CHANGED
@@ -10,11 +10,11 @@ import {
10
10
  disableAutostart,
11
11
  enableAutostart,
12
12
  ensureIdentity,
13
+ generateAppConnectToken,
13
14
  getAutostartStatus,
14
15
  loadConfig,
15
16
  loadIdentity,
16
17
  normalizeLanHost,
17
- readJsonFile,
18
18
  readRecentGatewayLogEntries,
19
19
  readRecentLogEntries,
20
20
  resolveRuntimePaths,
@@ -22,10 +22,10 @@ import {
22
22
  saveConfig,
23
23
  startLinkService,
24
24
  writeJsonFile
25
- } from "../chunk-CIUWLPSN.js";
25
+ } from "../chunk-QL5SEM7J.js";
26
26
 
27
27
  // src/cli/index.ts
28
- import { mkdir as mkdir2 } from "fs/promises";
28
+ import { mkdir as mkdir3 } from "fs/promises";
29
29
  import qrcode from "qrcode-terminal";
30
30
 
31
31
  // src/daemon/process.ts
@@ -190,42 +190,9 @@ async function runDaemonSupervisor(options) {
190
190
  await cleanup();
191
191
  }
192
192
 
193
- // src/security/app-connect-token.ts
194
- import crypto from "crypto";
193
+ // src/pairing/preflight.ts
195
194
  import path2 from "path";
196
- var TOKENS_FILE = "app-connect-tokens.json";
197
- var TOKEN_EXPIRY_MS = 5 * 60 * 1e3;
198
- function tokensFilePath(paths) {
199
- return path2.join(paths.homeDir, TOKENS_FILE);
200
- }
201
- async function readTokens(paths) {
202
- const raw = await readJsonFile(tokensFilePath(paths));
203
- if (!Array.isArray(raw)) return [];
204
- const now = /* @__PURE__ */ new Date();
205
- return raw.filter(isValidToken).filter((t) => new Date(t.expiresAt) > now);
206
- }
207
- function isValidToken(value) {
208
- if (!value || typeof value !== "object") return false;
209
- const t = value;
210
- return typeof t.token === "string" && typeof t.createdAt === "string" && typeof t.expiresAt === "string";
211
- }
212
- async function saveTokens(tokens, paths) {
213
- await writeJsonFile(tokensFilePath(paths), tokens);
214
- }
215
- async function generateAppConnectToken(paths) {
216
- const runtimePaths = paths ?? resolveRuntimePaths();
217
- const now = /* @__PURE__ */ new Date();
218
- const token = {
219
- token: crypto.randomBytes(32).toString("base64url"),
220
- createdAt: now.toISOString(),
221
- usedAt: null,
222
- expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS).toISOString()
223
- };
224
- const tokens = await readTokens(runtimePaths);
225
- tokens.push(token);
226
- await saveTokens(tokens, runtimePaths);
227
- return token;
228
- }
195
+ import { mkdir as mkdir2 } from "fs/promises";
229
196
 
230
197
  // src/runtime/browser.ts
231
198
  import { spawn as spawn2 } from "child_process";
@@ -261,28 +228,48 @@ async function spawnDetached(command2, args2) {
261
228
  }
262
229
 
263
230
  // src/pairing/preflight.ts
231
+ function pairingSessionPath(sessionId, paths) {
232
+ return path2.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
233
+ }
264
234
  async function runPairingPreflight(options) {
265
235
  const token = await generateAppConnectToken(options.paths);
266
- const baseUrl = (options.serverBaseUrl ?? options.config.serverBaseUrl).replace(/\/+$/u, "");
267
- const pairingUrl = buildPairingUrl(baseUrl, {
236
+ const localApiUrl = `http://127.0.0.1:${options.config.port}`;
237
+ const sessionId = `ps_${token.token.slice(0, 16)}`;
238
+ const session = {
239
+ session_id: sessionId,
240
+ code: token.token,
241
+ link_id: options.identity.link_id ?? "",
242
+ display_name: "Hermes Link",
243
+ local_api_url: localApiUrl,
244
+ server_base_url: options.config.serverBaseUrl,
245
+ relay_base_url: options.config.relayBaseUrl,
246
+ preferred_urls: [localApiUrl],
247
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
248
+ expires_at: token.expiresAt
249
+ };
250
+ await mkdir2(options.paths.pairingDir, { recursive: true, mode: 448 }).catch(() => void 0);
251
+ await writeJsonFile(pairingSessionPath(sessionId, options.paths), session);
252
+ const pairingUrl = buildPairingUrl({
268
253
  linkId: options.identity.link_id ?? "",
269
254
  installId: options.identity.install_id,
270
255
  connectToken: token.token,
271
- port: options.config.port
256
+ port: options.config.port,
257
+ localApiUrl
272
258
  });
273
259
  if (options.openBrowser !== false) {
274
260
  await openSystemBrowser(pairingUrl).catch(() => void 0);
275
261
  }
276
262
  return { pairingUrl, connectToken: token.token };
277
263
  }
278
- function buildPairingUrl(serverBaseUrl, params) {
264
+ function buildPairingUrl(params) {
279
265
  const qs = new URLSearchParams({
280
266
  link_id: params.linkId,
281
267
  install_id: params.installId,
282
268
  connect_token: params.connectToken,
283
- port: String(params.port)
269
+ port: String(params.port),
270
+ local_url: params.localApiUrl
284
271
  });
285
- return `${serverBaseUrl}/link/pair?${qs.toString()}`;
272
+ return `hermesapp://pair?${qs.toString()}`;
286
273
  }
287
274
 
288
275
  // src/cli/index.ts
@@ -308,7 +295,7 @@ async function main() {
308
295
  return;
309
296
  }
310
297
  const paths = resolveRuntimePaths();
311
- await mkdir2(paths.homeDir, { recursive: true, mode: 448 });
298
+ await mkdir3(paths.homeDir, { recursive: true, mode: 448 });
312
299
  switch (command) {
313
300
  case "start":
314
301
  await cmdStart(paths);
package/dist/http/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  startLinkService
3
- } from "../chunk-CIUWLPSN.js";
3
+ } from "../chunk-QL5SEM7J.js";
4
4
  export {
5
5
  startLinkService
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bulolo/hermes-link",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Hermes Link companion service and CLI for connecting hermes-agent through zhiji",
5
5
  "license": "MIT",
6
6
  "type": "module",