mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
feat(wallet): migrate cocod data to routstrd wallet directory
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# Migrate wallet storage from `~/.cocod` to `~/.routstrd/wallet`
|
||||
|
||||
Task: [`routstrd-migrate-cocod-to-routstrd-files`](https://github.com/nodestrich/routstrd)
|
||||
Event ID: `2f420644005154c5b106017c4a753854bb2660ba834cde7e6e267532cb5448e9`
|
||||
Priority: 997 · Status: open
|
||||
|
||||
---
|
||||
|
||||
## Baseline
|
||||
|
||||
This plan is based on `coco-integration` at `86ac688` (after the default-mint, NPC/npubx.cash, and Windows-support changes).
|
||||
|
||||
The task branch is currently based on `04f8c22`; rebase it onto the latest `coco-integration` before implementing this plan.
|
||||
|
||||
## Goal
|
||||
|
||||
Move the in-process Cashu wallet's persistent data from:
|
||||
|
||||
```text
|
||||
~/.cocod/config.json
|
||||
~/.cocod/coco.db
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
~/.routstrd/wallet/config.json
|
||||
~/.routstrd/wallet/coco.db
|
||||
```
|
||||
|
||||
The migration must preserve the mnemonic, proofs, default mint, and NPC identity. Existing users must not accidentally get a new wallet, and `cocod` and routstrd must never open the same database concurrently.
|
||||
|
||||
Fresh installs must use `~/.routstrd/wallet` immediately. Existing installs must be migrated automatically and safely; merely continuing to run from `~/.cocod` is not sufficient for this task.
|
||||
|
||||
---
|
||||
|
||||
## Important corrections to the previous plan
|
||||
|
||||
### 1. Do not rename or migrate `cocod.sock`
|
||||
|
||||
The in-process `coco-core` wallet does not expose a Unix socket. `cocod.sock` belongs to the legacy external `cocod` daemon and is used only to determine whether that daemon is still alive.
|
||||
|
||||
There should therefore be no `wallet.sock`. Do not copy a socket inode into the new directory.
|
||||
|
||||
### 2. Keep legacy-process paths separate from wallet-data paths
|
||||
|
||||
On the current base, `src/daemon/wallet/coco-client.ts` derives all of these from one `CONFIG_DIR`:
|
||||
|
||||
- wallet config and database;
|
||||
- legacy cocod socket;
|
||||
- legacy cocod PID/process lock.
|
||||
|
||||
That coupling must be removed. After migration:
|
||||
|
||||
- wallet data comes from `~/.routstrd/wallet`;
|
||||
- probes and shutdown logic for an external legacy cocod continue to use `~/.cocod/cocod.sock` and `~/.cocod/cocod.pid`;
|
||||
- routstrd uses `~/.routstrd/wallet/wallet.pid` as the in-process wallet lock.
|
||||
|
||||
While routstrd owns the migrated wallet, it should also retain the existing legacy-cocod exclusion mechanism (claiming the legacy `cocod.pid`) so an old cocod cannot be started against a stale or partially migrated legacy wallet. Acquisition and release of both locks must be rollback-safe.
|
||||
|
||||
### 3. Do not rename `cocodPath`
|
||||
|
||||
`RoutstrdConfig.cocodPath` is an executable path for the external compatibility client, not a wallet storage path. Renaming it to `walletPath` would change its meaning and would not help this migration.
|
||||
|
||||
The current daemon creates `createCocoClient()` directly and injects it into `createWalletAdapter()`, so `cocodPath` is effectively bypassed on the normal in-process path. Cleanup or removal of that compatibility setting is a separate task.
|
||||
|
||||
### 4. Do not implement fallback as the steady state
|
||||
|
||||
A resolver that permanently falls back to `~/.cocod` does not move the data. Legacy detection is needed to initiate migration, but successful startup should resolve to the canonical directory afterward.
|
||||
|
||||
### 5. Account for the updated base
|
||||
|
||||
The latest `coco-integration` adds:
|
||||
|
||||
- `defaultMintUrl` persistence in the wallet's `config.json`;
|
||||
- the NPC plugin, whose Nostr identity is derived from the same mnemonic;
|
||||
- `src/daemon/wallet/coco-client.npc.test.ts`;
|
||||
- Windows support and `USERPROFILE` fallback.
|
||||
|
||||
Migration must preserve the complete config JSON rather than reconstructing selected fields. Path construction must use `path.join` rather than hard-coded `/` separators.
|
||||
|
||||
---
|
||||
|
||||
## Current runtime path references on the latest base
|
||||
|
||||
### Wallet data and legacy process coordination
|
||||
|
||||
| File | Current responsibility | Required change |
|
||||
|---|---|---|
|
||||
| `src/daemon/wallet/coco-client.ts` | Uses one `.cocod` directory for config, DB, socket, and PID; owns the in-process wallet and legacy-cocod guard | Split canonical data paths from legacy process paths; migrate before opening the DB; use `wallet.pid` for the in-process lock |
|
||||
| `src/daemon/wallet/cocod-client.ts` | External cocod compatibility client; defaults to `.cocod/cocod.sock` | Keep legacy defaults and clarify that they are external-cocod paths |
|
||||
| `src/cli.ts` | Initializes `.cocod`; two restart paths wait on `.cocod/cocod.pid` | Initialize/migrate the canonical wallet and wait on `wallet.pid` |
|
||||
| `src/utils/config.ts` | Defines `~/.routstrd`, but no wallet subdirectory | Export canonical and legacy wallet/process path helpers or constants |
|
||||
| `src/daemon/index.ts` | Calls `createCocoClient()` before building the adapter | Ensure migration occurs before the client opens the DB |
|
||||
|
||||
### Tests and documentation
|
||||
|
||||
| File | Required update |
|
||||
|---|---|
|
||||
| `src/cli.test.ts` | Change fresh-wallet expectations and add migration coverage |
|
||||
| `src/daemon/wallet/coco-client.test.ts` | Update data/lock paths and add migration/dual-lock tests |
|
||||
| `src/daemon/wallet/coco-client.npc.test.ts` | Use the canonical wallet layout in fixtures; verify migrated mnemonic/config still drives NPC identity |
|
||||
| `README.md` | Document the new location and migration behavior; retain legacy cocod terminology only where discussing compatibility |
|
||||
| `SKILL.md` | Update wallet storage and environment-variable documentation |
|
||||
|
||||
The fixture `src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz` keeps its name because it records the fixture's provenance.
|
||||
|
||||
---
|
||||
|
||||
## Target path model
|
||||
|
||||
Define the path model in one dependency-light module (for example `src/daemon/wallet/paths.ts`, or in `src/utils/config.ts` if that does not introduce a cycle):
|
||||
|
||||
```text
|
||||
ROUTSTRD config root process.env.ROUTSTRD_DIR || ~/.routstrd
|
||||
canonical wallet directory process.env.ROUTSTRD_WALLET_DIR || <config root>/wallet
|
||||
canonical wallet config <wallet directory>/config.json
|
||||
canonical wallet database <wallet directory>/coco.db
|
||||
canonical wallet lock process.env.ROUTSTRD_WALLET_PID || <wallet directory>/wallet.pid
|
||||
|
||||
legacy cocod directory process.env.COCOD_DIR || ~/.cocod
|
||||
legacy cocod socket process.env.COCOD_SOCKET || <legacy directory>/cocod.sock
|
||||
legacy cocod PID process.env.COCOD_PID || <legacy directory>/cocod.pid
|
||||
```
|
||||
|
||||
Use `HOME` with `USERPROFILE` fallback, matching the updated Windows-support base. Use functions or injectable path objects where tests need to change environment variables after module import.
|
||||
|
||||
`COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` remain compatibility controls for locating an old external cocod. They must not redirect the new in-process wallet away from `~/.routstrd/wallet`. `ROUTSTRD_WALLET_DIR` is the new explicit data-directory override.
|
||||
|
||||
---
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Phase 1 — Centralize and separate paths
|
||||
|
||||
1. Add the canonical wallet and legacy cocod path definitions above.
|
||||
2. Remove the local `.cocod` path construction from `coco-client.ts` and `cli.ts`.
|
||||
3. Change `CreateCocoClientOptions` so tests/tooling can independently override:
|
||||
- wallet data directory;
|
||||
- wallet lock path;
|
||||
- legacy cocod socket and PID paths.
|
||||
4. Keep `cocod-client.ts` pointed at the legacy socket. Do not make it import a resolver that prefers the new wallet directory.
|
||||
|
||||
### Phase 2 — Add a safe automatic migration primitive
|
||||
|
||||
Add an idempotent `migrateLegacyWallet()` helper with injectable paths/filesystem operations for tests.
|
||||
|
||||
Preconditions and behavior:
|
||||
|
||||
1. If the canonical wallet already contains both `config.json` and `coco.db`, return `already-current` and do not touch legacy data.
|
||||
2. If neither canonical nor legacy wallet data exists, return `fresh`; initialization will create the canonical directory.
|
||||
3. If only the legacy wallet exists:
|
||||
- first verify that legacy cocod is not running using the existing PID and socket guard;
|
||||
- create a private staging directory under `~/.routstrd` with mode `0700`;
|
||||
- copy the complete `config.json` and `coco.db` into staging without parsing or rewriting them;
|
||||
- apply `0600` to both files;
|
||||
- validate that the staged files exist and have the expected byte sizes;
|
||||
- atomically rename the staged directory to `wallet`;
|
||||
- only after the canonical directory is committed, remove the legacy `config.json` and `coco.db`;
|
||||
- never copy `cocod.sock` or `cocod.pid`.
|
||||
4. If canonical storage is partial, legacy storage is partial, both contain wallet data, or files conflict, stop with an actionable error. Never merge databases and never generate a new mnemonic over an ambiguous state.
|
||||
5. Clean up an uncommitted staging directory after failure. A committed canonical wallet remains authoritative if cleanup of the old files fails; report that cleanup warning clearly.
|
||||
|
||||
The migration should preserve unknown config fields, including `defaultMintUrl`, and preserve the database byte-for-byte. This also preserves the NPC identity because that identity is derived from the mnemonic.
|
||||
|
||||
A staging copy plus atomic directory rename is preferred over two independent file renames: a crash must not expose a half-created canonical wallet. Copying also permits a clear rollback before commit and supports a legacy directory located on another filesystem through `COCOD_DIR`.
|
||||
|
||||
### Phase 3 — Run migration from every wallet-opening path
|
||||
|
||||
1. **CLI onboarding/init:** run migration before `initializeWallet()`. Only initialize a new mnemonic when migration reports `fresh`.
|
||||
2. **Daemon direct startup:** run migration before `createCocoClient()` opens `coco.db`. This covers users who invoke the daemon without rerunning onboarding.
|
||||
3. **Start/restart/update/service paths:** retain `stopLegacyCocod()` before migration/startup where those paths already stop legacy cocod. Direct daemon startup should refuse with the existing actionable error rather than silently running two wallet engines.
|
||||
4. Print a concise success message showing old and new directories, but never print config contents or the mnemonic during migration.
|
||||
|
||||
A separate `routstrd wallet migrate` command is optional as a manual recovery/preview entry point, but it must call the same migration primitive. It is not a substitute for automatic migration.
|
||||
|
||||
### Phase 4 — Separate process locking
|
||||
|
||||
Refactor the current `claimLegacyCocodPidFile()` behavior into explicit responsibilities:
|
||||
|
||||
1. Claim `wallet.pid` for the lifetime of the in-process wallet to prevent two routstrd wallet instances from opening `coco.db`.
|
||||
2. Continue claiming the legacy `cocod.pid` while routstrd is active, after verifying no real cocod owns it, to prevent an old cocod from starting.
|
||||
3. If either claim fails, release any claim already acquired before returning the error.
|
||||
4. On `CocodClient.dispose()`, startup failure, and daemon shutdown, release only PID files still owned by the current process.
|
||||
5. Keep `stopLegacyCocod()` and `assertLegacyCocodNotRunning()` operating only on legacy cocod paths.
|
||||
6. Update both PID-release waits in `src/cli.ts` (`restartDaemonsAfterUpdate` and `restart`) to wait for the canonical `wallet.pid` rather than `.cocod/cocod.pid`.
|
||||
|
||||
There is no canonical wallet socket.
|
||||
|
||||
### Phase 5 — Update initialization and client defaults
|
||||
|
||||
1. Make `initializeWallet()` default to the canonical wallet directory.
|
||||
2. Preserve directory mode `0700` and config mode `0600` on both migrated and fresh wallets.
|
||||
3. In `createCocoClient()`, derive `config.json` and `coco.db` from the canonical wallet directory, but take legacy guard paths separately.
|
||||
4. Update the wallet-access comment near `unlock()` to reference `~/.routstrd/wallet/config.json`.
|
||||
5. Leave `CocodClient`, `resolveCocodExecutable()`, and `cocodPath` compatibility behavior unchanged.
|
||||
|
||||
### Phase 6 — Tests
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
- fresh initialization creates `~/.routstrd/wallet`, not `.cocod`;
|
||||
- a legacy config and database migrate byte-for-byte;
|
||||
- `defaultMintUrl` and unknown config fields survive migration;
|
||||
- the NPC-derived identity is unchanged after migration;
|
||||
- migration never copies socket or PID files;
|
||||
- migration refuses while a real legacy cocod is running;
|
||||
- stale legacy socket/PID handling remains safe;
|
||||
- an existing complete canonical wallet wins without modifying it;
|
||||
- partial canonical, partial legacy, and conflicting dual-wallet states fail without generating a mnemonic;
|
||||
- staging cleanup and retry after an interrupted migration;
|
||||
- custom `ROUTSTRD_DIR`, `ROUTSTRD_WALLET_DIR`, and legacy `COCOD_DIR` paths;
|
||||
- Windows-compatible path construction;
|
||||
- acquiring the second process lock rolls back the first on failure;
|
||||
- `dispose()` releases both owned locks and does not unlink another process's lock;
|
||||
- both CLI restart paths wait for `wallet.pid`.
|
||||
|
||||
Keep `cocod-0.0.24-wallet.db.gz` unchanged and continue using it to prove the migrated database opens successfully with the current in-process wallet.
|
||||
|
||||
### Phase 7 — Documentation
|
||||
|
||||
Update `README.md` and `SKILL.md` to state:
|
||||
|
||||
- wallet data is stored in `~/.routstrd/wallet`;
|
||||
- existing `~/.cocod` data is migrated automatically on first startup;
|
||||
- users must back up the mnemonic before migration;
|
||||
- `ROUTSTRD_WALLET_DIR` overrides the canonical wallet directory;
|
||||
- `COCOD_DIR`, `COCOD_SOCKET`, `COCOD_PID`, and `cocodPath` refer only to legacy external-cocod compatibility.
|
||||
|
||||
Do not replace every use of the word `cocod`: references to the external daemon, compatibility client, legacy guard, package, and fixture are still accurate.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A fresh install creates wallet data only under `~/.routstrd/wallet`.
|
||||
- Starting from a valid `.cocod` wallet results in the same config and database under the canonical directory without changing balances, mnemonic-derived NPC identity, or default mint.
|
||||
- No startup path silently creates a new mnemonic when recoverable legacy data exists.
|
||||
- A running legacy cocod blocks migration and database opening.
|
||||
- The new wallet directory contains no copied Unix socket and uses `wallet.pid` only as an in-process lock.
|
||||
- Legacy cocod probing and exclusion continue to use `.cocod/cocod.sock` and `.cocod/cocod.pid`.
|
||||
- The implementation works with `USERPROFILE`/Windows path construction and custom directory overrides.
|
||||
- All wallet, CLI, typecheck, and build tests pass on the rebased `coco-integration` branch.
|
||||
|
||||
---
|
||||
|
||||
## Suggested commit sequence
|
||||
|
||||
1. `refactor(wallet): separate routstrd wallet paths from legacy cocod paths`
|
||||
2. `feat(wallet): atomically migrate legacy cocod wallet data`
|
||||
3. `fix(wallet): use independent routstrd and legacy cocod process locks`
|
||||
4. `test(wallet): cover migration, conflicts, NPC identity, and lock rollback`
|
||||
5. `docs: document routstrd wallet storage and legacy migration`
|
||||
@@ -161,6 +161,17 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
## Wallet storage
|
||||
|
||||
The in-process Cashu wallet stores its mnemonic and proof database in
|
||||
`~/.routstrd/wallet/`. On first startup, an existing wallet in `~/.cocod/` is
|
||||
migrated automatically after routstrd verifies that the legacy cocod daemon is
|
||||
not running. Back up your mnemonic before upgrading.
|
||||
|
||||
Set `ROUTSTRD_WALLET_DIR` to override the canonical wallet directory. The
|
||||
`COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` variables are retained only for
|
||||
locating and excluding a legacy external cocod process.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `~/.routstrd/config.json`:
|
||||
|
||||
@@ -170,7 +170,9 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ROUTSTRD_DIR` | `~/.routstrd` | Config directory |
|
||||
| `COCOD_DIR` | `~/.cocod` | Wallet config directory |
|
||||
| `ROUTSTRD_WALLET_DIR` | `~/.routstrd/wallet` | In-process Cashu wallet data directory |
|
||||
| `ROUTSTRD_WALLET_PID` | `<wallet>/wallet.pid` | In-process wallet lock path |
|
||||
| `COCOD_DIR` | `~/.cocod` | Legacy external cocod compatibility directory |
|
||||
|
||||
### `routstrd mode`
|
||||
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
|
||||
describe("initializeWallet", () => {
|
||||
test("creates the wallet directory and config with restrictive permissions", () => {
|
||||
const walletDir = join(makeTempDir(), ".cocod");
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
|
||||
initializeWallet(walletDir);
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("initializeWallet", () => {
|
||||
});
|
||||
|
||||
test("repairs permissions without replacing an existing wallet", () => {
|
||||
const walletDir = join(makeTempDir(), ".cocod");
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
const walletConfig = join(walletDir, "config.json");
|
||||
const existingConfig = JSON.stringify({ mnemonic: "existing seed" });
|
||||
|
||||
|
||||
+37
-11
@@ -17,6 +17,7 @@ import {
|
||||
} from "./utils/clients";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { dirname, join } from "path";
|
||||
import {
|
||||
CONFIG_DIR,
|
||||
DB_PATH,
|
||||
@@ -27,7 +28,18 @@ import {
|
||||
} from "./utils/config";
|
||||
import { logger } from "./utils/logger";
|
||||
import { setupIntegration, runIntegrationsForClients } from "./integrations";
|
||||
import { stopLegacyCocod } from "./daemon/wallet/coco-client";
|
||||
import {
|
||||
assertLegacyCocodNotRunning,
|
||||
claimLegacyCocodPidFile,
|
||||
stopLegacyCocod,
|
||||
} from "./daemon/wallet/coco-client";
|
||||
import { migrateLegacyWallet } from "./daemon/wallet/migration";
|
||||
import {
|
||||
legacyCocodPidPath,
|
||||
legacyCocodSocketPath,
|
||||
walletDir as defaultWalletDir,
|
||||
walletPidPath,
|
||||
} from "./daemon/wallet/paths";
|
||||
import { getClientsList } from "./utils/clients";
|
||||
import * as QRCode from "qrcode";
|
||||
import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98";
|
||||
@@ -85,12 +97,8 @@ async function printLightningInvoice(invoice: string): Promise<void> {
|
||||
console.log(`${qr}\nInvoice:\n${invoice}`);
|
||||
}
|
||||
|
||||
export function initializeWallet(
|
||||
walletDir =
|
||||
process.env.COCOD_DIR ||
|
||||
`${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`,
|
||||
): void {
|
||||
const walletConfig = `${walletDir}/config.json`;
|
||||
export function initializeWallet(walletDir = defaultWalletDir()): void {
|
||||
const walletConfig = join(walletDir, "config.json");
|
||||
|
||||
// The wallet directory and config contain the plaintext seed phrase. Correct
|
||||
// permissions on existing installations as well as newly created ones.
|
||||
@@ -144,8 +152,8 @@ async function restartDaemonsAfterUpdate(): Promise<void> {
|
||||
|
||||
await callDaemon("/stop", { method: "POST" });
|
||||
|
||||
// Wait for HTTP health check to fail AND pidfile to be released.
|
||||
const pidFilePath = `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod/cocod.pid`;
|
||||
// Wait for HTTP health check to fail AND wallet lock to be released.
|
||||
const pidFilePath = walletPidPath();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const healthDown = !(await isDaemonRunning());
|
||||
@@ -232,6 +240,24 @@ async function initDaemon(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(`Database will be stored at: ${DB_PATH}`);
|
||||
const migration = await migrateLegacyWallet({
|
||||
assertLegacyStopped: () =>
|
||||
assertLegacyCocodNotRunning({
|
||||
socketPath: legacyCocodSocketPath(),
|
||||
pidFilePath: legacyCocodPidPath(),
|
||||
}),
|
||||
acquireLegacyLock: () => {
|
||||
mkdirSync(dirname(legacyCocodPidPath()), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() });
|
||||
},
|
||||
});
|
||||
if (migration.status === "migrated") {
|
||||
console.log(`Migrated wallet from ${migration.from} to ${migration.to}.`);
|
||||
for (const warning of migration.cleanupWarnings) console.warn(warning);
|
||||
}
|
||||
initializeWallet();
|
||||
|
||||
await startDaemon({ port: String(config.port || 8008), host: config.host || undefined });
|
||||
@@ -1833,8 +1859,8 @@ program
|
||||
console.log("Stopping daemon...");
|
||||
await callDaemon("/stop", { method: "POST" });
|
||||
|
||||
// Wait for HTTP health check to fail AND pidfile to be released.
|
||||
const pidFilePath = `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod/cocod.pid`;
|
||||
// Wait for HTTP health check to fail AND wallet lock to be released.
|
||||
const pidFilePath = walletPidPath();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const healthDown = !(await isDaemonRunning());
|
||||
|
||||
+31
-1
@@ -54,7 +54,18 @@ import { createDaemonRequestHandler } from "./http";
|
||||
import { FileRequestResponseLogSink } from "./request-response-log-sink";
|
||||
import { refreshModelsAndIntegrations } from "../integrations";
|
||||
import { RoutstrClient } from "@routstr/sdk";
|
||||
import { createCocoClient } from "./wallet/coco-client";
|
||||
import { mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import {
|
||||
assertLegacyCocodNotRunning,
|
||||
claimLegacyCocodPidFile,
|
||||
createCocoClient,
|
||||
} from "./wallet/coco-client";
|
||||
import { migrateLegacyWallet } from "./wallet/migration";
|
||||
import {
|
||||
legacyCocodPidPath,
|
||||
legacyCocodSocketPath,
|
||||
} from "./wallet/paths";
|
||||
|
||||
// Global error handlers — the daemon is spawned detached with stdout/stderr
|
||||
// redirected to a file, so without these, uncaught async errors would kill
|
||||
@@ -115,6 +126,25 @@ async function main(): Promise<void> {
|
||||
const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } =
|
||||
createModelService(modelManager, providerManager, store);
|
||||
|
||||
const migration = await migrateLegacyWallet({
|
||||
assertLegacyStopped: () =>
|
||||
assertLegacyCocodNotRunning({
|
||||
socketPath: legacyCocodSocketPath(),
|
||||
pidFilePath: legacyCocodPidPath(),
|
||||
}),
|
||||
acquireLegacyLock: () => {
|
||||
mkdirSync(dirname(legacyCocodPidPath()), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() });
|
||||
},
|
||||
});
|
||||
if (migration.status === "migrated") {
|
||||
startupProgress(`Wallet migrated from ${migration.from} to ${migration.to}.`);
|
||||
for (const warning of migration.cleanupWarnings) logger.warn(warning);
|
||||
}
|
||||
|
||||
const walletClient = await createCocoClient();
|
||||
|
||||
// ── Auto-refill configuration ────────────────────────────────
|
||||
|
||||
@@ -49,7 +49,7 @@ const tempDirs: string[] = [];
|
||||
function makeWalletDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-"));
|
||||
tempDirs.push(dir);
|
||||
const walletDir = join(dir, ".cocod");
|
||||
const walletDir = join(dir, "wallet");
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(walletDir, "config.json"),
|
||||
@@ -58,6 +58,14 @@ function makeWalletDir(): string {
|
||||
return walletDir;
|
||||
}
|
||||
|
||||
function testClientOptions(walletDir = makeWalletDir()) {
|
||||
return {
|
||||
walletDir,
|
||||
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
||||
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
||||
};
|
||||
}
|
||||
|
||||
function resetNpcState(): void {
|
||||
npcState.info = { name: "alice", pubkey: "ab".repeat(32) };
|
||||
npcState.usernameResult = { success: true };
|
||||
@@ -74,7 +82,7 @@ afterEach(() => {
|
||||
|
||||
describe("in-process coco client NPC integration", () => {
|
||||
it("registers the plugin and exposes the wallet's NPC lightning address", async () => {
|
||||
const client = await createCocoClient({ configDir: makeWalletDir() });
|
||||
const client = await createCocoClient(testClientOptions());
|
||||
try {
|
||||
const info = await client.getNpcAddress();
|
||||
expect(info).toEqual({
|
||||
@@ -89,7 +97,7 @@ describe("in-process coco client NPC integration", () => {
|
||||
|
||||
it("falls back to the npub address when no username is set", async () => {
|
||||
npcState.info = { name: null, pubkey: "ab".repeat(32) };
|
||||
const client = await createCocoClient({ configDir: makeWalletDir() });
|
||||
const client = await createCocoClient(testClientOptions());
|
||||
try {
|
||||
const info = await client.getNpcAddress();
|
||||
expect(info.address).toStartWith("npub1");
|
||||
@@ -106,7 +114,7 @@ describe("in-process coco client NPC integration", () => {
|
||||
success: false,
|
||||
pr: { amount: 21, mints: ["https://mint.example.com"] },
|
||||
};
|
||||
const client = await createCocoClient({ configDir: makeWalletDir() });
|
||||
const client = await createCocoClient(testClientOptions());
|
||||
try {
|
||||
const result = await client.setNpcUsername("bob");
|
||||
expect(result).toEqual({
|
||||
@@ -122,7 +130,7 @@ describe("in-process coco client NPC integration", () => {
|
||||
});
|
||||
|
||||
it("confirms the claim fee payment when requested", async () => {
|
||||
const client = await createCocoClient({ configDir: makeWalletDir() });
|
||||
const client = await createCocoClient(testClientOptions());
|
||||
try {
|
||||
const result = await client.setNpcUsername("bob", true);
|
||||
expect(result).toEqual({ success: true });
|
||||
@@ -135,7 +143,7 @@ describe("in-process coco client NPC integration", () => {
|
||||
});
|
||||
|
||||
it("triggers a manual quote sync", async () => {
|
||||
const client = await createCocoClient({ configDir: makeWalletDir() });
|
||||
const client = await createCocoClient(testClientOptions());
|
||||
try {
|
||||
await client.syncNpc();
|
||||
expect(npcState.syncCalls).toBe(1);
|
||||
@@ -146,7 +154,7 @@ describe("in-process coco client NPC integration", () => {
|
||||
|
||||
it("rejects NPC calls with a clear error when the plugin is disabled", async () => {
|
||||
const client = await createCocoClient({
|
||||
configDir: makeWalletDir(),
|
||||
...testClientOptions(),
|
||||
enableNpc: false,
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||
import { gunzipSync } from "bun";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
@@ -43,7 +44,7 @@ function socketOnly(path: string): boolean {
|
||||
|
||||
describe("default mint functionality", () => {
|
||||
it("automatically adds default mint when no mints exist", async () => {
|
||||
const walletDir = join(makeTempDir(), ".cocod");
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(walletDir, "config.json"),
|
||||
@@ -55,8 +56,19 @@ describe("default mint functionality", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const client = await createCocoClient({ configDir: walletDir });
|
||||
const client = await createCocoClient({
|
||||
walletDir,
|
||||
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
||||
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
||||
});
|
||||
try {
|
||||
expect(readFileSync(join(walletDir, "wallet.pid"), "utf8")).toBe(
|
||||
String(process.pid),
|
||||
);
|
||||
expect(readFileSync(join(walletDir, "legacy-cocod.pid"), "utf8")).toBe(
|
||||
String(process.pid),
|
||||
);
|
||||
|
||||
const mints = await client.listMints();
|
||||
expect(mints).toContain("https://mint.cubabitcoin.org");
|
||||
|
||||
@@ -65,10 +77,12 @@ describe("default mint functionality", () => {
|
||||
} finally {
|
||||
await client.dispose?.();
|
||||
}
|
||||
expect(existsSync(join(walletDir, "wallet.pid"))).toBe(false);
|
||||
expect(existsSync(join(walletDir, "legacy-cocod.pid"))).toBe(false);
|
||||
});
|
||||
|
||||
it("respects existing default mint in config", async () => {
|
||||
const walletDir = join(makeTempDir(), ".cocod");
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
const configuredDefault = "https://mint.cubabitcoin.org";
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
|
||||
@@ -84,7 +98,11 @@ describe("default mint functionality", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const client = await createCocoClient({ configDir: walletDir });
|
||||
const client = await createCocoClient({
|
||||
walletDir,
|
||||
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
||||
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
||||
});
|
||||
try {
|
||||
// Should respect the configured default mint
|
||||
const defaultMint = await client.getDefaultMint();
|
||||
@@ -99,7 +117,7 @@ describe("default mint functionality", () => {
|
||||
});
|
||||
|
||||
it("allows setting default mint to an already trusted mint", async () => {
|
||||
const walletDir = join(makeTempDir(), ".cocod");
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(walletDir, "config.json"),
|
||||
@@ -111,7 +129,11 @@ describe("default mint functionality", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const client = await createCocoClient({ configDir: walletDir });
|
||||
const client = await createCocoClient({
|
||||
walletDir,
|
||||
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
||||
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
||||
});
|
||||
try {
|
||||
// The initial default should be the auto-added Cuba mint
|
||||
const initialDefault = await client.getDefaultMint();
|
||||
@@ -133,7 +155,7 @@ describe("default mint functionality", () => {
|
||||
|
||||
describe("legacy cocod wallet migration", () => {
|
||||
it("opens an existing unencrypted config and preserves database balances", async () => {
|
||||
const walletDir = join(makeTempDir(), ".cocod");
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
const mintUrl = "https://mint.example.com";
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
|
||||
@@ -164,7 +186,9 @@ describe("legacy cocod wallet migration", () => {
|
||||
// NPC is disabled here: this test verifies database migration, and the
|
||||
// real plugin would otherwise open a websocket to npubx.cash.
|
||||
const client = await createCocoClient({
|
||||
configDir: walletDir,
|
||||
walletDir,
|
||||
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
||||
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
||||
enableNpc: false,
|
||||
});
|
||||
try {
|
||||
@@ -177,7 +201,9 @@ describe("legacy cocod wallet migration", () => {
|
||||
// Prove the migrated schema remains reopenable and the pre-existing
|
||||
// proofs survive a complete in-process wallet restart.
|
||||
const reopenedClient = await createCocoClient({
|
||||
configDir: walletDir,
|
||||
walletDir,
|
||||
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
||||
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
||||
enableNpc: false,
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -16,13 +16,14 @@ import { finalizeEvent, nip19, type EventTemplate } from "nostr-tools";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { dirname, join } from "path";
|
||||
import { mnemonicToSeedSync } from "@scure/bip39";
|
||||
import type {
|
||||
CocodClient,
|
||||
@@ -31,20 +32,15 @@ import type {
|
||||
NpcUsernameResult,
|
||||
} from "./cocod-client";
|
||||
import { logger } from "../../utils/logger";
|
||||
import {
|
||||
legacyCocodPidPath,
|
||||
legacyCocodSocketPath,
|
||||
walletDir as defaultWalletDir,
|
||||
walletPidPath as defaultWalletPidPath,
|
||||
} from "./paths";
|
||||
|
||||
const NPC_DEFAULT_BASE_URL = "https://npubx.cash";
|
||||
|
||||
const CONFIG_DIR =
|
||||
process.env.COCOD_DIR ||
|
||||
`${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
|
||||
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||
const DB_PATH = join(CONFIG_DIR, "coco.db");
|
||||
const LEGACY_COCOD_SOCKET =
|
||||
process.env.COCOD_SOCKET || join(CONFIG_DIR, "cocod.sock");
|
||||
const LEGACY_COCOD_PID_FILE =
|
||||
process.env.COCOD_PID || join(CONFIG_DIR, "cocod.pid");
|
||||
|
||||
const STALE_SOCKET_ERROR_CODES = new Set([
|
||||
"ECONNREFUSED",
|
||||
"ENOENT",
|
||||
@@ -167,7 +163,7 @@ function createCocoLogger(bindings: Record<string, unknown> = {}): CocoLogger {
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfig(configFile: string = CONFIG_FILE): CocodConfig {
|
||||
function loadConfig(configFile: string): CocodConfig {
|
||||
if (!existsSync(configFile)) {
|
||||
throw new Error(
|
||||
`Config file not found at ${configFile}. Run 'routstrd onboard' first.`,
|
||||
@@ -182,10 +178,7 @@ function loadConfig(configFile: string = CONFIG_FILE): CocodConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function saveConfig(
|
||||
config: CocodConfig,
|
||||
configFile: string = CONFIG_FILE,
|
||||
): void {
|
||||
function saveConfig(config: CocodConfig, configFile: string): void {
|
||||
const temporaryFile = `${configFile}.${process.pid}.tmp`;
|
||||
try {
|
||||
writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
|
||||
@@ -239,8 +232,8 @@ function hasErrorCode(error: unknown, codes: Set<string>): boolean {
|
||||
export async function assertLegacyCocodNotRunning(
|
||||
options: LegacyCocodGuardOptions = {},
|
||||
): Promise<void> {
|
||||
const socketPath = options.socketPath || LEGACY_COCOD_SOCKET;
|
||||
const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE;
|
||||
const socketPath = options.socketPath || legacyCocodSocketPath();
|
||||
const pidFilePath = options.pidFilePath || legacyCocodPidPath();
|
||||
const pathExists = options.pathExists || existsSync;
|
||||
const readFile = options.readFile || ((path) => readFileSync(path, "utf-8"));
|
||||
const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
|
||||
@@ -325,8 +318,8 @@ export async function assertLegacyCocodNotRunning(
|
||||
export async function stopLegacyCocod(
|
||||
options: LegacyCocodStopOptions = {},
|
||||
): Promise<void> {
|
||||
const socketPath = options.socketPath || LEGACY_COCOD_SOCKET;
|
||||
const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE;
|
||||
const socketPath = options.socketPath || legacyCocodSocketPath();
|
||||
const pidFilePath = options.pidFilePath || legacyCocodPidPath();
|
||||
const pathExists = options.pathExists || existsSync;
|
||||
const readFile =
|
||||
options.readFile || ((path: string) => readFileSync(path, "utf-8"));
|
||||
@@ -416,7 +409,14 @@ export async function stopLegacyCocod(
|
||||
export function claimLegacyCocodPidFile(
|
||||
options: LegacyCocodPidClaimOptions = {},
|
||||
): () => void {
|
||||
const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE;
|
||||
return claimPidFile({
|
||||
...options,
|
||||
pidFilePath: options.pidFilePath || legacyCocodPidPath(),
|
||||
});
|
||||
}
|
||||
|
||||
function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: string }): () => void {
|
||||
const pidFilePath = options.pidFilePath;
|
||||
const pid = options.pid ?? process.pid;
|
||||
const openExclusive =
|
||||
options.openExclusive || ((path: string) => openSync(path, "wx", 0o600));
|
||||
@@ -507,10 +507,15 @@ export function claimLegacyCocodPidFile(
|
||||
}
|
||||
|
||||
export interface CreateCocoClientOptions {
|
||||
/** Override the wallet directory, primarily for migration tests and tooling. */
|
||||
/** Override the canonical wallet data directory. */
|
||||
walletDir?: string;
|
||||
/** Deprecated alias retained for existing callers during migration. */
|
||||
configDir?: string;
|
||||
socketPath?: string;
|
||||
pidFilePath?: string;
|
||||
/** Override the in-process wallet lock path. */
|
||||
walletPidPath?: string;
|
||||
/** Override legacy external-cocod coordination paths. */
|
||||
legacySocketPath?: string;
|
||||
legacyPidPath?: string;
|
||||
/** Set to false to skip NPC (npubx.cash) plugin registration. Default: true. */
|
||||
enableNpc?: boolean;
|
||||
/** NPC server base URL. Default: https://npubx.cash */
|
||||
@@ -520,20 +525,36 @@ export interface CreateCocoClientOptions {
|
||||
export async function createCocoClient(
|
||||
options: CreateCocoClientOptions = {},
|
||||
): Promise<CocodClient> {
|
||||
const configDir = options.configDir || CONFIG_DIR;
|
||||
const configDir = options.walletDir || options.configDir || defaultWalletDir();
|
||||
const configFile = join(configDir, "config.json");
|
||||
const dbPath = join(configDir, "coco.db");
|
||||
const socketPath =
|
||||
options.socketPath ||
|
||||
(options.configDir ? join(configDir, "cocod.sock") : LEGACY_COCOD_SOCKET);
|
||||
const pidFilePath =
|
||||
options.pidFilePath ||
|
||||
(options.configDir ? join(configDir, "cocod.pid") : LEGACY_COCOD_PID_FILE);
|
||||
const walletPidFile =
|
||||
options.walletPidPath ||
|
||||
(options.walletDir || options.configDir
|
||||
? join(configDir, "wallet.pid")
|
||||
: defaultWalletPidPath());
|
||||
const legacySocket = options.legacySocketPath || legacyCocodSocketPath();
|
||||
const legacyPidFile = options.legacyPidPath || legacyCocodPidPath();
|
||||
const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL;
|
||||
const npcAddressDomain = new URL(npcBaseUrl).host;
|
||||
|
||||
await assertLegacyCocodNotRunning({ socketPath, pidFilePath });
|
||||
const releaseLegacyPidClaim = claimLegacyCocodPidFile({ pidFilePath });
|
||||
await assertLegacyCocodNotRunning({
|
||||
socketPath: legacySocket,
|
||||
pidFilePath: legacyPidFile,
|
||||
});
|
||||
// The canonical wallet directory is created by initialization/migration.
|
||||
// Keep a legacy PID claim as an exclusion fence for old cocod binaries.
|
||||
mkdirSync(dirname(legacyPidFile), { recursive: true, mode: 0o700 });
|
||||
const releaseWalletPidClaim = claimPidFile({ pidFilePath: walletPidFile });
|
||||
let releaseLegacyPidClaim: () => void;
|
||||
try {
|
||||
releaseLegacyPidClaim = claimLegacyCocodPidFile({
|
||||
pidFilePath: legacyPidFile,
|
||||
});
|
||||
} catch (error) {
|
||||
releaseWalletPidClaim();
|
||||
throw error;
|
||||
}
|
||||
|
||||
let database: Database | undefined;
|
||||
let coco: Awaited<ReturnType<typeof initializeCoco>> | undefined;
|
||||
@@ -616,6 +637,7 @@ export async function createCocoClient(
|
||||
} catch (error) {
|
||||
database?.close();
|
||||
releaseLegacyPidClaim();
|
||||
releaseWalletPidClaim();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -653,7 +675,7 @@ export async function createCocoClient(
|
||||
|
||||
async unlock(_passphrase: string): Promise<string> {
|
||||
// coco-core does not support passphrase locking.
|
||||
// Wallet access is controlled via the mnemonic in ~/.cocod/config.json.
|
||||
// Wallet access is controlled by ~/.routstrd/wallet/config.json.
|
||||
return "wallet does not require unlocking";
|
||||
},
|
||||
|
||||
@@ -756,6 +778,7 @@ export async function createCocoClient(
|
||||
database.close();
|
||||
} finally {
|
||||
releaseLegacyPidClaim();
|
||||
releaseWalletPidClaim();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { migrateLegacyWallet } from "./migration";
|
||||
|
||||
const roots: string[] = [];
|
||||
function root(): string {
|
||||
const path = mkdtempSync(join(tmpdir(), "routstrd-migration-"));
|
||||
roots.push(path);
|
||||
return path;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("migrateLegacyWallet", () => {
|
||||
it("copies wallet data byte-for-byte and leaves process files behind", async () => {
|
||||
const base = root();
|
||||
const legacyDir = join(base, ".cocod");
|
||||
const walletDir = join(base, ".routstrd", "wallet");
|
||||
mkdirSync(legacyDir);
|
||||
const config = JSON.stringify({
|
||||
mnemonic: "seed words",
|
||||
encrypted: false,
|
||||
defaultMintUrl: "https://mint.example",
|
||||
unknown: { preserved: true },
|
||||
});
|
||||
const database = new Uint8Array([0, 1, 2, 3, 255]);
|
||||
writeFileSync(join(legacyDir, "config.json"), config);
|
||||
writeFileSync(join(legacyDir, "coco.db"), database);
|
||||
writeFileSync(join(legacyDir, "cocod.pid"), "123");
|
||||
writeFileSync(join(legacyDir, "cocod.sock"), "not-a-real-socket");
|
||||
const assertStopped = mock(async () => {});
|
||||
const releaseLock = mock(() => {});
|
||||
const acquireLock = mock(() => releaseLock);
|
||||
|
||||
const result = await migrateLegacyWallet({
|
||||
walletDir,
|
||||
legacyDir,
|
||||
assertLegacyStopped: assertStopped,
|
||||
acquireLegacyLock: acquireLock,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("migrated");
|
||||
expect(assertStopped).toHaveBeenCalledTimes(2);
|
||||
expect(acquireLock).toHaveBeenCalledTimes(1);
|
||||
expect(releaseLock).toHaveBeenCalledTimes(1);
|
||||
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe(config);
|
||||
expect(readFileSync(join(walletDir, "coco.db"))).toEqual(Buffer.from(database));
|
||||
expect(statSync(walletDir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(walletDir, "config.json")).mode & 0o777).toBe(0o600);
|
||||
expect(existsSync(join(walletDir, "cocod.pid"))).toBe(false);
|
||||
expect(existsSync(join(walletDir, "cocod.sock"))).toBe(false);
|
||||
expect(existsSync(join(legacyDir, "config.json"))).toBe(false);
|
||||
expect(existsSync(join(legacyDir, "coco.db"))).toBe(false);
|
||||
expect(existsSync(join(legacyDir, "cocod.pid"))).toBe(true);
|
||||
expect(existsSync(join(legacyDir, "cocod.sock"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a config-only initialized wallet as migratable", async () => {
|
||||
const base = root();
|
||||
const legacyDir = join(base, ".cocod");
|
||||
const walletDir = join(base, ".routstrd", "wallet");
|
||||
mkdirSync(legacyDir);
|
||||
writeFileSync(join(legacyDir, "config.json"), '{"mnemonic":"seed"}');
|
||||
|
||||
expect((await migrateLegacyWallet({ walletDir, legacyDir })).status).toBe("migrated");
|
||||
expect(existsSync(join(walletDir, "config.json"))).toBe(true);
|
||||
expect(existsSync(join(walletDir, "coco.db"))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a database without its mnemonic config", async () => {
|
||||
const base = root();
|
||||
const legacyDir = join(base, ".cocod");
|
||||
mkdirSync(legacyDir);
|
||||
writeFileSync(join(legacyDir, "coco.db"), "database");
|
||||
|
||||
await expect(migrateLegacyWallet({ walletDir: join(base, "wallet"), legacyDir })).rejects.toThrow(
|
||||
"without config.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses when both canonical and legacy configs exist", async () => {
|
||||
const base = root();
|
||||
const legacyDir = join(base, ".cocod");
|
||||
const walletDir = join(base, ".routstrd", "wallet");
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
writeFileSync(join(legacyDir, "config.json"), "legacy");
|
||||
writeFileSync(join(walletDir, "config.json"), "current");
|
||||
|
||||
await expect(migrateLegacyWallet({ walletDir, legacyDir })).rejects.toThrow(
|
||||
"both",
|
||||
);
|
||||
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe("current");
|
||||
expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toBe("legacy");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
} from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { legacyCocodDir, walletDir } from "./paths";
|
||||
|
||||
export type WalletMigrationResult =
|
||||
| { status: "fresh" }
|
||||
| { status: "already-current" }
|
||||
| { status: "migrated"; from: string; to: string; cleanupWarnings: string[] };
|
||||
|
||||
export interface WalletMigrationOptions {
|
||||
walletDir?: string;
|
||||
legacyDir?: string;
|
||||
/** Called after state validation and before legacy files are copied. */
|
||||
assertLegacyStopped?: () => void | Promise<void>;
|
||||
/** Claims the legacy cocod exclusion lock for the duration of the copy. */
|
||||
acquireLegacyLock?: () => (() => void) | Promise<() => void>;
|
||||
}
|
||||
|
||||
type WalletState = "absent" | "database-only" | "initialized";
|
||||
|
||||
function state(configPath: string, dbPath: string): WalletState {
|
||||
const hasConfig = existsSync(configPath);
|
||||
const hasDb = existsSync(dbPath);
|
||||
if (hasConfig) return "initialized";
|
||||
if (hasDb) return "database-only";
|
||||
return "absent";
|
||||
}
|
||||
|
||||
function filesEqual(left: string, right: string): boolean {
|
||||
if (!existsSync(left) || !existsSync(right)) return false;
|
||||
if (statSync(left).size !== statSync(right).size) return false;
|
||||
return readFileSync(left).equals(readFileSync(right));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a legacy cocod wallet into the canonical routstrd wallet directory.
|
||||
* The staging directory is renamed atomically, so the canonical path is never
|
||||
* exposed with only one of its two required files.
|
||||
*/
|
||||
export async function migrateLegacyWallet(
|
||||
options: WalletMigrationOptions = {},
|
||||
): Promise<WalletMigrationResult> {
|
||||
const targetDir = options.walletDir || walletDir();
|
||||
const sourceDir = options.legacyDir || legacyCocodDir();
|
||||
const targetConfig = join(targetDir, "config.json");
|
||||
const targetDb = join(targetDir, "coco.db");
|
||||
const sourceConfig = join(sourceDir, "config.json");
|
||||
const sourceDb = join(sourceDir, "coco.db");
|
||||
const targetState = state(targetConfig, targetDb);
|
||||
const sourceState = state(sourceConfig, sourceDb);
|
||||
|
||||
// config.json is sufficient for a newly initialized wallet; coco.db is
|
||||
// created on first open. A database without its mnemonic is never usable.
|
||||
if (targetState === "initialized" && sourceState === "initialized") {
|
||||
// A prior migration may have committed successfully but failed to remove
|
||||
// its source files. Identical leftovers are safe; divergent wallets are not.
|
||||
const configsMatch = filesEqual(targetConfig, sourceConfig);
|
||||
const databasesMatch =
|
||||
!existsSync(targetDb) && !existsSync(sourceDb)
|
||||
? true
|
||||
: filesEqual(targetDb, sourceDb);
|
||||
if (configsMatch && databasesMatch) return { status: "already-current" };
|
||||
throw new Error(
|
||||
`Cannot migrate wallet: both ${targetDir} and ${sourceDir} contain different wallet data. ` +
|
||||
"Refusing to choose a mnemonic or merge wallet databases automatically.",
|
||||
);
|
||||
}
|
||||
if (targetState === "initialized") return { status: "already-current" };
|
||||
if (targetState === "database-only" || sourceState === "database-only") {
|
||||
throw new Error(
|
||||
`Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` +
|
||||
"A coco.db without config.json cannot be opened; restore the matching config first.",
|
||||
);
|
||||
}
|
||||
if (sourceState === "absent") return { status: "fresh" };
|
||||
|
||||
await options.assertLegacyStopped?.();
|
||||
const releaseLegacyLock = await options.acquireLegacyLock?.();
|
||||
const stagingDir = join(
|
||||
dirname(targetDir),
|
||||
`.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Recheck after claiming the exclusion lock to close the probe/claim race.
|
||||
await options.assertLegacyStopped?.();
|
||||
mkdirSync(dirname(targetDir), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(stagingDir, { mode: 0o700 });
|
||||
const stagedConfig = join(stagingDir, "config.json");
|
||||
const stagedDb = join(stagingDir, "coco.db");
|
||||
copyFileSync(sourceConfig, stagedConfig);
|
||||
chmodSync(stagedConfig, 0o600);
|
||||
const hasSourceDb = existsSync(sourceDb);
|
||||
if (hasSourceDb) {
|
||||
copyFileSync(sourceDb, stagedDb);
|
||||
chmodSync(stagedDb, 0o600);
|
||||
}
|
||||
|
||||
if (
|
||||
statSync(stagedConfig).size !== statSync(sourceConfig).size ||
|
||||
(hasSourceDb && statSync(stagedDb).size !== statSync(sourceDb).size)
|
||||
) {
|
||||
throw new Error("Cannot migrate wallet: staged files failed size validation.");
|
||||
}
|
||||
|
||||
renameSync(stagingDir, targetDir);
|
||||
} catch (error) {
|
||||
rmSync(stagingDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
} finally {
|
||||
releaseLegacyLock?.();
|
||||
}
|
||||
|
||||
const cleanupWarnings: string[] = [];
|
||||
for (const path of [sourceConfig, sourceDb]) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
unlinkSync(path);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cleanupWarnings.push(`Could not remove legacy wallet file ${path}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { status: "migrated", from: sourceDir, to: targetDir, cleanupWarnings };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { join } from "path";
|
||||
|
||||
function homeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || "";
|
||||
}
|
||||
|
||||
/** Root directory for routstrd state. Evaluated lazily so env overrides work in tests. */
|
||||
export function routstrdConfigDir(): string {
|
||||
return process.env.ROUTSTRD_DIR || join(homeDir(), ".routstrd");
|
||||
}
|
||||
|
||||
/** Canonical directory for the in-process Cashu wallet. */
|
||||
export function walletDir(): string {
|
||||
return process.env.ROUTSTRD_WALLET_DIR || join(routstrdConfigDir(), "wallet");
|
||||
}
|
||||
|
||||
/** Lock owned by an in-process routstrd wallet instance. */
|
||||
export function walletPidPath(): string {
|
||||
return process.env.ROUTSTRD_WALLET_PID || join(walletDir(), "wallet.pid");
|
||||
}
|
||||
|
||||
/** Legacy external cocod directory. This is not the routstrd wallet directory. */
|
||||
export function legacyCocodDir(): string {
|
||||
return process.env.COCOD_DIR || join(homeDir(), ".cocod");
|
||||
}
|
||||
|
||||
export function legacyCocodSocketPath(): string {
|
||||
return process.env.COCOD_SOCKET || join(legacyCocodDir(), "cocod.sock");
|
||||
}
|
||||
|
||||
export function legacyCocodPidPath(): string {
|
||||
return process.env.COCOD_PID || join(legacyCocodDir(), "cocod.pid");
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { logger } from "./utils/logger";
|
||||
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
|
||||
import { withCrossProcessLock } from "./utils/process-lock";
|
||||
import { fileURLToPath } from "url";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
|
||||
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
|
||||
|
||||
Reference in New Issue
Block a user