---
title: "Bring an Ink tool over"
audience: "anyone with an Ink CLI that runs under Node today"
description: "You'll move a small Ink notes browser into FSR with `fsr migrate`. It rewrites Node's modules, gray-matter and pino onto FSR's members and turns the props `render` passed into a loader."
snippet_caption: "app/routes/page.loader.ts"
snippet: "import type { Ctx } from \"@snapfire/fsr/host\";\nimport { path } from \"@snapfire/fsr/host\";\nimport { system } from \"@snapfire/fsr/terminal\";\n\nexport async function load({ params }: Ctx<\"/\">) {\n  const dir = system.env(\"NOTES_DIR\") ?? path.join(system.home(), \"notes\");\n  return { dir };\n}"
---

import { Install } from "@src/ui/Install";

You'll move a small Ink notes browser into FSR with `fsr migrate`. It rewrites Node's modules, gray-matter and pino onto FSR's members and turns the props `render` passed into a loader. The tutorials before this one built a terminal app from scratch. This one starts from a tool that already runs under Node.

<Install captured="0.x" />

## The tool

The notes browser lists the Markdown files in a directory, reads each one's title from its front matter and shows the selected note's text. It has the usual shape of an Ink CLI. An entry works out its settings and calls `render`. A component does the rest.

```json title="notes/package.json"
{
  "name": "notes",
  "version": "1.0.0",
  "type": "module",
  "bin": "dist/cli.js",
  "scripts": {
    "build": "tsc"
  },
  "dependencies": {
    "gray-matter": "^4.0.3",
    "ink": "^6.3.1",
    "pino": "^9.9.0",
    "react": "^19.1.1"
  }
}
```

```tsx title="notes/source/cli.tsx"
#!/usr/bin/env node
import React from "react";
import { render } from "ink";
import os from "node:os";
import path from "node:path";
import App from "./app.js";

const dir = process.env.NOTES_DIR ?? path.join(os.homedir(), "notes");

render(<App dir={dir} />);
```

```tsx title="notes/source/app.tsx"
import React, { useState } from "react";
import { Box, Text, useApp, useInput } from "ink";
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import pino from "pino";

const logger = pino({ level: "info" });

type Note = { file: string; title: string; body: string };

function readNotes(dir: string): Note[] {
  if (!fs.existsSync(dir)) return [];
  const notes = fs
    .readdirSync(dir)
    .filter((name) => name.endsWith(".md"))
    .map((name) => {
      const { data, content } = matter(fs.readFileSync(path.join(dir, name), "utf8"));
      return { file: name, title: data.title ?? name, body: content.trim() };
    });
  logger.info({ dir, count: notes.length }, "read notes");
  return notes;
}

export default function App({ dir }: { dir: string }) {
  const { exit } = useApp();
  const [notes] = useState(() => readNotes(dir));
  const [selected, setSelected] = useState(0);
  const note = notes[selected];

  useInput((input, key) => {
    if (input === "q") exit();
    if (key.upArrow) setSelected(Math.max(selected - 1, 0));
    if (key.downArrow) setSelected(Math.min(selected + 1, notes.length - 1));
  });

  return (
    <Box flexDirection="column">
      <Text bold>
        {notes.length} notes in {dir}
      </Text>
      <Box>
        <Box flexDirection="column" borderStyle="round" width={30} paddingX={1}>
          {notes.map((n, index) => (
            <Text key={n.file} color={index === selected ? "cyan" : undefined}>
              {index === selected ? "> " : "  "}
              {n.title}
            </Text>
          ))}
        </Box>
        <Box borderStyle="round" flexGrow={1} paddingX={1}>
          <Text>{note ? note.body : "no notes yet"}</Text>
        </Box>
      </Box>
      <Text dimColor>up and down move, q quits</Text>
    </Box>
  );
}
```

As it stands, FSR refuses `app.tsx`: it imports `node:fs` and `node:path` and two npm packages the build does not follow.

## Migrate it

`fsr migrate` takes the tool's directory and a new one to write. It needs no `node_modules`:

```shell-session
$ fsr migrate ./notes ./notes-fsr
from      Ink
wrote     3 files under ./notes-fsr
added     react                        react/react.bundle.mjs  10471 bytes
added     react/jsx-runtime            react/jsx-runtime.bundle.mjs  1493 bytes
added     react-dom/client             react-dom/client.bundle.mjs  215154 bytes
note      source/cli.tsx:10: `render` passed `App` the props `dir`; `routes/page.loader.ts` returns them from the entry's statements and `routes/page.tsx` gives them to `App`
note      app/src/app.tsx:8: `pino({ level: "info" })`: the options are dropped; the host's `tracing` filter decides which lines are written
next      fsr check ./notes-fsr/app
```

