npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@frontierjs/cli

v0.1.11

Published

fli — the FrontierJS command line: scaffold, develop, check, deploy and release an app

Readme

fli

The developer interface to FrontierJS — domain 01 of the FJS World, and the one command you type. It scaffolds an application, checks it against the rules the framework publishes, runs the realms, brokers the ports, and ships the result to a server.

fli new my-app          # the whole application — db/ api/ web/, deploy, CI
fli check               # the arch tests: `fli check --list` names every rule
fli dev                 # both servers, after a port preflight
fli deploy              # to a machine you own

It is also a markdown-native command runtime: every command above is a .md file — prose, frontmatter, and a fenced code block that runs. That is the how rather than the what, and it is below. Your application's own commands are written the same way and sit beside the framework's.


Install

npm install -g @frontierjs/cli     # global
fli new my-app

npm create frontier@latest my-app is the same scaffold through the front door — create-frontier resolves this package and runs fli new, so there is one implementation of what an application looks like.

Working on fli itself, from a clone of the monorepo:

cd packages/cli
bun install
bun link                # `fli` on PATH, pointed at this tree

What it does for an application

Commands are markdown files under commands/, one namespace per directory — fli --help is the list. The ones that carry the framework:

| | | | --- | --- | | fli new · fli make:* | Scaffold — an app, a model, a service, a resource, a route, a widget, an extension, a deploy config | | fli check | The arch tests. fli check --list names every rule over the file tree — see below | | fli dev · fli dev:api · fli dev:web | Run the realms. dev refuses a port already answering, and warns about an empty database | | fli db:* | The Data realm — push, pull, migrate, seed, studio, repl, reset, tables | | fli test:* | access, ddl, snapshots, mutate, types — the Testing realm's committed-artefact half | | fli release:check | Can the release still serving and the release starting share one database? | | fli auth:* | Install the schema fragments, create a user, revoke sessions, rotate the key | | fli deploy · deploy:* | Setup a server, build, swap, health-check, roll back. deploy:local is the same pipeline against Docker on this machine | | fli ws:* | The workspace — version, status, add, install, link, npm, exec, run, changed, clean, graph, exports, invariants, atlas, init; fli list names the rest | | fli ps | What holds each port the scheme can name, and which dev slot each app has | | fli project:map | What this application IS — the terminal report, or --json; fli gui's :map draws the same model |

fli list prints all of them, fli <command> -h prints one (--help adds its guide), and fli list --json is the machine-readable form.

The edges are aspirational and this is the honest warning. Several documented commands do not do what their prose says, and the count above is files rather than commands that have been run. Verify a command by running it before citing it.


fli check — the rules a linter cannot reach

A Biome or an ESLint owns generic JavaScript. fli check owns everything derived from the schema, and the boundary is not tooling immaturity: neither linter reads .mesa or .lite, and the questions worth asking here are cross-file. Does this resource name resolve to a model? cannot be answered from the file it appears in.

A rule earns its place by being silent when broken:

