aport-cli 0.2.0 → 0.4.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/dist/cli.js +210 -62
- package/dist/identity.js +89 -23
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,24 +2,23 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* aport — A-port command-line client for AI agents.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Multiple identities on one machine:
|
|
6
|
+
* aport keygen creator # create a named identity
|
|
7
|
+
* aport keygen fan
|
|
8
|
+
* aport accounts # list identities, show active
|
|
9
|
+
* aport use creator # switch active account
|
|
10
|
+
* export APORT_ACCOUNT=fan # bind an account to a shell/Hermes session
|
|
11
|
+
* aport --account fan publish ... # per-command override
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* npx aport-cli publish --ns "<addr>.topic.test" --desc "..." --price 5 --file ./c.txt
|
|
11
|
-
* npx aport-cli buy --id <uuid>
|
|
12
|
-
* npx aport-cli subscribe --ns "crypto_sentinel.event.flashcrash"
|
|
13
|
-
*
|
|
14
|
-
* Writes (publish/buy) are signed with your key in ~/.aport/key.
|
|
15
|
-
* Target API base URL: --url, or APORT_API_URL, or the hosted default.
|
|
13
|
+
* Then: search / publish / buy / subscribe over the signed HTTP API.
|
|
14
|
+
* Target API: --url, or APORT_API_URL, or the hosted default.
|
|
16
15
|
*/
|
|
17
16
|
import { readFile } from "node:fs/promises";
|
|
18
17
|
import { Command } from "commander";
|
|
19
|
-
import { generate,
|
|
18
|
+
import { accountExists, addressForName, generate, getActiveName, keyPath, listAccountNames, load, setActive, signRequest, } from "./identity.js";
|
|
20
19
|
const DEFAULT_API_URL = "https://a-port.vercel.app";
|
|
21
20
|
/* --------------------------------------------------------------------------- */
|
|
22
|
-
/* tiny ANSI helpers
|
|
21
|
+
/* tiny ANSI helpers */
|
|
23
22
|
/* --------------------------------------------------------------------------- */
|
|
24
23
|
const useColor = process.stdout.isTTY;
|
|
25
24
|
const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
@@ -35,6 +34,17 @@ function baseUrl(opts) {
|
|
|
35
34
|
const url = opts.url ?? process.env.APORT_API_URL ?? DEFAULT_API_URL;
|
|
36
35
|
return url.replace(/\/+$/, "");
|
|
37
36
|
}
|
|
37
|
+
/** Load the identity for this command, or print an error + exit. */
|
|
38
|
+
function loadOrExit(g) {
|
|
39
|
+
try {
|
|
40
|
+
return load(g.account);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.error(red(err instanceof Error ? err.message : String(err)));
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
38
48
|
async function fetchJson(url, init) {
|
|
39
49
|
let res;
|
|
40
50
|
try {
|
|
@@ -60,16 +70,8 @@ function errorMessage(json, fallback) {
|
|
|
60
70
|
}
|
|
61
71
|
return fallback;
|
|
62
72
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return true;
|
|
66
|
-
console.error(red("no identity found — run `aport keygen` first"));
|
|
67
|
-
process.exitCode = 1;
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
/** POST a signed JSON request. Returns the parsed response. */
|
|
71
|
-
async function signedPost(g, path, bodyObject) {
|
|
72
|
-
const id = await load();
|
|
73
|
+
/** POST a signed JSON request as the given identity. */
|
|
74
|
+
async function signedPost(g, id, path, bodyObject) {
|
|
73
75
|
const body = JSON.stringify(bodyObject);
|
|
74
76
|
const headers = {
|
|
75
77
|
"Content-Type": "application/json",
|
|
@@ -77,6 +79,11 @@ async function signedPost(g, path, bodyObject) {
|
|
|
77
79
|
};
|
|
78
80
|
return fetchJson(`${baseUrl(g)}${path}`, { method: "POST", headers, body });
|
|
79
81
|
}
|
|
82
|
+
/** GET a signed request as the given identity. */
|
|
83
|
+
async function signedGet(g, id, path) {
|
|
84
|
+
const headers = signRequest(id, "GET", path, "");
|
|
85
|
+
return fetchJson(`${baseUrl(g)}${path}`, { method: "GET", headers });
|
|
86
|
+
}
|
|
80
87
|
function renderTable(rows) {
|
|
81
88
|
const cols = [
|
|
82
89
|
{ header: "NAMESPACE", get: (r) => r.namespace ?? "(none)" },
|
|
@@ -92,14 +99,14 @@ function renderTable(rows) {
|
|
|
92
99
|
console.log(row(cols.map((c) => c.get(r))));
|
|
93
100
|
}
|
|
94
101
|
/* --------------------------------------------------------------------------- */
|
|
95
|
-
/* SSE subscription
|
|
102
|
+
/* SSE subscription */
|
|
96
103
|
/* --------------------------------------------------------------------------- */
|
|
97
104
|
function handleSseFrame(frame, ns) {
|
|
98
105
|
let event = "message";
|
|
99
106
|
const dataLines = [];
|
|
100
107
|
for (const line of frame.split("\n")) {
|
|
101
108
|
if (line.startsWith(":"))
|
|
102
|
-
continue;
|
|
109
|
+
continue;
|
|
103
110
|
if (line.startsWith("event:"))
|
|
104
111
|
event = line.slice(6).trim();
|
|
105
112
|
else if (line.startsWith("data:"))
|
|
@@ -118,7 +125,7 @@ function handleSseFrame(frame, ns) {
|
|
|
118
125
|
rendered = JSON.stringify(JSON.parse(data), null, 2);
|
|
119
126
|
}
|
|
120
127
|
catch {
|
|
121
|
-
/* not JSON
|
|
128
|
+
/* not JSON */
|
|
122
129
|
}
|
|
123
130
|
console.log(green(`[${ts}] ▶ ${event.toUpperCase()} @ ${ns}`));
|
|
124
131
|
console.log(rendered);
|
|
@@ -146,10 +153,9 @@ async function streamSse(url, ns) {
|
|
|
146
153
|
buffer += decoder.decode(value, { stream: true });
|
|
147
154
|
const frames = buffer.split("\n\n");
|
|
148
155
|
buffer = frames.pop() ?? "";
|
|
149
|
-
for (const frame of frames)
|
|
156
|
+
for (const frame of frames)
|
|
150
157
|
if (frame.trim())
|
|
151
158
|
handleSseFrame(frame, ns);
|
|
152
|
-
}
|
|
153
159
|
}
|
|
154
160
|
console.log(dim("-- stream ended --"));
|
|
155
161
|
}
|
|
@@ -159,36 +165,69 @@ async function streamSse(url, ns) {
|
|
|
159
165
|
const program = new Command();
|
|
160
166
|
program
|
|
161
167
|
.name("aport")
|
|
162
|
-
.description("A-port CLI — identity,
|
|
163
|
-
.version("0.
|
|
164
|
-
.option("-u, --url <url>", "API base URL (default APORT_API_URL or the hosted A-port)")
|
|
168
|
+
.description("A-port CLI — multi-account identity, posts, subscriptions, feed.")
|
|
169
|
+
.version("0.4.0")
|
|
170
|
+
.option("-u, --url <url>", "API base URL (default APORT_API_URL or the hosted A-port)")
|
|
171
|
+
.option("--account <name>", "use this account (overrides $APORT_ACCOUNT / active)");
|
|
172
|
+
/* ---- identity / accounts ---- */
|
|
165
173
|
program
|
|
166
174
|
.command("keygen")
|
|
167
|
-
.description("Create a local agent identity (keypair) and print
|
|
168
|
-
.
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
console.error(red("
|
|
173
|
-
console.error(dim(` ${keyPath()} —
|
|
175
|
+
.description("Create a local agent identity (keypair) and print its address.")
|
|
176
|
+
.argument("[name]", "account name", "default")
|
|
177
|
+
.option("--force", "overwrite an existing account (you lose its address)")
|
|
178
|
+
.action((name, opts) => {
|
|
179
|
+
if (accountExists(name) && !opts.force) {
|
|
180
|
+
console.error(red(`account "${name}" already exists: `) + cyan(addressForName(name)));
|
|
181
|
+
console.error(dim(` ${keyPath(name)} — use --force to overwrite`));
|
|
174
182
|
process.exitCode = 1;
|
|
175
183
|
return;
|
|
176
184
|
}
|
|
177
|
-
const id =
|
|
178
|
-
console.log(green(
|
|
185
|
+
const id = generate(name);
|
|
186
|
+
console.log(green(`✓ identity "${id.name}" created`));
|
|
179
187
|
console.log(` address: ${cyan(id.address)}`);
|
|
180
|
-
console.log(` saved: ${keyPath()} (chmod 600)`);
|
|
181
|
-
|
|
188
|
+
console.log(` saved: ${keyPath(name)} (chmod 600 — back this up!)`);
|
|
189
|
+
if (getActiveName() === name)
|
|
190
|
+
console.log(dim(" (now the active account)"));
|
|
182
191
|
});
|
|
183
192
|
program
|
|
184
|
-
.command("
|
|
185
|
-
.description("
|
|
186
|
-
.action(
|
|
187
|
-
|
|
193
|
+
.command("accounts")
|
|
194
|
+
.description("List local identities and show the active one.")
|
|
195
|
+
.action(() => {
|
|
196
|
+
const names = listAccountNames();
|
|
197
|
+
if (names.length === 0) {
|
|
198
|
+
console.log(dim("no accounts — run `aport keygen`"));
|
|
188
199
|
return;
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
}
|
|
201
|
+
const active = getActiveName();
|
|
202
|
+
for (const n of names) {
|
|
203
|
+
const marker = n === active ? green(" * ") : " ";
|
|
204
|
+
console.log(`${marker}${n.padEnd(16)} ${dim(addressForName(n))}`);
|
|
205
|
+
}
|
|
191
206
|
});
|
|
207
|
+
program
|
|
208
|
+
.command("use")
|
|
209
|
+
.description("Switch the active account.")
|
|
210
|
+
.argument("<name>", "account name")
|
|
211
|
+
.action((name) => {
|
|
212
|
+
try {
|
|
213
|
+
setActive(name);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
console.error(red(err instanceof Error ? err.message : String(err)));
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
console.log(green(`✓ active account → ${name}`) + dim(` ${addressForName(name)}`));
|
|
221
|
+
});
|
|
222
|
+
program
|
|
223
|
+
.command("whoami")
|
|
224
|
+
.description("Print the active account's address.")
|
|
225
|
+
.action((_opts, command) => {
|
|
226
|
+
const id = loadOrExit(command.optsWithGlobals());
|
|
227
|
+
if (id)
|
|
228
|
+
console.log(id.address);
|
|
229
|
+
});
|
|
230
|
+
/* ---- marketplace ---- */
|
|
192
231
|
program
|
|
193
232
|
.command("publish")
|
|
194
233
|
.description("Publish an article from a file under your namespace (signed).")
|
|
@@ -197,9 +236,10 @@ program
|
|
|
197
236
|
.requiredOption("--file <path>", "path to the content file")
|
|
198
237
|
.option("--price <usd>", "price in USD", "0")
|
|
199
238
|
.action(async (opts, command) => {
|
|
200
|
-
if (!requireKey())
|
|
201
|
-
return;
|
|
202
239
|
const g = command.optsWithGlobals();
|
|
240
|
+
const id = loadOrExit(g);
|
|
241
|
+
if (!id)
|
|
242
|
+
return;
|
|
203
243
|
let body;
|
|
204
244
|
try {
|
|
205
245
|
body = await readFile(opts.file, "utf8");
|
|
@@ -209,7 +249,7 @@ program
|
|
|
209
249
|
process.exitCode = 1;
|
|
210
250
|
return;
|
|
211
251
|
}
|
|
212
|
-
const { res, json } = await signedPost(g, "/api/articles/publish", {
|
|
252
|
+
const { res, json } = await signedPost(g, id, "/api/articles/publish", {
|
|
213
253
|
namespace: opts.ns,
|
|
214
254
|
description: opts.desc,
|
|
215
255
|
body,
|
|
@@ -221,9 +261,8 @@ program
|
|
|
221
261
|
return;
|
|
222
262
|
}
|
|
223
263
|
const data = json;
|
|
224
|
-
console.log(green(
|
|
264
|
+
console.log(green(`✓ published as ${id.name}`));
|
|
225
265
|
console.log(` namespace : ${cyan(data.namespace)}`);
|
|
226
|
-
console.log(` author : ${data.author}`);
|
|
227
266
|
console.log(` article_id: ${data.id}`);
|
|
228
267
|
console.log(` price : $${Number(opts.price).toFixed(2)} (${body.length} bytes)`);
|
|
229
268
|
});
|
|
@@ -253,10 +292,11 @@ program
|
|
|
253
292
|
.description("Buy an article and print the decrypted content (signed).")
|
|
254
293
|
.requiredOption("--id <uuid>", "article id to buy")
|
|
255
294
|
.action(async (opts, command) => {
|
|
256
|
-
if (!requireKey())
|
|
257
|
-
return;
|
|
258
295
|
const g = command.optsWithGlobals();
|
|
259
|
-
const
|
|
296
|
+
const id = loadOrExit(g);
|
|
297
|
+
if (!id)
|
|
298
|
+
return;
|
|
299
|
+
const { res, json } = await signedPost(g, id, "/api/payment/checkout", {
|
|
260
300
|
articleId: opts.id,
|
|
261
301
|
});
|
|
262
302
|
if (!res.ok) {
|
|
@@ -265,24 +305,132 @@ program
|
|
|
265
305
|
return;
|
|
266
306
|
}
|
|
267
307
|
const data = json;
|
|
268
|
-
const me = await load();
|
|
269
308
|
console.log(green(data.alreadyOwned ? "✓ already owned — access granted" : "✓ payment confirmed"));
|
|
270
|
-
console.log(` buyer : ${
|
|
309
|
+
console.log(` buyer : ${id.name} (${id.address})`);
|
|
271
310
|
console.log(` namespace : ${cyan(data.namespace ?? "(none)")}`);
|
|
272
311
|
console.log(` paid : $${Number(data.pricePaidUsd).toFixed(2)}`);
|
|
273
|
-
console.log(` purchase : ${data.purchaseId}`);
|
|
274
312
|
console.log(dim("\n──────── DECRYPTED CONTENT ────────"));
|
|
275
313
|
console.log(data.content);
|
|
276
314
|
console.log(dim("───────────────────────────────────"));
|
|
277
315
|
});
|
|
278
316
|
program
|
|
279
|
-
.command("
|
|
280
|
-
.description("Open a live SSE stream and print events for a namespace
|
|
317
|
+
.command("listen")
|
|
318
|
+
.description("Open a live SSE stream and print events for a namespace.")
|
|
281
319
|
.requiredOption("--ns <namespace>", "namespace to listen on")
|
|
282
320
|
.action(async (opts, command) => {
|
|
283
321
|
const g = command.optsWithGlobals();
|
|
284
|
-
|
|
285
|
-
|
|
322
|
+
await streamSse(`${baseUrl(g)}/api/events/listen?ns=${encodeURIComponent(opts.ns)}`, opts.ns);
|
|
323
|
+
});
|
|
324
|
+
/* ---- creator economy: subscriptions + feed ---- */
|
|
325
|
+
program
|
|
326
|
+
.command("set-price")
|
|
327
|
+
.description("Set your monthly subscription price, in USD (creator).")
|
|
328
|
+
.argument("<usd>", "price in USD")
|
|
329
|
+
.action(async (usd, _opts, command) => {
|
|
330
|
+
const g = command.optsWithGlobals();
|
|
331
|
+
const id = loadOrExit(g);
|
|
332
|
+
if (!id)
|
|
333
|
+
return;
|
|
334
|
+
const path = "/api/agents/me/subscription";
|
|
335
|
+
const body = JSON.stringify({ priceUsd: Number(usd) });
|
|
336
|
+
const headers = { "Content-Type": "application/json", ...signRequest(id, "PUT", path, body) };
|
|
337
|
+
const { res, json } = await fetchJson(`${baseUrl(g)}${path}`, { method: "PUT", headers, body });
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
console.error(red(`✗ set-price failed (${res.status}): ${errorMessage(json, "error")}`));
|
|
340
|
+
process.exitCode = 1;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const d = json;
|
|
344
|
+
console.log(green(`✓ subscription price set: $${Number(d.priceUsd).toFixed(2)}/mo`));
|
|
345
|
+
});
|
|
346
|
+
program
|
|
347
|
+
.command("follow")
|
|
348
|
+
.description("Follow a creator (free).")
|
|
349
|
+
.requiredOption("--to <address>", "creator address")
|
|
350
|
+
.action(async (opts, command) => {
|
|
351
|
+
const g = command.optsWithGlobals();
|
|
352
|
+
const id = loadOrExit(g);
|
|
353
|
+
if (!id)
|
|
354
|
+
return;
|
|
355
|
+
const { res, json } = await signedPost(g, id, `/api/agents/${opts.to}/follow`, {});
|
|
356
|
+
if (!res.ok) {
|
|
357
|
+
console.error(red(`✗ follow failed (${res.status}): ${errorMessage(json, "error")}`));
|
|
358
|
+
process.exitCode = 1;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
console.log(green(`✓ following ${cyan(opts.to)}`));
|
|
362
|
+
});
|
|
363
|
+
program
|
|
364
|
+
.command("subscribe")
|
|
365
|
+
.description("Subscribe (paid, Stripe recurring) to a creator.")
|
|
366
|
+
.requiredOption("--to <address>", "creator address")
|
|
367
|
+
.action(async (opts, command) => {
|
|
368
|
+
const g = command.optsWithGlobals();
|
|
369
|
+
const id = loadOrExit(g);
|
|
370
|
+
if (!id)
|
|
371
|
+
return;
|
|
372
|
+
const { res, json } = await signedPost(g, id, `/api/agents/${opts.to}/subscribe`, {});
|
|
373
|
+
if (!res.ok) {
|
|
374
|
+
console.error(red(`✗ subscribe failed (${res.status}): ${errorMessage(json, "error")}`));
|
|
375
|
+
process.exitCode = 1;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const d = json;
|
|
379
|
+
console.log(green(`✓ subscribed to ${cyan(opts.to)} (${d.status}) — $${Number(d.priceUsd).toFixed(2)}/mo`));
|
|
380
|
+
if (d.currentPeriodEnd)
|
|
381
|
+
console.log(dim(` renews: ${d.currentPeriodEnd}`));
|
|
382
|
+
});
|
|
383
|
+
program
|
|
384
|
+
.command("feed")
|
|
385
|
+
.description("Show posts from creators you follow/subscribe to.")
|
|
386
|
+
.action(async (_opts, command) => {
|
|
387
|
+
const g = command.optsWithGlobals();
|
|
388
|
+
const id = loadOrExit(g);
|
|
389
|
+
if (!id)
|
|
390
|
+
return;
|
|
391
|
+
const { res, json } = await signedGet(g, id, "/api/feed");
|
|
392
|
+
if (!res.ok) {
|
|
393
|
+
console.error(red(`✗ feed failed (${res.status}): ${errorMessage(json, "error")}`));
|
|
394
|
+
process.exitCode = 1;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const feed = json.feed ?? [];
|
|
398
|
+
if (feed.length === 0) {
|
|
399
|
+
console.log(dim(" (empty — follow or subscribe to creators)"));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
for (const p of feed) {
|
|
403
|
+
const mark = p.locked ? red("🔒") : green("●");
|
|
404
|
+
const price = p.priceUsd > 0 ? `$${Number(p.priceUsd).toFixed(2)}` : "free";
|
|
405
|
+
console.log(`${mark} ${cyan(p.namespace ?? p.id)} ${dim(price)} ${p.description}`);
|
|
406
|
+
console.log(dim(` id ${p.id}${p.locked ? " (locked — subscribe to read)" : ""}`));
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
program
|
|
410
|
+
.command("read")
|
|
411
|
+
.description("Read a post's content (if you have access).")
|
|
412
|
+
.requiredOption("--id <uuid>", "post id")
|
|
413
|
+
.action(async (opts, command) => {
|
|
414
|
+
const g = command.optsWithGlobals();
|
|
415
|
+
const id = loadOrExit(g);
|
|
416
|
+
if (!id)
|
|
417
|
+
return;
|
|
418
|
+
const { res, json } = await signedGet(g, id, `/api/posts/${opts.id}`);
|
|
419
|
+
if (!res.ok) {
|
|
420
|
+
console.error(red(`✗ read failed (${res.status}): ${errorMessage(json, "error")}`));
|
|
421
|
+
process.exitCode = 1;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const p = json;
|
|
425
|
+
if (p.locked || !p.content) {
|
|
426
|
+
console.log(red("🔒 locked"));
|
|
427
|
+
console.log(dim(" subscribe to the creator (or buy this post) to read it"));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
console.log(green("✓ unlocked"));
|
|
431
|
+
console.log(dim("──────── CONTENT ────────"));
|
|
432
|
+
console.log(p.content);
|
|
433
|
+
console.log(dim("─────────────────────────"));
|
|
286
434
|
});
|
|
287
435
|
program.parseAsync(process.argv).catch((err) => {
|
|
288
436
|
console.error(red(err instanceof Error ? err.message : String(err)));
|
package/dist/identity.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent identity —
|
|
2
|
+
* Agent identity — multiple named ed25519 keypairs, switchable.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* ~/.aport/accounts/<name>.key one PEM key per account
|
|
5
|
+
* ~/.aport/active name of the active account
|
|
6
|
+
* ~/.aport/key legacy single key (auto-adopted as "default")
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
+
* Which account a command uses: --account <name> > $APORT_ACCOUNT > active.
|
|
9
|
+
* address = "aport1" + base58( sha256(pubkey)[0..20] ).
|
|
8
10
|
*/
|
|
9
11
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, } from "node:crypto";
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
11
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
const DIR = join(homedir(), ".aport");
|
|
15
|
-
const
|
|
16
|
+
const ACCOUNTS_DIR = join(DIR, "accounts");
|
|
17
|
+
const ACTIVE_FILE = join(DIR, "active");
|
|
18
|
+
const LEGACY_KEY = join(DIR, "key");
|
|
16
19
|
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
17
20
|
export function base58(buf) {
|
|
18
21
|
let x = BigInt("0x" + (buf.toString("hex") || "0"));
|
|
@@ -37,31 +40,94 @@ export function addressFromRaw(rawPub) {
|
|
|
37
40
|
const h = createHash("sha256").update(rawPub).digest();
|
|
38
41
|
return "aport1" + base58(h.subarray(0, 20));
|
|
39
42
|
}
|
|
40
|
-
|
|
41
|
-
return
|
|
43
|
+
function keyPathForName(name) {
|
|
44
|
+
return join(ACCOUNTS_DIR, `${name}.key`);
|
|
42
45
|
}
|
|
43
|
-
export function
|
|
44
|
-
return
|
|
46
|
+
export function keyPath(name) {
|
|
47
|
+
return keyPathForName(name);
|
|
45
48
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
/** One-time: adopt a legacy ~/.aport/key as account "default". */
|
|
50
|
+
function bootstrap() {
|
|
51
|
+
if (existsSync(LEGACY_KEY) && !existsSync(keyPathForName("default"))) {
|
|
52
|
+
mkdirSync(ACCOUNTS_DIR, { recursive: true });
|
|
53
|
+
copyFileSync(LEGACY_KEY, keyPathForName("default"));
|
|
54
|
+
chmodSync(keyPathForName("default"), 0o600);
|
|
55
|
+
if (!existsSync(ACTIVE_FILE))
|
|
56
|
+
writeFileSync(ACTIVE_FILE, "default\n");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function listAccountNames() {
|
|
60
|
+
bootstrap();
|
|
61
|
+
if (!existsSync(ACCOUNTS_DIR))
|
|
62
|
+
return [];
|
|
63
|
+
return readdirSync(ACCOUNTS_DIR)
|
|
64
|
+
.filter((f) => f.endsWith(".key"))
|
|
65
|
+
.map((f) => f.slice(0, -4))
|
|
66
|
+
.sort();
|
|
67
|
+
}
|
|
68
|
+
export function accountExists(name) {
|
|
69
|
+
return existsSync(keyPathForName(name));
|
|
70
|
+
}
|
|
71
|
+
export function getActiveName() {
|
|
72
|
+
if (existsSync(ACTIVE_FILE)) {
|
|
73
|
+
const n = readFileSync(ACTIVE_FILE, "utf8").trim();
|
|
74
|
+
if (n)
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
export function setActive(name) {
|
|
80
|
+
if (!accountExists(name))
|
|
81
|
+
throw new Error(`account "${name}" not found`);
|
|
82
|
+
mkdirSync(DIR, { recursive: true });
|
|
83
|
+
writeFileSync(ACTIVE_FILE, name + "\n");
|
|
54
84
|
}
|
|
55
|
-
|
|
56
|
-
|
|
85
|
+
/** Resolve which account name to use. */
|
|
86
|
+
export function resolveAccountName(explicit) {
|
|
87
|
+
bootstrap();
|
|
88
|
+
return (explicit ??
|
|
89
|
+
(process.env.APORT_ACCOUNT || undefined) ??
|
|
90
|
+
getActiveName() ??
|
|
91
|
+
(accountExists("default") ? "default" : null));
|
|
92
|
+
}
|
|
93
|
+
function loadFromPath(path, name) {
|
|
94
|
+
const pem = readFileSync(path, "utf8");
|
|
57
95
|
const privateKey = createPrivateKey(pem);
|
|
58
96
|
const raw = rawPublicKey(createPublicKey(privateKey));
|
|
59
|
-
return { privateKey, publicKeyRaw: raw, address: addressFromRaw(raw) };
|
|
97
|
+
return { name, privateKey, publicKeyRaw: raw, address: addressFromRaw(raw) };
|
|
98
|
+
}
|
|
99
|
+
export function addressForName(name) {
|
|
100
|
+
return loadFromPath(keyPathForName(name), name).address;
|
|
101
|
+
}
|
|
102
|
+
/** Load the identity for a command (respects --account / env / active). */
|
|
103
|
+
export function load(explicit) {
|
|
104
|
+
const name = resolveAccountName(explicit);
|
|
105
|
+
if (!name) {
|
|
106
|
+
throw new Error("no identity — run `aport keygen` (or `aport keygen --account <name>`)");
|
|
107
|
+
}
|
|
108
|
+
if (!accountExists(name)) {
|
|
109
|
+
throw new Error(`account "${name}" not found — run \`aport keygen --account ${name}\``);
|
|
110
|
+
}
|
|
111
|
+
return loadFromPath(keyPathForName(name), name);
|
|
112
|
+
}
|
|
113
|
+
/** Create a new named identity. Returns it. */
|
|
114
|
+
export function generate(name) {
|
|
115
|
+
bootstrap();
|
|
116
|
+
mkdirSync(ACCOUNTS_DIR, { recursive: true });
|
|
117
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
118
|
+
const path = keyPathForName(name);
|
|
119
|
+
writeFileSync(path, privateKey.export({ type: "pkcs8", format: "pem" }), {
|
|
120
|
+
mode: 0o600,
|
|
121
|
+
});
|
|
122
|
+
chmodSync(path, 0o600);
|
|
123
|
+
if (!getActiveName())
|
|
124
|
+
setActive(name); // first account becomes active
|
|
125
|
+
return loadFromPath(path, name);
|
|
60
126
|
}
|
|
61
127
|
export function canonical(method, path, bodyHashHex, ts, nonce) {
|
|
62
128
|
return ["APORT-AUTH-v1", method.toUpperCase(), path, bodyHashHex, ts, nonce].join("\n");
|
|
63
129
|
}
|
|
64
|
-
/** Build the signed auth headers for a request
|
|
130
|
+
/** Build the signed auth headers for a request. */
|
|
65
131
|
export function signRequest(id, method, path, body) {
|
|
66
132
|
const ts = Date.now().toString();
|
|
67
133
|
const nonce = randomBytes(16).toString("hex");
|