@clanker-chain/clanker-cli 2026.9.12 → 2026.9.13

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.
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Privy device-authorization client (no app secret).
3
+ * https://docs.privy.io/recipes/agent-integrations/agent-authorization
4
+ */
5
+
6
+ import { getAddress } from "viem";
7
+ import { PRIVY_AUTH_BASE, resolvePrivyAppId } from "./privy-constants.mjs";
8
+ import {
9
+ generateAuthorizationSignature,
10
+ setupHpkeRecipient,
11
+ } from "./privy-hpke.mjs";
12
+ import {
13
+ clearPrivySession,
14
+ loadPrivySession,
15
+ savePrivySession,
16
+ } from "./privy-session.mjs";
17
+
18
+ /**
19
+ * @param {string} path
20
+ * @param {{
21
+ * appId: string,
22
+ * method?: string,
23
+ * body?: object|null,
24
+ * accessToken?: string|null,
25
+ * grantType?: string|null,
26
+ * authorizationSignature?: string|null,
27
+ * fetchImpl?: typeof fetch,
28
+ * }} opts
29
+ */
30
+ export async function privyFetch(path, opts) {
31
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
32
+ if (!fetchImpl) throw new Error("fetch is not available");
33
+ const method = opts.method ?? "POST";
34
+ const headers = {
35
+ "Content-Type": "application/json",
36
+ "privy-app-id": opts.appId,
37
+ };
38
+ if (opts.accessToken) {
39
+ headers.Authorization = `Bearer ${opts.accessToken}`;
40
+ }
41
+ if (opts.grantType) {
42
+ headers["privy-grant-type"] = opts.grantType;
43
+ }
44
+ if (opts.authorizationSignature) {
45
+ headers["privy-authorization-signature"] = opts.authorizationSignature;
46
+ }
47
+ const res = await fetchImpl(`${PRIVY_AUTH_BASE}${path}`, {
48
+ method,
49
+ headers,
50
+ body: opts.body == null ? undefined : JSON.stringify(opts.body),
51
+ });
52
+ const text = await res.text();
53
+ let json = null;
54
+ try {
55
+ json = text ? JSON.parse(text) : null;
56
+ } catch {
57
+ json = { raw: text };
58
+ }
59
+ return { ok: res.ok, status: res.status, json, text };
60
+ }
61
+
62
+ /**
63
+ * Start device authorization.
64
+ * @param {{ appId?: string, fetchImpl?: typeof fetch }} [opts]
65
+ */
66
+ export async function requestDeviceAuthorization(opts = {}) {
67
+ const appId = opts.appId ?? resolvePrivyAppId();
68
+ const { ok, status, json, text } = await privyFetch(
69
+ "/api/oauth/v2/device_authorization",
70
+ { appId, body: {}, fetchImpl: opts.fetchImpl },
71
+ );
72
+ if (!ok) {
73
+ if (status === 403) {
74
+ throw new Error(
75
+ "Privy device auth is not enabled. In the Privy dashboard: Authentication → Advanced → Enable CLI and agent access, Verification URI = https://clanker-chain.com/authorize",
76
+ );
77
+ }
78
+ throw new Error(
79
+ `device_authorization failed (${status}): ${text?.slice(0, 300) || JSON.stringify(json)}`,
80
+ );
81
+ }
82
+ return {
83
+ appId,
84
+ deviceCode: json.device_code,
85
+ userCode: json.user_code,
86
+ verificationUri: json.verification_uri,
87
+ verificationUriComplete: json.verification_uri_complete,
88
+ expiresIn: Number(json.expires_in) || 600,
89
+ interval: Number(json.interval) || 5,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Poll until user approves (or deny / expire).
95
+ * @param {{
96
+ * appId: string,
97
+ * deviceCode: string,
98
+ * interval?: number,
99
+ * expiresIn?: number,
100
+ * fetchImpl?: typeof fetch,
101
+ * sleep?: (ms: number) => Promise<void>,
102
+ * now?: () => number,
103
+ * onPending?: () => void,
104
+ * }} opts
105
+ */
106
+ export async function pollDeviceToken(opts) {
107
+ const fetchImpl = opts.fetchImpl;
108
+ const sleep =
109
+ opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
110
+ const now = opts.now ?? (() => Date.now());
111
+ let intervalMs = Math.max(1, (opts.interval ?? 5) * 1000);
112
+ const deadline = now() + (opts.expiresIn ?? 600) * 1000;
113
+
114
+ while (now() < deadline) {
115
+ await sleep(intervalMs);
116
+ const { ok, status, json, text } = await privyFetch("/api/oauth/v2/token", {
117
+ appId: opts.appId,
118
+ body: {
119
+ grant_type: "device_code",
120
+ device_code: opts.deviceCode,
121
+ },
122
+ fetchImpl,
123
+ });
124
+ if (ok && json?.access_token) {
125
+ return {
126
+ accessToken: json.access_token,
127
+ refreshToken: json.refresh_token,
128
+ expiresIn: Number(json.expires_in) || 900,
129
+ };
130
+ }
131
+ const err =
132
+ json?.error ||
133
+ json?.error_code ||
134
+ (typeof json === "object" && json?.error?.code) ||
135
+ "";
136
+ const errStr = String(err);
137
+ if (errStr === "authorization_pending" || status === 400) {
138
+ if (errStr === "slow_down") {
139
+ intervalMs += 5000;
140
+ } else if (errStr === "expired_token") {
141
+ throw new Error("Login timed out — run clanker login again");
142
+ } else if (errStr === "access_denied") {
143
+ throw new Error("Login denied in the browser");
144
+ } else if (errStr && errStr !== "authorization_pending") {
145
+ // Some responses nest error differently
146
+ if (/expired/i.test(text || "")) {
147
+ throw new Error("Login timed out — run clanker login again");
148
+ }
149
+ if (/access_denied|denied/i.test(text || "")) {
150
+ throw new Error("Login denied in the browser");
151
+ }
152
+ }
153
+ opts.onPending?.();
154
+ continue;
155
+ }
156
+ throw new Error(
157
+ `token poll failed (${status}): ${text?.slice(0, 300) || JSON.stringify(json)}`,
158
+ );
159
+ }
160
+ throw new Error("Login timed out — run clanker login again");
161
+ }
162
+
163
+ /**
164
+ * Refresh access token; persists rotated refresh token.
165
+ * @param {{
166
+ * home?: string,
167
+ * session?: object,
168
+ * appId?: string,
169
+ * fetchImpl?: typeof fetch,
170
+ * }} [opts]
171
+ */
172
+ export async function refreshPrivySession(opts = {}) {
173
+ const home = opts.home;
174
+ const session = opts.session ?? loadPrivySession(home);
175
+ if (!session?.refreshToken) {
176
+ throw new Error("Not logged in — run clanker login");
177
+ }
178
+ const appId = opts.appId ?? session.appId ?? resolvePrivyAppId();
179
+ const { ok, status, json, text } = await privyFetch("/api/oauth/v2/token", {
180
+ appId,
181
+ body: {
182
+ grant_type: "refresh_token",
183
+ refresh_token: session.refreshToken,
184
+ },
185
+ fetchImpl: opts.fetchImpl,
186
+ });
187
+ if (!ok || !json?.access_token) {
188
+ clearPrivySession(home);
189
+ throw new Error(
190
+ `Session expired — run clanker login again (${status}): ${text?.slice(0, 200) || ""}`,
191
+ );
192
+ }
193
+ const next = {
194
+ ...session,
195
+ appId,
196
+ accessToken: json.access_token,
197
+ refreshToken: json.refresh_token || session.refreshToken,
198
+ expiresAt: Date.now() + (Number(json.expires_in) || 900) * 1000,
199
+ };
200
+ savePrivySession(next, home);
201
+ return next;
202
+ }
203
+
204
+ /**
205
+ * Exchange access token for ephemeral authorization key + wallet list.
206
+ * Auth key stays in memory only.
207
+ * @param {{
208
+ * appId: string,
209
+ * accessToken: string,
210
+ * fetchImpl?: typeof fetch,
211
+ * }} opts
212
+ */
213
+ export async function authenticateWallets(opts) {
214
+ const hpke = await setupHpkeRecipient();
215
+ const { ok, status, json, text } = await privyFetch(
216
+ "/api/oauth/v2/wallets/authenticate",
217
+ {
218
+ appId: opts.appId,
219
+ accessToken: opts.accessToken,
220
+ grantType: "device_code",
221
+ body: {
222
+ encryption_type: "HPKE",
223
+ recipient_public_key: hpke.publicKeySpkiBase64,
224
+ },
225
+ fetchImpl: opts.fetchImpl,
226
+ },
227
+ );
228
+ if (!ok) {
229
+ throw new Error(
230
+ `wallets/authenticate failed (${status}): ${text?.slice(0, 300) || JSON.stringify(json)}`,
231
+ );
232
+ }
233
+ const enc = json.encrypted_authorization_key;
234
+ if (!enc?.encapsulated_key || !enc?.ciphertext) {
235
+ throw new Error("wallets/authenticate missing encrypted_authorization_key");
236
+ }
237
+ const decrypted = await hpke.decryptPayload(
238
+ Buffer.from(enc.encapsulated_key, "base64"),
239
+ Buffer.from(enc.ciphertext, "base64"),
240
+ );
241
+ const authorizationPrivateKey = new TextDecoder().decode(decrypted);
242
+ const wallets = Array.isArray(json.wallets) ? json.wallets : [];
243
+ const eth =
244
+ wallets.find((w) => w.chain_type === "ethereum" || w.chainType === "ethereum") ||
245
+ wallets[0];
246
+ if (!eth?.id || !eth?.address) {
247
+ throw new Error(
248
+ "No Ethereum wallet on this login — open /join once to create the embedded EOA, then clanker login again",
249
+ );
250
+ }
251
+ return {
252
+ authorizationPrivateKey,
253
+ expiresAt: json.expires_at
254
+ ? Date.parse(json.expires_at)
255
+ : Date.now() + 14 * 60 * 1000,
256
+ walletId: String(eth.id),
257
+ address: getAddress(eth.address),
258
+ wallets,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Ensure a valid session with access token (refresh if near expiry).
264
+ * @param {{ home?: string, fetchImpl?: typeof fetch, skewMs?: number }} [opts]
265
+ */
266
+ export async function ensurePrivyAccess(opts = {}) {
267
+ const skewMs = opts.skewMs ?? 60_000;
268
+ let session = loadPrivySession(opts.home);
269
+ if (!session) {
270
+ throw new Error("Not logged in — run clanker login");
271
+ }
272
+ if (!session.expiresAt || session.expiresAt < Date.now() + skewMs) {
273
+ session = await refreshPrivySession({
274
+ home: opts.home,
275
+ session,
276
+ fetchImpl: opts.fetchImpl,
277
+ });
278
+ }
279
+ return session;
280
+ }
281
+
282
+ /**
283
+ * Call Privy wallet RPC (personal_sign / eth_sendTransaction) with device grant.
284
+ * @param {{
285
+ * method: string,
286
+ * params: object,
287
+ * home?: string,
288
+ * session?: object,
289
+ * fetchImpl?: typeof fetch,
290
+ * authCache?: { key: string, expiresAt: number, walletId: string, address: string }|null,
291
+ * }} opts
292
+ */
293
+ export async function privyWalletRpc(opts) {
294
+ let session = opts.session ?? (await ensurePrivyAccess({ home: opts.home, fetchImpl: opts.fetchImpl }));
295
+ const appId = session.appId ?? resolvePrivyAppId();
296
+
297
+ const runOnce = async (sess) => {
298
+ const auth = await authenticateWallets({
299
+ appId,
300
+ accessToken: sess.accessToken,
301
+ fetchImpl: opts.fetchImpl,
302
+ });
303
+ if (
304
+ sess.walletId &&
305
+ auth.walletId !== sess.walletId
306
+ ) {
307
+ throw new Error(
308
+ `Privy wallet changed (${auth.walletId} ≠ ${sess.walletId}) — run clanker login again`,
309
+ );
310
+ }
311
+ if (
312
+ sess.address &&
313
+ getAddress(auth.address) !== getAddress(sess.address)
314
+ ) {
315
+ throw new Error(
316
+ `Privy address ${auth.address} does not match session ${sess.address}`,
317
+ );
318
+ }
319
+ const walletId = sess.walletId || auth.walletId;
320
+ const url = `${PRIVY_AUTH_BASE}/api/oauth/v2/wallets/${walletId}/rpc`;
321
+ const body = {
322
+ method: opts.method,
323
+ params: opts.params,
324
+ };
325
+ const authorizationSignature = generateAuthorizationSignature({
326
+ authorizationPrivateKey: auth.authorizationPrivateKey,
327
+ method: "POST",
328
+ url,
329
+ body,
330
+ appId,
331
+ });
332
+ const { ok, status, json, text } = await privyFetch(
333
+ `/api/oauth/v2/wallets/${walletId}/rpc`,
334
+ {
335
+ appId,
336
+ accessToken: sess.accessToken,
337
+ grantType: "device_code",
338
+ authorizationSignature,
339
+ body,
340
+ fetchImpl: opts.fetchImpl,
341
+ },
342
+ );
343
+ return { ok, status, json, text, auth };
344
+ };
345
+
346
+ let result = await runOnce(session);
347
+ if (result.status === 401) {
348
+ session = await refreshPrivySession({
349
+ home: opts.home,
350
+ session,
351
+ fetchImpl: opts.fetchImpl,
352
+ });
353
+ result = await runOnce(session);
354
+ }
355
+ if (!result.ok) {
356
+ throw new Error(
357
+ `Privy wallet RPC ${opts.method} failed (${result.status}): ${result.text?.slice(0, 400) || JSON.stringify(result.json)}`,
358
+ );
359
+ }
360
+ return {
361
+ session,
362
+ address: result.auth.address,
363
+ walletId: result.auth.walletId,
364
+ data: result.json?.data ?? result.json,
365
+ json: result.json,
366
+ };
367
+ }
368
+
369
+ /**
370
+ * personal_sign via Privy.
371
+ * @param {string} message
372
+ * @param {{ home?: string, fetchImpl?: typeof fetch }} [opts]
373
+ * @returns {Promise<`0x${string}`>}
374
+ */
375
+ export async function privySignMessage(message, opts = {}) {
376
+ const out = await privyWalletRpc({
377
+ method: "personal_sign",
378
+ params: { message, encoding: "utf-8" },
379
+ home: opts.home,
380
+ fetchImpl: opts.fetchImpl,
381
+ });
382
+ const sig =
383
+ out.data?.signature ||
384
+ out.data?.data?.signature ||
385
+ out.json?.signature ||
386
+ out.json?.data?.signature;
387
+ if (!sig || typeof sig !== "string") {
388
+ throw new Error(
389
+ `Privy personal_sign returned no signature: ${JSON.stringify(out.json).slice(0, 400)}`,
390
+ );
391
+ }
392
+ return /** @type {`0x${string}`} */ (sig.startsWith("0x") ? sig : `0x${sig}`);
393
+ }
394
+
395
+ /**
396
+ * eth_sendTransaction via Privy (Privy broadcasts).
397
+ * @param {object} transaction — viem-ish { to, data, value, chain_id }
398
+ * @param {{ home?: string, fetchImpl?: typeof fetch }} [opts]
399
+ * @returns {Promise<`0x${string}`>}
400
+ */
401
+ export async function privySendTransaction(transaction, opts = {}) {
402
+ const out = await privyWalletRpc({
403
+ method: "eth_sendTransaction",
404
+ params: { transaction },
405
+ home: opts.home,
406
+ fetchImpl: opts.fetchImpl,
407
+ });
408
+ const hash =
409
+ out.data?.hash ||
410
+ out.data?.data?.hash ||
411
+ out.json?.hash ||
412
+ out.json?.data?.hash;
413
+ if (!hash || typeof hash !== "string") {
414
+ throw new Error(
415
+ `Privy eth_sendTransaction returned no hash: ${JSON.stringify(out.json).slice(0, 400)}`,
416
+ );
417
+ }
418
+ return /** @type {`0x${string}`} */ (hash.startsWith("0x") ? hash : `0x${hash}`);
419
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Public Privy app id for device-authorization (no app secret).
3
+ * Override with PRIVY_APP_ID. Same production app as /join + /authorize.
4
+ */
5
+
6
+ /** Production clanker-chain.com Privy app (public). */
7
+ export const DEFAULT_PRIVY_APP_ID = "cmtyejsb100ok0ckz9ldfvakw";
8
+
9
+ export const PRIVY_AUTH_BASE = "https://auth.privy.io";
10
+
11
+ /**
12
+ * @param {NodeJS.ProcessEnv} [env]
13
+ */
14
+ export function resolvePrivyAppId(env = process.env) {
15
+ const id = (env.PRIVY_APP_ID || DEFAULT_PRIVY_APP_ID || "").trim();
16
+ if (!id) {
17
+ throw new Error("PRIVY_APP_ID is empty");
18
+ }
19
+ return id;
20
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * HPKE recipient helpers for Privy device-grant wallet authenticate.
3
+ * Ported from @privy-io/node cryptography (DhkemP256 + ChaCha20Poly1305).
4
+ */
5
+
6
+ import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
7
+ import { CipherSuite, DhkemP256HkdfSha256, HkdfSha256 } from "@hpke/core";
8
+ import { p256 } from "@noble/curves/nist.js";
9
+ import { sha256 } from "@noble/hashes/sha2.js";
10
+ import canonicalize from "canonicalize";
11
+
12
+ /**
13
+ * @returns {Promise<{
14
+ * publicKeySpkiBase64: string,
15
+ * decryptPayload: (encapsulatedKey: Uint8Array, ciphertext: Uint8Array) => Promise<Uint8Array>,
16
+ * }>}
17
+ */
18
+ export async function setupHpkeRecipient() {
19
+ const suite = new CipherSuite({
20
+ kem: new DhkemP256HkdfSha256(),
21
+ kdf: new HkdfSha256(),
22
+ aead: new Chacha20Poly1305(),
23
+ });
24
+
25
+ const keypair = await suite.kem.generateKeyPair();
26
+ const subtle = globalThis.crypto?.subtle;
27
+ if (!subtle) {
28
+ throw new Error("crypto.subtle required for Privy HPKE (Node 20+)");
29
+ }
30
+ const publicKeySpki = new Uint8Array(
31
+ await subtle.exportKey("spki", keypair.publicKey),
32
+ );
33
+
34
+ return {
35
+ publicKeySpkiBase64: Buffer.from(publicKeySpki).toString("base64"),
36
+ decryptPayload: async (encapsulatedKey, ciphertext) => {
37
+ const recipient = await suite.createRecipientContext({
38
+ recipientKey: keypair.privateKey,
39
+ enc: encapsulatedKey,
40
+ });
41
+ return new Uint8Array(await recipient.open(ciphertext));
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Import Privy authorization private key (base64 PKCS8, optional wallet-auth: prefix).
48
+ * @param {string} privateKey
49
+ * @returns {Uint8Array} raw 32-byte scalar for noble P-256
50
+ */
51
+ export function importPkcs8PrivateKey(privateKey) {
52
+ const stripped = String(privateKey)
53
+ .replace(/^wallet-auth:/, "")
54
+ .replace(/^wallet-api:/, "")
55
+ .trim();
56
+ const pkcs8Bytes = Buffer.from(stripped, "base64");
57
+ const marker = Buffer.from([0x04, 0x20]);
58
+ const start = pkcs8Bytes.indexOf(marker);
59
+ if (start === -1) {
60
+ throw new Error("Invalid Privy authorization private key");
61
+ }
62
+ return pkcs8Bytes.subarray(start + 2, start + 34);
63
+ }
64
+
65
+ /**
66
+ * Build + sign Privy authorization signature (P-256 ECDSA over RFC 8785 canonical JSON).
67
+ * @param {{
68
+ * authorizationPrivateKey: string,
69
+ * method: 'POST'|'PUT'|'PATCH'|'DELETE',
70
+ * url: string,
71
+ * body: object,
72
+ * appId: string,
73
+ * }} opts
74
+ * @returns {string} base64 DER signature
75
+ */
76
+ export function generateAuthorizationSignature(opts) {
77
+ const payload = {
78
+ version: 1,
79
+ method: opts.method,
80
+ url: opts.url,
81
+ body: opts.body,
82
+ headers: {
83
+ "privy-app-id": opts.appId,
84
+ },
85
+ };
86
+ if (
87
+ typeof payload.body === "object" &&
88
+ payload.body !== null &&
89
+ Object.keys(payload.body).length === 0
90
+ ) {
91
+ payload.body = "";
92
+ }
93
+ const serialized = canonicalize(payload);
94
+ if (!serialized) {
95
+ throw new Error("Failed to canonicalize Privy authorization payload");
96
+ }
97
+ const bytes = new TextEncoder().encode(serialized);
98
+ const sk = importPkcs8PrivateKey(opts.authorizationPrivateKey);
99
+ const signature = p256.sign(sha256(bytes), sk).toBytes("der");
100
+ return Buffer.from(signature).toString("base64");
101
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Persist Privy OAuth tokens (never operator hex, never ephemeral auth keys).
3
+ * Prefer macOS Keychain; fall back to mode-0600 ~/.clanker/privy-session.json.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ import { clankerHome } from "./profile.mjs";
10
+
11
+ const KEYCHAIN_SERVICE = "clanker-privy-session";
12
+ const KEYCHAIN_ACCOUNT = "default";
13
+
14
+ /**
15
+ * @param {string} [home]
16
+ */
17
+ export function privySessionPath(home = clankerHome()) {
18
+ return join(home, "privy-session.json");
19
+ }
20
+
21
+ /**
22
+ * @typedef {{
23
+ * appId: string,
24
+ * accessToken: string,
25
+ * refreshToken: string,
26
+ * expiresAt: number,
27
+ * walletId: string,
28
+ * address: string,
29
+ * createdAt: number,
30
+ * }} PrivySession
31
+ */
32
+
33
+ /**
34
+ * @param {string} [home]
35
+ * @returns {PrivySession|null}
36
+ */
37
+ export function loadPrivySession(home = clankerHome()) {
38
+ const fromKeychain = loadFromKeychain();
39
+ if (fromKeychain) return fromKeychain;
40
+ const path = privySessionPath(home);
41
+ if (!existsSync(path)) return null;
42
+ try {
43
+ const raw = JSON.parse(readFileSync(path, "utf8"));
44
+ if (!raw?.accessToken || !raw?.refreshToken || !raw?.walletId || !raw?.address) {
45
+ return null;
46
+ }
47
+ return raw;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {PrivySession} session
55
+ * @param {string} [home]
56
+ */
57
+ export function savePrivySession(session, home = clankerHome()) {
58
+ const payload = JSON.stringify(session);
59
+ if (saveToKeychain(payload)) {
60
+ // Prefer keychain; remove file copy if present.
61
+ const path = privySessionPath(home);
62
+ if (existsSync(path)) {
63
+ try {
64
+ rmSync(path);
65
+ } catch {
66
+ /* ignore */
67
+ }
68
+ }
69
+ return { storage: "keychain" };
70
+ }
71
+ mkdirSync(home, { recursive: true });
72
+ const path = privySessionPath(home);
73
+ writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
74
+ return { storage: "file", path };
75
+ }
76
+
77
+ /**
78
+ * @param {string} [home]
79
+ */
80
+ export function clearPrivySession(home = clankerHome()) {
81
+ clearKeychain();
82
+ const path = privySessionPath(home);
83
+ if (existsSync(path)) {
84
+ rmSync(path);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * @returns {PrivySession|null}
90
+ */
91
+ function loadFromKeychain() {
92
+ if (process.platform !== "darwin") return null;
93
+ const out = spawnSync(
94
+ "security",
95
+ [
96
+ "find-generic-password",
97
+ "-s",
98
+ KEYCHAIN_SERVICE,
99
+ "-a",
100
+ KEYCHAIN_ACCOUNT,
101
+ "-w",
102
+ ],
103
+ { encoding: "utf8" },
104
+ );
105
+ if (out.status !== 0) return null;
106
+ try {
107
+ const raw = JSON.parse(String(out.stdout || "").trim());
108
+ if (!raw?.accessToken || !raw?.refreshToken) return null;
109
+ return raw;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {string} payload
117
+ */
118
+ function saveToKeychain(payload) {
119
+ if (process.platform !== "darwin") return false;
120
+ clearKeychain();
121
+ const out = spawnSync(
122
+ "security",
123
+ [
124
+ "add-generic-password",
125
+ "-s",
126
+ KEYCHAIN_SERVICE,
127
+ "-a",
128
+ KEYCHAIN_ACCOUNT,
129
+ "-w",
130
+ payload,
131
+ "-U",
132
+ ],
133
+ { encoding: "utf8" },
134
+ );
135
+ return out.status === 0;
136
+ }
137
+
138
+ function clearKeychain() {
139
+ if (process.platform !== "darwin") return;
140
+ spawnSync(
141
+ "security",
142
+ [
143
+ "delete-generic-password",
144
+ "-s",
145
+ KEYCHAIN_SERVICE,
146
+ "-a",
147
+ KEYCHAIN_ACCOUNT,
148
+ ],
149
+ { encoding: "utf8", stdio: "ignore" },
150
+ );
151
+ }
package/lib/profile.mjs CHANGED
@@ -127,7 +127,7 @@ export function loadOperator(home = clankerHome()) {
127
127
  /**
128
128
  * Write operator profile (label + owner + optional key pointer). Never stores hex keys.
129
129
  * Omit `key` (or pass null) for a read-only profile; mint/revoke still need a pointer later.
130
- * @param {{ label: string, owner: string, key?: { type: 'env'|'keyFile', value?: string }|null }} op
130
+ * @param {{ label: string, owner: string, key?: { type: 'env'|'keyFile'|'privy', value?: string }|null }} op
131
131
  * @param {string} [home]
132
132
  */
133
133
  export function writeOperator(op, home = clankerHome()) {