It found Ink in `package.json` and wrote an FSR project: the files under `source/` went to `app/src/` and the entry that called `render` was left behind. `ink`, `react` and the packages FSR provides are not reported as dependencies to add. gray-matter and pino are not either, since both were rewritten.

## The entry becomes a page and a loader

`app/routes/page.tsx` renders the component the entry rendered:

```tsx title="notes-fsr/app/routes/page.tsx"
import type { RootProps } from "@generated/client";
import App from "@src/app";

export default function Page({ dir }: RootProps) {
  return <App dir={dir} />;
}
```

`render` passed `App` a prop, so the statements before it became `app/routes/page.loader.ts`, with each prop a field of what `load` returns:

```ts title="notes-fsr/app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";
import { path } from "@snapfire/fsr/host";
import { system } from "@snapfire/fsr/terminal";

export async function load({ params }: Ctx<"/">) {
  const dir = system.env("NOTES_DIR") ?? path.join(system.home(), "notes");
  return { dir };
}
```

`process.env.NOTES_DIR` became `system.env("NOTES_DIR")` and `os.homedir()` became `system.home()`. This is the loader from [620](https://snapfirers.com/fsr/learn/620-read-the-command-line): it runs once when the terminal opens and anything after `--` on the command line arrives as `params`.

## Node's modules become FSR's members

`app/src/app.tsx` keeps its shape. Its imports and the calls that used them are rewritten:

```tsx title="notes-fsr/app/src/app.tsx"
import React, { useState } from "react";
import { Box, Text, useApp, useInput } from "ink";
import { file as std_file, path } from "@snapfire/fsr/host";
import { text } from "@snapfire/fsr/host";
import { log } from "@snapfire/fsr/host";

type Note = { file: string; title: string; body: string };

function readNotes(dir: string): Note[] {
  if (!std_file.exists(dir)) return [];
  const notes = std_file.list(dir)
    .filter((name) => name.endsWith(".md"))
    .map((name) => {
      const { data, content } = text.frontMatter(std_file.read(path.join(dir, name)));
      return { file: name, title: data.title ?? name, body: content.trim() };
    });
  log.info("read notes", { dir, count: notes.length });
  return notes;
}
```

The component below `readNotes` is unchanged. `file` is imported as `std_file` because the file already uses `file` as a field name. Each rewrite maps one thing onto one member:

| Under Node | In FSR |
| --- | --- |
| `fs.existsSync`, `readFileSync`, `writeFileSync`, `mkdirSync`, `readdirSync`, `statSync` | `file.exists`, `read`, `write`, `makeDir`, `list`, `stat` |
| `path.join` and the rest of `path` | `path.join` and the rest of `path` |
| `os.homedir()`, `os.platform()`, `os.tmpdir()`, `os.EOL` | `system.home()`, `platform()`, `tempDir()`, `lineEnd()` |
| `process.env.NAME`, `process.platform` | `system.env("NAME")`, `system.platform()` |
| `spawn` from `child_process` | `system.spawn` |
| `matter(source, options)` from gray-matter | `text.frontMatter(source, options)` |
| `matter.stringify({ content }, data, options)` | `text.withFrontMatter(content, data, options)` |
| a `pino(options)` or winston `createLogger(options)` logger | `log`, with the message first and the fields second |

The encoding argument to `readFileSync` is dropped, since `file.read` always reads text. The logger's options are dropped with a note, because the host's `tracing` filter decides which lines are written. A file that uses gray-matter or a logger some other way, `matter.read(path)` or `logger.flush()` for example, keeps its import and gets a note naming the line.

## Run it

Make a notes directory with two notes:

```shell-session
$ mkdir -p ~/notes
$ printf -- '---\ntitle: Release checklist\n---\nTag, push, publish.\n' > ~/notes/release.md
$ printf -- '---\ntitle: Ideas\n---\nA timer per task.\n' > ~/notes/ideas.md
```

Then run the migrated app from its project directory:

```shell-session
$ cd notes-fsr
$ fsr dev app --terminal
```

The header names the directory and counts two notes. The list on the left shows `Ideas` and `Release checklist` by their front matter titles and the pane on the right shows the selected note's text. Up and down move between them and `q` quits. `NOTES_DIR=~/work-notes fsr dev app --terminal` reads another directory, through the same `system.env` the loader now calls.
