@ldgr/cli 0.1.1 → 0.2.1

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 CHANGED
@@ -1,11 +1,12 @@
1
1
  # @ldgr/cli
2
2
 
3
- LDGR's self-custody terminal wallet. Version 0.1.0 creates or imports encrypted local wallets, lets you select between them, shows addresses and balances, sends native LDGR or a token, and persistently switches the selected wallet between mainnet and testnet.
3
+ LDGR's self-custody terminal wallet. Version 0.2.0 creates or imports local wallets, lets you select between them, shows addresses and balances, sends native LDGR or a token, and persistently switches the selected wallet between mainnet and testnet.
4
4
 
5
5
  ```bash
6
6
  npm install -g @ldgr/cli
7
7
  ldgr wallet create
8
- ldgr wallet import
8
+ ldgr wallet import ~/Downloads/ldgr-recovery-key.txt
9
+ ldgr wallet export ~/Desktop/ldgr-recovery-key.txt
9
10
  ldgr wallet list
10
11
  ldgr wallet select 2
11
12
  ldgr address
@@ -15,4 +16,4 @@ ldgr send-token t1abcdef01234567 ldgr1… 10
15
16
  ldgr network set testnet
16
17
  ```
17
18
 
18
- The default owner-only wallet store lives at `~/.config/ldgr/wallet.json`. Each added wallet is encrypted separately using a passphrase requested on the terminal. `wallet list` shows one-based indexes and marks the active wallet; `wallet select <index>` changes it without exposing a secret. Record the recovery key shown at creation offline. `wallet import --testnet` imports a recovery key as a testnet wallet. Testnet prints a warning on every wallet command and only uses `https://tst.ldgr.ltd`.
19
+ The default owner-only wallet store lives at `~/.ldgr/wallet.json`. On first use, an existing `~/.config/ldgr/wallet.json` store is validated, copied to the new owner-only location, and removed from its former location. At creation/import you can choose passphrase protection or an **unprotected** wallet, which follows the extension model and relies only on the local file's permissions. `wallet list` shows one-based indexes and marks the active wallet; `wallet select <index>` changes it without exposing a secret. `wallet export <path>` writes a `0600` `ldgr-recovery-key.txt` file containing the raw recovery-key value accepted by the extension; `wallet import <path>` reads the same file. Testnet prints a warning on every wallet command and only uses `https://tst.ldgr.ltd`.
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  nativeAssetDefinitionForLedger,
9
9
  resolvePublicAssetCode
10
10
  } from "@ldgr/protocol";
11
+ import { readFile as readFile2 } from "node:fs/promises";
11
12
 
12
13
  // src/transfer.ts
