feat(cli): invite-link parsing + join flow + keypair generation
End-to-end join: user runs `claudemesh join ic://join/<base64>` and walks away with a signed member record + persistent keypair. new modules: - src/crypto/keypair.ts: libsodium ed25519 keypair generation. Format is crypto_sign_keypair raw bytes, hex-encoded (32-byte pub, 64-byte secret = seed || pub). Same format libsodium will need in Step 18 for sign/verify. - src/invite/parse.ts: ic://join/<base64url(JSON)> parser with Zod shape validation + expiry check. encodeInviteLink helper for tests. - src/invite/enroll.ts: POST /join to broker, converts ws:// to http:// transparently. rewritten join command wires them together: 1. parse invite → 2. generate keypair → 3. POST /join → 4. persist config → 5. print success. state/config.ts: saveConfig now chmods the file to 0600 after write, since it holds ed25519 secret keys. No-op on Windows. signature verification (step 18) + invite-token one-time-use tracking are deferred. For now the invite link is a plain bearer token; any client with the link can join. verified end-to-end via apps/cli/scripts/join-roundtrip.ts: build invite → run join subprocess → load new config → connect as new member → send A→B → receive push. Flow passes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
apps/cli/scripts/join-roundtrip.ts
Normal file
115
apps/cli/scripts/join-roundtrip.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Full join → connect → send round-trip.
|
||||||
|
*
|
||||||
|
* Uses a mesh already seeded in the DB (reads /tmp/cli-seed.json).
|
||||||
|
* Creates a fresh invite link, runs the join command, connects with
|
||||||
|
* the newly-generated member identity, sends a message to peer B,
|
||||||
|
* asserts receipt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Run this script with CLAUDEMESH_CONFIG_DIR=/tmp/... set in env —
|
||||||
|
// ESM imports hoist above statements, so we can't set process.env
|
||||||
|
// after the `import { env }` side effect has already run.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { BrokerClient } from "../src/ws/client";
|
||||||
|
import type { JoinedMesh } from "../src/state/config";
|
||||||
|
import { loadConfig, getConfigPath } from "../src/state/config";
|
||||||
|
|
||||||
|
if (!process.env.CLAUDEMESH_CONFIG_DIR) {
|
||||||
|
console.error(
|
||||||
|
"Run with: CLAUDEMESH_CONFIG_DIR=/tmp/claudemesh-join-test-rt bun scripts/join-roundtrip.ts",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
|
||||||
|
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
||||||
|
meshId: string;
|
||||||
|
peerB: { memberId: string; pubkey: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// 1. Build invite.
|
||||||
|
const link = execSync("bun scripts/make-invite.ts").toString().trim();
|
||||||
|
console.log("[rt] invite:", link.slice(0, 60) + "…");
|
||||||
|
|
||||||
|
// 2. Run `claudemesh join` with the same CONFIG_DIR.
|
||||||
|
const joinOut = execSync(`bun src/index.ts join "${link}"`, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-join-test-rt",
|
||||||
|
},
|
||||||
|
}).toString();
|
||||||
|
console.log("[rt] join output (tail):");
|
||||||
|
console.log(
|
||||||
|
joinOut
|
||||||
|
.split("\n")
|
||||||
|
.slice(-7)
|
||||||
|
.map((l) => " " + l)
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Load the fresh config and connect as the new peer.
|
||||||
|
console.log(`[rt] loading config from: ${getConfigPath()}`);
|
||||||
|
const config = loadConfig();
|
||||||
|
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
|
||||||
|
const joined = config.meshes.find((m) => m.slug === "rt-join");
|
||||||
|
if (!joined) throw new Error("rt-join mesh not found in config");
|
||||||
|
const joinedMesh: JoinedMesh = joined;
|
||||||
|
console.log(
|
||||||
|
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}…`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Connect also as peer-B (the target) so we can observe receipt.
|
||||||
|
const targetMesh: JoinedMesh = {
|
||||||
|
...joinedMesh,
|
||||||
|
memberId: seed.peerB.memberId,
|
||||||
|
slug: "rt-join-b",
|
||||||
|
pubkey: seed.peerB.pubkey,
|
||||||
|
};
|
||||||
|
const joiner = new BrokerClient(joinedMesh);
|
||||||
|
const target = new BrokerClient(targetMesh);
|
||||||
|
|
||||||
|
let received = "";
|
||||||
|
target.onPush((m) => {
|
||||||
|
received = Buffer.from(m.ciphertext, "base64").toString("utf-8");
|
||||||
|
console.log(`[rt] target got: "${received}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([joiner.connect(), target.connect()]);
|
||||||
|
console.log(`[rt] joiner=${joiner.status} target=${target.status}`);
|
||||||
|
|
||||||
|
const res = await joiner.send(
|
||||||
|
seed.peerB.pubkey,
|
||||||
|
"sent-by-newly-joined-peer",
|
||||||
|
"now",
|
||||||
|
);
|
||||||
|
console.log("[rt] send result:", res);
|
||||||
|
|
||||||
|
for (let i = 0; i < 30 && !received; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
joiner.close();
|
||||||
|
target.close();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("✗ FAIL: send did not ack");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (received !== "sent-by-newly-joined-peer") {
|
||||||
|
console.error(`✗ FAIL: receive mismatch: "${received}"`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("✓ join → connect → send → receive FLOW PASSED");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
24
apps/cli/scripts/make-invite.ts
Normal file
24
apps/cli/scripts/make-invite.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Build a test invite link from a seeded mesh (reads /tmp/cli-seed.json).
|
||||||
|
* Writes the link to stdout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { encodeInviteLink } from "../src/invite/parse";
|
||||||
|
|
||||||
|
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
||||||
|
meshId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const link = encodeInviteLink({
|
||||||
|
v: 1,
|
||||||
|
mesh_id: seed.meshId,
|
||||||
|
mesh_slug: "rt-join",
|
||||||
|
broker_url: process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws",
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
mesh_root_key: "Y2xhdWRlbWVzaC10ZXN0LW1lc2gta2V5LWRldm9ubHk",
|
||||||
|
role: "member",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(link);
|
||||||
@@ -1,30 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh join <invite-link>` — parse a mesh invite link and
|
* `claudemesh join <invite-link>` — full join flow.
|
||||||
* join the mesh.
|
|
||||||
*
|
*
|
||||||
* STUB: real invite-link parsing + keypair generation + broker
|
* 1. Parse + validate the ic://join/... link
|
||||||
* enrollment lands in Step 17. For now this just validates the link
|
* 2. Generate a fresh ed25519 keypair (libsodium)
|
||||||
* shape and tells the user what's coming.
|
* 3. POST /join to the broker → get member_id
|
||||||
|
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600)
|
||||||
|
* 5. Print success
|
||||||
|
*
|
||||||
|
* Signature verification + invite-token one-time-use land in Step 18.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function runJoin(args: string[]): void {
|
import { parseInviteLink } from "../invite/parse";
|
||||||
|
import { enrollWithBroker } from "../invite/enroll";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
|
||||||
|
export async function runJoin(args: string[]): Promise<void> {
|
||||||
const link = args[0];
|
const link = args[0];
|
||||||
if (!link) {
|
if (!link) {
|
||||||
console.error("Usage: claudemesh join <invite-link>");
|
console.error("Usage: claudemesh join <invite-link>");
|
||||||
console.error("");
|
console.error("");
|
||||||
console.error("Example: claudemesh join ic://join/BASE64URL...");
|
console.error("Example: claudemesh join ic://join/eyJ2IjoxLC4uLn0");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!link.startsWith("ic://join/")) {
|
|
||||||
|
// 1. Parse.
|
||||||
|
let invite;
|
||||||
|
try {
|
||||||
|
invite = parseInviteLink(link);
|
||||||
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`claudemesh: invalid invite link. Expected ic://join/... got "${link}"`,
|
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log("claudemesh: join not yet implemented (Step 17).");
|
const { payload } = invite;
|
||||||
console.log(` Invite link parsed: ${link.slice(0, 40)}...`);
|
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
|
||||||
console.log(
|
|
||||||
" Real flow will: verify sig, generate keypair, enroll member, persist to ~/.claudemesh/config.json",
|
// 2. Generate keypair.
|
||||||
|
const keypair = await generateKeypair();
|
||||||
|
|
||||||
|
// 3. Enroll with broker.
|
||||||
|
const displayName = `${hostname()}-${process.pid}`;
|
||||||
|
let enroll;
|
||||||
|
try {
|
||||||
|
enroll = await enrollWithBroker({
|
||||||
|
brokerWsUrl: payload.broker_url,
|
||||||
|
meshId: payload.mesh_id,
|
||||||
|
peerPubkey: keypair.publicKey,
|
||||||
|
displayName,
|
||||||
|
role: payload.role,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Persist.
|
||||||
|
const config = loadConfig();
|
||||||
|
config.meshes = config.meshes.filter(
|
||||||
|
(m) => m.slug !== payload.mesh_slug,
|
||||||
);
|
);
|
||||||
process.exit(0);
|
config.meshes.push({
|
||||||
|
meshId: payload.mesh_id,
|
||||||
|
memberId: enroll.memberId,
|
||||||
|
slug: payload.mesh_slug,
|
||||||
|
name: payload.mesh_slug,
|
||||||
|
pubkey: keypair.publicKey,
|
||||||
|
secretKey: keypair.secretKey,
|
||||||
|
brokerUrl: payload.broker_url,
|
||||||
|
joinedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
saveConfig(config);
|
||||||
|
|
||||||
|
// 5. Report.
|
||||||
|
console.log("");
|
||||||
|
console.log(
|
||||||
|
`✓ Joined "${payload.mesh_slug}" as ${displayName}${enroll.alreadyMember ? " (already a member — re-enrolled with same pubkey)" : ""}`,
|
||||||
|
);
|
||||||
|
console.log(` member id: ${enroll.memberId}`);
|
||||||
|
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
||||||
|
console.log(` broker: ${payload.broker_url}`);
|
||||||
|
console.log(` config: ${getConfigPath()}`);
|
||||||
|
console.log("");
|
||||||
|
console.log("Restart Claude Code to pick up the new mesh.");
|
||||||
}
|
}
|
||||||
|
|||||||
36
apps/cli/src/crypto/keypair.ts
Normal file
36
apps/cli/src/crypto/keypair.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Ed25519 keypair generation using libsodium.
|
||||||
|
*
|
||||||
|
* We use libsodium-wrappers even in Step 17 (pre-crypto) so the key
|
||||||
|
* format matches what Step 18's signing/encryption code will expect —
|
||||||
|
* no migration needed later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
|
export async function ensureSodium(): Promise<typeof sodium> {
|
||||||
|
if (!ready) {
|
||||||
|
await sodium.ready;
|
||||||
|
ready = true;
|
||||||
|
}
|
||||||
|
return sodium;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ed25519Keypair {
|
||||||
|
/** 32-byte public key, hex-encoded. */
|
||||||
|
publicKey: string;
|
||||||
|
/** 64-byte secret key (seed || publicKey), hex-encoded. */
|
||||||
|
secretKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a fresh ed25519 keypair. */
|
||||||
|
export async function generateKeypair(): Promise<Ed25519Keypair> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const kp = s.crypto_sign_keypair();
|
||||||
|
return {
|
||||||
|
publicKey: s.to_hex(kp.publicKey),
|
||||||
|
secretKey: s.to_hex(kp.privateKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ async function main(): Promise<void> {
|
|||||||
runInstall();
|
runInstall();
|
||||||
return;
|
return;
|
||||||
case "join":
|
case "join":
|
||||||
runJoin(args);
|
await runJoin(args);
|
||||||
return;
|
return;
|
||||||
case "list":
|
case "list":
|
||||||
runList();
|
runList();
|
||||||
|
|||||||
56
apps/cli/src/invite/enroll.ts
Normal file
56
apps/cli/src/invite/enroll.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Broker /join HTTP enrollment.
|
||||||
|
*
|
||||||
|
* Takes a parsed invite + freshly generated keypair, POSTs to the
|
||||||
|
* broker, returns the member_id. Converts the broker's WSS URL to
|
||||||
|
* HTTPS for the /join call (same host, different protocol).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EnrollResult {
|
||||||
|
memberId: string;
|
||||||
|
alreadyMember: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsToHttp(wsUrl: string): string {
|
||||||
|
// wss://host/ws → https://host
|
||||||
|
// ws://host:port/ws → http://host:port
|
||||||
|
const u = new URL(wsUrl);
|
||||||
|
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
|
||||||
|
return `${httpScheme}//${u.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrollWithBroker(args: {
|
||||||
|
brokerWsUrl: string;
|
||||||
|
meshId: string;
|
||||||
|
peerPubkey: string;
|
||||||
|
displayName: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
}): Promise<EnrollResult> {
|
||||||
|
const base = wsToHttp(args.brokerWsUrl);
|
||||||
|
const res = await fetch(`${base}/join`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mesh_id: args.meshId,
|
||||||
|
peer_pubkey: args.peerPubkey,
|
||||||
|
display_name: args.displayName,
|
||||||
|
role: args.role,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
ok?: boolean;
|
||||||
|
memberId?: string;
|
||||||
|
error?: string;
|
||||||
|
alreadyMember?: boolean;
|
||||||
|
};
|
||||||
|
if (!res.ok || !body.ok || !body.memberId) {
|
||||||
|
throw new Error(
|
||||||
|
`broker /join failed (${res.status}): ${body.error ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
memberId: body.memberId,
|
||||||
|
alreadyMember: body.alreadyMember ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
81
apps/cli/src/invite/parse.ts
Normal file
81
apps/cli/src/invite/parse.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Invite-link parser for claudemesh `ic://join/<base64url(JSON)>` links.
|
||||||
|
*
|
||||||
|
* v0.1.0: parses + shape-validates + checks expiry. Signature
|
||||||
|
* verification and one-time-use invite-token tracking land in Step 18.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const invitePayloadSchema = z.object({
|
||||||
|
v: z.literal(1),
|
||||||
|
mesh_id: z.string().min(1),
|
||||||
|
mesh_slug: z.string().min(1),
|
||||||
|
broker_url: z.string().min(1),
|
||||||
|
expires_at: z.number().int().positive(),
|
||||||
|
mesh_root_key: z.string().min(1),
|
||||||
|
role: z.enum(["admin", "member"]),
|
||||||
|
signature: z.string().optional(), // ed25519 b64, validated in Step 18
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
|
||||||
|
|
||||||
|
export interface ParsedInvite {
|
||||||
|
payload: InvitePayload;
|
||||||
|
raw: string; // the original ic://join/... string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInviteLink(link: string): ParsedInvite {
|
||||||
|
if (!link.startsWith("ic://join/")) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid invite link: expected prefix "ic://join/", got "${link.slice(0, 20)}…"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const encoded = link.slice("ic://join/".length);
|
||||||
|
if (!encoded) throw new Error("invite link has no payload");
|
||||||
|
|
||||||
|
let json: string;
|
||||||
|
try {
|
||||||
|
json = Buffer.from(encoded, "base64url").toString("utf-8");
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`invite link base64 decode failed: ${e instanceof Error ? e.message : e}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj: unknown;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(json);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`invite link JSON parse failed: ${e instanceof Error ? e.message : e}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = invitePayloadSchema.safeParse(obj);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(
|
||||||
|
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry check (unix seconds).
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
if (parsed.data.expires_at < nowSeconds) {
|
||||||
|
throw new Error(
|
||||||
|
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload: parsed.data, raw: link };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a payload back to an `ic://join/...` link. Used for testing
|
||||||
|
* + for building links server-side once we add that flow.
|
||||||
|
*/
|
||||||
|
export function encodeInviteLink(payload: InvitePayload): string {
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const encoded = Buffer.from(json, "utf-8").toString("base64url");
|
||||||
|
return `ic://join/${encoded}`;
|
||||||
|
}
|
||||||
@@ -6,7 +6,13 @@
|
|||||||
* and on every join/leave.
|
* and on every join/leave.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
import {
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
chmodSync,
|
||||||
|
} from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -51,6 +57,12 @@ export function loadConfig(): Config {
|
|||||||
export function saveConfig(config: Config): void {
|
export function saveConfig(config: Config): void {
|
||||||
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||||
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||||
|
// Config holds ed25519 secret keys — restrict to owner read/write.
|
||||||
|
try {
|
||||||
|
chmodSync(CONFIG_PATH, 0o600);
|
||||||
|
} catch {
|
||||||
|
// Windows filesystems ignore chmod; that's fine.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigPath(): string {
|
export function getConfigPath(): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user