| Rule | Invariant | What it catches | | --- | --- | --- | | model-name-case · model-name-plural | 2 | A model that is not PascalCase singular. Three resolvers agree only because it is | | enum-value-case · boolean-prefix · duration-unit-spelled | — | A schema that spells a name two ways (FJS-D1748–D1750, warn): an enum value not camelCase (a model name, a <Kind>.notification.ts basename and an HTTP method are exempt, FJS-D1755), a Boolean field without is/has (a channel name, email and inApp, is exempt, FJS-D1753), a duration field ending Sec, Min or Hr | | resource-export-name | 19 | A Resource over a model that exports, or registers, anything but the model's camelCase plural (singular for a one-row model whose @id defaults to a constant, FJS-D1762), or any createResource('kebab-name') (FJS-D1751, warn) | | resource-dir-mesa · resource-script | 18 | A src/resources/ file that is not .mesa, or has markup outside <script module> | | resource-file-name · resource-one-per-file | 19 | A Resource not named for its noun, or two in one file | | vite-strict-port | — | A Vite config without strictPort — vite otherwise hops to the next free port in silence and the second app's drive tests the first app's app | | body-tag-in-comment | — | The body tag mentioned inside an HTML comment. Vite injects the built <script> at the first textual match and does not skip comments, so the build succeeds and the page loads no JavaScript | | app-layout | 3 | A surface hiding inside another one, a schema that is not at the root, or a file in the wrong src/ folder (FJS-D625–D627) | | widget-entry-name | 19 | A widget whose name cannot be a custom element | | package-root-md | 17 | A fifth markdown file at a package root — a warning naming it, because the rule cannot tell a stray design note from the next thing everyone needs | | command-parses | 15 | A project command under cli/src/routes/ whose compiled JavaScript does not parse — compiled with its _module.md, since the two share a scope, and reported at the .md line. A command compiles when it runs, so one nobody has run is broken in silence. Run it after editing a command: fli check --only command-parses |

core/checks.js is the engine and this repo is its other caller. The structure phase of bun run ci imports it directly and runs it over FrontierJS itself, so a rule loosened for this repo is loosened for every application on the next release — which is the point. Two implementations of one rule is how a framework ends up breaking rules it publishes.

fli check --list prints the table with the invariant each rule comes from.


A command is a markdown file

Prose, YAML frontmatter, and code. The same file is the documentation, the CLI command, and the form the Web GUI renders — so there is nothing to keep in sync.

commands/hello/greet.md
│
├── YAML frontmatter   → title, description, args, flags, examples
├── <script> block     → helpers, shared by the CLI and the GUI
└── ```js block        → the body — runs on execute

Only a fence runs. ```js/```ts is the body as written and ```bash runs through $, the shell; every other fence, and all prose, is a comment. An indented block is prose too — markdown's indented code block is not code here, so an example indented in a paragraph cannot break the command.

Running one:

  1. Scan <fliRoot>/commands/ (the framework's), $XDG_CONFIG_HOME/fli/commands/ (yours, in every project; ~/.config/fli/commands/ by default) and <projectRoot>/cli/src/routes/ (the project's). A title in a later one overrides the same title in an earlier one
  2. Compile the .md to an ESM module — no temp build, no bundler
  3. Validate args and flags against the frontmatter
  4. Call the body with a context

Commands are live: drop a .md file in and it runs. Nothing is rebuilt.

Writing one

The file path decides nothing — title is the command name.

---
title: hello:greet
description: Greet someone from the command line
alias: greet
examples:
  - fli hello:greet World
  - fli hello:greet World --shout
args:
  -
    name: name
    description: Name to greet
    required: true
flags:
  shout:
    type: boolean
    char: s
    description: Uppercase the greeting
    defaultValue: false
  times:
    type: number
    char: n
    description: How many times to greet
    defaultValue: 1
---

<script>
const buildGreeting = (name, shout) => {
  const msg = `Hello, ${name}!`
  return shout ? msg.toUpperCase() : msg
}
</script>

```js
arg.name ??= await tty.line('Who should I greet?')

const greeting = buildGreeting(arg.name, flag.shout)

for (let i = 0; i < (flag.times ?? 1); i++) {
  echo(greeting)
}

log.success(`Greeted ${arg.name} ${flag.times} time(s)`)
```

fli make:command scaffolds this interactively; fli edit <command> opens an existing one.

A shortcut

A command line you type often can have a name of its own:

fli make:shortcut go-time "fli ws:atlas --open --live"

Typing that name now runs it, from the project root, with anything typed after the name appended verbatim. What that writes is an ordinary command file under cli/src/routes/shortcut/, so fli list, completion and fli edit find it with no further wiring — and a shortcut that grows into a real command is an edit to that file rather than a migration out of a table. A leading fli is rewritten to the fli that is running, so a shortcut cannot reach a different install than the one that made it.

