routstrd 0.2.6 → 0.2.9
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 +17 -20
- package/SKILL.md +46 -13
- package/bun.lock +16 -217
- package/dist/daemon/index.js +588 -404
- package/dist/index.js +10328 -31646
- package/package.json +2 -1
- package/src/cli.ts +291 -208
- package/src/daemon/wallet/cocod-client.ts +22 -6
- package/src/integrations/claudecode.ts +19 -40
- package/src/integrations/openclaw.ts +8 -34
- package/src/integrations/opencode.ts +8 -34
- package/src/integrations/pi.ts +7 -34
- package/src/integrations/registry.ts +4 -12
- package/src/start-daemon.ts +52 -30
- package/src/tui/usage/data.ts +19 -7
- package/src/utils/clients.ts +304 -0
- package/src/utils/config.ts +2 -0
- package/src/utils/daemon-client.ts +79 -28
- package/src/utils/nip98.ts +102 -0
- package/src/utils/process-lock.ts +136 -0
- package/src/daemon/http/index.ts +0 -1130
- package/src/daemon/index.ts +0 -242
- package/src/daemon/wallet/index.ts +0 -122
- package/src/index.ts +0 -4
- package/src/integrations/index.ts +0 -76
- package/src/tui/usage/index.ts +0 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
callDaemon,
|
|
3
|
+
loadConfig,
|
|
4
|
+
getDaemonBaseUrl,
|
|
5
|
+
ensureDaemonRunning,
|
|
6
|
+
} from "./daemon-client";
|
|
7
|
+
import {
|
|
8
|
+
parseSecretKey,
|
|
9
|
+
npubFromSecretKey,
|
|
10
|
+
} from "./nip98";
|
|
11
|
+
import { type RoutstrdConfig } from "./config";
|
|
12
|
+
import { logger } from "./logger";
|
|
13
|
+
import { CLIENT_INTEGRATIONS, CLIENT_CONFIGS } from "../integrations/registry";
|
|
14
|
+
|
|
15
|
+
export function getNpubSuffix(config: RoutstrdConfig): string | null {
|
|
16
|
+
if (!config.daemonUrl || !config.nsec) return null;
|
|
17
|
+
try {
|
|
18
|
+
const secretKey = parseSecretKey(config.nsec);
|
|
19
|
+
const npub = npubFromSecretKey(secretKey);
|
|
20
|
+
return npub.slice(-7);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Add suffix to a client ID.
|
|
28
|
+
*/
|
|
29
|
+
export function addSuffixToId(id: string, suffix: string): string {
|
|
30
|
+
return `${id}-${suffix}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Remove suffix from a client ID if present.
|
|
35
|
+
*/
|
|
36
|
+
export function removeSuffixFromId(id: string, suffix: string): string {
|
|
37
|
+
const suffixStr = `-${suffix}`;
|
|
38
|
+
if (id.endsWith(suffixStr)) {
|
|
39
|
+
return id.slice(0, -suffixStr.length);
|
|
40
|
+
}
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ClientEntry {
|
|
45
|
+
clientId: string;
|
|
46
|
+
name: string;
|
|
47
|
+
apiKey: string;
|
|
48
|
+
createdAt: number;
|
|
49
|
+
lastUsed?: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DaemonClient {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
apiKey: string;
|
|
56
|
+
createdAt: number;
|
|
57
|
+
lastUsed?: number | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the clients list directly from the SDK store.
|
|
62
|
+
* Use this when running inside the daemon (local mode).
|
|
63
|
+
*/
|
|
64
|
+
export function getClientsFromStore(store: { getState(): any }): ClientEntry[] {
|
|
65
|
+
const state = store.getState();
|
|
66
|
+
const clientIds = state.clientIds || [];
|
|
67
|
+
return clientIds.map(
|
|
68
|
+
(c: {
|
|
69
|
+
clientId: string;
|
|
70
|
+
name: string;
|
|
71
|
+
apiKey: string;
|
|
72
|
+
createdAt: number;
|
|
73
|
+
lastUsed?: number | null;
|
|
74
|
+
}) => ({
|
|
75
|
+
clientId: c.clientId,
|
|
76
|
+
name: c.name,
|
|
77
|
+
apiKey: c.apiKey,
|
|
78
|
+
createdAt: c.createdAt,
|
|
79
|
+
lastUsed: c.lastUsed,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fetch the clients list from the daemon API.
|
|
86
|
+
* Use this when running remotely (CLI in remote mode).
|
|
87
|
+
*/
|
|
88
|
+
export async function getClientsList(): Promise<ClientEntry[]> {
|
|
89
|
+
const config = await loadConfig();
|
|
90
|
+
const result = await callDaemon("/clients");
|
|
91
|
+
const clients = (
|
|
92
|
+
result.output as
|
|
93
|
+
| {
|
|
94
|
+
clients?: Array<{
|
|
95
|
+
id: string;
|
|
96
|
+
name: string;
|
|
97
|
+
apiKey: string;
|
|
98
|
+
createdAt: number;
|
|
99
|
+
lastUsed?: number | null;
|
|
100
|
+
}>;
|
|
101
|
+
}
|
|
102
|
+
| undefined
|
|
103
|
+
)?.clients;
|
|
104
|
+
|
|
105
|
+
if (!clients) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const suffix = config.daemonUrl ? getNpubSuffix(config) : null;
|
|
110
|
+
|
|
111
|
+
return clients
|
|
112
|
+
.filter((c) => !suffix || c.id.endsWith(`-${suffix}`))
|
|
113
|
+
.map((c) => ({
|
|
114
|
+
clientId: suffix ? removeSuffixFromId(c.id, suffix) : c.id,
|
|
115
|
+
name: c.name,
|
|
116
|
+
apiKey: c.apiKey,
|
|
117
|
+
createdAt: c.createdAt,
|
|
118
|
+
lastUsed: c.lastUsed,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function addDaemonClient(
|
|
123
|
+
name: string,
|
|
124
|
+
clientId?: string,
|
|
125
|
+
): Promise<{ message?: string; client: DaemonClient; created: boolean }> {
|
|
126
|
+
const existingClients = await getClientsList();
|
|
127
|
+
const existing = clientId
|
|
128
|
+
? existingClients.find((c) => c.clientId === clientId)
|
|
129
|
+
: existingClients.find((c) => c.name === name);
|
|
130
|
+
|
|
131
|
+
if (existing) {
|
|
132
|
+
const client: DaemonClient = {
|
|
133
|
+
id: existing.clientId,
|
|
134
|
+
name: existing.name,
|
|
135
|
+
apiKey: existing.apiKey,
|
|
136
|
+
createdAt: existing.createdAt,
|
|
137
|
+
lastUsed: existing.lastUsed,
|
|
138
|
+
};
|
|
139
|
+
return { client, created: false };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Derive id from name by replacing spaces with hyphens
|
|
143
|
+
const derivedId = name.replace(/\s+/g, "-").toLowerCase();
|
|
144
|
+
|
|
145
|
+
const result = await callDaemon("/clients/add", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: { name, id: derivedId },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
const output = result.output as
|
|
152
|
+
| { message?: string; client?: DaemonClient }
|
|
153
|
+
| undefined;
|
|
154
|
+
|
|
155
|
+
if (!output?.client?.apiKey) {
|
|
156
|
+
throw new Error(`Daemon did not return an API key for ${name}.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { message: output.message, client: output.client, created: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function listClientsAction(): Promise<void> {
|
|
163
|
+
await ensureDaemonRunning();
|
|
164
|
+
|
|
165
|
+
const entries = await getClientsList();
|
|
166
|
+
|
|
167
|
+
const clients = entries.map((c) => ({
|
|
168
|
+
id: c.clientId,
|
|
169
|
+
name: c.name,
|
|
170
|
+
apiKey: c.apiKey,
|
|
171
|
+
createdAt: c.createdAt,
|
|
172
|
+
lastUsed: c.lastUsed,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
if (clients.length === 0) {
|
|
176
|
+
console.log("No clients found.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(`Clients (${clients.length} total):\n`);
|
|
181
|
+
for (const client of clients) {
|
|
182
|
+
const createdAt = new Date(client.createdAt).toISOString();
|
|
183
|
+
const lastUsed = client.lastUsed
|
|
184
|
+
? new Date(client.lastUsed).toISOString()
|
|
185
|
+
: "never";
|
|
186
|
+
console.log(` ${client.id}`);
|
|
187
|
+
console.log(` Name: ${client.name}`);
|
|
188
|
+
console.log(` API Key: ${client.apiKey}`);
|
|
189
|
+
console.log(` Created: ${createdAt}`);
|
|
190
|
+
console.log("");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function deleteClientAction(id: string): Promise<void> {
|
|
195
|
+
await ensureDaemonRunning();
|
|
196
|
+
|
|
197
|
+
const config = await loadConfig();
|
|
198
|
+
const suffix = getNpubSuffix(config);
|
|
199
|
+
const resolvedId = suffix ? addSuffixToId(id, suffix) : id;
|
|
200
|
+
|
|
201
|
+
const result = await callDaemon("/clients/delete", {
|
|
202
|
+
method: "POST",
|
|
203
|
+
body: { id: resolvedId },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (result.error) {
|
|
207
|
+
console.log(result.error);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const output = result.output as
|
|
212
|
+
| {
|
|
213
|
+
message: string;
|
|
214
|
+
id: string;
|
|
215
|
+
}
|
|
216
|
+
| undefined;
|
|
217
|
+
|
|
218
|
+
if (output) {
|
|
219
|
+
console.log(output.message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface AddClientOptions {
|
|
224
|
+
name?: string;
|
|
225
|
+
opencode?: boolean;
|
|
226
|
+
openclaw?: boolean;
|
|
227
|
+
piAgent?: boolean;
|
|
228
|
+
claudeCode?: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function addClientAction(options: AddClientOptions): Promise<void> {
|
|
232
|
+
await ensureDaemonRunning();
|
|
233
|
+
const config = await loadConfig();
|
|
234
|
+
|
|
235
|
+
const integrationKeys: string[] = [];
|
|
236
|
+
if (options.opencode) integrationKeys.push("opencode");
|
|
237
|
+
if (options.openclaw) integrationKeys.push("openclaw");
|
|
238
|
+
if (options.piAgent) integrationKeys.push("pi-agent");
|
|
239
|
+
if (options.claudeCode) integrationKeys.push("claude-code");
|
|
240
|
+
|
|
241
|
+
if (integrationKeys.length > 0) {
|
|
242
|
+
for (const key of integrationKeys) {
|
|
243
|
+
const integrationFn = CLIENT_INTEGRATIONS[key];
|
|
244
|
+
const integrationConfig = CLIENT_CONFIGS[key];
|
|
245
|
+
if (!integrationFn || !integrationConfig) continue;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const { client, created } = await addDaemonClient(
|
|
249
|
+
integrationConfig.name,
|
|
250
|
+
integrationConfig.clientId,
|
|
251
|
+
);
|
|
252
|
+
if (created) {
|
|
253
|
+
logger.log(`Created new API key for ${integrationConfig.name}`);
|
|
254
|
+
} else {
|
|
255
|
+
logger.log(`Using existing API key for ${integrationConfig.name}`);
|
|
256
|
+
}
|
|
257
|
+
await integrationFn(config, client.apiKey, integrationConfig);
|
|
258
|
+
|
|
259
|
+
console.log(`\n ${integrationConfig.name}:`);
|
|
260
|
+
console.log(` Client ID: ${client.id}`);
|
|
261
|
+
console.log(` API Key: ${client.apiKey}`);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
logger.error(
|
|
264
|
+
`Failed to set up ${integrationConfig.name} integration:`,
|
|
265
|
+
error,
|
|
266
|
+
);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!options.name) {
|
|
276
|
+
console.error(
|
|
277
|
+
"error: required option '-n, --name <name>' not specified",
|
|
278
|
+
);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const { message, client, created } = await addDaemonClient(options.name);
|
|
284
|
+
|
|
285
|
+
if (!created) {
|
|
286
|
+
console.log(`Client '${options.name}' already exists.`);
|
|
287
|
+
console.log(`\n ID: ${client.id}`);
|
|
288
|
+
console.log(` Name: ${client.name}`);
|
|
289
|
+
console.log(` API Key: ${client.apiKey}`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (message) {
|
|
294
|
+
console.log(message);
|
|
295
|
+
}
|
|
296
|
+
console.log(`\n ID: ${client.id}`);
|
|
297
|
+
console.log(` Name: ${client.name}`);
|
|
298
|
+
console.log(` API Key: ${client.apiKey}`);
|
|
299
|
+
console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.log((error as Error).message);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/utils/config.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
+
import { startDaemon } from "../start-daemon";
|
|
2
3
|
import {
|
|
3
4
|
CONFIG_FILE,
|
|
4
5
|
DEFAULT_CONFIG,
|
|
5
|
-
LOGS_DIR,
|
|
6
6
|
type RoutstrdConfig,
|
|
7
7
|
} from "./config";
|
|
8
|
+
import {
|
|
9
|
+
createNIP98Authorization,
|
|
10
|
+
parseSecretKey,
|
|
11
|
+
npubFromSecretKey,
|
|
12
|
+
type HttpMethod,
|
|
13
|
+
} from "./nip98";
|
|
8
14
|
|
|
9
15
|
export interface CommandResponse {
|
|
10
16
|
output?: unknown;
|
|
@@ -23,17 +29,44 @@ export async function loadConfig(): Promise<RoutstrdConfig> {
|
|
|
23
29
|
return DEFAULT_CONFIG;
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
|
|
33
|
+
return (
|
|
34
|
+
config.daemonUrl?.replace(/\/$/, "") || `http://localhost:${config.port}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
export async function callDaemon(
|
|
27
39
|
path: string,
|
|
28
|
-
options: { method?: "GET" | "POST"; body?: object } = {},
|
|
40
|
+
options: { method?: "GET" | "POST" | "DELETE"; body?: object } = {},
|
|
29
41
|
): Promise<CommandResponse> {
|
|
30
42
|
const { method = "GET", body } = options;
|
|
31
43
|
const config = await loadConfig();
|
|
44
|
+
const baseUrl = getDaemonBaseUrl(config);
|
|
45
|
+
const url = `${baseUrl}${path}`;
|
|
46
|
+
|
|
47
|
+
const bodyString = body ? JSON.stringify(body) : undefined;
|
|
48
|
+
const bodyBytes = bodyString
|
|
49
|
+
? new TextEncoder().encode(bodyString)
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
let authorization: string | undefined;
|
|
53
|
+
if (config.daemonUrl && config.nsec) {
|
|
54
|
+
const secretKey = parseSecretKey(config.nsec);
|
|
55
|
+
authorization = await createNIP98Authorization(
|
|
56
|
+
secretKey,
|
|
57
|
+
url,
|
|
58
|
+
method as HttpMethod,
|
|
59
|
+
bodyBytes,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
32
62
|
|
|
33
|
-
const response = await fetch(
|
|
63
|
+
const response = await fetch(url, {
|
|
34
64
|
method,
|
|
35
|
-
headers:
|
|
36
|
-
|
|
65
|
+
headers: {
|
|
66
|
+
...(authorization ? { Authorization: authorization } : {}),
|
|
67
|
+
...(bodyString ? { "Content-Type": "application/json" } : {}),
|
|
68
|
+
},
|
|
69
|
+
body: bodyString,
|
|
37
70
|
});
|
|
38
71
|
|
|
39
72
|
if (!response.ok) {
|
|
@@ -47,36 +80,46 @@ export async function callDaemon(
|
|
|
47
80
|
export async function isDaemonRunning(): Promise<boolean> {
|
|
48
81
|
try {
|
|
49
82
|
const config = await loadConfig();
|
|
50
|
-
const
|
|
83
|
+
const baseUrl = getDaemonBaseUrl(config);
|
|
84
|
+
const url = `${baseUrl}/health`;
|
|
85
|
+
|
|
86
|
+
let authorization: string | undefined;
|
|
87
|
+
if (config.daemonUrl && config.nsec) {
|
|
88
|
+
const secretKey = parseSecretKey(config.nsec);
|
|
89
|
+
authorization = await createNIP98Authorization(secretKey, url, "GET");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const response = await fetch(url, {
|
|
93
|
+
headers: authorization ? { Authorization: authorization } : {},
|
|
94
|
+
});
|
|
51
95
|
return response.ok;
|
|
52
96
|
} catch {
|
|
53
97
|
return false;
|
|
54
98
|
}
|
|
55
99
|
}
|
|
56
100
|
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
101
|
+
export function getUserNpub(config: RoutstrdConfig): string | null {
|
|
102
|
+
if (!config.nsec) return null;
|
|
103
|
+
try {
|
|
104
|
+
const secretKey = parseSecretKey(config.nsec);
|
|
105
|
+
return npubFromSecretKey(secretKey);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
61
108
|
}
|
|
109
|
+
}
|
|
62
110
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
proc.unref();
|
|
71
|
-
|
|
72
|
-
for (let i = 0; i < 50; i++) {
|
|
73
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
74
|
-
if (await isDaemonRunning()) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
111
|
+
export function getNpubSuffix(config: RoutstrdConfig): string | null {
|
|
112
|
+
const npub = getUserNpub(config);
|
|
113
|
+
if (!npub) return null;
|
|
114
|
+
return npub.slice(-7);
|
|
115
|
+
}
|
|
78
116
|
|
|
79
|
-
|
|
117
|
+
export async function startDaemonProcess(): Promise<void> {
|
|
118
|
+
const config = await loadConfig();
|
|
119
|
+
await startDaemon({
|
|
120
|
+
port: String(config.port || 8008),
|
|
121
|
+
provider: config.provider || undefined,
|
|
122
|
+
});
|
|
80
123
|
}
|
|
81
124
|
|
|
82
125
|
export async function ensureDaemonRunning(): Promise<void> {
|
|
@@ -84,6 +127,11 @@ export async function ensureDaemonRunning(): Promise<void> {
|
|
|
84
127
|
return;
|
|
85
128
|
}
|
|
86
129
|
|
|
130
|
+
const config = await loadConfig();
|
|
131
|
+
if (config.daemonUrl) {
|
|
132
|
+
throw new Error(`Daemon is not reachable at ${config.daemonUrl}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
87
135
|
console.log("Starting daemon...");
|
|
88
136
|
await startDaemonProcess();
|
|
89
137
|
}
|
|
@@ -117,11 +165,14 @@ export async function handleDaemonCommand(
|
|
|
117
165
|
return result;
|
|
118
166
|
} catch (error) {
|
|
119
167
|
const message = (error as Error).message;
|
|
120
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
message?.includes("fetch failed") ||
|
|
170
|
+
message?.includes("Connection refused")
|
|
171
|
+
) {
|
|
121
172
|
console.error("Daemon is not running and failed to auto-start");
|
|
122
173
|
process.exit(1);
|
|
123
174
|
}
|
|
124
175
|
console.error(message);
|
|
125
176
|
process.exit(1);
|
|
126
177
|
}
|
|
127
|
-
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { finalizeEvent, getPublicKey, nip19, type EventTemplate } from "nostr-tools";
|
|
2
|
+
|
|
3
|
+
const NIP98_KIND = 27235;
|
|
4
|
+
|
|
5
|
+
export type HttpMethod = "GET" | "POST" | "DELETE";
|
|
6
|
+
|
|
7
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
8
|
+
const normalized = hex.trim().toLowerCase();
|
|
9
|
+
if (!/^[a-f0-9]{64}$/.test(normalized)) {
|
|
10
|
+
throw new Error("Expected a 64-char hex private key or an nsec private key.");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const bytes = new Uint8Array(32);
|
|
14
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
15
|
+
bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
|
|
16
|
+
}
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseSecretKey(value: string): Uint8Array {
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
throw new Error("Missing Nostr private key.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (trimmed.toLowerCase().startsWith("nsec1")) {
|
|
27
|
+
const decoded = nip19.decode(trimmed);
|
|
28
|
+
if (decoded.type !== "nsec" || !(decoded.data instanceof Uint8Array)) {
|
|
29
|
+
throw new Error("Invalid nsec private key.");
|
|
30
|
+
}
|
|
31
|
+
return decoded.data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return hexToBytes(trimmed);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function sha256Hex(data: Uint8Array): Promise<string> {
|
|
38
|
+
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(data));
|
|
39
|
+
return [...new Uint8Array(digest)]
|
|
40
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
41
|
+
.join("");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function base64EncodeUtf8(value: string): string {
|
|
45
|
+
return btoa(String.fromCharCode(...new TextEncoder().encode(value)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeNostrPubkey(value: string): string | null {
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (!trimmed) return null;
|
|
51
|
+
|
|
52
|
+
if (/^[a-f0-9]{64}$/i.test(trimmed)) {
|
|
53
|
+
return trimmed.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (trimmed.toLowerCase().startsWith("npub1")) {
|
|
57
|
+
try {
|
|
58
|
+
const decoded = nip19.decode(trimmed);
|
|
59
|
+
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
|
60
|
+
return decoded.data.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function npubFromPubkey(pubkey: string): string {
|
|
71
|
+
return nip19.npubEncode(pubkey.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function npubFromSecretKey(secretKey: Uint8Array): string {
|
|
75
|
+
return npubFromPubkey(getPublicKey(secretKey));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function createNIP98Authorization(
|
|
79
|
+
secretKey: Uint8Array,
|
|
80
|
+
url: string,
|
|
81
|
+
method: HttpMethod,
|
|
82
|
+
body?: Uint8Array,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
const tags = [
|
|
85
|
+
["u", url],
|
|
86
|
+
["method", method.toUpperCase()],
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
if (body && body.byteLength > 0) {
|
|
90
|
+
tags.push(["payload", await sha256Hex(body)]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const template: EventTemplate = {
|
|
94
|
+
kind: NIP98_KIND,
|
|
95
|
+
created_at: Math.round(Date.now() / 1000),
|
|
96
|
+
content: "",
|
|
97
|
+
tags,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const signed = finalizeEvent(template, secretKey);
|
|
101
|
+
return `Nostr ${base64EncodeUtf8(JSON.stringify(signed))}`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
|
|
5
|
+
export interface CrossProcessLockOptions {
|
|
6
|
+
/** How long to wait while another process holds the lock. */
|
|
7
|
+
acquireTimeoutMs?: number;
|
|
8
|
+
/** How often to retry acquiring the lock. */
|
|
9
|
+
retryIntervalMs?: number;
|
|
10
|
+
/** Treat locks older than this as stale even if their PID cannot be checked. */
|
|
11
|
+
staleAfterMs?: number;
|
|
12
|
+
/** Optional logger used when removing stale locks. */
|
|
13
|
+
log?: (message: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LockOwner {
|
|
17
|
+
pid: number;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
token?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function delay(ms: number): Promise<void> {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isProcessRunning(pid: number): boolean {
|
|
27
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
36
|
+
return code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readLockOwner(lockDir: string): Promise<LockOwner | null> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(`${lockDir}/owner.json`, "utf8");
|
|
43
|
+
const parsed = JSON.parse(raw) as Partial<LockOwner>;
|
|
44
|
+
if (
|
|
45
|
+
typeof parsed.pid === "number" &&
|
|
46
|
+
typeof parsed.createdAt === "number"
|
|
47
|
+
) {
|
|
48
|
+
return {
|
|
49
|
+
pid: parsed.pid,
|
|
50
|
+
createdAt: parsed.createdAt,
|
|
51
|
+
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// The lock may have been created but not fully written yet.
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function isLockStale(
|
|
61
|
+
lockDir: string,
|
|
62
|
+
staleAfterMs: number,
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const owner = await readLockOwner(lockDir);
|
|
65
|
+
if (owner) {
|
|
66
|
+
return !isProcessRunning(owner.pid) || Date.now() - owner.createdAt > staleAfterMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(lockDir);
|
|
71
|
+
return Date.now() - info.mtimeMs > staleAfterMs;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function acquireCrossProcessLock(
|
|
78
|
+
lockDir: string,
|
|
79
|
+
options: CrossProcessLockOptions = {},
|
|
80
|
+
): Promise<() => Promise<void>> {
|
|
81
|
+
const acquireTimeoutMs = options.acquireTimeoutMs ?? 120_000;
|
|
82
|
+
const retryIntervalMs = options.retryIntervalMs ?? 100;
|
|
83
|
+
const staleAfterMs = options.staleAfterMs ?? 120_000;
|
|
84
|
+
const deadline = Date.now() + acquireTimeoutMs;
|
|
85
|
+
|
|
86
|
+
await mkdir(dirname(lockDir), { recursive: true });
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(lockDir);
|
|
91
|
+
const token = randomUUID();
|
|
92
|
+
const owner: LockOwner = { pid: process.pid, createdAt: Date.now(), token };
|
|
93
|
+
await writeFile(`${lockDir}/owner.json`, JSON.stringify(owner), "utf8");
|
|
94
|
+
let released = false;
|
|
95
|
+
return async () => {
|
|
96
|
+
if (released) return;
|
|
97
|
+
released = true;
|
|
98
|
+
|
|
99
|
+
const currentOwner = await readLockOwner(lockDir);
|
|
100
|
+
if (currentOwner?.token === token) {
|
|
101
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
106
|
+
if (code !== "EEXIST") {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (await isLockStale(lockDir, staleAfterMs)) {
|
|
111
|
+
options.log?.(`Removing stale lock at ${lockDir}`);
|
|
112
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Date.now() >= deadline) {
|
|
117
|
+
throw new Error(`Timed out waiting to acquire lock ${lockDir}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await delay(retryIntervalMs);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function withCrossProcessLock<T>(
|
|
126
|
+
lockDir: string,
|
|
127
|
+
fn: () => Promise<T>,
|
|
128
|
+
options: CrossProcessLockOptions = {},
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
const release = await acquireCrossProcessLock(lockDir, options);
|
|
131
|
+
try {
|
|
132
|
+
return await fn();
|
|
133
|
+
} finally {
|
|
134
|
+
await release();
|
|
135
|
+
}
|
|
136
|
+
}
|