13
14
  import {
@@ -49,7 +50,7 @@ async function buildTransferSubmission(input) {
49
50
 
50
51
  // src/wallet-file.ts
51
52
  import { generateLocalWallet, loadLocalWallet } from "@ldgr/client";
52
- import { canonicalizeLedgerAddress as canonicalizeLedgerAddress2 } from "@ldgr/protocol";
53
+ import { canonicalizeLedgerAddress as canonicalizeLedgerAddress2, decodeEd25519RecoveryKey } from "@ldgr/protocol";
53
54
  import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
54
55
  import { dirname, join } from "node:path";
55
56
  var KDF_ITERATIONS = 310000;
@@ -82,11 +83,21 @@ function parseWalletEntry(value) {
82
83
  if (!value || typeof value !== "object" || Array.isArray(value))
83
84
  throw new TypeError("Wallet file is not a JSON object");
84
85
  const file = value;
85
- if (file.format !== "ldgr-cli-wallet" || file.version !== 1 || file.network !== "mainnet" && file.network !== "testnet" || file.ledgerId !== 0 && file.ledgerId !== 1 || typeof file.address !== "string" || typeof file.publicKey !== "string" || typeof file.ciphertext !== "string" || typeof file.iv !== "string" || !file.kdf || file.kdf.name !== "PBKDF2" || file.kdf.hash !== "SHA-256" || file.kdf.iterations !== KDF_ITERATIONS || typeof file.kdf.salt !== "string")
86
+ if (file.format !== "ldgr-cli-wallet" || file.version !== 1 || file.network !== "mainnet" && file.network !== "testnet" || file.ledgerId !== 0 && file.ledgerId !== 1 || typeof file.address !== "string" || typeof file.publicKey !== "string")
86
87
  throw new TypeError("Unsupported wallet file format");
87
88
  if (file.network !== networkForLedger(file.ledgerId))
88
89
  throw new TypeError("Wallet file network does not match ledgerId");
89
90
  canonicalizeLedgerAddress2(file.address, file.ledgerId);
91
+ if (file.protection === "none") {
92
+ if (typeof file.recoveryKey !== "string")
93
+ throw new TypeError("Unsupported wallet file format");
94
+ decodeEd25519RecoveryKey(file.recoveryKey);
95
+ return file;
96
+ }
97
+ if (file.protection !== undefined && file.protection !== "passphrase")
98
+ throw new TypeError("Unsupported wallet file format");
99
+ if (typeof file.ciphertext !== "string" || typeof file.iv !== "string" || !file.kdf || file.kdf.name !== "PBKDF2" || file.kdf.hash !== "SHA-256" || file.kdf.iterations !== KDF_ITERATIONS || typeof file.kdf.salt !== "string")
100
+ throw new TypeError("Unsupported wallet file format");
90
101
  return file;
91
102
  }
92
103
  function parseWalletStore(value) {
@@ -118,13 +129,35 @@ function defaultWalletPath(input = {}) {
118
129
  const home = input.home ?? process.env.HOME;
119
130
  if (!home)
120
131
  throw new Error("Could not determine your home directory; use --wallet <path>");
121
- const configHome = process.env.XDG_CONFIG_HOME?.trim() || join(home, ".config");
132
+ return join(home, ".ldgr", "wallet.json");
133
+ }
134
+ function legacyDefaultWalletPath(input = {}) {
135
+ const home = input.home ?? process.env.HOME;
136
+ if (!home)
137
+ throw new Error("Could not determine your home directory; use --wallet <path>");
138
+ const configHome = input.configHome ?? (process.env.XDG_CONFIG_HOME?.trim() || join(home, ".config"));
122
139
  return join(configHome, "ldgr", "wallet.json");
123
140
  }
124
141
  async function createEncryptedWalletFile(input) {
125
142
  const wallet = await generateLocalWallet({ ledgerId: input.ledgerId });
143
+ if (input.passphrase === undefined)
144
+ return createUnprotectedWallet({ wallet, recoveryKey: wallet.recoveryKey });
126
145
  return encryptWallet({ wallet, recoveryKey: wallet.recoveryKey, passphrase: input.passphrase });
127
146
  }
147
+ function createUnprotectedWallet(input) {
148
+ const file = {
149
+ format: "ldgr-cli-wallet",
150
+ version: 1,
151
+ protection: "none",
152
+ network: networkForLedger(input.wallet.ledgerId),
153
+ ledgerId: input.wallet.ledgerId,
154
+ address: input.wallet.address,
155
+ publicKey: input.wallet.publicKey,
156
+ recoveryKey: input.recoveryKey
157
+ };
158
+ return { file, serialized: `${JSON.stringify(file, null, 2)}
159
+ `, recoveryKey: input.recoveryKey };
160
+ }
128
161
  async function encryptWallet(input) {
129
162
  const salt = crypto.getRandomValues(new Uint8Array(16));
130
163
  const iv = crypto.getRandomValues(new Uint8Array(12));
@@ -146,7 +179,7 @@ async function encryptWallet(input) {
146
179
  return { file, serialized: `${JSON.stringify(file, null, 2)}
147
180
  `, recoveryKey: input.recoveryKey };
148
181
  }
149
- async function writeNewWalletFile(path, file) {
182
+ async function writeNewWalletStore(path, store) {
150
183
  await mkdir(dirname(path), { recursive: true, mode: 448 });
151
184
  const handle = await open(path, "wx", 384).catch((error) => {
152
185
  if (error.code === "EEXIST")
@@ -154,12 +187,15 @@ async function writeNewWalletFile(path, file) {
154
187
  throw error;
155
188
  });
156
189
  try {
157
- await handle.writeFile(serializeWalletStore(newWalletStore(file)), "utf8");
190
+ await handle.writeFile(serializeWalletStore(store), "utf8");
158
191
  await handle.sync();
159
192
  } finally {
160
193
  await handle.close();
161
194
  }
162
195
  }
196
+ async function writeNewWalletFile(path, file) {
197
+ await writeNewWalletStore(path, newWalletStore(file));
198
+ }
163
199
  async function readWalletStore(path) {
164
200
  const details = await lstat(path).catch((error) => {
165
201
  if (error.code === "ENOENT")
@@ -178,7 +214,43 @@ async function readWalletStore(path) {
178
214
  }
179
215
  return parseWalletStore(parsed);
180
216
  }
217
+ async function migrateDefaultWalletPath(input = {}) {
218
+ const path = defaultWalletPath(input);
219
+ try {
220
+ await lstat(path);
221
+ return { path, migrated: false };
222
+ } catch (error) {
223
+ if (error.code !== "ENOENT")
224
+ throw error;
225
+ }
226
+ const legacyPath = legacyDefaultWalletPath(input);
227
+ let legacyStore;
228
+ try {
229
+ legacyStore = await readWalletStore(legacyPath);
230
+ } catch (error) {
231
+ if (error instanceof Error && error.message === `Wallet file not found: ${legacyPath}`)
232
+ return { path, migrated: false };
233
+ throw error;
234
+ }
235
+ await writeNewWalletStore(path, legacyStore);
236
+ try {
237
+ await unlink(legacyPath);
238
+ } catch (error) {
239
+ throw new Error(`Migrated wallet to ${path}, but could not remove legacy wallet file ${legacyPath}: ${error instanceof Error ? error.message : String(error)}`);
240
+ }
241
+ return { path, migrated: true };
242
+ }
243
+ async function walletFromRecoveryKey(file, recoveryKey) {
244
+ const wallet = await loadLocalWallet({ recoveryKey, ledgerId: file.ledgerId });
245
+ if (wallet.address !== file.address || wallet.publicKey !== file.publicKey)
246
+ throw new Error("wallet identity mismatch");
247
+ return { wallet, recoveryKey };
248
+ }
181
249
  async function unlockWalletFile(file, passphrase) {
250
+ if (file.protection === "none")
251
+ return walletFromRecoveryKey(file, file.recoveryKey);
252
+ if (passphrase === undefined)
253
+ throw new Error("This wallet requires a passphrase");
182
254
  try {
183
255
  const key = await deriveEncryptionKey(passphrase, decodeBase64Url(file.kdf.salt));
184
256
  const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: AES_GCM, iv: arrayBuffer(decodeBase64Url(file.iv)) }, key, arrayBuffer(decodeBase64Url(file.ciphertext))));
@@ -186,10 +258,7 @@ async function unlockWalletFile(file, passphrase) {
186
258
  plaintext.fill(0);
187
259
  if (typeof value.recoveryKey !== "string")
188
260
  throw new Error("missing recovery key");
189
- const wallet = await loadLocalWallet({ recoveryKey: value.recoveryKey, ledgerId: file.ledgerId });
190
- if (wallet.address !== file.address || wallet.publicKey !== file.publicKey)
191
- throw new Error("wallet identity mismatch");
192
- return { wallet, recoveryKey: value.recoveryKey };
261
+ return await walletFromRecoveryKey(file, value.recoveryKey);
193
262
  } catch {
194
263
  throw new Error("Could not unlock wallet: check your passphrase or wallet file");
195
264
  }
@@ -199,6 +268,22 @@ async function readEncryptedWalletFile(path, passphrase) {
199
268
  const file = store.wallets[store.activeWalletIndex];
200
269
  return (await unlockWalletFile(file, passphrase)).wallet;
201
270
  }
271
+ async function exportRecoveryKeyFile(input) {
272
+ const store = await readWalletStore(input.walletPath);
273
+ const unlocked = await unlockWalletFile(store.wallets[store.activeWalletIndex], input.passphrase);
274
+ await mkdir(dirname(input.backupPath), { recursive: true, mode: 448 });
275
+ const handle = await open(input.backupPath, "wx", 384).catch((error) => {
276
+ if (error.code === "EEXIST")
277
+ throw new Error(`Recovery-key file already exists: ${input.backupPath}`);
278
+ throw error;
279
+ });
280
+ try {
281
+ await handle.writeFile(unlocked.recoveryKey, "utf8");
282
+ await handle.sync();
283
+ } finally {
284
+ await handle.close();
285
+ }
286
+ }
202
287
  async function replaceWalletStore(path, store) {
203
288
  await readWalletStore(path);
204
289
  const temporaryPath = join(dirname(path), `.${crypto.randomUUID()}.wallet.tmp`);
@@ -238,8 +323,8 @@ async function addWalletFile(path, file) {
238
323
  }
239
324
  async function importEncryptedWalletFile(input) {
240
325
  const wallet = await loadLocalWallet({ recoveryKey: input.recoveryKey, ledgerId: input.ledgerId });
241
- const encrypted = await encryptWallet({ wallet, recoveryKey: input.recoveryKey, passphrase: input.passphrase });
242
- return { file: encrypted.file, index: await addWalletFile(input.path, encrypted.file) };
326
+ const created = input.passphrase === undefined ? createUnprotectedWallet({ wallet, recoveryKey: input.recoveryKey }) : await encryptWallet({ wallet, recoveryKey: input.recoveryKey, passphrase: input.passphrase });
327
+ return { file: created.file, index: await addWalletFile(input.path, created.file) };
243
328
  }
244
329
  async function listWalletFileMetadata(path) {
245
330
  const store = await readWalletStore(path);
@@ -261,14 +346,14 @@ async function switchWalletNetwork(path, passphrase, ledgerId) {
261
346
  return existing;
262
347
  const unlocked = await unlockWalletFile(existing, passphrase);
263
348
  const wallet = await loadLocalWallet({ recoveryKey: unlocked.recoveryKey, ledgerId });
264
- const encrypted = await encryptWallet({ wallet, recoveryKey: unlocked.recoveryKey, passphrase });
349
+ const updated = existing.protection === "none" ? createUnprotectedWallet({ wallet, recoveryKey: unlocked.recoveryKey }) : await encryptWallet({ wallet, recoveryKey: unlocked.recoveryKey, passphrase });
265
350
  if (store.wallets.some((entry, index) => index !== store.activeWalletIndex && entry.ledgerId === ledgerId && entry.publicKey === wallet.publicKey)) {
266
351
  throw new Error("This recovery key already has a wallet in the selected network; select that wallet instead");
267
352
  }
268
353
  const wallets = [...store.wallets];
269
- wallets[store.activeWalletIndex] = encrypted.file;
354
+ wallets[store.activeWalletIndex] = updated.file;
270
355
  await replaceWalletStore(path, { ...store, wallets });
271
- return encrypted.file;
356
+ return updated.file;
272
357
  }
273
358
  async function walletFileMetadata(path) {
274
359
  const store = await readWalletStore(path);
@@ -277,11 +362,12 @@ async function walletFileMetadata(path) {
277
362
 
278
363
  // src/cli.ts
279
364
  var HELP = `
280
- LDGR CLI 0.1.1 — self-custody wallet
365
+ LDGR CLI 0.2.1 — self-custody wallet
281
366
 
282
367
  Usage:
283
368
  ldgr wallet create [--testnet] [--wallet <path>]
284
- ldgr wallet import [--testnet] [--wallet <path>]
369
+ ldgr wallet import [recovery-key-file] [--testnet] [--wallet <path>]
370
+ ldgr wallet export <recovery-key-file> [--wallet <path>]
285
371
  ldgr wallet list [--wallet <path>]
286
372
  ldgr wallet select <index> [--wallet <path>]
287
373
  ldgr address [--wallet <path>]
@@ -291,16 +377,17 @@ Usage:
291
377
  ldgr network show [--wallet <path>]
292
378
  ldgr network set <mainnet|testnet> [--wallet <path>]
293
379
 
294
- The default wallet store is ~/.config/ldgr/wallet.json. Each wallet inside it
295
- is independently encrypted with a passphrase you enter through the terminal;
296
- the CLI never accepts recovery keys or passphrases as arguments or environment
297
- variables. Use \`ldgr wallet select <index>\` to change the active wallet, or
298
- \`ldgr network set testnet\` to switch its network persistently. Testnet mode
299
- prints a warning on every wallet command.
380
+ The default wallet store is ~/.ldgr/wallet.json. Existing wallets at
381
+ ~/.config/ldgr/wallet.json move there safely on first use. A wallet can be
382
+ passphrase-protected or unprotected (local-file permissions only). The CLI
383
+ never accepts recovery-key values or passphrases as arguments or environment
384
+ variables. Use \`ldgr wallet export <path>\` for an extension-compatible
385
+ recovery-key file. Testnet mode prints a warning on every wallet command.
300
386
  `;
301
387
  function parseArguments(argv) {
302
388
  const command = [];
303
389
  let walletPath = defaultWalletPath();
390
+ let walletPathExplicit = false;
304
391
  let walletOnTestnet = false;
305
392
  for (let index = 0;index < argv.length; index += 1) {
306
393
  const value = argv[index];
@@ -309,6 +396,7 @@ function parseArguments(argv) {
309
396
  if (!path || path.startsWith("-"))
310
397
  throw new TypeError("--wallet requires a file path");
311
398
  walletPath = path;
399
+ walletPathExplicit = true;
312
400
  index += 1;
313
401
  } else if (value === "--testnet") {
314
402
  walletOnTestnet = true;
@@ -321,7 +409,7 @@ function parseArguments(argv) {
321
409
  if (walletOnTestnet && !(command[0] === "wallet" && (command[1] === "create" || command[1] === "import"))) {
322
410
  throw new TypeError("--testnet is only used with wallet create or wallet import; use `ldgr network set testnet` to switch an existing wallet");
323
411
  }
324
- return { command, walletPath, walletOnTestnet };
412
+ return { command, walletPath, walletPathExplicit, walletOnTestnet };
325
413
  }
326
414
  function networkLabel(file) {
327
415
  return file.ledgerId === 0 ? "testnet" : "mainnet";
@@ -342,6 +430,23 @@ async function askPassphrase(message) {
342
430
  throw new Error("Cancelled");
343
431
  return answer;
344
432
  }
433
+ async function chooseWalletPassphrase() {
434
+ const protectedWallet = await p.confirm({ message: "Protect this wallet with a passphrase?", initialValue: true });
435
+ if (p.isCancel(protectedWallet))
436
+ throw new Error("Cancelled");
437
+ if (!protectedWallet) {
438
+ p.log.warn("This wallet will be unprotected. Anyone who can read its owner-only local file can spend its funds.");
439
+ return;
440
+ }
441
+ const passphrase = await askPassphrase("Choose a wallet passphrase");
442
+ const confirmation = await askPassphrase("Repeat the wallet passphrase");
443
+ if (passphrase !== confirmation)
444
+ throw new Error("Passphrases did not match");
445
+ return passphrase;
446
+ }
447
+ async function signingPassphrase(file, message) {
448
+ return file.protection === "none" ? undefined : askPassphrase(message);
449
+ }
345
450
  async function confirm2(message) {
346
451
  const answer = await p.confirm({ message, initialValue: false });
347
452
  if (p.isCancel(answer) || !answer)
@@ -374,39 +479,34 @@ async function runCreate(path, testnet) {
374
479
  const ledgerId = testnet ? 0 : 1;
375
480
  if (ledgerId === 0)
376
481
  showNetworkWarning({ ledgerId });
377
- const passphrase = await askPassphrase("Choose a wallet passphrase");
378
- const confirmation = await askPassphrase("Repeat the wallet passphrase");
379
- if (passphrase !== confirmation)
380
- throw new Error("Passphrases did not match");
482
+ const passphrase = await chooseWalletPassphrase();
381
483
  const spinner2 = p.spinner();
382
- spinner2.start("Generating and encrypting your wallet");
484
+ spinner2.start("Generating your wallet");
383
485
  const created = await createEncryptedWalletFile({ ledgerId, passphrase });
384
486
  const index = await addWalletFile(path, created.file);
385
- spinner2.stop("Encrypted wallet added");
487
+ spinner2.stop(passphrase === undefined ? "Unprotected wallet added" : "Protected wallet added");
386
488
  p.note(`Address
387
489
  ${created.file.address}
388
490
 
389
491
  Recovery key — save this offline now; it will not be shown again
390
492
  ${created.recoveryKey}`, "Your self-custody backup");
391
- p.log.warn("This recovery key alone can restore and control this wallet. Your passphrase protects only this local file; never share either secret.");
493
+ p.log.warn("This recovery key alone can restore and control this wallet. Export it as a file with `ldgr wallet export <path>` and keep it offline.");
392
494
  p.outro(`Wallet #${index + 1} selected in ${path}`);
393
495
  }
394
- async function runImport(path, testnet) {
496
+ async function runImport(path, testnet, recoveryKeyPath) {
395
497
  const ledgerId = testnet ? 0 : 1;
396
498
  if (ledgerId === 0)
397
499
  showNetworkWarning({ ledgerId });
398
- const recoveryKey = await p.password({
500
+ const enteredRecoveryKey = recoveryKeyPath === undefined ? await p.password({
399
501
  message: "Enter the recovery key to import",
400
502
  validate(value) {
401
503
  return value.trim() ? undefined : "Enter a recovery key";
402
504
  }
403
- });
404
- if (p.isCancel(recoveryKey))
505
+ }) : (await readFile2(recoveryKeyPath, "utf8")).trim();
506
+ if (p.isCancel(enteredRecoveryKey) || !enteredRecoveryKey)
405
507
  throw new Error("Cancelled");
406
- const passphrase = await askPassphrase("Choose a passphrase for this imported wallet");
407
- const confirmation = await askPassphrase("Repeat the wallet passphrase");
408
- if (passphrase !== confirmation)
409
- throw new Error("Passphrases did not match");
508
+ const recoveryKey = enteredRecoveryKey;
509
+ const passphrase = await chooseWalletPassphrase();
410
510
  const spinner2 = p.spinner();
411
511
  spinner2.start("Validating and encrypting imported wallet");
412
512
  const imported = await importEncryptedWalletFile({ path, recoveryKey, ledgerId, passphrase });
@@ -420,12 +520,20 @@ ${networkLabel(imported.file)}`, `Wallet #${imported.index + 1} selected`);
420
520
  showNetworkWarning(imported.file);
421
521
  p.outro(`Wallet #${imported.index + 1} added to ${path}`);
422
522
  }
523
+ async function runExport(path, backupPath) {
524
+ const file = await walletFileMetadata(path);
525
+ showNetworkWarning(file);
526
+ const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to export");
527
+ await exportRecoveryKeyFile({ walletPath: path, backupPath, passphrase });
528
+ p.log.warn("Anyone with this recovery-key file can control the wallet. Keep it offline and never share it.");
529
+ p.outro(`Recovery-key file saved at ${backupPath}`);
530
+ }
423
531
  async function runWalletList(path) {
424
532
  const wallets = await listWalletFileMetadata(path);
425
533
  const selected = wallets.find((wallet) => wallet.selected);
426
534
  showNetworkWarning(selected);
427
535
  const rows = wallets.map((wallet) => [
428
- `${wallet.selected ? "● Selected" : "○"} #${wallet.index + 1} · ${networkLabel(wallet)}`,
536
+ `${wallet.selected ? "● Selected" : "○"} #${wallet.index + 1} · ${networkLabel(wallet)} · ${wallet.protection === "none" ? "Unprotected" : "Protected"}`,
429
537
  wallet.address
430
538
  ].join(`
431
539
  `));
@@ -477,7 +585,7 @@ async function runSend(path, rawAsset, recipient, amount) {
477
585
  const client = await selectedClient(file);
478
586
  const asset = resolvePublicAssetCode(rawAsset, file.ledgerId);
479
587
  const definition = await definitionForAsset(client, asset, file.ledgerId);
480
- const passphrase = await askPassphrase("Enter your wallet passphrase to sign");
588
+ const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to sign");
481
589
  const wallet = await readEncryptedWalletFile(path, passphrase);
482
590
  const makeSubmission = async () => buildTransferSubmission({
483
591
  wallet,
@@ -535,7 +643,7 @@ ${file.address}`, "Selected network");
535
643
  if (ledgerId === 0)
536
644
  showNetworkWarning({ ledgerId });
537
645
  p.log.info("Switching networks derives a different address from the same self-custody key.");
538
- const passphrase = await askPassphrase("Enter your wallet passphrase to switch networks");
646
+ const passphrase = await signingPassphrase(file, "Enter your wallet passphrase to switch networks");
539
647
  await confirm2(`Switch this wallet to ${target}?`);
540
648
  const spinner2 = p.spinner();
541
649
  spinner2.start("Re-encrypting wallet for the selected network");
@@ -561,26 +669,31 @@ async function main() {
561
669
  }
562
670
  p.intro("LDGR CLI");
563
671
  try {
672
+ const resolved = parsed.walletPathExplicit ? { path: parsed.walletPath, migrated: false } : await migrateDefaultWalletPath();
673
+ if (resolved.migrated)
674
+ p.log.info(`Moved legacy wallet store to ${resolved.path}`);
564
675
  if (first === "wallet" && second === "create" && third === undefined)
565
- await runCreate(parsed.walletPath, parsed.walletOnTestnet);
566
- else if (first === "wallet" && second === "import" && third === undefined)
567
- await runImport(parsed.walletPath, parsed.walletOnTestnet);
676
+ await runCreate(resolved.path, parsed.walletOnTestnet);
677
+ else if (first === "wallet" && second === "import" && fourth === undefined)
678
+ await runImport(resolved.path, parsed.walletOnTestnet, third);
679
+ else if (first === "wallet" && second === "export" && third && fourth === undefined)
680
+ await runExport(resolved.path, third);
568
681
  else if (first === "wallet" && second === "list" && third === undefined)
569
- await runWalletList(parsed.walletPath);
682
+ await runWalletList(resolved.path);
570
683
  else if (first === "wallet" && second === "select" && third && fourth === undefined)
571
- await runWalletSelect(parsed.walletPath, third);
684
+ await runWalletSelect(resolved.path, third);
572
685
  else if (first === "address" && second === undefined)
573
- await runAddress(parsed.walletPath);
686
+ await runAddress(resolved.path);
574
687
  else if (first === "balance" && second === undefined)
575
- await runBalance(parsed.walletPath);
688
+ await runBalance(resolved.path);
576
689
  else if (first === "send" && second && third && fourth === undefined)
577
- await runSend(parsed.walletPath, "dng", second, third);
690
+ await runSend(resolved.path, "dng", second, third);
578
691
  else if (first === "send-token" && second && third && fourth)
579
- await runSend(parsed.walletPath, second, third, fourth);
692
+ await runSend(resolved.path, second, third, fourth);
580
693
  else if (first === "network" && second === "show" && third === undefined)
581
- await runNetwork(parsed.walletPath);
694
+ await runNetwork(resolved.path);
582
695
  else if (first === "network" && second === "set" && third && fourth === undefined)
583
- await runNetwork(parsed.walletPath, third);
696
+ await runNetwork(resolved.path, third);
584
697
  else
585
698
  throw new TypeError("Unknown command. Run `ldgr --help` for available commands.");
586
699
  } catch (error) {
@@ -1,13 +1,17 @@
1
1
  import { type LdgrSigner } from "@ldgr/client";
2
2
  import { type LedgerId } from "@ldgr/protocol";
3
3
  export type WalletNetwork = "mainnet" | "testnet";
4
- export type EncryptedWalletFile = {
4
+ type WalletIdentity = {
5
5
  readonly format: "ldgr-cli-wallet";
6
6
  readonly version: 1;
7
7
  readonly network: WalletNetwork;
8
8
  readonly ledgerId: LedgerId;
9
9
  readonly address: string;
10
10
  readonly publicKey: string;
11
+ };
12
+ /** Passphrase protection is the legacy format when `protection` is omitted. */
13
+ export type EncryptedWalletFile = WalletIdentity & {
14
+ readonly protection?: "passphrase";
11
15
  readonly ciphertext: string;
12
16
  readonly iv: string;
13
17
  readonly kdf: {
@@ -17,45 +21,72 @@ export type EncryptedWalletFile = {
17
21
  readonly salt: string;
18
22
  };
19
23
  };
24
+ /** Matches the extension's explicit local-only, no-passphrase vault mode. */
25
+ export type UnprotectedWalletFile = WalletIdentity & {
26
+ readonly protection: "none";
27
+ /** Local secret material; protected by the file's owner-only permissions only. */
28
+ readonly recoveryKey: string;
29
+ };
30
+ export type LocalWalletFile = EncryptedWalletFile | UnprotectedWalletFile;
20
31
  export type EncryptedWalletStore = {
21
32
  readonly format: "ldgr-cli-wallet-store";
22
33
  readonly version: 1;
23
34
  readonly activeWalletIndex: number;
24
- readonly wallets: readonly EncryptedWalletFile[];
35
+ readonly wallets: readonly LocalWalletFile[];
25
36
  };
26
- export type WalletFileMetadata = EncryptedWalletFile & {
37
+ export type WalletFileMetadata = LocalWalletFile & {
27
38
  readonly index: number;
28
39
  readonly selected: boolean;
29
40
  };
41
+ export type DefaultWalletPathInput = {
42
+ readonly home?: string | undefined;
43
+ /** Test seam for the former XDG default; production reads XDG_CONFIG_HOME. */
44
+ readonly configHome?: string | undefined;
45
+ };
30
46
  export type CreatedEncryptedWallet = {
31
- readonly file: EncryptedWalletFile;
47
+ readonly file: LocalWalletFile;
32
48
  readonly serialized: string;
33
49
  /** Display once during creation; never persist or log this value. */
34
50
  readonly recoveryKey: string;
35
51
  };
36
- export declare function defaultWalletPath(input?: {
37
- readonly home?: string | undefined;
38
- }): string;
52
+ export declare function defaultWalletPath(input?: DefaultWalletPathInput): string;
53
+ /** The v0.1 default location, used only to preserve existing local wallets. */
54
+ export declare function legacyDefaultWalletPath(input?: DefaultWalletPathInput): string;
39
55
  export declare function createEncryptedWalletFile(input: {
40
56
  readonly ledgerId: LedgerId;
41
- readonly passphrase: string;
57
+ readonly passphrase?: string | undefined;
42
58
  }): Promise<CreatedEncryptedWallet>;
43
- export declare function writeNewWalletFile(path: string, file: EncryptedWalletFile): Promise<void>;
44
- export declare function readEncryptedWalletFile(path: string, passphrase: string): Promise<LdgrSigner>;
59
+ export declare function writeNewWalletFile(path: string, file: LocalWalletFile): Promise<void>;
60
+ /**
61
+ * Moves the validated former XDG default into ~/.ldgr/ on first wallet use.
62
+ * Explicit --wallet paths are deliberately never considered here.
63
+ */
64
+ export declare function migrateDefaultWalletPath(input?: DefaultWalletPathInput): Promise<{
65
+ readonly path: string;
66
+ readonly migrated: boolean;
67
+ }>;
68
+ export declare function readEncryptedWalletFile(path: string, passphrase?: string): Promise<LdgrSigner>;
69
+ /** Writes the exact raw recovery-key text accepted by the extension importer. */
70
+ export declare function exportRecoveryKeyFile(input: {
71
+ readonly walletPath: string;
72
+ readonly backupPath: string;
73
+ readonly passphrase?: string | undefined;
74
+ }): Promise<void>;
45
75
  /** Adds a separately encrypted wallet and selects it. */
46
- export declare function addWalletFile(path: string, file: EncryptedWalletFile): Promise<number>;
47
- /** Encrypts canonical recovery material, adds it to the store, and selects it. */
76
+ export declare function addWalletFile(path: string, file: LocalWalletFile): Promise<number>;
77
+ /** Adds canonical recovery material with optional passphrase protection and selects it. */
48
78
  export declare function importEncryptedWalletFile(input: {
49
79
  readonly path: string;
50
80
  readonly recoveryKey: string;
51
81
  readonly ledgerId: LedgerId;
52
- readonly passphrase: string;
82
+ readonly passphrase?: string | undefined;
53
83
  }): Promise<{
54
- readonly file: EncryptedWalletFile;
84
+ readonly file: LocalWalletFile;
55
85
  readonly index: number;
56
86
  }>;
57
87
  export declare function listWalletFileMetadata(path: string): Promise<readonly WalletFileMetadata[]>;
58
- export declare function selectWalletFile(path: string, index: number): Promise<EncryptedWalletFile>;
88
+ export declare function selectWalletFile(path: string, index: number): Promise<LocalWalletFile>;
59
89
  /** Re-encrypts the same self-custody key for a different LDGR ledger. */
60
- export declare function switchWalletNetwork(path: string, passphrase: string, ledgerId: LedgerId): Promise<EncryptedWalletFile>;
61
- export declare function walletFileMetadata(path: string): Promise<EncryptedWalletFile>;
90
+ export declare function switchWalletNetwork(path: string, passphrase: string | undefined, ledgerId: LedgerId): Promise<LocalWalletFile>;
91
+ export declare function walletFileMetadata(path: string): Promise<LocalWalletFile>;
92
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ldgr/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ldgr": "dist/cli.js"