A name the registry already answers to is refused, naming what holds it. That refusal is the reason the command exists rather than a line in a config file: a project command overrides a core one in silence, which is right for authoring and wrong for a name typed from memory — fli make:shortcut new "…" would otherwise eat fli new and say nothing.

An alias is claimed first-come and a contested one is a bug. Two commands claiming one alias warns, and the winner is whichever loads last — the walk is sorted so that is at least reproducible, but nothing about utils sorting after ports says which should own dev. Resolve it by renaming one side.

Frontmatter

Read by @frontierjs/toolbelt/frontmatter, the same YAML subset sierra and mesa read (FJS-D549). A block outside it — an unquoted : in a value, an anchor, a tab in the indentation — is refused, naming the file and line: fli list warns and leaves the command out, and fli check's command-parses reports it. Quote a value that holds : or #.

| Field | Required | Description | |---|---|---| | title | ✅ | Command name, namespace:command | | description | | Shown in fli list and the GUI | | alias | | Short name — fli new for project:new | | examples | | Example invocations | | args | | Ordered positional definitions | | flags | | Named flag definitions | | mode | | strict refuses an undeclared flag · passthrough accepts it in silence | | effects | | What the command does beyond this machine, one line — shown by --help, fli list --json and the GUI | | confirm | | human: a person approves each run. Needs effects |

confirm: human is enforced before the body runs. At a terminal, the person typing the command is the approval. Anywhere else — an agent's shell, a pipe, fli gui — the run is refused unless it carries --approved, a flag fli adds to the command rather than one it declares. --dry does not skip it, because whether a dry run touches nothing is the command's promise, not fli's. fli gui asks the person at the page and sends --approved on a yes.

effects: sends a message to a customer
confirm: human

--approved is only as good as whoever grants it. fli cannot tell a person from an agent typing the flag itself; the enforcement is that the agent has to ask. An agent host's permission rule that holds any command line carrying --approved for a person makes that real, and the person sees the whole line — so a reply passed as --body is approved word for word, and one passed as --file is approved by its path.

Arg fields — name (read as arg.name), description, required, defaultValue, variadic (joins the remaining positionals into one string; must be last).

Flag fields — type (string · boolean · number), char (single-letter shorthand), description, defaultValue, required, multiple (the flag may be given more than once and arrives as an array). A flag with both required and defaultValue is always satisfied — the default fills it in.

Constraints — choices, a list of the values allowed, and min/max on a number flag, both inclusive. A value outside them is refused with a message written from the declaration, --help prints them beside the type, and the GUI draws choices as a select. A broken declaration — a range on a string, a choices that is not a list — is refused on every run, not only on the run that passes the flag.

flags:
  every:
    type: number
    min: 5
    max: 90
  sort:
    type: string
    choices:
      - name
      - size

