mongo-cli — A TUI for MongoDB Snapshots

I needed a way to snapshot and restore MongoDB databases across a few different instances without pulling in mongodump and mongorestore as external dependencies. So I built one. mongo-cli is a Node.js terminal UI tool — a TUI — that lets you snapshot databases, restore them to any configured instance, and manage your backups, all from an interactive menu in the terminal.

This post covers how it works, some of the interesting implementation choices, and what it looks like under the hood.

The stack

The whole thing is built on five packages, each doing exactly one job:


    {
      "dependencies": {
        "chalk":     "^5.6.2",   // colours and styling
        "commander": "^14.0.3",  // CLI argument parsing
        "inquirer":  "^13.3.2",  // interactive prompts and menus
        "mongodb":   "^7.1.1",   // driver + BSON/EJSON
        "ora":       "^8.2.0"    // loading spinners
      }
    }
            

No framework. ES modules throughout, Node.js 18+ required. The project structure keeps things simple — a bin/ entry point, src/ for the logic, and a src/commands/ folder for each operation.

The TUI loop

When you run mcli without any subcommands you land in the interactive menu. It's a while loop that dispatches to command handlers based on what you pick, and keeps running until you choose Exit.


    let running = true;
    while (running) {
        const action = await select({
            message: 'What would you like to do?',
            choices: [
                { name: '📋  List snapshots',         value: 'list'     },
                { name: '📸  Create snapshot',         value: 'snapshot' },
                { name: '♻️   Restore from snapshot',  value: 'restore'  },
                { name: '🗑️   Delete snapshot(s)',      value: 'delete'   },
                new Separator(),
                { name: '⚙️   Configure',              value: 'config'   },
                { name: '🔄  Switch instance',         value: 'switch'   },
                { name: '🚪  Exit',                    value: 'exit'     },
            ],
        });

        switch (action) {
            case 'list':     await cmdList(opts);     break;
            case 'snapshot': await cmdSnapshot(opts); break;
            case 'restore':  await cmdRestore(opts);  break;
            case 'delete':   await cmdDelete(opts);   break;
            case 'config':   await cmdConfig();        break;
            case 'exit':     running = false;          break;
        }
    }
            

inquirer's select prompt handles all the keyboard navigation and rendering. It feels snappy and the code stays clean because each command is just a function call.

Spinners for everything async

Every operation that touches the network or disk gets an ora spinner. Connect, export, import, write — they all show progress and then either tick green or fail with a clear message.


    const spinner = ora('Connecting to MongoDB…').start();
    let client;
    try {
        client = await connect(uri);
        spinner.succeed('Connected to MongoDB');
    } catch (err) {
        spinner.fail(`Connection failed: ${err.message}`);
        return;
    }

    for (const dbName of selectedDbs) {
        const spin = ora(`Exporting ${dbName}…`).start();
        try {
            exportData[dbName] = await exportDatabase(client, dbName);
            const colCount = Object.keys(exportData[dbName]).length;
            const docCount = Object.values(exportData[dbName])
                .reduce((s, d) => s + d.length, 0);
            spin.succeed(`${dbName}  (${colCount} collections, ${docCount} docs)`);
        } catch (err) {
            spin.fail(`Failed to export ${dbName}: ${err.message}`);
            return;
        }
    }
            

Each database gets its own spinner, so you can see exactly where things are up to if you're snapshotting a server with a lot of databases.

EJSON — keeping BSON types intact

This was the most important design decision. If you just JSON.stringify documents from the MongoDB driver, you lose type information. Dates become strings, ObjectIds become objects, binary data gets mangled. The snapshot wouldn't be a real backup — it'd be a lossy copy.

The fix is MongoDB's Extended JSON format, which the bson package (bundled with the driver) provides. EJSON.stringify and EJSON.parse preserve every BSON type through the round-trip.


    import { EJSON } from 'bson';

    // Writing a snapshot — raw BSON objects serialised with full type info
    writeFileSync(
        join(snapshotDir, dbName + '.json'),
        EJSON.stringify(dbData, null, 2)
    );

    // Reading it back — types reconstructed exactly as they were
    databases[dbName] = EJSON.parse(readFileSync(dbPath, 'utf8'));
            

The on-disk format is readable JSON, but with type annotations that mean a Date stays a Date and an ObjectId stays an ObjectId when you restore.

Snapshot structure

Snapshots are stored as directories under ~/.mongo-cli/snapshots/. Each one gets a timestamped name based on when it was created plus the label you give it:


    ~/.mongo-cli/snapshots/
        2026-03-28_14-32-07_my-label/
            metadata.json       ← label, timestamp, source URI, db list
            my_database.json    ← EJSON of all collections and documents
            other_db.json
            

The metadata file is what the list and restore commands read first — it means you don't have to load the actual database files to display the snapshot index.


    const meta = {
        label,
        timestamp,
        mongoUri: redactUri(mongoUri),  // password masked
        databases,
        version: '1',
    };

    writeFileSync(
        join(snapshotDir, 'metadata.json'),
        JSON.stringify(meta, null, 2)
    );
            

Note the redactUri call — credentials in the source URI never make it to disk in plain text.

Multi-instance support

Configuration lives in ~/.mongo-cli/config.json and supports multiple named instances. When you start the tool with more than one instance configured, you get a prompt to pick which one to connect to. The restore command then lets you pick a different instance as the target, which makes cross-instance migrations straightforward.


    {
      "instances": [
        { "name": "Local",       "uri": "mongodb://localhost:27017" },
        { "name": "Staging",     "uri": "mongodb+srv://user:***@staging.example.com" },
        { "name": "Production",  "uri": "mongodb+srv://user:***@prod.example.com" }
      ],
      "backupDir": "/Users/you/.mongo-cli/snapshots"
    }
            

Adding instances is done through the Configure menu — name, URI, validate, save. You can also override the URI entirely at the command line with -u if you need to connect to something not in the config.

The CLI layer

Everything is also accessible as direct subcommands via commander, which means you can script it or use it in pipelines without going through the interactive menu:


    mcli snap -u mongodb://localhost:27017
    mcli ls
    mcli r
    mcli rm
            

The -u and -d flags work on all commands, so you can point at any instance and any backup directory without touching the config file.

Wrapping up

Building this with inquirer and ora was genuinely enjoyable. The TUI layer stays out of the way — the prompts handle all the rendering and keyboard input, so the command logic is just async functions that read, transform, and write data. The EJSON decision was the most important one to get right early; everything else followed naturally once the data model was solid.

If you ever find yourself needing quick MongoDB snapshots without reaching for external tools, this approach works well. Five packages, pure Node.js, and it runs anywhere Node runs.