Tutorial 52 of 54 · for anyone with an Ink CLI that runs under Node today · Markdown

Bring an Ink tool over

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 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.

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"
  }
}
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} />);
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:

$ 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:

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:

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: 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:

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 NodeIn FSR
fs.existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSyncfile.exists, read, write, makeDir, list, stat
path.join and the rest of pathpath.join and the rest of path
os.homedir(), os.platform(), os.tmpdir(), os.EOLsystem.home(), platform(), tempDir(), lineEnd()
process.env.NAME, process.platformsystem.env("NAME"), system.platform()
spawn from child_processsystem.spawn
matter(source, options) from gray-mattertext.frontMatter(source, options)
matter.stringify({ content }, data, options)text.withFrontMatter(content, data, options)
a pino(options) or winston createLogger(options) loggerlog, 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:

$ 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:

$ 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.