@hermespilot/link 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HermesPilot
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 ADDED
@@ -0,0 +1,60 @@
1
+ # Hermes Link
2
+
3
+ `Hermes Link` is a secure companion service for hermes-agent. It provides the `hermeslink` CLI and a local service layer that lets HermesPilot App connect to a Hermes Agent running on the user's own computer or server.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 22.14.0 or newer
8
+ - A local hermes-agent setup on the same machine
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g @hermespilot/link
14
+ ```
15
+
16
+ The package installs the `hermeslink` command. It does not start the service automatically during installation.
17
+
18
+ ## Common commands
19
+
20
+ ```bash
21
+ hermeslink --version
22
+ hermeslink status
23
+ hermeslink pair
24
+ hermeslink start
25
+ hermeslink doctor
26
+ ```
27
+
28
+ `hermeslink pair` requires HermesPilot Server and Relay to be available. The terminal side does not ask for a HermesPilot account; the App must be logged in before it scans or claims a pairing session.
29
+
30
+ ## Runtime data
31
+
32
+ Hermes Link keeps its local identity and runtime state under:
33
+
34
+ ```text
35
+ ~/.hermeslink/
36
+ ```
37
+
38
+ Uninstalling the npm package does not remove this directory, so the same Link ID can be reused after reinstalling.
39
+
40
+ ## Publish
41
+
42
+ This package is published as a public scoped npm package:
43
+
44
+ ```bash
45
+ npm publish --access public
46
+ ```
47
+
48
+ Before publishing, run:
49
+
50
+ ```bash
51
+ npm run release -- --version 0.1.1 --no-publish
52
+ ```
53
+
54
+ The release script syncs `package.json`, `package-lock.json`, and `src/constants.ts`, then runs type checks, tests, and an npm pack dry run. To publish through the script, use `--publish` explicitly:
55
+
56
+ ```bash
57
+ npm run release -- --version 0.1.1 --publish
58
+ ```
59
+
60
+ The package command is always `hermeslink`; there is no `hermes-link` alias.
@@ -0,0 +1,255 @@
1
+ // src/constants.ts
2
+ var LINK_VERSION = "0.1.0";
3
+ var LINK_COMMAND = "hermeslink";
4
+ var LINK_DEFAULT_PORT = 52379;
5
+ var LINK_RUNTIME_DIR_NAME = ".hermeslink";
6
+
7
+ // src/runtime/paths.ts
8
+ import os from "os";
9
+ import path from "path";
10
+ function resolveRuntimeHome() {
11
+ return process.env.HERMESLINK_HOME?.trim() ? path.resolve(process.env.HERMESLINK_HOME) : path.join(os.homedir(), LINK_RUNTIME_DIR_NAME);
12
+ }
13
+ function resolveRuntimePaths(homeDir = resolveRuntimeHome()) {
14
+ return {
15
+ homeDir,
16
+ identityFile: path.join(homeDir, "identity.json"),
17
+ configFile: path.join(homeDir, "config.json"),
18
+ stateFile: path.join(homeDir, "state.json"),
19
+ credentialsFile: path.join(homeDir, "credentials.json"),
20
+ databaseFile: path.join(homeDir, "link.db"),
21
+ logsDir: path.join(homeDir, "logs"),
22
+ runDir: path.join(homeDir, "run"),
23
+ pairingDir: path.join(homeDir, "pairing")
24
+ };
25
+ }
26
+
27
+ // src/storage/atomic-json.ts
28
+ import { mkdir, open, readFile, rename, rm } from "fs/promises";
29
+ import path2 from "path";
30
+ async function readJsonFile(filePath) {
31
+ try {
32
+ const raw = await readFile(filePath, "utf8");
33
+ return JSON.parse(raw);
34
+ } catch (error) {
35
+ if (isNodeError(error, "ENOENT")) {
36
+ return null;
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+ async function writeJsonFile(filePath, value, mode = 384) {
42
+ await mkdir(path2.dirname(filePath), { recursive: true, mode: 448 });
43
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
44
+ const payload = `${JSON.stringify(value, null, 2)}
45
+ `;
46
+ const handle = await open(tmpPath, "w", mode);
47
+ try {
48
+ await handle.writeFile(payload, "utf8");
49
+ await handle.sync();
50
+ } finally {
51
+ await handle.close();
52
+ }
53
+ try {
54
+ await rename(tmpPath, filePath);
55
+ } catch (error) {
56
+ await rm(tmpPath, { force: true });
57
+ throw error;
58
+ }
59
+ }
60
+ function isNodeError(error, code) {
61
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
62
+ }
63
+
64
+ // src/config/config.ts
65
+ var defaultLinkConfig = {
66
+ port: LINK_DEFAULT_PORT,
67
+ serverBaseUrl: "http://127.0.0.1:8787",
68
+ relayBaseUrl: "http://127.0.0.1:8788",
69
+ language: "zh-CN"
70
+ };
71
+ async function loadConfig(paths = resolveRuntimePaths()) {
72
+ const existing = await readJsonFile(paths.configFile);
73
+ return {
74
+ ...defaultLinkConfig,
75
+ ...existing ?? {}
76
+ };
77
+ }
78
+
79
+ // src/hermes/config.ts
80
+ import { randomBytes } from "crypto";
81
+ import { copyFile, mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
82
+ import os2 from "os";
83
+ import path3 from "path";
84
+ import YAML from "yaml";
85
+ function resolveHermesProfileDir(profileName = "default") {
86
+ if (profileName === "default") {
87
+ return path3.join(os2.homedir(), ".hermes");
88
+ }
89
+ return path3.join(os2.homedir(), ".hermes", "profiles", profileName);
90
+ }
91
+ function resolveHermesConfigPath(profileName = "default") {
92
+ return path3.join(resolveHermesProfileDir(profileName), "config.yaml");
93
+ }
94
+ async function readHermesApiServerConfig(profileName = "default", configPath = resolveHermesConfigPath(profileName)) {
95
+ const existingRaw = await readFile2(configPath, "utf8").catch((error) => {
96
+ if (isNodeError2(error, "ENOENT")) {
97
+ return null;
98
+ }
99
+ throw error;
100
+ });
101
+ if (!existingRaw) {
102
+ return {};
103
+ }
104
+ const config = toRecord(YAML.parse(existingRaw));
105
+ const platforms = toRecord(config.platforms);
106
+ const apiServer = toRecord(platforms.api_server);
107
+ return readApiServerConfig(toRecord(apiServer.extra));
108
+ }
109
+ async function ensureHermesApiServerKey(profileName = "default", configPath = resolveHermesConfigPath(profileName)) {
110
+ const existingRaw = await readFile2(configPath, "utf8").catch((error) => {
111
+ if (isNodeError2(error, "ENOENT")) {
112
+ return null;
113
+ }
114
+ throw error;
115
+ });
116
+ const document = existingRaw ? YAML.parseDocument(existingRaw) : new YAML.Document({});
117
+ const config = toRecord(document.toJSON());
118
+ const platforms = ensureRecord(config, "platforms");
119
+ const apiServer = ensureRecord(platforms, "api_server");
120
+ const extra = ensureRecord(apiServer, "extra");
121
+ const beforeKey = typeof extra.key === "string" && extra.key.length > 0 ? extra.key : null;
122
+ let changed = false;
123
+ if (!beforeKey) {
124
+ extra.key = randomBytes(32).toString("base64url");
125
+ changed = true;
126
+ }
127
+ if (!changed) {
128
+ return {
129
+ configPath,
130
+ apiServer: readApiServerConfig(extra),
131
+ changed: false,
132
+ keyAdded: false,
133
+ backupPath: null,
134
+ notice: null
135
+ };
136
+ }
137
+ const backupPath = existingRaw ? `${configPath}.bak.${Date.now()}` : null;
138
+ await mkdir2(path3.dirname(configPath), { recursive: true, mode: 448 });
139
+ if (backupPath) {
140
+ await copyFile(configPath, backupPath);
141
+ }
142
+ document.contents = document.createNode(config);
143
+ await writeFile(configPath, document.toString(), { mode: 384 });
144
+ return {
145
+ configPath,
146
+ apiServer: readApiServerConfig(extra),
147
+ changed: true,
148
+ keyAdded: true,
149
+ backupPath,
150
+ notice: "\u5DF2\u4E3A Hermes API Server \u81EA\u52A8\u8865\u5145 key\uFF1B\u672A\u8986\u76D6\u5DF2\u6709 port/host/key\u3002"
151
+ };
152
+ }
153
+ function readApiServerConfig(extra) {
154
+ return {
155
+ host: typeof extra.host === "string" ? extra.host : void 0,
156
+ port: typeof extra.port === "number" ? extra.port : void 0,
157
+ key: typeof extra.key === "string" ? extra.key : void 0
158
+ };
159
+ }
160
+ function toRecord(value) {
161
+ return typeof value === "object" && value !== null ? value : {};
162
+ }
163
+ function ensureRecord(parent, key) {
164
+ const value = parent[key];
165
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
166
+ return value;
167
+ }
168
+ const next = {};
169
+ parent[key] = next;
170
+ return next;
171
+ }
172
+ function isNodeError2(error, code) {
173
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
174
+ }
175
+
176
+ // src/identity/identity.ts
177
+ import { generateKeyPairSync, randomUUID, sign } from "crypto";
178
+ import { mkdir as mkdir3, chmod } from "fs/promises";
179
+ import { z } from "zod";
180
+ var linkIdentitySchema = z.object({
181
+ install_id: z.string().min(1),
182
+ link_id: z.string().min(1).nullable().optional(),
183
+ public_key_pem: z.string().min(1),
184
+ private_key_pem: z.string().min(1),
185
+ created_at: z.string().min(1),
186
+ updated_at: z.string().min(1)
187
+ });
188
+ async function loadIdentity(paths = resolveRuntimePaths()) {
189
+ const value = await readJsonFile(paths.identityFile);
190
+ if (value === null) {
191
+ return null;
192
+ }
193
+ return linkIdentitySchema.parse(value);
194
+ }
195
+ async function ensureIdentity(paths = resolveRuntimePaths()) {
196
+ const existing = await loadIdentity(paths);
197
+ if (existing) {
198
+ return existing;
199
+ }
200
+ await mkdir3(paths.homeDir, { recursive: true, mode: 448 });
201
+ await chmod(paths.homeDir, 448).catch(() => void 0);
202
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
203
+ const now = (/* @__PURE__ */ new Date()).toISOString();
204
+ const identity = {
205
+ install_id: `install_${randomUUID().replaceAll("-", "")}`,
206
+ link_id: null,
207
+ public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
208
+ private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
209
+ created_at: now,
210
+ updated_at: now
211
+ };
212
+ await writeJsonFile(paths.identityFile, identity);
213
+ return identity;
214
+ }
215
+ async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
216
+ const identity = await ensureIdentity(paths);
217
+ const next = {
218
+ ...identity,
219
+ link_id: linkId,
220
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
221
+ };
222
+ await writeJsonFile(paths.identityFile, next);
223
+ return next;
224
+ }
225
+ function signRelayNonce(identity, nonce) {
226
+ const signature = sign(null, Buffer.from(nonce, "utf8"), identity.private_key_pem);
227
+ return signature.toString("base64url");
228
+ }
229
+ function getIdentityStatus(identity) {
230
+ return {
231
+ installId: identity.install_id,
232
+ linkId: identity.link_id ?? null,
233
+ hasPrivateKey: identity.private_key_pem.trim().length > 0,
234
+ publicKeyPem: identity.public_key_pem
235
+ };
236
+ }
237
+
238
+ export {
239
+ LINK_VERSION,
240
+ LINK_COMMAND,
241
+ resolveRuntimePaths,
242
+ readJsonFile,
243
+ writeJsonFile,
244
+ loadConfig,
245
+ resolveHermesProfileDir,
246
+ resolveHermesConfigPath,
247
+ readHermesApiServerConfig,
248
+ ensureHermesApiServerKey,
249
+ loadIdentity,
250
+ ensureIdentity,
251
+ saveAssignedLinkId,
252
+ signRelayNonce,
253
+ getIdentityStatus
254
+ };
255
+ //# sourceMappingURL=chunk-YTX3DQGX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/constants.ts","../src/runtime/paths.ts","../src/storage/atomic-json.ts","../src/config/config.ts","../src/hermes/config.ts","../src/identity/identity.ts"],"sourcesContent":["export const LINK_VERSION = '0.1.0'\nexport const LINK_COMMAND = 'hermeslink'\nexport const LINK_DEFAULT_PORT = 52379\nexport const LINK_RUNTIME_DIR_NAME = '.hermeslink'\n\n","import os from 'node:os'\nimport path from 'node:path'\nimport { LINK_RUNTIME_DIR_NAME } from '../constants.js'\n\nexport interface RuntimePaths {\n homeDir: string\n identityFile: string\n configFile: string\n stateFile: string\n credentialsFile: string\n databaseFile: string\n logsDir: string\n runDir: string\n pairingDir: string\n}\n\nexport function resolveRuntimeHome(): string {\n return process.env.HERMESLINK_HOME?.trim()\n ? path.resolve(process.env.HERMESLINK_HOME)\n : path.join(os.homedir(), LINK_RUNTIME_DIR_NAME)\n}\n\nexport function resolveRuntimePaths(homeDir = resolveRuntimeHome()): RuntimePaths {\n return {\n homeDir,\n identityFile: path.join(homeDir, 'identity.json'),\n configFile: path.join(homeDir, 'config.json'),\n stateFile: path.join(homeDir, 'state.json'),\n credentialsFile: path.join(homeDir, 'credentials.json'),\n databaseFile: path.join(homeDir, 'link.db'),\n logsDir: path.join(homeDir, 'logs'),\n runDir: path.join(homeDir, 'run'),\n pairingDir: path.join(homeDir, 'pairing'),\n }\n}\n\n","import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'\nimport path from 'node:path'\n\nexport async function readJsonFile<T>(filePath: string): Promise<T | null> {\n try {\n const raw = await readFile(filePath, 'utf8')\n return JSON.parse(raw) as T\n } catch (error) {\n if (isNodeError(error, 'ENOENT')) {\n return null\n }\n throw error\n }\n}\n\nexport async function writeJsonFile(filePath: string, value: unknown, mode = 0o600): Promise<void> {\n await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })\n const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`\n const payload = `${JSON.stringify(value, null, 2)}\\n`\n const handle = await open(tmpPath, 'w', mode)\n try {\n await handle.writeFile(payload, 'utf8')\n await handle.sync()\n } finally {\n await handle.close()\n }\n try {\n await rename(tmpPath, filePath)\n } catch (error) {\n await rm(tmpPath, { force: true })\n throw error\n }\n}\n\nfunction isNodeError(error: unknown, code: string): boolean {\n return typeof error === 'object' && error !== null && 'code' in error && error.code === code\n}\n\n","import { LINK_DEFAULT_PORT } from '../constants.js'\nimport { resolveRuntimePaths, type RuntimePaths } from '../runtime/paths.js'\nimport { readJsonFile, writeJsonFile } from '../storage/atomic-json.js'\n\nexport interface LinkConfig {\n port: number\n serverBaseUrl: string\n relayBaseUrl: string\n language: 'zh-CN' | 'en'\n}\n\nexport const defaultLinkConfig: LinkConfig = {\n port: LINK_DEFAULT_PORT,\n serverBaseUrl: 'http://127.0.0.1:8787',\n relayBaseUrl: 'http://127.0.0.1:8788',\n language: 'zh-CN',\n}\n\nexport async function loadConfig(paths: RuntimePaths = resolveRuntimePaths()): Promise<LinkConfig> {\n const existing = await readJsonFile<Partial<LinkConfig>>(paths.configFile)\n return {\n ...defaultLinkConfig,\n ...(existing ?? {}),\n }\n}\n\nexport async function saveConfig(\n patch: Partial<LinkConfig>,\n paths: RuntimePaths = resolveRuntimePaths(),\n): Promise<LinkConfig> {\n const current = await loadConfig(paths)\n const next = { ...current, ...patch }\n await writeJsonFile(paths.configFile, next)\n return next\n}\n\n","import { randomBytes } from 'node:crypto'\nimport { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport YAML from 'yaml'\n\nexport interface HermesApiServerConfig {\n host?: string\n port?: number\n key?: string\n}\n\nexport interface EnsureHermesConfigResult {\n configPath: string\n apiServer: HermesApiServerConfig\n changed: boolean\n keyAdded: boolean\n backupPath: string | null\n notice: string | null\n}\n\nexport function resolveHermesProfileDir(profileName = 'default'): string {\n if (profileName === 'default') {\n return path.join(os.homedir(), '.hermes')\n }\n return path.join(os.homedir(), '.hermes', 'profiles', profileName)\n}\n\nexport function resolveHermesConfigPath(profileName = 'default'): string {\n return path.join(resolveHermesProfileDir(profileName), 'config.yaml')\n}\n\nexport async function readHermesApiServerConfig(\n profileName = 'default',\n configPath = resolveHermesConfigPath(profileName),\n): Promise<HermesApiServerConfig> {\n const existingRaw = await readFile(configPath, 'utf8').catch((error: unknown) => {\n if (isNodeError(error, 'ENOENT')) {\n return null\n }\n throw error\n })\n if (!existingRaw) {\n return {}\n }\n const config = toRecord(YAML.parse(existingRaw))\n const platforms = toRecord(config.platforms)\n const apiServer = toRecord(platforms.api_server)\n return readApiServerConfig(toRecord(apiServer.extra))\n}\n\nexport async function ensureHermesApiServerKey(\n profileName = 'default',\n configPath = resolveHermesConfigPath(profileName),\n): Promise<EnsureHermesConfigResult> {\n const existingRaw = await readFile(configPath, 'utf8').catch((error: unknown) => {\n if (isNodeError(error, 'ENOENT')) {\n return null\n }\n throw error\n })\n const document = existingRaw ? YAML.parseDocument(existingRaw) : new YAML.Document({})\n const config = toRecord(document.toJSON())\n const platforms = ensureRecord(config, 'platforms')\n const apiServer = ensureRecord(platforms, 'api_server')\n const extra = ensureRecord(apiServer, 'extra')\n const beforeKey = typeof extra.key === 'string' && extra.key.length > 0 ? extra.key : null\n\n let changed = false\n if (!beforeKey) {\n extra.key = randomBytes(32).toString('base64url')\n changed = true\n }\n\n if (!changed) {\n return {\n configPath,\n apiServer: readApiServerConfig(extra),\n changed: false,\n keyAdded: false,\n backupPath: null,\n notice: null,\n }\n }\n\n const backupPath = existingRaw ? `${configPath}.bak.${Date.now()}` : null\n await mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 })\n if (backupPath) {\n await copyFile(configPath, backupPath)\n }\n document.contents = document.createNode(config)\n await writeFile(configPath, document.toString(), { mode: 0o600 })\n\n return {\n configPath,\n apiServer: readApiServerConfig(extra),\n changed: true,\n keyAdded: true,\n backupPath,\n notice: '已为 Hermes API Server 自动补充 key;未覆盖已有 port/host/key。',\n }\n}\n\nfunction readApiServerConfig(extra: Record<string, unknown>): HermesApiServerConfig {\n return {\n host: typeof extra.host === 'string' ? extra.host : undefined,\n port: typeof extra.port === 'number' ? extra.port : undefined,\n key: typeof extra.key === 'string' ? extra.key : undefined,\n }\n}\n\nfunction toRecord(value: unknown): Record<string, unknown> {\n return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}\n}\n\nfunction ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {\n const value = parent[key]\n if (typeof value === 'object' && value !== null && !Array.isArray(value)) {\n return value as Record<string, unknown>\n }\n const next: Record<string, unknown> = {}\n parent[key] = next\n return next\n}\n\nfunction isNodeError(error: unknown, code: string): boolean {\n return typeof error === 'object' && error !== null && 'code' in error && error.code === code\n}\n","import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'\nimport { mkdir, chmod } from 'node:fs/promises'\nimport { z } from 'zod'\nimport { resolveRuntimePaths, type RuntimePaths } from '../runtime/paths.js'\nimport { readJsonFile, writeJsonFile } from '../storage/atomic-json.js'\n\nconst linkIdentitySchema = z.object({\n install_id: z.string().min(1),\n link_id: z.string().min(1).nullable().optional(),\n public_key_pem: z.string().min(1),\n private_key_pem: z.string().min(1),\n created_at: z.string().min(1),\n updated_at: z.string().min(1),\n})\n\nexport type LinkIdentity = z.infer<typeof linkIdentitySchema>\n\nexport interface IdentityStatus {\n installId: string\n linkId: string | null\n hasPrivateKey: boolean\n publicKeyPem: string\n}\n\nexport async function loadIdentity(paths: RuntimePaths = resolveRuntimePaths()): Promise<LinkIdentity | null> {\n const value = await readJsonFile<unknown>(paths.identityFile)\n if (value === null) {\n return null\n }\n return linkIdentitySchema.parse(value)\n}\n\nexport async function ensureIdentity(paths: RuntimePaths = resolveRuntimePaths()): Promise<LinkIdentity> {\n const existing = await loadIdentity(paths)\n if (existing) {\n return existing\n }\n\n await mkdir(paths.homeDir, { recursive: true, mode: 0o700 })\n await chmod(paths.homeDir, 0o700).catch(() => undefined)\n\n const { publicKey, privateKey } = generateKeyPairSync('ed25519')\n const now = new Date().toISOString()\n const identity: LinkIdentity = {\n install_id: `install_${randomUUID().replaceAll('-', '')}`,\n link_id: null,\n public_key_pem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),\n private_key_pem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),\n created_at: now,\n updated_at: now,\n }\n await writeJsonFile(paths.identityFile, identity)\n return identity\n}\n\nexport async function saveAssignedLinkId(\n linkId: string,\n paths: RuntimePaths = resolveRuntimePaths(),\n): Promise<LinkIdentity> {\n const identity = await ensureIdentity(paths)\n const next: LinkIdentity = {\n ...identity,\n link_id: linkId,\n updated_at: new Date().toISOString(),\n }\n await writeJsonFile(paths.identityFile, next)\n return next\n}\n\nexport function signRelayNonce(identity: LinkIdentity, nonce: string): string {\n const signature = sign(null, Buffer.from(nonce, 'utf8'), identity.private_key_pem)\n return signature.toString('base64url')\n}\n\nexport function getIdentityStatus(identity: LinkIdentity): IdentityStatus {\n return {\n installId: identity.install_id,\n linkId: identity.link_id ?? null,\n hasPrivateKey: identity.private_key_pem.trim().length > 0,\n publicKeyPem: identity.public_key_pem,\n }\n}\n\n"],"mappings":"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","names":["path","mkdir","readFile","os","path","isNodeError","mkdir","mkdir"]}
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ LINK_COMMAND,
4
+ LINK_VERSION,
5
+ ensureHermesApiServerKey,
6
+ ensureIdentity,
7
+ getIdentityStatus,
8
+ loadConfig,
9
+ loadIdentity,
10
+ resolveRuntimePaths,
11
+ saveAssignedLinkId,
12
+ signRelayNonce
13
+ } from "../chunk-YTX3DQGX.js";
14
+
15
+ // src/cli/index.ts
16
+ import { Command } from "commander";
17
+
18
+ // src/relay/bootstrap.ts
19
+ async function bootstrapRelayLink(options) {
20
+ const fetcher = options.fetchImpl ?? fetch;
21
+ const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
22
+ const commonPayload = {
23
+ install_id: options.identity.install_id,
24
+ link_id: options.identity.link_id ?? void 0,
25
+ public_key_pem: options.identity.public_key_pem
26
+ };
27
+ const challenge = await postJson(
28
+ fetcher,
29
+ `${baseUrl}/api/v1/relay/link/challenge`,
30
+ options.relayBootstrapToken,
31
+ commonPayload
32
+ );
33
+ if (challenge.ok !== true || typeof challenge.nonce !== "string") {
34
+ throw new Error("Relay did not return a valid install challenge");
35
+ }
36
+ const proof = {
37
+ nonce: challenge.nonce,
38
+ signature: signRelayNonce(options.identity, challenge.nonce)
39
+ };
40
+ const assigned = await postJson(
41
+ fetcher,
42
+ `${baseUrl}/api/v1/relay/link/bootstrap`,
43
+ options.relayBootstrapToken,
44
+ {
45
+ ...commonPayload,
46
+ proof
47
+ }
48
+ );
49
+ if (assigned.ok !== true || typeof assigned.link_id !== "string") {
50
+ throw new Error("Relay did not return a valid link_id");
51
+ }
52
+ await saveAssignedLinkId(assigned.link_id, options.paths ?? resolveRuntimePaths());
53
+ return {
54
+ linkId: assigned.link_id,
55
+ reused: assigned.reused === true
56
+ };
57
+ }
58
+ async function postJson(fetcher, url, token, body) {
59
+ const response = await fetcher(url, {
60
+ method: "POST",
61
+ headers: {
62
+ authorization: `Bearer ${token}`,
63
+ "content-type": "application/json"
64
+ },
65
+ body: JSON.stringify(body)
66
+ });
67
+ const payload = await response.json().catch(() => null);
68
+ if (!response.ok) {
69
+ const message = readErrorMessage(payload) ?? `Relay request failed with HTTP ${response.status}`;
70
+ throw new Error(message);
71
+ }
72
+ if (!payload) {
73
+ throw new Error("Relay returned an empty response");
74
+ }
75
+ return payload;
76
+ }
77
+ function readErrorMessage(payload) {
78
+ if (typeof payload !== "object" || payload === null) {
79
+ return null;
80
+ }
81
+ const error = payload.error;
82
+ if (typeof error !== "object" || error === null) {
83
+ return null;
84
+ }
85
+ const message = error.message;
86
+ return typeof message === "string" ? message : null;
87
+ }
88
+
89
+ // src/cli/index.ts
90
+ var program = new Command();
91
+ program.name(LINK_COMMAND).description("Hermes Link companion service").version(LINK_VERSION, "-v, --version", "print Hermes Link version");
92
+ program.command("status").option("--json", "print machine-readable status").description("Show local Hermes Link status").action(async (options) => {
93
+ const paths = resolveRuntimePaths();
94
+ const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
95
+ const payload = {
96
+ version: LINK_VERSION,
97
+ runtimeHome: paths.homeDir,
98
+ paired: Boolean(identity?.link_id),
99
+ mode: identity?.link_id ? "paired" : "local-only",
100
+ port: config.port,
101
+ identity: identity ? getIdentityStatus(identity) : null,
102
+ relay: {
103
+ configured: Boolean(config.relayBaseUrl),
104
+ connected: false
105
+ }
106
+ };
107
+ if (options.json) {
108
+ console.log(JSON.stringify(payload, null, 2));
109
+ return;
110
+ }
111
+ console.log(`Hermes Link ${payload.version}`);
112
+ console.log(`Runtime: ${payload.runtimeHome}`);
113
+ console.log(`Mode: ${payload.mode}`);
114
+ console.log(`Local port: ${payload.port}`);
115
+ console.log(`Link ID: ${payload.identity?.linkId ?? "not paired"}`);
116
+ });
117
+ program.command("start").description("Start Hermes Link daemon").action(async () => {
118
+ const identity = await loadIdentity();
119
+ if (!identity?.link_id) {
120
+ console.log("Hermes Link is not paired yet. Starting in local-only maintenance mode.");
121
+ console.log("Relay, Server polling, and LAN entrypoints stay disabled until you run `hermeslink pair`.");
122
+ return;
123
+ }
124
+ console.log("Daemon start is not wired yet in this scaffold. Protocol identity is ready.");
125
+ });
126
+ program.command("pair").option("--relay-bootstrap-token <token>", "short-lived relay bootstrap token from HermesPilot Server").description("Create a Hermes Link pairing session").action(async (options) => {
127
+ const paths = resolveRuntimePaths();
128
+ const identity = await ensureIdentity(paths);
129
+ const config = await loadConfig(paths);
130
+ const token = options.relayBootstrapToken ?? process.env.HERMESLINK_RELAY_BOOTSTRAP_TOKEN;
131
+ if (!token) {
132
+ console.log("Pairing requires HermesPilot Server and Relay.");
133
+ console.log("Server must issue a short-lived relay_bootstrap_token before Link can request a link_id.");
134
+ return;
135
+ }
136
+ const result = await bootstrapRelayLink({
137
+ relayBaseUrl: config.relayBaseUrl,
138
+ relayBootstrapToken: token,
139
+ identity,
140
+ paths
141
+ });
142
+ console.log(`Relay link_id: ${result.linkId}${result.reused ? " (reused)" : ""}`);
143
+ });
144
+ program.command("doctor").description("Run local diagnostics").action(async () => {
145
+ const identity = await ensureIdentity();
146
+ const hermesConfig = await ensureHermesApiServerKey();
147
+ console.log("Runtime identity: OK");
148
+ console.log(`Install ID: ${identity.install_id}`);
149
+ console.log(`Link ID: ${identity.link_id ?? "not assigned"}`);
150
+ if (hermesConfig.notice) {
151
+ console.log(hermesConfig.notice);
152
+ if (hermesConfig.backupPath) {
153
+ console.log(`Hermes config backup: ${hermesConfig.backupPath}`);
154
+ }
155
+ }
156
+ });
157
+ program.parseAsync(process.argv).catch((error) => {
158
+ console.error(error instanceof Error ? error.message : String(error));
159
+ process.exitCode = 1;
160
+ });
161
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/cli/index.ts","../../src/relay/bootstrap.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { Command } from 'commander'\nimport { LINK_COMMAND, LINK_VERSION } from '../constants.js'\nimport { loadConfig } from '../config/config.js'\nimport { ensureHermesApiServerKey } from '../hermes/config.js'\nimport { ensureIdentity, getIdentityStatus, loadIdentity } from '../identity/identity.js'\nimport { bootstrapRelayLink } from '../relay/bootstrap.js'\nimport { resolveRuntimePaths } from '../runtime/paths.js'\n\nconst program = new Command()\n\nprogram\n .name(LINK_COMMAND)\n .description('Hermes Link companion service')\n .version(LINK_VERSION, '-v, --version', 'print Hermes Link version')\n\nprogram\n .command('status')\n .option('--json', 'print machine-readable status')\n .description('Show local Hermes Link status')\n .action(async (options: { json?: boolean }) => {\n const paths = resolveRuntimePaths()\n const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)])\n const payload = {\n version: LINK_VERSION,\n runtimeHome: paths.homeDir,\n paired: Boolean(identity?.link_id),\n mode: identity?.link_id ? 'paired' : 'local-only',\n port: config.port,\n identity: identity ? getIdentityStatus(identity) : null,\n relay: {\n configured: Boolean(config.relayBaseUrl),\n connected: false,\n },\n }\n if (options.json) {\n console.log(JSON.stringify(payload, null, 2))\n return\n }\n console.log(`Hermes Link ${payload.version}`)\n console.log(`Runtime: ${payload.runtimeHome}`)\n console.log(`Mode: ${payload.mode}`)\n console.log(`Local port: ${payload.port}`)\n console.log(`Link ID: ${payload.identity?.linkId ?? 'not paired'}`)\n })\n\nprogram\n .command('start')\n .description('Start Hermes Link daemon')\n .action(async () => {\n const identity = await loadIdentity()\n if (!identity?.link_id) {\n console.log('Hermes Link is not paired yet. Starting in local-only maintenance mode.')\n console.log('Relay, Server polling, and LAN entrypoints stay disabled until you run `hermeslink pair`.')\n return\n }\n console.log('Daemon start is not wired yet in this scaffold. Protocol identity is ready.')\n })\n\nprogram\n .command('pair')\n .option('--relay-bootstrap-token <token>', 'short-lived relay bootstrap token from HermesPilot Server')\n .description('Create a Hermes Link pairing session')\n .action(async (options: { relayBootstrapToken?: string }) => {\n const paths = resolveRuntimePaths()\n const identity = await ensureIdentity(paths)\n const config = await loadConfig(paths)\n const token = options.relayBootstrapToken ?? process.env.HERMESLINK_RELAY_BOOTSTRAP_TOKEN\n if (!token) {\n console.log('Pairing requires HermesPilot Server and Relay.')\n console.log('Server must issue a short-lived relay_bootstrap_token before Link can request a link_id.')\n return\n }\n const result = await bootstrapRelayLink({\n relayBaseUrl: config.relayBaseUrl,\n relayBootstrapToken: token,\n identity,\n paths,\n })\n console.log(`Relay link_id: ${result.linkId}${result.reused ? ' (reused)' : ''}`)\n })\n\nprogram\n .command('doctor')\n .description('Run local diagnostics')\n .action(async () => {\n const identity = await ensureIdentity()\n const hermesConfig = await ensureHermesApiServerKey()\n console.log('Runtime identity: OK')\n console.log(`Install ID: ${identity.install_id}`)\n console.log(`Link ID: ${identity.link_id ?? 'not assigned'}`)\n if (hermesConfig.notice) {\n console.log(hermesConfig.notice)\n if (hermesConfig.backupPath) {\n console.log(`Hermes config backup: ${hermesConfig.backupPath}`)\n }\n }\n })\n\nprogram.parseAsync(process.argv).catch((error) => {\n console.error(error instanceof Error ? error.message : String(error))\n process.exitCode = 1\n})\n","import { saveAssignedLinkId, signRelayNonce, type LinkIdentity } from '../identity/identity.js'\nimport { resolveRuntimePaths, type RuntimePaths } from '../runtime/paths.js'\n\nexport interface RelayBootstrapOptions {\n relayBaseUrl: string\n relayBootstrapToken: string\n identity: LinkIdentity\n paths?: RuntimePaths\n fetchImpl?: typeof fetch\n}\n\nexport interface RelayBootstrapResult {\n linkId: string\n reused: boolean\n}\n\ninterface RelayChallengeResponse {\n ok?: unknown\n nonce?: unknown\n expires_at?: unknown\n}\n\ninterface RelayBootstrapResponse {\n ok?: unknown\n link_id?: unknown\n reused?: unknown\n}\n\nexport async function bootstrapRelayLink(options: RelayBootstrapOptions): Promise<RelayBootstrapResult> {\n const fetcher = options.fetchImpl ?? fetch\n const baseUrl = options.relayBaseUrl.replace(/\\/+$/u, '')\n const commonPayload = {\n install_id: options.identity.install_id,\n link_id: options.identity.link_id ?? undefined,\n public_key_pem: options.identity.public_key_pem,\n }\n const challenge = await postJson<RelayChallengeResponse>(\n fetcher,\n `${baseUrl}/api/v1/relay/link/challenge`,\n options.relayBootstrapToken,\n commonPayload,\n )\n if (challenge.ok !== true || typeof challenge.nonce !== 'string') {\n throw new Error('Relay did not return a valid install challenge')\n }\n\n const proof = {\n nonce: challenge.nonce,\n signature: signRelayNonce(options.identity, challenge.nonce),\n }\n const assigned = await postJson<RelayBootstrapResponse>(\n fetcher,\n `${baseUrl}/api/v1/relay/link/bootstrap`,\n options.relayBootstrapToken,\n {\n ...commonPayload,\n proof,\n },\n )\n if (assigned.ok !== true || typeof assigned.link_id !== 'string') {\n throw new Error('Relay did not return a valid link_id')\n }\n\n await saveAssignedLinkId(assigned.link_id, options.paths ?? resolveRuntimePaths())\n return {\n linkId: assigned.link_id,\n reused: assigned.reused === true,\n }\n}\n\nasync function postJson<T>(\n fetcher: typeof fetch,\n url: string,\n token: string,\n body: Record<string, unknown>,\n): Promise<T> {\n const response = await fetcher(url, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify(body),\n })\n const payload = (await response.json().catch(() => null)) as T | null\n if (!response.ok) {\n const message = readErrorMessage(payload) ?? `Relay request failed with HTTP ${response.status}`\n throw new Error(message)\n }\n if (!payload) {\n throw new Error('Relay returned an empty response')\n }\n return payload\n}\n\nfunction readErrorMessage(payload: unknown): string | null {\n if (typeof payload !== 'object' || payload === null) {\n return null\n }\n const error = (payload as { error?: unknown }).error\n if (typeof error !== 'object' || error === null) {\n return null\n }\n const message = (error as { message?: unknown }).message\n return typeof message === 'string' ? message : null\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,5 @@
1
+ import Koa from 'koa';
2
+
3
+ declare function createApp(): Promise<Koa>;
4
+
5
+ export { createApp };
@@ -0,0 +1,241 @@
1
+ import {
2
+ LINK_VERSION,
3
+ ensureHermesApiServerKey,
4
+ loadConfig,
5
+ loadIdentity,
6
+ readHermesApiServerConfig,
7
+ readJsonFile,
8
+ resolveHermesConfigPath,
9
+ resolveHermesProfileDir,
10
+ resolveRuntimePaths,
11
+ writeJsonFile
12
+ } from "../chunk-YTX3DQGX.js";
13
+
14
+ // src/http/app.ts
15
+ import Koa from "koa";
16
+ import Router from "@koa/router";
17
+
18
+ // src/hermes/profiles.ts
19
+ import { mkdir, readdir, rename, rm, stat } from "fs/promises";
20
+ import os from "os";
21
+ import path from "path";
22
+ var DEFAULT_PROFILE = "default";
23
+ var PROFILE_NAME_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
24
+ async function listHermesProfiles(paths = resolveRuntimePaths()) {
25
+ const activeProfile = await getActiveProfile(paths);
26
+ const profiles = /* @__PURE__ */ new Map();
27
+ profiles.set(DEFAULT_PROFILE, profileInfo(DEFAULT_PROFILE, activeProfile));
28
+ const profilesDir = path.join(os.homedir(), ".hermes", "profiles");
29
+ const entries = await readdir(profilesDir, { withFileTypes: true }).catch((error) => {
30
+ if (isNodeError(error, "ENOENT")) {
31
+ return [];
32
+ }
33
+ throw error;
34
+ });
35
+ for (const entry of entries) {
36
+ if (entry.isDirectory() && PROFILE_NAME_PATTERN.test(entry.name)) {
37
+ profiles.set(entry.name, profileInfo(entry.name, activeProfile));
38
+ }
39
+ }
40
+ return [...profiles.values()].sort((left, right) => {
41
+ if (left.name === DEFAULT_PROFILE) {
42
+ return -1;
43
+ }
44
+ if (right.name === DEFAULT_PROFILE) {
45
+ return 1;
46
+ }
47
+ return left.name.localeCompare(right.name);
48
+ });
49
+ }
50
+ async function getHermesProfileStatus(name, paths = resolveRuntimePaths()) {
51
+ assertProfileName(name);
52
+ const profile = profileInfo(name, await getActiveProfile(paths));
53
+ const exists = await stat(profile.path).then((value) => value.isDirectory()).catch((error) => {
54
+ if (isNodeError(error, "ENOENT")) {
55
+ return false;
56
+ }
57
+ throw error;
58
+ });
59
+ const config = await readHermesApiServerConfig(name, profile.configPath);
60
+ return {
61
+ ...profile,
62
+ exists,
63
+ apiKeyConfigured: Boolean(config.key)
64
+ };
65
+ }
66
+ async function createHermesProfile(name) {
67
+ assertProfileName(name);
68
+ if (name === DEFAULT_PROFILE) {
69
+ throw new Error("default profile already exists");
70
+ }
71
+ const profile = profileInfo(name, DEFAULT_PROFILE);
72
+ if (await pathExists(profile.path)) {
73
+ throw new Error("profile already exists");
74
+ }
75
+ await mkdir(profile.path, { recursive: true, mode: 448 });
76
+ await ensureHermesApiServerKey(name, profile.configPath);
77
+ return profile;
78
+ }
79
+ async function useHermesProfile(name, paths = resolveRuntimePaths()) {
80
+ assertProfileName(name);
81
+ const profile = profileInfo(name, name);
82
+ await mkdir(profile.path, { recursive: true, mode: 448 });
83
+ const current = await readJsonFile(paths.stateFile) ?? {};
84
+ await writeJsonFile(paths.stateFile, { ...current, activeProfile: name });
85
+ return profile;
86
+ }
87
+ async function renameHermesProfile(oldName, newName) {
88
+ assertMutableProfile(oldName);
89
+ assertMutableProfile(newName);
90
+ const oldProfile = profileInfo(oldName, DEFAULT_PROFILE);
91
+ const newProfile = profileInfo(newName, DEFAULT_PROFILE);
92
+ await rename(oldProfile.path, newProfile.path);
93
+ return newProfile;
94
+ }
95
+ async function deleteHermesProfile(name) {
96
+ assertMutableProfile(name);
97
+ await rm(resolveHermesProfileDir(name), { recursive: true, force: true });
98
+ }
99
+ async function getActiveProfile(paths) {
100
+ const state = await readJsonFile(paths.stateFile);
101
+ return typeof state?.activeProfile === "string" && PROFILE_NAME_PATTERN.test(state.activeProfile) ? state.activeProfile : DEFAULT_PROFILE;
102
+ }
103
+ function profileInfo(name, activeProfile) {
104
+ return {
105
+ name,
106
+ active: name === activeProfile,
107
+ path: resolveHermesProfileDir(name),
108
+ configPath: resolveHermesConfigPath(name)
109
+ };
110
+ }
111
+ function assertMutableProfile(name) {
112
+ assertProfileName(name);
113
+ if (name === DEFAULT_PROFILE) {
114
+ throw new Error("default profile cannot be renamed or deleted");
115
+ }
116
+ }
117
+ function assertProfileName(name) {
118
+ if (!PROFILE_NAME_PATTERN.test(name)) {
119
+ throw new Error("invalid profile name");
120
+ }
121
+ }
122
+ async function pathExists(targetPath) {
123
+ return await stat(targetPath).then(() => true).catch((error) => {
124
+ if (isNodeError(error, "ENOENT")) {
125
+ return false;
126
+ }
127
+ throw error;
128
+ });
129
+ }
130
+ function isNodeError(error, code) {
131
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
132
+ }
133
+
134
+ // src/http/app.ts
135
+ async function createApp() {
136
+ const app = new Koa();
137
+ const router = new Router();
138
+ app.use(async (ctx, next) => {
139
+ try {
140
+ await next();
141
+ } catch (error) {
142
+ ctx.status = error instanceof Error && error.message === "invalid profile name" ? 400 : 500;
143
+ ctx.body = {
144
+ ok: false,
145
+ error: {
146
+ code: ctx.status === 400 ? "invalid_profile_name" : "internal_error",
147
+ message: error instanceof Error ? error.message : "Internal error"
148
+ }
149
+ };
150
+ }
151
+ });
152
+ router.get("/api/v1/bootstrap", async (ctx) => {
153
+ const [identity, config] = await Promise.all([loadIdentity(), loadConfig()]);
154
+ ctx.set("cache-control", "no-store");
155
+ ctx.body = {
156
+ link_id: identity?.link_id ?? null,
157
+ display_name: identity?.link_id ? "Hermes Link" : "Unpaired Hermes Link",
158
+ version: LINK_VERSION,
159
+ api_version: 1,
160
+ paired: Boolean(identity?.link_id),
161
+ routes: identity?.link_id ? [
162
+ {
163
+ kind: "lan",
164
+ url: `http://127.0.0.1:${config.port}`,
165
+ priority: 10,
166
+ observed_at: (/* @__PURE__ */ new Date()).toISOString()
167
+ }
168
+ ] : [],
169
+ capabilities: {
170
+ runs: true,
171
+ sse: true,
172
+ relay: true,
173
+ profiles: true
174
+ }
175
+ };
176
+ });
177
+ router.get("/api/v1/profiles", async (ctx) => {
178
+ ctx.set("cache-control", "no-store");
179
+ ctx.body = {
180
+ ok: true,
181
+ profiles: await listHermesProfiles()
182
+ };
183
+ });
184
+ router.get("/api/v1/profiles/:name/status", async (ctx) => {
185
+ ctx.set("cache-control", "no-store");
186
+ ctx.body = {
187
+ ok: true,
188
+ profile: await getHermesProfileStatus(ctx.params.name)
189
+ };
190
+ });
191
+ router.post("/api/v1/profiles", async (ctx) => {
192
+ const body = await readJsonBody(ctx.req);
193
+ const name = readProfileName(body);
194
+ ctx.status = 201;
195
+ ctx.body = {
196
+ ok: true,
197
+ profile: await createHermesProfile(name)
198
+ };
199
+ });
200
+ router.post("/api/v1/profiles/:name/use", async (ctx) => {
201
+ ctx.body = {
202
+ ok: true,
203
+ profile: await useHermesProfile(ctx.params.name)
204
+ };
205
+ });
206
+ router.patch("/api/v1/profiles/:name", async (ctx) => {
207
+ const body = await readJsonBody(ctx.req);
208
+ const name = readProfileName(body);
209
+ ctx.body = {
210
+ ok: true,
211
+ profile: await renameHermesProfile(ctx.params.name, name)
212
+ };
213
+ });
214
+ router.delete("/api/v1/profiles/:name", async (ctx) => {
215
+ await deleteHermesProfile(ctx.params.name);
216
+ ctx.status = 204;
217
+ });
218
+ app.use(router.routes());
219
+ app.use(router.allowedMethods());
220
+ return app;
221
+ }
222
+ async function readJsonBody(request) {
223
+ const chunks = [];
224
+ for await (const chunk of request) {
225
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
226
+ }
227
+ if (chunks.length === 0) {
228
+ return {};
229
+ }
230
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
231
+ }
232
+ function readProfileName(body) {
233
+ if (typeof body.name !== "string") {
234
+ throw new Error("invalid profile name");
235
+ }
236
+ return body.name;
237
+ }
238
+ export {
239
+ createApp
240
+ };
241
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/http/app.ts","../../src/hermes/profiles.ts"],"sourcesContent":["import Koa from 'koa'\nimport Router from '@koa/router'\nimport { LINK_VERSION } from '../constants.js'\nimport { loadConfig } from '../config/config.js'\nimport {\n createHermesProfile,\n deleteHermesProfile,\n getHermesProfileStatus,\n listHermesProfiles,\n renameHermesProfile,\n useHermesProfile,\n} from '../hermes/profiles.js'\nimport { loadIdentity } from '../identity/identity.js'\n\nexport async function createApp(): Promise<Koa> {\n const app = new Koa()\n const router = new Router()\n\n app.use(async (ctx, next) => {\n try {\n await next()\n } catch (error) {\n ctx.status = error instanceof Error && error.message === 'invalid profile name' ? 400 : 500\n ctx.body = {\n ok: false,\n error: {\n code: ctx.status === 400 ? 'invalid_profile_name' : 'internal_error',\n message: error instanceof Error ? error.message : 'Internal error',\n },\n }\n }\n })\n\n router.get('/api/v1/bootstrap', async (ctx) => {\n const [identity, config] = await Promise.all([loadIdentity(), loadConfig()])\n ctx.set('cache-control', 'no-store')\n ctx.body = {\n link_id: identity?.link_id ?? null,\n display_name: identity?.link_id ? 'Hermes Link' : 'Unpaired Hermes Link',\n version: LINK_VERSION,\n api_version: 1,\n paired: Boolean(identity?.link_id),\n routes: identity?.link_id\n ? [\n {\n kind: 'lan',\n url: `http://127.0.0.1:${config.port}`,\n priority: 10,\n observed_at: new Date().toISOString(),\n },\n ]\n : [],\n capabilities: {\n runs: true,\n sse: true,\n relay: true,\n profiles: true,\n },\n }\n })\n\n router.get('/api/v1/profiles', async (ctx) => {\n ctx.set('cache-control', 'no-store')\n ctx.body = {\n ok: true,\n profiles: await listHermesProfiles(),\n }\n })\n\n router.get('/api/v1/profiles/:name/status', async (ctx) => {\n ctx.set('cache-control', 'no-store')\n ctx.body = {\n ok: true,\n profile: await getHermesProfileStatus(ctx.params.name),\n }\n })\n\n router.post('/api/v1/profiles', async (ctx) => {\n const body = await readJsonBody(ctx.req)\n const name = readProfileName(body)\n ctx.status = 201\n ctx.body = {\n ok: true,\n profile: await createHermesProfile(name),\n }\n })\n\n router.post('/api/v1/profiles/:name/use', async (ctx) => {\n ctx.body = {\n ok: true,\n profile: await useHermesProfile(ctx.params.name),\n }\n })\n\n router.patch('/api/v1/profiles/:name', async (ctx) => {\n const body = await readJsonBody(ctx.req)\n const name = readProfileName(body)\n ctx.body = {\n ok: true,\n profile: await renameHermesProfile(ctx.params.name, name),\n }\n })\n\n router.delete('/api/v1/profiles/:name', async (ctx) => {\n await deleteHermesProfile(ctx.params.name)\n ctx.status = 204\n })\n\n app.use(router.routes())\n app.use(router.allowedMethods())\n return app\n}\n\nasync function readJsonBody(request: NodeJS.ReadableStream): Promise<Record<string, unknown>> {\n const chunks: Buffer[] = []\n for await (const chunk of request) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))\n }\n if (chunks.length === 0) {\n return {}\n }\n return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>\n}\n\nfunction readProfileName(body: Record<string, unknown>): string {\n if (typeof body.name !== 'string') {\n throw new Error('invalid profile name')\n }\n return body.name\n}\n","import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { resolveRuntimePaths, type RuntimePaths } from '../runtime/paths.js'\nimport { readJsonFile, writeJsonFile } from '../storage/atomic-json.js'\nimport {\n ensureHermesApiServerKey,\n readHermesApiServerConfig,\n resolveHermesConfigPath,\n resolveHermesProfileDir,\n} from './config.js'\n\nexport interface HermesProfile {\n name: string\n active: boolean\n path: string\n configPath: string\n}\n\ninterface ProfileState {\n activeProfile: string\n}\n\nconst DEFAULT_PROFILE = 'default'\nconst PROFILE_NAME_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/\n\nexport async function listHermesProfiles(paths: RuntimePaths = resolveRuntimePaths()): Promise<HermesProfile[]> {\n const activeProfile = await getActiveProfile(paths)\n const profiles = new Map<string, HermesProfile>()\n profiles.set(DEFAULT_PROFILE, profileInfo(DEFAULT_PROFILE, activeProfile))\n\n const profilesDir = path.join(os.homedir(), '.hermes', 'profiles')\n const entries = await readdir(profilesDir, { withFileTypes: true }).catch((error: unknown) => {\n if (isNodeError(error, 'ENOENT')) {\n return []\n }\n throw error\n })\n for (const entry of entries) {\n if (entry.isDirectory() && PROFILE_NAME_PATTERN.test(entry.name)) {\n profiles.set(entry.name, profileInfo(entry.name, activeProfile))\n }\n }\n\n return [...profiles.values()].sort((left, right) => {\n if (left.name === DEFAULT_PROFILE) {\n return -1\n }\n if (right.name === DEFAULT_PROFILE) {\n return 1\n }\n return left.name.localeCompare(right.name)\n })\n}\n\nexport async function getHermesProfileStatus(\n name: string,\n paths: RuntimePaths = resolveRuntimePaths(),\n): Promise<HermesProfile & { exists: boolean; apiKeyConfigured: boolean }> {\n assertProfileName(name)\n const profile = profileInfo(name, await getActiveProfile(paths))\n const exists = await stat(profile.path)\n .then((value) => value.isDirectory())\n .catch((error: unknown) => {\n if (isNodeError(error, 'ENOENT')) {\n return false\n }\n throw error\n })\n const config = await readHermesApiServerConfig(name, profile.configPath)\n return {\n ...profile,\n exists,\n apiKeyConfigured: Boolean(config.key),\n }\n}\n\nexport async function createHermesProfile(name: string): Promise<HermesProfile> {\n assertProfileName(name)\n if (name === DEFAULT_PROFILE) {\n throw new Error('default profile already exists')\n }\n const profile = profileInfo(name, DEFAULT_PROFILE)\n if (await pathExists(profile.path)) {\n throw new Error('profile already exists')\n }\n await mkdir(profile.path, { recursive: true, mode: 0o700 })\n await ensureHermesApiServerKey(name, profile.configPath)\n return profile\n}\n\nexport async function useHermesProfile(\n name: string,\n paths: RuntimePaths = resolveRuntimePaths(),\n): Promise<HermesProfile> {\n assertProfileName(name)\n const profile = profileInfo(name, name)\n await mkdir(profile.path, { recursive: true, mode: 0o700 })\n const current = (await readJsonFile<Record<string, unknown>>(paths.stateFile)) ?? {}\n await writeJsonFile(paths.stateFile, { ...current, activeProfile: name } satisfies ProfileState)\n return profile\n}\n\nexport async function renameHermesProfile(oldName: string, newName: string): Promise<HermesProfile> {\n assertMutableProfile(oldName)\n assertMutableProfile(newName)\n const oldProfile = profileInfo(oldName, DEFAULT_PROFILE)\n const newProfile = profileInfo(newName, DEFAULT_PROFILE)\n await rename(oldProfile.path, newProfile.path)\n return newProfile\n}\n\nexport async function deleteHermesProfile(name: string): Promise<void> {\n assertMutableProfile(name)\n await rm(resolveHermesProfileDir(name), { recursive: true, force: true })\n}\n\nasync function getActiveProfile(paths: RuntimePaths): Promise<string> {\n const state = await readJsonFile<Partial<ProfileState>>(paths.stateFile)\n return typeof state?.activeProfile === 'string' && PROFILE_NAME_PATTERN.test(state.activeProfile)\n ? state.activeProfile\n : DEFAULT_PROFILE\n}\n\nfunction profileInfo(name: string, activeProfile: string): HermesProfile {\n return {\n name,\n active: name === activeProfile,\n path: resolveHermesProfileDir(name),\n configPath: resolveHermesConfigPath(name),\n }\n}\n\nfunction assertMutableProfile(name: string): void {\n assertProfileName(name)\n if (name === DEFAULT_PROFILE) {\n throw new Error('default profile cannot be renamed or deleted')\n }\n}\n\nfunction assertProfileName(name: string): void {\n if (!PROFILE_NAME_PATTERN.test(name)) {\n throw new Error('invalid profile name')\n }\n}\n\nasync function pathExists(targetPath: string): Promise<boolean> {\n return await stat(targetPath)\n .then(() => true)\n .catch((error: unknown) => {\n if (isNodeError(error, 'ENOENT')) {\n return false\n }\n throw error\n })\n}\n\nfunction isNodeError(error: unknown, code: string): boolean {\n return typeof error === 'object' && error !== null && 'code' in error && error.code === code\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@hermespilot/link",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Hermes Link companion service and CLI for connecting hermes-agent through HermesPilot",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "hermeslink": "dist/cli/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "scripts/check-node-version.mjs",
14
+ "scripts/postinstall.mjs",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "hermespilot",
20
+ "hermes",
21
+ "hermes-agent",
22
+ "relay",
23
+ "link",
24
+ "cli"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup src/cli/index.ts src/http/app.ts --format esm --target node22 --dts --sourcemap --clean",
31
+ "check": "tsc --noEmit",
32
+ "dev": "tsx src/cli/index.ts",
33
+ "preinstall": "node ./scripts/check-node-version.mjs",
34
+ "postinstall": "node ./scripts/postinstall.mjs",
35
+ "prepack": "npm run build",
36
+ "pack:dry-run": "npm pack --dry-run",
37
+ "release": "node ./scripts/release.mjs",
38
+ "start": "node ./dist/cli/index.js",
39
+ "test": "vitest",
40
+ "publish:npm": "npm publish --access public"
41
+ },
42
+ "dependencies": {
43
+ "@koa/router": "^13.1.0",
44
+ "commander": "^12.1.0",
45
+ "koa": "^2.15.3",
46
+ "qrcode-terminal": "^0.12.0",
47
+ "ws": "^8.18.0",
48
+ "yaml": "^2.6.1",
49
+ "zod": "^3.24.1"
50
+ },
51
+ "devDependencies": {
52
+ "@types/koa": "^2.15.0",
53
+ "@types/koa__router": "^12.0.4",
54
+ "@types/node": "^22.10.2",
55
+ "@types/qrcode-terminal": "^0.12.2",
56
+ "@types/ws": "^8.5.13",
57
+ "tsup": "^8.3.5",
58
+ "tsx": "^4.19.2",
59
+ "typescript": "^5.7.2",
60
+ "vitest": "^2.1.8"
61
+ },
62
+ "engines": {
63
+ "node": ">=22.14.0"
64
+ }
65
+ }
@@ -0,0 +1,44 @@
1
+ import process from "node:process";
2
+
3
+ const MINIMUM_NODE_VERSION = "22.14.0";
4
+
5
+ function parseVersion(input) {
6
+ const normalized = String(input ?? "")
7
+ .trim()
8
+ .replace(/^v/i, "");
9
+ const [major = "0", minor = "0", patch = "0"] = normalized.split(".");
10
+ return [
11
+ Number.parseInt(major, 10) || 0,
12
+ Number.parseInt(minor, 10) || 0,
13
+ Number.parseInt(patch, 10) || 0,
14
+ ];
15
+ }
16
+
17
+ function compareVersions(left, right) {
18
+ for (let index = 0; index < 3; index += 1) {
19
+ if (left[index] > right[index]) {
20
+ return 1;
21
+ }
22
+ if (left[index] < right[index]) {
23
+ return -1;
24
+ }
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ const current = parseVersion(process.versions.node);
30
+ const minimum = parseVersion(MINIMUM_NODE_VERSION);
31
+
32
+ if (compareVersions(current, minimum) < 0) {
33
+ console.error("");
34
+ console.error("Hermes Link needs Node.js 22.14.0 or newer.");
35
+ console.error(`You are using Node.js ${process.versions.node}.`);
36
+ console.error("");
37
+ console.error("Why this is required:");
38
+ console.error("- Hermes Link targets the same modern Node.js runtime used by the Link daemon and CLI.");
39
+ console.error("- If installation continued on an older Node.js version, pairing or the background service could fail later.");
40
+ console.error("");
41
+ console.error("Please update Node.js first, then run the install command again.");
42
+ console.error("");
43
+ process.exit(1);
44
+ }
@@ -0,0 +1,30 @@
1
+ import process from "node:process";
2
+
3
+ function isTruthy(value) {
4
+ return ["1", "true", "yes", "on"].includes(String(value ?? "").toLowerCase());
5
+ }
6
+
7
+ function shouldPrintInstallHint() {
8
+ if (isTruthy(process.env.HERMESLINK_POSTINSTALL_QUIET)) {
9
+ return false;
10
+ }
11
+ if (isTruthy(process.env.CI)) {
12
+ return false;
13
+ }
14
+ return process.env.npm_config_global === "true";
15
+ }
16
+
17
+ async function main() {
18
+ if (!shouldPrintInstallHint()) {
19
+ return;
20
+ }
21
+
22
+ console.log("");
23
+ console.log("Hermes Link installed.");
24
+ console.log("Run `hermeslink pair` to connect this computer with HermesPilot App.");
25
+ console.log("");
26
+ }
27
+
28
+ main().catch(() => {
29
+ // Postinstall advice must never block npm install itself.
30
+ });