@clanker-chain/clanker-cli 2026.9.7-2 → 2026.9.7-4

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/lib/setup.mjs CHANGED
@@ -2,13 +2,14 @@
2
2
  * Interactive / flag-driven `clanker setup`.
3
3
  */
4
4
 
5
- import { createInterface } from "node:readline/promises";
6
- import { stdin as input, stdout as output } from "node:process";
5
+ import { stdin as input } from "node:process";
7
6
  import { existsSync } from "node:fs";
8
7
  import { getAddress } from "viem";
8
+ import * as clack from "@clack/prompts";
9
9
  import {
10
10
  ANVIL_DEFAULT_ADDRESS,
11
11
  PRESETS,
12
+ SEPOLIA_FAST_FROM_BLOCK,
12
13
  clankerHome,
13
14
  configPath,
14
15
  initProfile,
@@ -22,11 +23,12 @@ import {
22
23
  resolvePreferredOperator,
23
24
  } from "./identity-query.mjs";
24
25
  import {
25
- SEPOLIA_FAST_FROM_BLOCK,
26
26
  addressFromEnv,
27
27
  addressFromKeyFile,
28
28
  detectSetupHints,
29
+ formatSetupDetectTable,
29
30
  } from "./setup-detect.mjs";
31
+ import { c, nextHint } from "./ui.mjs";
30
32
 
31
33
  /**
32
34
  * @param {string[]} argv
@@ -71,6 +73,14 @@ export function parseSetupFlags(argv) {
71
73
  };
72
74
  }
73
75
 
76
+ function cancelIf(value) {
77
+ if (clack.isCancel(value)) {
78
+ clack.cancel("Setup aborted.");
79
+ process.exit(0);
80
+ }
81
+ return value;
82
+ }
83
+
74
84
  /**
75
85
  * Validate address + label against registry (and Anvil-on-public).
76
86
  * @param {{ rpc: string, registry: string, label: string, address: string, skipChainCheck?: boolean }} opts
@@ -100,7 +110,6 @@ export async function assertSetupIdentity(opts) {
100
110
  const op = await readOperator(pub, opts.registry, opts.label);
101
111
 
102
112
  if (op.registeredAt === 0n) {
103
- // New operator — fine; mint later
104
113
  return { address, operator: op, skipped: false, unregistered: true };
105
114
  }
106
115
 
@@ -116,14 +125,12 @@ export async function assertSetupIdentity(opts) {
116
125
  }
117
126
 
118
127
  /**
119
- * Build key pointer from flags / choices.
120
128
  * @returns {{ type: 'env'|'keyFile', value: string }|null}
121
129
  */
122
130
  export function buildKeyPointer({ keyFile, keyEnv, skipKey, env = process.env }) {
123
131
  if (skipKey) return null;
124
132
  if (keyFile) {
125
133
  if (!existsSync(keyFile)) throw new Error(`Key file not found: ${keyFile}`);
126
- // validate readable
127
134
  addressFromKeyFile(keyFile);
128
135
  return { type: "keyFile", value: keyFile };
129
136
  }
@@ -175,8 +182,6 @@ export function applySetup(choices, opts = {}) {
175
182
 
176
183
  /**
177
184
  * Non-interactive setup (agents / CI).
178
- * @param {string[]} argv
179
- * @param {{ home?: string, env?: NodeJS.ProcessEnv, skipChainCheck?: boolean }} [opts]
180
185
  */
181
186
  export async function runSetupNonInteractive(argv, opts = {}) {
182
187
  const flags = parseSetupFlags(argv);
@@ -226,7 +231,6 @@ export async function runSetupNonInteractive(argv, opts = {}) {
226
231
  env,
227
232
  });
228
233
 
229
- // Address from key must match --address when both set
230
234
  if (flags.keyFile && flags.address) {
231
235
  const fromKey = getAddress(addressFromKeyFile(flags.keyFile));
232
236
  if (fromKey !== getAddress(flags.address)) {
@@ -251,7 +255,7 @@ export async function runSetupNonInteractive(argv, opts = {}) {
251
255
  }
252
256
 
253
257
  /**
254
- * Interactive wizard when stdin is a TTY.
258
+ * Interactive wizard (Clack) when stdin is a TTY.
255
259
  */
256
260
  export async function runSetupInteractive(argv, opts = {}) {
257
261
  const flags = parseSetupFlags(argv);
@@ -265,273 +269,249 @@ export async function runSetupInteractive(argv, opts = {}) {
265
269
  openclawDir: opts.openclawDir,
266
270
  });
267
271
 
268
- const rl =
269
- opts.rl ??
270
- createInterface({ input, output });
271
- const ask = async (q, def) => {
272
- const suffix = def != null && def !== "" ? ` [${def}]` : "";
273
- const ans = (await rl.question(`${q}${suffix}: `)).trim();
274
- return ans || def || "";
275
- };
276
- const askYesNo = async (q, defaultYes = true) => {
277
- const def = defaultYes ? "Y/n" : "y/N";
278
- const ans = (await rl.question(`${q} (${def}): `)).trim().toLowerCase();
279
- if (!ans) return defaultYes;
280
- return ans === "y" || ans === "yes";
281
- };
282
-
283
- try {
284
- console.log("");
285
- console.log("clanker setup — create your local operator profile");
286
- console.log("");
287
- console.log(
288
- "This writes ~/.clanker/config.json (network) and operator.json (who you are).",
289
- );
290
- console.log(
291
- "Reads like `clanker whoami` can use the owner address without a private key;",
292
- );
293
- console.log("mint/revoke still need a key file or OPERATOR_PRIVATE_KEY later.");
294
- console.log("");
295
- console.log(`Profile directory: ${home}`);
296
- console.log("");
297
-
298
- // Detection summary
299
- console.log("Detected on this machine:");
300
- if (hints.hasConfig) {
301
- const p = hints.config?.preset ?? "?";
302
- const reg = hints.config?.registryAddress ?? "(none)";
303
- console.log(` • Network config already at config.json (preset=${p}, registry=${reg})`);
304
- } else {
305
- console.log(" • No config.json yet — will create one");
306
- }
307
- if (hints.hasOperator) {
308
- console.log(
309
- ` • Operator profile already at operator.json (label=${hints.operator?.label}, owner=${hints.operator?.owner})`,
272
+ clack.intro(c.bold("clanker setup"));
273
+ clack.log.step("Creates ~/.clanker/config.json (network) and operator.json (identity).");
274
+ clack.log.message(
275
+ c.dim("whoami works from owner without a key; mint/revoke need a key pointer later."),
276
+ );
277
+ clack.log.message(c.dim(`Profile: ${home}`));
278
+ console.log("");
279
+ console.log(c.dim(formatSetupDetectTable(hints)));
280
+ console.log("");
281
+
282
+ if ((hints.hasConfig || hints.hasOperator) && !flags.force) {
283
+ if (hints.hasConfig && !hints.hasOperator) {
284
+ const cont = cancelIf(
285
+ await clack.confirm({
286
+ message: "Network config exists; continue to create operator.json?",
287
+ initialValue: true,
288
+ }),
310
289
  );
290
+ if (!cont) {
291
+ clack.cancel("Aborted.");
292
+ process.exit(0);
293
+ }
294
+ flags.force = true;
311
295
  } else {
312
- console.log(" • No operator.json yet — that is what we need for bare `whoami`");
313
- }
314
- if (hints.foundryAvailable && hints.foundryAccounts.length) {
315
- console.log(
316
- ` • Foundry keystore accounts: ${hints.foundryAccounts.join(", ")} (names only; you paste the 0x address)`,
317
- );
318
- } else if (!hints.foundryAvailable) {
319
- console.log(" • Foundry `cast` not on PATH (optional)");
320
- }
321
- if (hints.openclawBots.length) {
322
- console.log(
323
- ` • OpenClaw bot key files: ${hints.openclawBots.join(", ")}`,
324
- );
325
- console.log(
326
- " (These are bot signing keys — not your operator wallet. Useful context only.)",
296
+ const overwrite = cancelIf(
297
+ await clack.confirm({
298
+ message: "Replace existing config.json / operator.json?",
299
+ initialValue: false,
300
+ }),
327
301
  );
328
- }
329
- if (hints.hasOperatorPrivateKeyEnv) {
330
- console.log(
331
- ` • OPERATOR_PRIVATE_KEY is set in this shell → ${hints.envAddress ?? "(invalid key)"}`,
332
- );
333
- }
334
- console.log("");
335
-
336
- if ((hints.hasConfig || hints.hasOperator) && !flags.force) {
337
- if (hints.hasConfig && !hints.hasOperator) {
338
- console.log(
339
- "You already have network settings from `clanker init`, but no operator identity.",
340
- );
341
- console.log(
342
- "Continuing will refresh config.json if needed and create operator.json.",
343
- );
344
- const cont = await askYesNo("Continue setup?", true);
345
- if (!cont) {
346
- throw new Error("Aborted. Re-run `clanker setup` when ready.");
347
- }
348
- flags.force = true;
349
- } else {
350
- console.log(
351
- "A full profile already exists. Continuing will REPLACE config.json and/or operator.json",
352
- );
353
- console.log("with the answers you give next (same files, new contents).");
354
- const overwrite = await askYesNo("Replace existing profile files?", false);
355
- if (!overwrite) {
356
- throw new Error(
357
- "Aborted. Pass --force to replace, or edit ~/.clanker/*.json by hand.",
358
- );
359
- }
360
- flags.force = true;
302
+ if (!overwrite) {
303
+ clack.cancel("Aborted. Pass --force to replace.");
304
+ process.exit(0);
361
305
  }
362
- console.log("");
306
+ flags.force = true;
363
307
  }
308
+ }
364
309
 
365
- console.log("— Network —");
366
- let preset =
367
- flags.preset ||
368
- hints.config?.preset ||
369
- (await ask("Which network? sepolia (public hub) or local (Anvil)", "sepolia"));
370
- preset = String(preset).toLowerCase();
371
- if (!PRESETS[preset]) throw new Error(`Unknown preset "${preset}"`);
372
-
373
- let fromBlock = flags.fromBlock;
374
- if (preset === "sepolia") {
375
- const useFast =
376
- fromBlock != null
377
- ? false
378
- : await askYesNo(
379
- `Speed up chain scans? Use fromBlock ${SEPOLIA_FAST_FROM_BLOCK} (recommended on public RPC)`,
380
- true,
381
- );
382
- if (fromBlock == null) {
383
- fromBlock = useFast ? SEPOLIA_FAST_FROM_BLOCK : PRESETS.sepolia.fromBlock;
384
- }
385
- }
310
+ let preset = flags.preset || hints.config?.preset || null;
311
+ if (!preset) {
312
+ preset = cancelIf(
313
+ await clack.select({
314
+ message: "Network",
315
+ options: [
316
+ { value: "sepolia", label: "sepolia", hint: "public closed-beta hub" },
317
+ { value: "local", label: "local", hint: "Anvil" },
318
+ ],
319
+ initialValue: "sepolia",
320
+ }),
321
+ );
322
+ }
323
+ preset = String(preset).toLowerCase();
324
+ if (!PRESETS[preset]) throw new Error(`Unknown preset "${preset}"`);
325
+
326
+ // Fast Sepolia default — no interactive fromBlock question
327
+ let fromBlock = flags.fromBlock;
328
+ if (fromBlock == null && preset === "sepolia") {
329
+ fromBlock = SEPOLIA_FAST_FROM_BLOCK;
330
+ }
386
331
 
387
- console.log("");
388
- console.log("— Operator identity —");
389
- console.log(
390
- "We need the wallet address that owns (or will own) your on-chain operator label.",
332
+ let address = flags.address ?? null;
333
+ if (!address && flags.keyFile) {
334
+ address = addressFromKeyFile(flags.keyFile);
335
+ clack.log.info(`Address from --key-file: ${address}`);
336
+ }
337
+ if (!address && hints.envAddress) {
338
+ const useEnv = cancelIf(
339
+ await clack.confirm({
340
+ message: `Use OPERATOR_PRIVATE_KEY address (${hints.envAddress})?`,
341
+ initialValue: true,
342
+ }),
391
343
  );
392
- let address = flags.address ?? null;
393
- if (!address && flags.keyFile) {
394
- address = addressFromKeyFile(flags.keyFile);
395
- console.log(`Using address from --key-file: ${address}`);
396
- }
397
- if (!address && hints.envAddress) {
398
- const useEnv = await askYesNo(
399
- `Use address from OPERATOR_PRIVATE_KEY (${hints.envAddress})?`,
400
- true,
401
- );
402
- if (useEnv) address = hints.envAddress;
403
- }
404
- if (!address && hints.operator?.owner) {
405
- const useOp = await askYesNo(
406
- `Keep existing operator.json owner (${hints.operator.owner})?`,
407
- true,
344
+ if (useEnv) address = hints.envAddress;
345
+ }
346
+ if (!address && hints.operator?.owner) {
347
+ const useOp = cancelIf(
348
+ await clack.confirm({
349
+ message: `Keep existing owner (${hints.operator.owner})?`,
350
+ initialValue: true,
351
+ }),
352
+ );
353
+ if (useOp) address = hints.operator.owner;
354
+ }
355
+ if (!address && hints.foundryAccounts.length) {
356
+ const options = [
357
+ ...hints.foundryAccounts.map((n) => ({
358
+ value: n,
359
+ label: n,
360
+ hint: "Foundry keystore — paste 0x next",
361
+ })),
362
+ { value: "__paste__", label: "Paste an address…", hint: "0x…" },
363
+ ];
364
+ const pick = cancelIf(
365
+ await clack.select({
366
+ message: "Operator owner source",
367
+ options,
368
+ initialValue: hints.foundryAccounts[0],
369
+ }),
370
+ );
371
+ if (pick === "__paste__") {
372
+ address = cancelIf(
373
+ await clack.text({
374
+ message: "Operator owner address",
375
+ placeholder: "0x…",
376
+ validate: (v) =>
377
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
378
+ }),
408
379
  );
409
- if (useOp) address = hints.operator.owner;
410
- }
411
- if (!address && hints.foundryAccounts.length) {
412
- console.log("");
413
- console.log("Foundry accounts on this machine (passwords are never stored here):");
414
- hints.foundryAccounts.forEach((n, i) => console.log(` ${i + 1}. ${n}`));
415
- const pick = await ask(
416
- "Type a Foundry account name to use, or leave blank to paste an address",
417
- hints.foundryAccounts[0] ?? "",
380
+ } else {
381
+ address = cancelIf(
382
+ await clack.text({
383
+ message: `Paste 0x address for Foundry account "${pick}"`,
384
+ placeholder: "0x…",
385
+ validate: (v) =>
386
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
387
+ }),
418
388
  );
419
- if (pick) {
420
- address = await ask(
421
- `Paste the 0x address for "${pick}" (cast wallet address ${pick} after unlock)`,
422
- "",
423
- );
424
- }
425
- }
426
- if (!address) {
427
- address = await ask("Operator owner address (0x…)", "");
428
389
  }
429
- if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address)) {
430
- throw new Error("A valid 0x operator address is required");
431
- }
432
- address = getAddress(address);
433
-
434
- let label =
435
- flags.operator ||
436
- hints.operator?.label ||
437
- (await ask("Operator label on-chain (e.g. org.openclaw.pat or org.you)", ""));
438
- if (!label) throw new Error("Operator label is required");
439
-
440
- const presetCfg = PRESETS[preset];
441
- console.log("");
442
- console.log("— Chain check —");
443
- console.log(`Looking up "${label}" on ${preset}…`);
444
- const check = await assertSetupIdentity({
390
+ }
391
+ if (!address) {
392
+ address = cancelIf(
393
+ await clack.text({
394
+ message: "Operator owner address",
395
+ placeholder: "0x…",
396
+ validate: (v) =>
397
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
398
+ }),
399
+ );
400
+ }
401
+ address = getAddress(address);
402
+
403
+ let label = flags.operator || hints.operator?.label || null;
404
+ if (!label) {
405
+ label = cancelIf(
406
+ await clack.text({
407
+ message: "Operator label",
408
+ placeholder: "org.openclaw.pat",
409
+ validate: (v) => (v && v.trim() ? undefined : "Label required"),
410
+ }),
411
+ );
412
+ }
413
+
414
+ const presetCfg = PRESETS[preset];
415
+ const spin = clack.spinner();
416
+ spin.start(`Looking up "${label}" on ${preset}`);
417
+ let check;
418
+ try {
419
+ check = await assertSetupIdentity({
445
420
  rpc: presetCfg.chainRpcUrl,
446
421
  registry: presetCfg.registryAddress,
447
422
  label,
448
423
  address,
449
424
  skipChainCheck: flags.skipChainCheck || !presetCfg.registryAddress,
450
425
  });
451
- if (check.unregistered) {
452
- console.log(
453
- `OK: "${label}" is not registered yet — after setup, run: clanker operator mint ${label}`,
426
+ spin.stop(
427
+ check.unregistered
428
+ ? c.yellow(`"${label}" not registered yet — mint after setup`)
429
+ : check.skipped
430
+ ? "Chain check skipped"
431
+ : c.green(`"${label}" active and owned by this address`),
432
+ );
433
+ } catch (err) {
434
+ spin.stop(c.red("Chain check failed"));
435
+ throw err;
436
+ }
437
+
438
+ let keyFile = flags.keyFile;
439
+ let keyEnv = flags.keyEnv;
440
+ let skipKey = flags.skipKey;
441
+ if (!skipKey && !keyFile && !keyEnv) {
442
+ if (hints.hasOperatorPrivateKeyEnv) {
443
+ const use = cancelIf(
444
+ await clack.confirm({
445
+ message: "Store OPERATOR_PRIVATE_KEY pointer for signing?",
446
+ initialValue: true,
447
+ }),
454
448
  );
455
- } else if (check.operator) {
456
- console.log(`OK: "${label}" is active on-chain and owned by this address.`);
449
+ if (use) keyEnv = "OPERATOR_PRIVATE_KEY";
450
+ else skipKey = true;
451
+ } else {
452
+ const path = cancelIf(
453
+ await clack.text({
454
+ message: "Operator key file path (Enter = read-only)",
455
+ placeholder: "~/.clanker/op.key",
456
+ }),
457
+ );
458
+ if (path && String(path).trim()) keyFile = String(path).trim();
459
+ else skipKey = true;
457
460
  }
461
+ }
458
462
 
459
- console.log("");
460
- console.log("— Signing key (optional) ");
461
- console.log(
462
- "Only needed for mint/revoke/transfer. Skip for read-only whoami/bots.",
463
- );
464
- let keyFile = flags.keyFile;
465
- let keyEnv = flags.keyEnv;
466
- let skipKey = flags.skipKey;
467
- if (!skipKey && !keyFile && !keyEnv) {
468
- if (hints.hasOperatorPrivateKeyEnv) {
469
- const use = await askYesNo(
470
- "Remember OPERATOR_PRIVATE_KEY as the signing pointer in operator.json?",
471
- true,
472
- );
473
- if (use) keyEnv = "OPERATOR_PRIVATE_KEY";
474
- else skipKey = true;
475
- } else {
476
- const path = await ask(
477
- "Path to operator private-key file (blank = read-only profile)",
478
- "",
479
- );
480
- if (path) keyFile = path;
481
- else skipKey = true;
482
- }
463
+ const key = buildKeyPointer({ keyFile, keyEnv, skipKey, env });
464
+ if (key?.type === "keyFile") {
465
+ const fromKey = getAddress(addressFromKeyFile(key.value));
466
+ if (fromKey !== address) {
467
+ throw new Error(`Key file address ${fromKey} does not match chosen owner ${address}`);
483
468
  }
469
+ }
484
470
 
485
- const key = buildKeyPointer({ keyFile, keyEnv, skipKey, env });
486
- if (key?.type === "keyFile") {
487
- const fromKey = getAddress(addressFromKeyFile(key.value));
488
- if (fromKey !== address) {
489
- throw new Error(`Key file address ${fromKey} does not match chosen owner ${address}`);
490
- }
491
- }
471
+ clack.note(
472
+ [
473
+ `network: ${preset}`,
474
+ `fromBlock: ${fromBlock ?? presetCfg.fromBlock}`,
475
+ `label: ${label}`,
476
+ `owner: ${address}`,
477
+ `signing: ${key ? `${key.type}=${key.value}` : "none (read-only)"}`,
478
+ ].join("\n"),
479
+ "Summary",
480
+ );
492
481
 
493
- console.log("");
494
- console.log("— Summary (about to write) —");
495
- console.log(` network: ${preset}`);
496
- console.log(` fromBlock:${fromBlock ?? presetCfg.fromBlock}`);
497
- console.log(` label: ${label}`);
498
- console.log(` owner: ${address}`);
499
- console.log(
500
- ` signing: ${key ? `${key.type}=${key.value}` : "none (read-only whoami/bots)"}`,
501
- );
502
- const ok = flags.yes || (await askYesNo("Write these files now?", true));
503
- if (!ok) throw new Error("Aborted");
504
-
505
- const result = applySetup(
506
- {
507
- preset,
508
- force: true,
509
- fromBlock: fromBlock ?? undefined,
510
- registryAddress: presetCfg.registryAddress,
511
- label,
512
- address,
513
- key,
514
- },
515
- { home, env },
482
+ if (!flags.yes) {
483
+ const ok = cancelIf(
484
+ await clack.confirm({ message: "Write these files?", initialValue: true }),
516
485
  );
517
-
518
- console.log("");
519
- console.log(`Wrote ${result.configPath}`);
520
- console.log(`Wrote ${result.operatorPath}`);
521
- if (!key) {
522
- console.log("");
523
- console.log("Next: clanker whoami");
524
- console.log(
525
- "For mint/revoke later: re-run setup with a key file, or pass --key-file / OPERATOR_PRIVATE_KEY.",
526
- );
527
- } else {
528
- console.log("");
529
- console.log("Next: clanker whoami");
486
+ if (!ok) {
487
+ clack.cancel("Aborted.");
488
+ process.exit(0);
530
489
  }
531
- return result;
532
- } finally {
533
- if (!opts.rl) rl.close();
534
490
  }
491
+
492
+ const result = applySetup(
493
+ {
494
+ preset,
495
+ force: true,
496
+ fromBlock: fromBlock ?? undefined,
497
+ registryAddress: presetCfg.registryAddress,
498
+ label,
499
+ address,
500
+ key,
501
+ },
502
+ { home, env },
503
+ );
504
+
505
+ clack.outro(c.green(`Wrote ${result.configPath}\nWrote ${result.operatorPath}`));
506
+ if (!key) {
507
+ nextHint([
508
+ "clanker whoami",
509
+ "clanker setup --key-file ~/.clanker/op.key --force # when you need mint",
510
+ ]);
511
+ } else {
512
+ nextHint(["clanker whoami", `clanker bot mint <label>`]);
513
+ }
514
+ return result;
535
515
  }
536
516
 
537
517
  /**
@@ -541,7 +521,6 @@ export async function runSetup(argv, opts = {}) {
541
521
  const flags = parseSetupFlags(argv);
542
522
  const isTTY = opts.isTTY ?? Boolean(input.isTTY);
543
523
 
544
- // Fully flagged non-interactive path
545
524
  if (
546
525
  !isTTY ||
547
526
  (flags.yes && flags.preset && flags.operator && (flags.address || flags.keyFile))
@@ -549,7 +528,15 @@ export async function runSetup(argv, opts = {}) {
549
528
  if (!isTTY && !(flags.preset && flags.operator)) {
550
529
  return runSetupNonInteractive(argv, opts);
551
530
  }
552
- if (flags.yes && flags.preset && flags.operator && (flags.address || flags.keyFile || opts.env?.OPERATOR_PRIVATE_KEY || process.env.OPERATOR_PRIVATE_KEY)) {
531
+ if (
532
+ flags.yes &&
533
+ flags.preset &&
534
+ flags.operator &&
535
+ (flags.address ||
536
+ flags.keyFile ||
537
+ opts.env?.OPERATOR_PRIVATE_KEY ||
538
+ process.env.OPERATOR_PRIVATE_KEY)
539
+ ) {
553
540
  return runSetupNonInteractive(argv, opts);
554
541
  }
555
542
  }