A flag is named for what it does. A boolean that is on unless asked is declared push with defaultValue: true, typed --no-push (the argv parser's own reading), and read as flag.push. Its description says what --no-push does, because that is the spelling --help and completion print. A flag declared no-push is refused by name.

JSON is --json, on every command (FJS-D401). A command a program might read declares a json boolean and prints its model when it is set. --as picks among the layouts a PERSON reads — --as=report, --as=page — which may change on any commit, because nothing reads them but people. An as whose choices include json is refused, and --as=json names --json in the refusal.


Multi-step commands — _steps/

A large command breaks into numbered step files that run in order and share state. Step files are never registered as commands of their own.

commands/deploy/
  index.md          ← orchestrator: defines the flags, populates $.config
  _steps/
    01-validate.md
    02-build.md     ← optional: true   — failure warns and continues
    03-push.md      ← skip: "flag.dry" — skipped when --dry
    04-lint.md      ← parallel: true   — runs with 05 and 06
    05-typecheck.md ← parallel: true
    06-test.md      ← parallel: true
    07-finish.md    ← serial checkpoint — waits for 04/05/06
fli deploy              # every step
fli deploy --dry        # 03 skipped by its own predicate
fli deploy --step 2     # re-run one step, shown as [2/3]

| Field | Description | |---|---| | optional: true | A failure warns and the run continues | | skip: "expr" | A JS expression; truthy means skip | | parallel: true | Runs with adjacent parallel steps. A non-parallel step is a serial checkpoint — everything before it must finish first. Parallel steps share $.config, so write to distinct keys |

Steps sort lexicographically by filename, and two files sharing a numeric prefix warn at runtime rather than picking an order nobody wrote.


The context — $

The ```js block runs inside run($). $ is the command in progress: the context, and callable as the shell. It is one per invocation and it is not a request context — nothing here acts on behalf of a remote caller, so there is no principal, no auth, no query; the fields are capabilities rather than inputs. The spelling is the framework's: Junction's $ is the call in progress and Mesa's is the component runtime, so knowing one teaches the next.

// Destructured from $ at the top of the body:
arg             // positional args        → arg.name, arg.path
flag            // flags                  → flag.dry, flag.force
log             // the styled logger      → log.info/success/warn/error/dry/detail
tty             // the terminal, held     → tty.keys/line/live/aside/onExit/title/wrap
echo            // one output line (stdout, or the GUI's event stream); a call or a tag, echo`  ● ${name}`
                //   echo.lines(rows, { indent, style }) — a block; false/null rows are dropped
chalk           // core/color.js — follows NO_COLOR, as fli's own output does; a call or a tag (§ Printing a line)

// On $:
$.config   // shared mutable state across _steps/
$.paths    // the resolved project → .root .api .web .db .cli .widgets …
$.env      // process.env
$.git      // repo access — pkgState(name, dir), wsRepo(packages)
$.exec     // a synchronous shell command
$.stream   // an async streaming one
$.execute  // several, in sequence
$.forwarded // the words typed after the command name, fli's own --dry taken out
$.wsRoot() // the workspace root, found from cwd

// $ itself, as a tag — Bun's shell, under fli's rules:
const sha = (await $`git rev-parse HEAD`).text().trim()

// Imported for every command, so they need no <script>:
path  fs

$`…` captures by default and prints only under --verbose; .stdout is a Buffer, so read .text(), .json() or .lines(). --dry runs nothing and logs the command, the rule $.exec has, through the same owner. A non-zero exit throws with the command in the message and .exitCode, .stdout, .stderr on the error; .nothrow() opts out. The shell is Bun's, not bash: no heredoc and no for/while — a body that needs them spells $`bash -c ${script}`. An interpolation is one argument whatever it holds.

A <script> block is module scope and sees none of the destructured names — log, echo, tty, flag are ReferenceErrors there. A helper takes what it needs as a parameter. fli check's command-resolves rule reports every such name at its line, and a name declared in both the namespace module and the command.

$.git asks with a pathspec, and that is not a nicety. Every member of this workspace shares one .git, so a bare git status --porcelain run from packages/mesa describes the whole monorepo — which had all sixteen ws:* rows reporting one another's state.

Printing a line

A line that mixes styles is one chalk tag, in chalk-template's syntax: {style text}, dotted to combine ({bold.cyan …}), nestable. A line in one style stays a call, chalk.dim(s). A count with a noun is plural(n, word) from core/text.js. commands/ports/status.md is the reference:

const { plural } = await import(resolve(global.fliRoot, 'core/text.js'))

echo(chalk`  {bold ${plural(busy.length, 'port')} in use}\n`)
echo(chalk`  {green ●}  {bold.cyan ${String(b.port).padEnd(6)}} {dim ·} ${what}`)
echo(b.command
  ? chalk`     {dim pid ${b.pids.join(', ')}}  {gray ${b.command}}`
  : chalk`     {dim (no lsof here)}`)

Markup is read only from the template's own text, so a path or a command line interpolated into it prints as itself, braces included. A { opens a style only when a style name and a space follow it, and an unclosed one throws at the call. A row of parts with one separator is an array .join(chalk.dim(' · ')).

A fixed block is one tagged echo, written in the shape it prints — echo is a tag as well as a call, and the template prints verbatim, so every leading space in the source is output and a body line starts at column 0. echo adds the final newline, so the closing backtick on its own line leaves one blank line after the block. commands/basecamp/create.md is the reference:

const block = `Host ${made.name}
  HostName ${made.ip}
  User root
`
echo`  Add to ~/.ssh/config:

${block.replace(/^(?=.)/gm, '  ')}`

echo's tag reads the same markup as chalk's, so a styled line or block is one tag with no chalk around it — echo` {dim name } ${key.name}`. As with chalk, only the literal text is markup and an interpolated value prints as itself.

A block whose rows exist only sometimes is echo.lines, which drops a false or null row, so started && dim(started) needs no if around it.

exec and stream

Every command gets --dry for free and both respect it.

$.exec is synchronous — for short work whose output can arrive at the end (git, a quick docker call, file manipulation):

$.exec({ command: `git push origin ${flag.branch}` })
// --dry logs "[dry] git push origin main" and runs nothing

args appends words one shell word each, quoted, which is how a command hands what it was given to another one — a shortcut is exactly that:

$.exec({ command: `${$.fli} dev:desktop`, args: $.forwarded })
// fli basecamp --no-build → fli dev:desktop '--no-build'

$.stream is async — for long work where live output is the point (a docker build, tailing logs, a dev server, an SSH session). In the GUI it streams line by line as SSE rather than landing all at once:

await $.stream({ command: `ssh ${host} "docker logs --follow ${container}"` })

tty — a command that holds the terminal

For a command that stays open: a single-key prompt, a status line pinned to the bottom, the screen handed to an editor and taken back.

tty.onExit(() => api('POST', '/status', { online: false }))  // Ctrl-C included, capped at 3s
tty.title('● online')
const bar = tty.live(() => [chalk.green('● online'), `${waiting} waiting`, chalk.dim('? keys')])

const k = await tty.keys(`Send #${id}?`, { y: 'yes', e: 'edit', s: 'skip' })
if (k === 'e') await tty.aside(() => $.stream({ command: `$EDITOR ${file}` }))

Whatever the command prints lands above the footer: echo, log and console all clear it, print, and redraw it, so nothing has to be routed through tty. live takes a string or an array of parts, and parts drop from the right until the line fits. keys takes one keypress with no Enter: Enter picks a question's first choice, which is shown upper-case ([Y]es), Esc is a choice only when one is named esc, and a null question listens without taking the footer and with no default — a menu under the status line. The terminal is put back however the command ends: raw mode, the footer and the title on a return, a throw, Ctrl-C or SIGTERM. onExit runs on all four, and a second Ctrl-C does not wait for it. While aside runs, Ctrl-C belongs to the child, and this command's own output waits until it returns.

With no terminal — a pipe, or fli gui — tty.interactive is false: live, title and aside add nothing, and keys refuses by name, or answers with its first choice when the command declares --yes and it was passed. Check tty.interactive before offering keys.


Which project am I in

$.paths.* all hang off one root, walked up from the working directory:

  1. --project <dir> / FLI_PROJECT — explicit, beats everything
  2. .fli.json — an explicit marker. Deepest match wins
  3. db/schema.lite — an FJS application root. Deepest match wins
  4. .git/ — the repo root, skipped when running inside fli's own checkout
  5. package.json — the fallback for a project not in git

Rule 3 is what makes a monorepo work. example/ and packages/basecamp/ are each their own application, so fli project:map inside either resolves to that application rather than to the repo's .git. It is also why create-frontier pins --project: walking up is right for every other command and wrong for a scaffold, which would otherwise write into the enclosing repository's root.

.fli.json

{
  "routesDir":        "cli/src/routes",
  "defaultNamespace": "hello",
  "editor":           "code"
}

| Field | Default | | |---|---|---| | routesDir | cli/src/routes | Where your own commands live | | defaultNamespace | hello | For fli init and make:command | | editor | $EDITOR | Opened by fli edit |

Directory names are environment variables, one per surface — WEB_DIR, API_DIR, DB_DIR, CLI_DIR, WIDGETS_DIR, EXTENSION_DIR, SITE_DIR, MOBILE_DIR, TESTS_DIR, WIKI_DIR.


Built-in flags

| Flag | Short | | |---|---|---| | --dry | -d | Show what would run, run nothing | | --test | -t | NODE_ENV=test | | --step | | Re-run one _steps/ step by number | | --project <dir> | | Run against that root instead of resolving one | | --version | -v | With no command: print fli's own version and exit |

--project is consumed before the command runs — it sets $.paths.* and is stripped from the command's own flags, so it drives an application from outside it: fli project:map --project packages/basecamp.


Ports

core/ports.js is the scheme, and it is the whole of it — port = env*1000 + category*100 + project*10 + service. env: 7 test · 8 dev · 9 prod. category: 0 fe · 1 be · 2 widgets-dev · 3 widgets-served · 4 extension · 5 tooling · 6 site-dev · 7 site-served · 8 desktop-dev.

A scaffolded app is project 0 — web on 8000, API on 8100. fli dev gives each such app a slot of its own, the service digit: the second app you start runs on 8001/8101, and the slot is remembered per app directory in ~/.fli/sessions.lock, so it comes back on the same ports next time. The ports reach the servers as FRONTIER_PORT_FE, FRONTIER_PORT_BE, FRONTIER_PORT_SITE, FRONTIER_PORT_WIDGET and FRONTIER_PORT_DESKTOP, which the scaffolded configs read; an app whose configs ignore them stays on slot 0. fli ps lists the slots. Global fli tooling has no project and so no env, and takes a 5 where the env digit goes — 5500–5509, of which 5500 is the GUI, 5502 db studio and 5503 junction's devtools, which the GUI's :live reads. No project port in any env can land there.


The Web GUI

fli gui                # in a project: its tooling port beside its web port (8001 → 8501); outside one: 5500
fli gui --test         # 7500 — --production is 9500
fli gui --port 8080    # or FLI_PORT
fli gui --open         # and open a browser

The GUI builds a form for every command out of its frontmatter — the same file, no second definition — and streams output live, colored by log level. A sample of its routes, core/server.js for the rest:

| Method | Path | | |---|---|---| | GET | / | The GUI | | GET | /api/commands | All command metadata | | GET | /api/commands/:name | One command, with its source blocks | | POST | /api/run/:name | Run it; answers an SSE stream | | GET | /api/proves | Which drive proves the change you just made | | GET | /api/check | The arch-test rules over this project | | GET | /api/release | The Release realm's pivot verdict |

{ "args": ["value1"], "flags": { "shout": true, "times": 3 } }
data: {"type":"output","text":"HELLO, WORLD!\n"}
data: {"type":"log","level":"success","text":"Greeted World 3 time(s)"}
data: {"type":"done"}
data: {"type":"error","text":"arg [name] is required!"}

Tab completion

fli completion:install
source ~/.zshrc            # or ~/.bashrc, or restart the shell
fli <TAB>                  # every command and alias
fli dep<TAB>               # deploy, deploy:logs, deploy:run …
fli deploy:logs <TAB>      # --production, --stage, --follow, --tail …

The script calls fli completion:query on every Tab press, against a disk cache at ~/.fli/cache/registry-<key>.json that rebuilds when any command file changes. completion:generate prints the script; completion:refresh forces a rebuild.


The Frontier network

fli frontier:* makes this machine a member of the Frontier network: one nick and one chain of machines, staked with an invite at the project's post, network.frontierjs.com. The verbs and what a machine keeps are commands/frontier/_module.md. These are the network's rules for nicks:

  • A nick is unique on the post and first come. Whoever stakes it with an invite holds it. It is 2 to 16 lower-case letters, digits, - and _.
  • A nick never changes, and neither does the number drawn with it (FJS-D1451).
  • Some nicks are reserved, the ones that read as the post, the project or everyone speaking — admin, support, frontier, all and the rest of RESERVED in core/tell-worker.js. stake refuses one and says why.
  • Squatting is against the rules. Squatting is staking a nick to hold it rather than use it, or to pass as a person or project it is not, as npm's dispute policy defines it. The post's owner may take back a squatted nick (FJS-D1450, FJS-D1475). No verb does that yet; it is built when the first case comes.

Files

A member leaves a file at the post and gets a link; anyone holding the link downloads it, member or not (FJS-D1566).

fli tell:upload report.pdf                      # prints the link and its limits
fli tell:upload build.zip --downloads 3 --hours 72 --to sam
fli tell:upload ./dist --qr                     # a folder goes as dist.zip; --qr for a phone
pg_dump app | fli tell:upload - --name app.sql  # - reads stdin
fli tell:download 'https://network.frontierjs.com/file/<id>#<key>'
  • Sealed before it leaves. The file, its name and its type are encrypted on the sender's machine. The key is the part of the link after #, which a browser never sends, so the post holds neither the content nor the name (FJS-D1564). Quote the link in a shell, which reads # as a comment. A file that compresses is compressed first, so a log or a CSV is stored at a fraction of its size; the 24 MiB limit counts the file as it is on disk (FJS-D1574).
  • The link is the file. Whoever holds it can download it, so --to sends it only sealed, to a member with a sealing key. The same link opens in a browser, where a page decrypts it; opening the page spends nothing until its Download button is pressed. The page shows the sender's crest, so a link is traced to a member at a glance.
  • Limits (FJS-D1565): 24 MiB a file. A link allows 1 download unless --downloads asks for up to 10, and lasts 24 hours unless --hours asks for up to a week. The post deletes the file after its last download or when it expires. A member uploads 10 files a day and holds 10 full files at once; the owner has no allowance. The uploader or the owner can delete a file early with a signed DELETE /file/<id>.

Traps

  • The read-only path imports no command runtime. list, help, ? and completion take color from core/color.js and reach runtime.js only at the call site that runs a command, never at the top of bootstrap.js — a static import there is paid by every fli list.
  • A clean compile is not proof of valid JS (Invariant 15). Compiling every command file and parsing the output found 14 producing broken JavaScript the compiler reported as fine. Every command file has a parse test; a new one needs one too.
  • The parse sweep compiles a command with no namespace module, so a command using a _module.md helper parses whether or not the module defines it. That is not theoretical: completion/_module.md used four imports it did not have, so all five completion commands threw and Tab completion had never worked while every suite stayed green. A command whose only proof is the sweep has not been run.
  • A fenced block in a _module.md renders as an empty heading. Module prose has every fence stripped, because in a command file a fence IS the body. Write a namespace overview as a plain list.

Inside a <script> block

Two things the compiler cannot see through, and both only matter when your command generates .md files — make:command does:

const fence       = '`'.repeat(3)     // no literal triple backticks
const scriptClose = '</' + 'script>'  // no literal closing script tag

Running tests

bun run test

Two batches, and the split is load-bearing: the _steps/ tests must run in a separate process, because bun's shared module cache carries state between them.

bun run test:browser is the GUI's own drive — one spec per panel of fli gui's front page, over mesa's harness. A change to a scaffold is proved by scaffolding into a temp directory and running what comes out — node scripts/scaffold-build.mjs from the repo root does exactly that, and the scaffold CI phase runs it against packed tarballs. A change to core/checks.js also needs node scripts/ci.mjs --fast, because this repo is its other caller.