Skip to content

Releases: roc-lang/basic-cli

0.26.0

Choose a tag to compare

@github-actions github-actions released this 21 Oct 00:32
Immutable release. Only release title and notes can be modified.

basic-cli 0.26.0

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.26.0/GddgvsGjiNXrHFW6au5i5dUDbTs8FinQdrgE2Gqmw53t.tar.zst" }

Requires the Roc new-compiler nightly nightly-2026-10-12-2664a27 or later.

One way to do each thing

Until now each basic-cli module had grown its own habits. A timeout was a bare U64 of milliseconds in one place and a tagged value in another, and zero meant "fail at once" for TCP but "no deadline" for a command. "No more input" was Err(EndOfFile) from stdin, an empty list from a file and an empty string from a socket. Errors arrived in a different shape from every module, and some could not be compared with == at all.

0.26.0 settles these questions once and applies the answers everywhere:

  • Basic-cli timeout options use Duration, and Duration.zero always expires immediately. The shared HTTP package keeps its existing request-timeout representation, described below.
  • The end of input is Err(EndOfInput), on stdin, files and sockets alike. An empty Ok is real data.
  • Anything you can read, you can loop over: lines() and chunks() return a Stream on stdin, file readers, TCP connections and HTTP responses, and rows does the same for SQLite.
  • A module's failures arrive under one tag (TcpErr(IOErr), CmdErr(Cmd.Err), SqliteErr(Sqlite.Err)), while conditions such as EndOfInput and Interrupted remain bare tags. Every error can be compared with ==.

The result is that code written against one module reads like code written against another:

conn = Tcp.connect!("example.org", 7, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?

for! item in conn.lines() {
    match item {
        Ok(line) => Stdout.line!(line)?
        Err(BadUtf8(_)) => {}
        Err(ReadLimitExceeded(_)) => {}
        Err(TcpErr(err)) => { return Err(TcpErr(err)) }
        Err(Interrupted) => { return Err(Interrupted) }
    }
}

This is a large release with many breaking changes. The upgrade guide below lists every one with its replacement. The rules themselves are written down in CONTRIBUTING.md, so future modules follow them too.

Steady allocation performance on Linux

The statically linked Linux hosts now use mimalloc for Rust and Roc general
allocations. Repeated small allocations reuse freed pages, avoiding musl
mallocng's heap-layout-dependent mmap/munmap churn (#530).
The Roc allocation ABI and resource-handle lifetime behavior are unchanged.

Time has types

start = Monotonic.now!()
build!()?
Stdout.line!("Completed in ${start.elapsed!().to_str()}")?   # "Completed in 1.5s"

stale = Utc.now!().since(path.time_modified!()?) > Duration.minutes(5)
  • Duration is a non-negative length of time with nanosecond resolution: Duration.millis(250), Duration.seconds(5), Duration.minutes(2). It supports +, -, ==, the ordering operators and List.sort, and prints as "250ms" or "1.5s". Nothing overflows or goes negative: results are clamped, so budget - start.elapsed!() is simply zero once the budget is spent.
  • Utc is now a real type holding a signed instant. Utc.now!() no longer returns 1970 when the clock cannot be read, and times before 1970 work, including in to_iso_8601.
  • Monotonic is an opaque clock reading. start.elapsed!() and later.since(earlier) return a Duration, so a reading can no longer be mistaken for a length of time.
  • File times (time_accessed!, time_modified!, time_created!) return Utc and succeed for files older than 1970.
  • Sleep is now fallible: use Sleep.millis!(250)? or duration.sleep!()?. A caught stop request returns Err(Interrupted).

Utc supplies clock readings and signed epoch conversion. Timestamp parsing, calendar calculations and richer formatting belong in packages such as roc-time, rather than the native host. See its supported profiles: roc-time 0.3 uses microseconds, while Utc stores nanoseconds. Convert units explicitly between them. Utc.to_iso_8601 remains a whole-second display; to_nanos_since_epoch and from_nanos_since_epoch preserve the full stored precision.

Loop over input

Reading lines used to mean hand-written recursion. Now it is a loop, and the same loop works on every source:

var $count = 0
for! item in reader.lines() {
    match item {
        Ok(_) => { $count = $count + 1 }
        Err(BadUtf8(_)) => {}
        Err(FileErr(err)) => { return Err(FileErr(err)) }
    }
}
  • Stdin.lines(), Stdin.chunks(), reader.lines() and reader.chunks() are new. Lines never include the \n, and one preceding \r is removed. A final line without a terminator is still a line.
  • File.Reader.read_until!(byte) returns bytes through and including a delimiter.
  • A line that is not valid UTF-8 is one Err(BadUtf8(_)) item and the loop carries on. A failure of the source itself is the last item.
  • Streams buffer nothing in Roc. You can break out of Stdin.lines() after a header and hand the rest to Stdin.read_to_end!() without losing a byte, and mix reader.lines() with seek! and read_exactly!.

Write files piece by piece

Path.write_utf8! replaces a file with one payload, so output had to be built in memory first. File.Writer opens a file once and writes to it as you go:

File.with_writer!("report.csv", Truncate, {}, |writer| {
    writer.write_utf8!("name,total\n")?
    for row in rows {
        writer.write_utf8!("${row.name},${row.total.to_str()}\n")?
    }
    Ok({})
})?
  • The mode is Truncate, Append or RequireNew (fails with AlreadyExists if anything is at the path).
  • writer.write_stream!(stream) writes any chunk stream as it arrives, so a file can be copied or transformed in bounded memory: writer.write_stream!(reader.chunks()).
  • File.with_writer! flushes when your callback returns and reports a failed flush. With File.open_writer!, call flush! yourself after the last write.
  • Path.append_bytes! and Path.append_utf8! are also new, for adding to a file in one call (#516).

Create private files and symbolic links

The writer options choose permissions for a new regular file, with the same access policy on Linux, macOS and Windows:

File.with_writer!("credentials.json", RequireNew, { create_permissions: OwnerOnly }, |writer| {
    writer.write_utf8!(credentials_json)
})?
  • OwnerOnly grants the file's owner read/write access and grants others no access. PublicRead additionally grants everyone read access. Both replace inherited discretionary access grants and remove execute permission; {} keeps the operating system's default creation policy.
  • Permissions are enforced before a writer is returned or any content is accepted. If the filesystem cannot enforce the requested policy, opening fails. A failed creation can leave an empty file. Existing handles and privileged operating system access remain outside these policies.
  • Existing files keep their permissions, including with Append or Truncate. Use RequireNew with OwnerOnly for secrets: an existing file or symbolic link fails with AlreadyExists, before its contents can change.
  • path.set_permissions!(OwnerOnly) and path.set_permissions!(PublicRead) replace an existing regular file's access grants while preserving its owner and contents. They follow symbolic links and reject directories and special files. On macOS the target currently must be readable so the platform can open a handle before replacing its ACL; a mode-000 file returns PermissionDenied. Linux can restore access to a mode-000 file through a held metadata handle and requires /proc/self/fd for that repair.

Create a link with Path.symlink!(target, link, File) or Path.symlink!(target, link, Directory), and read its stored target with link.read_link!(). A relative target resolves from the link's parent directory and is returned unchanged. Dangling targets are allowed; the declared kind must match the target when it exists. Creating a link never replaces an existing path. Windows requires Developer Mode or permission to create symbolic links.

Download without holding the body in memory

Http.send_streaming! returns as soon as the status and headers arrive, and hands you the body as a stream:

request = Request.from_method(GET).with_uri(url.to_str())
response = Http.send_streaming!(request, {})?
if response.status() != 200 {
    return Err(UnexpectedStatus(response.status()))
}
File.with_writer!(destination, Truncate, {}, |writer| writer.write_stream!(response.chunks()))?
  • You see the status before any of the body is read, so you can decline a 404's body.
  • idle_timeout (30 seconds unless you set it) bounds each single wait, not the whole download. A slow but healthy download keeps going; a stalled one stops. After an idle timeout the response is still usable.
  • The shared HTTP Request keeps its existing timeout representation: TimeoutMilliseconds(n) sets the overall deadline, while NoTimeout (the default) and TimeoutMilliseconds(0) mean no deadline. Streaming idle_timeout uses After(Duration) or NoTimeout; After(Duration.zero) expires immediately.
  • response.read_to_end!(MaxBytes(1_048_576)) reads the rest with a bound, and response.content_length() is Known(n) or Unknown.
  • Http.send!, send_json!, get! and get_utf8! now take trailing options: pass {} to return the first response, as before.
  • Requests send User-Agent: roc-basic-cli/0.26 by default, so APIs that require a user agent can be called directly. An explicit User-Agent ov...
Read more

0.26.0-rc3

0.26.0-rc3 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 19 Oct 09:33
Immutable release. Only release title and notes can be modified.
32a2222

basic-cli 0.26.0-rc3

Release candidate for 0.26.0. The APIs are still being tested before the final release.
This follows 0.26.0-rc2
and adds the release feedback from PR #529:

  • Catch Ctrl-C and Unix SIGTERM with Signal.catch!/poll!, and handle cooperative Interrupted results so applications can clean up before exiting.
  • Use compact Cmd.call!, a reusable Cmd.Command builder, and Unix Cmd.exec! for process replacement.
  • Add OS witnesses, enforced file permissions, symlinks, Unix stream sockets, and opt-in HTTP redirects.
  • Fix Linux musl allocation syscall churn (#530) and Windows console cancellation.

The draft 0.26.0 notes and migration guide follow. Please report behavior that differs from these notes.

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc3/72LPrJNPhao1zzEbawpgzKnkwAPRKy6Xm8jM1rPchQ7o.tar.zst" }

Requires the Roc new-compiler nightly nightly-2026-10-12-2664a27 or later.

One way to do each thing

Until now each basic-cli module had grown its own habits. A timeout was a bare U64 of milliseconds in one place and a tagged value in another, and zero meant "fail at once" for TCP but "no deadline" for a command. "No more input" was Err(EndOfFile) from stdin, an empty list from a file and an empty string from a socket. Errors arrived in a different shape from every module, and some could not be compared with == at all.

0.26.0 settles these questions once and applies the answers everywhere:

  • A timeout is a Duration, and Duration.zero always expires immediately.
  • The end of input is Err(EndOfInput), on stdin, files and sockets alike. An empty Ok is real data.
  • Anything you can read, you can loop over: lines() and chunks() return a Stream on stdin, file readers, TCP connections and HTTP responses, and rows does the same for SQLite.
  • A module's failures arrive under one tag (TcpErr(IOErr), CmdErr(Cmd.Err), SqliteErr(Sqlite.Err)), and every error can be compared with ==.

The result is that code written against one module reads like code written against another:

conn = Tcp.connect!("example.org", 7, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?

for! item in conn.lines() {
    match item {
        Ok(line) => Stdout.line!(line)?
        Err(BadUtf8(_)) => {}
        Err(ReadLimitExceeded(_)) => {}
        Err(TcpErr(err)) => { return Err(TcpErr(err)) }
        Err(Interrupted) => { return Err(Interrupted) }
    }
}

This is a large release with many breaking changes. The upgrade guide below lists every one with its replacement. The rules themselves are written down in CONTRIBUTING.md, so future modules follow them too.

Steady allocation performance on Linux

The statically linked Linux hosts now use mimalloc for Rust and Roc general
allocations. Repeated small allocations reuse freed pages, avoiding musl
mallocng's heap-layout-dependent mmap/munmap churn (#530).
The Roc allocation ABI and resource-handle lifetime behavior are unchanged.

Time has types

start = Monotonic.now!()
build!()?
Stdout.line!("Completed in ${start.elapsed!().to_str()}")?   # "Completed in 1.5s"

stale = Utc.now!().since(path.time_modified!()?) > Duration.minutes(5)
  • Duration is a non-negative length of time with nanosecond resolution: Duration.millis(250), Duration.seconds(5), Duration.minutes(2). It supports +, -, ==, the ordering operators and List.sort, and prints as "250ms" or "1.5s". Nothing overflows or goes negative: results are clamped, so budget - start.elapsed!() is simply zero once the budget is spent.
  • Utc is now a real type holding a signed instant. Utc.now!() no longer returns 1970 when the clock cannot be read, and times before 1970 work, including in to_iso_8601.
  • Monotonic is an opaque clock reading. start.elapsed!() and later.since(earlier) return a Duration, so a reading can no longer be mistaken for a length of time.
  • File times (time_accessed!, time_modified!, time_created!) return Utc and succeed for files older than 1970.
  • Sleep is now fallible: use Sleep.millis!(250)? or duration.sleep!()?. A caught stop request returns Err(Interrupted).

Loop over input

Reading lines used to mean hand-written recursion. Now it is a loop, and the same loop works on every source:

var $count = 0
for! item in reader.lines() {
    match item {
        Ok(_) => { $count = $count + 1 }
        Err(BadUtf8(_)) => {}
        Err(FileErr(err)) => { return Err(FileErr(err)) }
    }
}
  • Stdin.lines(), Stdin.chunks(), reader.lines() and reader.chunks() are new. Lines never include the \n, and one preceding \r is removed. A final line without a terminator is still a line.
  • File.Reader.read_until!(byte) returns bytes through and including a delimiter.
  • A line that is not valid UTF-8 is one Err(BadUtf8(_)) item and the loop carries on. A failure of the source itself is the last item.
  • Streams buffer nothing in Roc. You can break out of Stdin.lines() after a header and hand the rest to Stdin.read_to_end!() without losing a byte, and mix reader.lines() with seek! and read_exactly!.

Write files piece by piece

Path.write_utf8! replaces a file with one payload, so output had to be built in memory first. File.Writer opens a file once and writes to it as you go:

File.with_writer!("report.csv", Truncate, {}, |writer| {
    writer.write_utf8!("name,total\n")?
    for row in rows {
        writer.write_utf8!("${row.name},${row.total.to_str()}\n")?
    }
    Ok({})
})?
  • The mode is Truncate, Append or RequireNew (fails with AlreadyExists if anything is at the path).
  • writer.write_stream!(stream) writes any chunk stream as it arrives, so a file can be copied or transformed in bounded memory: writer.write_stream!(reader.chunks()).
  • File.with_writer! flushes when your callback returns and reports a failed flush. With File.open_writer!, call flush! yourself after the last write.
  • Path.append_bytes! and Path.append_utf8! are also new, for adding to a file in one call (#516).

Create private files and symbolic links

The writer options choose permissions for a new regular file, with the same access policy on Linux, macOS and Windows:

File.with_writer!("credentials.json", RequireNew, { create_permissions: OwnerOnly }, |writer| {
    writer.write_utf8!(credentials_json)
})?
  • OwnerOnly grants the file's owner read/write access and grants others no access. PublicRead additionally grants everyone read access. Both replace inherited discretionary access grants and remove execute permission; {} keeps the operating system's default creation policy.
  • Permissions are enforced before a writer is returned or any content is accepted. If the filesystem cannot enforce the requested policy, opening fails. A failed creation can leave an empty file. Existing handles and privileged operating system access remain outside these policies.
  • Existing files keep their permissions, including with Append or Truncate. Use RequireNew with OwnerOnly for secrets: an existing file or symbolic link fails with AlreadyExists, before its contents can change.
  • path.set_permissions!(OwnerOnly) and path.set_permissions!(PublicRead) replace an existing regular file's access grants while preserving its owner and contents. They follow symbolic links and reject directories and special files. On macOS the target currently must be readable so the platform can open a handle before replacing its ACL; a mode-000 file returns PermissionDenied. Linux can restore access to a mode-000 file through a held metadata handle and requires /proc/self/fd for that repair.

Create a link with Path.symlink!(target, link, File) or Path.symlink!(target, link, Directory), and read its stored target with link.read_link!(). A relative target resolves from the link's parent directory and is returned unchanged. Dangling targets are allowed; the declared kind must match the target when it exists. Creating a link never replaces an existing path. Windows requires Developer Mode or permission to create symbolic links.

Download without holding the body in memory

Http.send_streaming! returns as soon as the status and headers arrive, and hands you the body as a stream:

request = Request.from_method(GET).with_uri(url.to_str())
response = Http.send_streaming!(request, {})?
if response.status() != 200 {
    return Err(UnexpectedStatus(response.status()))
}
File.with_writer!(destination, Truncate, {}, |writer| writer.write_stream!(response.chunks()))?
  • You see the status before any of the body is read, so you can decline a 404's body.
  • idle_timeout (30 seconds unless you set it) bounds each single wait, not the whole download. A slow but healthy download keeps going; a stalled one stops. After an idle timeout the response is still usable.
  • response.read_to_end!(MaxBytes(1_048_576)) reads the rest with a bound, and response.content_length() is Known(n) or Unknown.
  • Http.send!, send_json!, get! and get_utf8! now take trailing options: pass {} to return the first response, as before.
  • Redirects are opt-in: Http.send!(request, { max_redirects: 5 }) follows 301, 302, 303, 307 and 308. Streaming accepts the same max_redirects option. Intermediate bodies are closed without collecting them; the final status, including a 404, is still a response.
  • Redirects use one overall request deadline, including ...
Read more

0.26.0-rc2

0.26.0-rc2 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 13 Oct 07:52
Immutable release. Only release title and notes can be modified.
392b1df

basic-cli 0.26.0-rc2

Release candidate for 0.26.0, for trying the new APIs before they are final.
It follows 0.26.0-rc1,
which tested the Windows MSVC host, and adds the API conventions work from
PR #528. The notes below are
the draft notes for 0.26.0; please report anything that does not behave as
they say.

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc2/Gj3zVDr3qrQa6aTh7Z1zgUfe9NeBE6R76T5ho3xrc8af.tar.zst" }

Requires the Roc new-compiler nightly nightly-2026-10-12-2664a27 or later.

One way to do each thing

Until now each basic-cli module had grown its own habits. A timeout was a bare U64 of milliseconds in one place and a tagged value in another, and zero meant "fail at once" for TCP but "no deadline" for a command. "No more input" was Err(EndOfFile) from stdin, an empty list from a file and an empty string from a socket. Errors arrived in a different shape from every module, and some could not be compared with == at all.

0.26.0 settles these questions once and applies the answers everywhere:

  • A timeout is a Duration, and Duration.zero always expires immediately.
  • The end of input is Err(EndOfInput), on stdin, files and sockets alike. An empty Ok is real data.
  • Anything you can read, you can loop over: lines() and chunks() return a Stream on stdin, file readers, TCP connections and HTTP responses, and rows does the same for SQLite.
  • A module's failures arrive under one tag (TcpErr(IOErr), CmdErr(Cmd.Err), SqliteErr(Sqlite.Err)), and every error can be compared with ==.

The result is that code written against one module reads like code written against another:

conn = Tcp.connect!("example.org", 7, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?

for! item in conn.lines() {
    match item {
        Ok(line) => Stdout.line!(line)?
        Err(BadUtf8(_)) => {}
        Err(ReadLimitExceeded(_)) => {}
        Err(TcpErr(err)) => { return Err(TcpErr(err)) }
    }
}

This is a large release with many breaking changes. The upgrade guide below lists every one with its replacement. The rules themselves are written down in CONTRIBUTING.md, so future modules follow them too.

Time has types

start = Monotonic.now!()
build!()?
Stdout.line!("Completed in ${start.elapsed!().to_str()}")?   # "Completed in 1.5s"

stale = Utc.now!().since(path.time_modified!()?) > Duration.minutes(5)
  • Duration is a non-negative length of time with nanosecond resolution: Duration.millis(250), Duration.seconds(5), Duration.minutes(2). It supports +, -, ==, the ordering operators and List.sort, and prints as "250ms" or "1.5s". Nothing overflows or goes negative: results are clamped, so budget - start.elapsed!() is simply zero once the budget is spent.
  • Utc is now a real type holding a signed instant. Utc.now!() no longer returns 1970 when the clock cannot be read, and times before 1970 work, including in to_iso_8601.
  • Monotonic is an opaque clock reading. start.elapsed!() and later.since(earlier) return a Duration, so a reading can no longer be mistaken for a length of time.
  • File times (time_accessed!, time_modified!, time_created!) return Utc and succeed for files older than 1970.
  • Sleep.millis!(250) is unchanged. For a wait you computed, call duration.sleep!().

Loop over input

Reading lines used to mean hand-written recursion. Now it is a loop, and the same loop works on every source:

var $count = 0
for! item in reader.lines() {
    match item {
        Ok(_) => { $count = $count + 1 }
        Err(BadUtf8(_)) => {}
        Err(FileErr(err)) => { return Err(FileErr(err)) }
    }
}
  • Stdin.lines(), Stdin.chunks(), reader.lines() and reader.chunks() are new. Lines never include the \n, and one preceding \r is removed. A final line without a terminator is still a line.
  • File.Reader.read_until!(byte) returns bytes through and including a delimiter.
  • A line that is not valid UTF-8 is one Err(BadUtf8(_)) item and the loop carries on. A failure of the source itself is the last item.
  • Streams buffer nothing in Roc. You can break out of Stdin.lines() after a header and hand the rest to Stdin.read_to_end!() without losing a byte, and mix reader.lines() with seek! and read_exactly!.

Write files piece by piece

Path.write_utf8! replaces a file with one payload, so output had to be built in memory first. File.Writer opens a file once and writes to it as you go:

File.with_writer!("report.csv", Truncate, |writer| {
    writer.write_utf8!("name,total\n")?
    for row in rows {
        writer.write_utf8!("${row.name},${row.total.to_str()}\n")?
    }
    Ok({})
})?
  • The mode is Truncate, Append or RequireNew (fails with AlreadyExists if anything is at the path).
  • writer.write_stream!(stream) writes any chunk stream as it arrives, so a file can be copied or transformed in bounded memory: writer.write_stream!(reader.chunks()).
  • File.with_writer! flushes when your callback returns and reports a failed flush. With File.open_writer!, call flush! yourself after the last write.
  • Path.append_bytes! and Path.append_utf8! are also new, for adding to a file in one call (#516).

Download without holding the body in memory

Http.send_streaming! returns as soon as the status and headers arrive, and hands you the body as a stream:

request = Request.from_method(GET).with_uri(url.to_str())
response = Http.send_streaming!(request, {})?
if response.status() != 200 {
    return Err(UnexpectedStatus(response.status()))
}
File.with_writer!(destination, Truncate, |writer| writer.write_stream!(response.chunks()))?
  • You see the status before any of the body is read, so you can decline a 404's body.
  • idle_timeout (30 seconds unless you set it) bounds each single wait, not the whole download. A slow but healthy download keeps going; a stalled one stops. After an idle timeout the response is still usable.
  • response.read_to_end!(MaxBytes(1_048_576)) reads the rest with a bound, and response.content_length() is Known(n) or Unknown.
  • Http.send! and the functions built on it are unchanged.

TCP: typed errors and a timeout the connection remembers

# 0.25
stream = Tcp.connect!("127.0.0.1", 8085, 1_000) ? |err| ConnectFailed(err)
stream.write_utf8!("${out_msg}\n", 5_000) ? |err| TcpWriteFailed(err)
in_msg = stream.read_line!(1_048_576, 5_000) ? |err| TcpReadFailed(err)

# 0.26
conn = Tcp.connect!("127.0.0.1", 8085, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?
conn.write_utf8!("${out_msg}\n")?
in_msg = conn.read_line!()?
  • Tcp.Stream is now Tcp.Connection. It holds its I/O timeout and line limit; conn.with_timeout(d) and conn.with_line_limit(n) return the same connection with another setting.
  • Every failure is TcpErr(IOErr), so result == Err(TcpErr(TimedOut)) and Err(TcpErr(ConnectionRefused)) can be matched directly. Windows takes about two seconds to report a refused connection, so a shorter connect_timeout gives TimedOut there. IOErr gains TimedOut, ConnectionRefused, ConnectionReset, AddrInUse and AddrNotAvailable.
  • A read that times out consumes nothing. The bytes that had arrived are returned by the next read, so a client that tolerates idle periods just calls conn.lines() again.
  • A line longer than the limit is Err(ReadLimitExceeded(n)); the rest of it is discarded and the next read starts at the next line.
  • listener.accept!({ accept_timeout: NoTimeout, io_timeout: Duration.seconds(30) }) separates how long to wait for a client from the timeout that client's connection gets.

Commands: four ways to run, one result, one error

Cmd had seven ways to run a command, which disagreed on whether a non-zero exit was an error. There are now four:

Cmd.new("cargo").args(["build", "--release"]).exec!()?              # inherit the terminal; must succeed
sha = Cmd.new("git").args(["rev-parse", "HEAD"]).capture!()?.trim()  # stdout as text; must succeed
output = Cmd.new("cat").arg("notes.txt").run!()?                     # status and output as data
child = Cmd.new("server").stdout(Pipe).spawn!()?                     # run in the background
  • run! returns a Cmd.Output with status, stdout_bytes and stderr_bytes, plus require_success(), stdout_utf8(), stdout_utf8_lossy() and stderr_utf8_lossy(). It prints its output as text, not as a list of byte numbers.
  • Every failure is CmdErr(Cmd.Err): StartFailed(IOErr, program), TimedOut(partial_output), OutputLimitExceeded(partial_output) or IOErr(IOErr). A missing program is Err(CmdErr(StartFailed(NotFound, _))).
  • .timeout(Duration.minutes(5)) bounds the whole run, including a spawned child, which is killed at the deadline.
  • Errors and to_inspect no longer contain environment values, so a secret passed with .env cannot leak into a log.
  • .stderr(MergeWithStdout) and .output_limit(NoLimit) say directly what used to be encoded in a Bool or a zero.

SQLite: a connection you hold, rows you can stream

decode_todo : Decoder({ id : I64, task : Str }, Sqlite.DecodeErr)
decode_todo = { id: Decoder.i64("id"), task: Decoder.str("task") }.Decoder

db = Sqlite.open!("app.db", {})?
todo = db.query_one!("SELECT id, task FROM todos WHERE id = ${Integer(id)}", decode_todo)?
  • Sqlite.open! returns a Db that you pass around and can close!. Open read-only with { mode: ReadOnly }. A locked database is now waited on (five seconds unless you set `busy_timeo...
Read more

0.26.0-rc1

0.26.0-rc1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 12 Oct 08:20
Immutable release. Only release title and notes can be modified.

basic-cli 0.26.0-rc1

Release candidate from PR #512,
for testing the MSVC host with Roc nightly-2026-10-11-7d256b9.

The Windows x64 host keeps the MSVC ABI and supplies the startup/runtime inputs
required by this nightly. Native C dependencies use Zig, and generated runtime
and Windows import archives are combined into windows_support.lib. Original
licenses, notices and source/build provenance are included in the bundle.

The producer validates every Windows binary input and uses compiler identities
to invalidate cached native builds when Zig or its adapter changes.

Use this platform URL in your application's header:

https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc1/GMezHWvmUP42Leqg8YkABvEk27cMUSVM6p7oz3XPj11N.tar.zst

Download the generated docs.

Windows execution is tested on Server 2025 CI. Testing on clean Windows 10/11
installations without Visual C++ redistributables is still needed.

0.25.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 20:02
Immutable release. Only release title and notes can be modified.

What's Changed

New Contributors

Full Changelog: 0.20.0...0.25.0

Roc API Changes

── ✗ type mismatch ─ ../../../.cache/roc/packages/AEjfyaMFFbh8FJrkkHJy68riVNPr3Qp6c6PawWQjBwMH/Locale.roc:318:13

This expression is used in an unexpected way.

decoded == Err(Json.invalid_json)
           ^^^^^^^^^^^^^^^^^^^^^^

It has the type:

    [Err({} -> [InvalidJson(Str)]), Ok(Locale)]

But you are trying to use it as:

    Try(Locale, [InvalidJson(Str)])

── ✗ type mismatch ─ ../../../.cache/roc/packages/AEjfyaMFFbh8FJrkkHJy68riVNPr3Qp6c6PawWQjBwMH/OsStr.roc:257:10

The from_interpolation method on OsStr has an incompatible type.

value = "${name}.toml"
        ^^^^^^^^^^^^^^

The method from_interpolation has the type:

    Str, Iter((Str, Str)) -> OsStr

But I need it to have the type:

    List(Str) -> Try(List(b) -> OsStr, [InvalidInterpolation(Str)])
      where [b.from_quote : Str -> Try(b, [BadQuotedBytes(Str)])]

Hint: This function expects 1 argument but got 2.

── ✗ type mismatch ─ ../../../.cache/roc/packages/AEjfyaMFFbh8FJrkkHJy68riVNPr3Qp6c6PawWQjBwMH/Path.roc:644:9

The from_interpolation method on Path has an incompatible type.

path = "${directory}/app.toml"
       ^^^^^^^^^^^^^^^^^^^^^^^

The method from_interpolation has the type:

    Str, Iter((Str, Str)) -> Path

But I need it to have the type:

    List(Str) -> Try(List(c) -> Path, [InvalidInterpolation(Str)])
      where [c.from_quote : Str -> Try(c, [BadQuotedBytes(Str)])]

Hint: This function expects 1 argument but got 2.

── ✗ type mismatch ─ ../../../.cache/roc/packages/AEjfyaMFFbh8FJrkkHJy68riVNPr3Qp6c6PawWQjBwMH/Url.roc:1390:13

This expression is used in an unexpected way.

decoded == Err(Json.invalid_json)
           ^^^^^^^^^^^^^^^^^^^^^^

It has the type:

    [Err({} -> [InvalidJson(Str)]), Ok(Url)]

But you are trying to use it as:

    Try(Url, [InvalidJson(Str)])

── ✗ package does not compile ──────────────────────────────────────────────────

roc bump failed.

The old package (/Users/runner/.cache/roc/packages/AEjfyaMFFbh8FJrkkHJy68riVNPr3Qp6c6PawWQjBwMH/main.roc) does not compile with this compiler. roc bump needs both packages to compile so their public APIs can be compared.


roc bump exited with status 1.

Docs

0.24.0

Choose a tag to compare

@github-actions github-actions released this 05 Oct 07:08
Immutable release. Only release title and notes can be modified.

What's Changed

New Contributors

Full Changelog: 0.20.0...0.24.0

Docs

0.23.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 11:00
Immutable release. Only release title and notes can be modified.

basic-cli 0.23.0

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.23.0/GNN5tt2gKdX4dhawg4915C4YB193woHFdcCkz31fhGxv.tar.zst" }

Requires the Roc new-compiler nightly nightly-2026-09-23-c7852fd or later.

Write the script in Roc, not bash

Most command-line programs eventually need a bash script alongside them. They call mktemp -d to get a scratch directory, cp -r to stage files, timeout 300 to stop a stuck build, and a trap to clean up afterwards. Those scripts are hard to make portable, the errors come back as exit codes and text, and a child process can easily be left running.

0.23.0 gives basic-cli native APIs for this work, so the whole script can be a Roc program. Arguments are passed straight through without shell quoting. Exit codes, timeouts and output are returned as data you can match on. Processes, listeners and temporary directories are cleaned up when your program finishes with them.

Here is a release-staging script that would usually need mktemp, cp -r and a trap:

Env.with_temp_dir!(|workspace| {
    staged = Path.join(workspace, "release")
    Path.copy_dir!(source, staged)?
    Path.write_utf8!(Path.join(staged, "RELEASE.txt"), "Prepared with basic-cli\n")?
    Path.copy_dir!(staged, destination)?   # fails rather than overwrite an existing release
    Ok({})
})?

The temporary workspace is deleted whether the callback succeeds or fails. If both the callback and the cleanup fail, you get both errors, not just one. See the full filesystem-tools.roc and process-control.roc examples.

Run and supervise processes

Cmd now covers what bash's timeout, tee and & are usually used for:

output = Cmd.new("cargo")
    .args(["build", "--release"])
    .stdout(Tee)              # stream to the terminal *and* capture
    .stderr(Tee)
    .timeout_ms(300_000)      # hard deadline, including draining output
    .manage_tree(Bool.True)   # on timeout, kill grandchildren too
    .run!()?

match output.status {
    Exited(0) => Stdout.line!("Build succeeded")
    Exited(code) => Err(BuildFailed(code))
    Signaled(signal) => Err(BuildKilled(signal))
}
  • Cmd.run! runs a command to completion. A nonzero exit is returned as a status for you to handle, not as an error. Errors are reserved for failures of the command itself: IO, Timeout(partial_output) and OutputLimit(partial_output). Both limit errors include the output collected before the command was stopped.
  • Cmd.spawn! starts a process in the background and returns a Child handle. Use pid!, wait!, try_wait!, kill! and close! to manage it. To interact with it, set .stdin(Pipe) and .stdout(Pipe), then use write!, read! and close_stdin!.
  • New builder options: cwd, stdin, stdout and stderr (Inherit, Null, Capture, Pipe, Tee, and Bytes for stdin), timeout_ms, output_limit, pending_limit, manage_tree and merge_stderr.
  • Cleanup is automatic. When your program drops its last reference to a Child, the process is terminated and reaped. Call close! if you need this to happen at a specific point.

The existing exec!, exec_cmd!, exec_output! and exec_output_bytes! helpers now run on the same process engine.

Files, paths and temporary workspaces

  • Path.copy! copies a file, including its permissions.
  • Path.copy_dir! copies a directory tree to a destination that must not exist yet. Path.copy_dir_with! lets you choose whether symlinks are preserved or followed, and whether an existing destination is merged into. Copies that would loop, or where the source and destination overlap, are rejected.
  • Path.absolute! makes a path absolute without touching the filesystem. Path.canonicalize! resolves an existing path, including symlinks.
  • Env.create_temp_dir!, Env.create_temp_dir_with_prefix! and Env.create_temp_dir_in! create private temporary directories. Env.with_temp_dir! also deletes the directory when your callback finishes.
  • OsStr.to_bytes returns the raw native bytes of an OS string.

Servers and timing

  • Accept TCP connections. Tcp.listen!(host, port, timeout_ms) returns a Tcp.Listener with accept!, local_port! and close! methods. Listen on port 0 and read local_port! to have the OS pick a free port. This is useful for tests and local helper servers.
  • Measure elapsed time. Monotonic.now! returns nanoseconds (U64) from a clock that system clock changes don't affect. Subtract two readings to get a duration. The values are only meaningful within a single run of your program, so don't store them.

Upgrading from 0.22

main! no longer receives the program name. args now contains only the arguments the user passed, which matches Roc's default platform (#494). Code that skipped the first element will now silently drop a real argument, so check every place that handles args:

# 0.22
main! = |args| {
    user_args = args.drop_first(1)
    ...
}

# 0.23
main! = |args| {
    # args are already the user's arguments.
    # Program name, if needed:
    name = Env.program_name!()?   # Err(ProgramNameUnavailable) if the launcher didn't supply one
    ...
}

Remove .. from error types in return position. Roc now makes tag unions in return position open automatically, so basic-cli's signatures are now written without .. (for example, Path.read_bytes! : Path => Try(List(U8), [PathErr(IOErr, Path)])), and yours can be too (#495). This change is why 0.23.0 needs the nightly listed at the top.

Captured output is capped at 16 MiB by default. When stdout and stderr together exceed the limit, the command is cancelled. run! returns OutputLimit(partial_output), and the exec_output* helpers return FailedToGetExitCode with the message "Process output limit exceeded". If a command legitimately produces more output, raise the limit with .output_limit(bytes), or use .stdout(Inherit) to send the output straight to the terminal.

Update the platform URL. The native host interface changed to support managed resources, so programs built against 0.22 need to be rebuilt against 0.23.0. Mixing hosts and platform sources from different versions is not supported.

Fixes

  • Windows: programs no longer hang on exit if they made an HTTP request in the previous ten seconds (#493).
  • File readers, TCP streams and SQLite statements now release their OS resources as soon as the last reference to them is dropped, instead of at program exit.
  • SQLite: fixed a type-inference problem in the row decoders that could cause errors in application code using them. Columns are now looked up by name (#475, 0.22.1).
  • Path.to_str errors now combine with other errors in the same function when you use ? (#479, 0.22.2). With the implicit-open change above, every basic-cli function now behaves this way.

Documentation and tooling

  • The API docs have been revised, with runnable examples for the new process, filesystem and TCP APIs.
  • Contributors can use the new Nix flake devshell with direnv (#432), and point the test runner at a specific compiler with ROC=... (#477).

Thanks

Thanks to everyone who contributed to 0.22.1 through 0.23.0: @ageron, @Anton-4, @Karakatiza666, @lukewilliamboswell, @niclas-ahden, @rtfeldman and @thebrandonlucas.


0.23.0-rc1

0.23.0-rc1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 08 Sep 04:09
Immutable release. Only release title and notes can be modified.

What's Changed

New Contributors

Full Changelog: 0.21.0-rc4...0.23.0-rc1

Roc API Changes

── ✗ cannot extract public api ─────────────────────────────────────────────────

roc bump failed.

The old package's public API references the type `InternalHttp.TransportErr`, which is not itself part of the exposed API.

Docs

0.22.2

Choose a tag to compare

@github-actions github-actions released this 02 Sep 08:41
Immutable release. Only release title and notes can be modified.

What's Changed

New Contributors

Full Changelog: 0.20.0...0.22.2

Roc API Changes

── ✗ cannot extract public api ─────────────────────────────────────────────────

roc bump failed.

The old package's public API references the type `InternalHttp.TransportErr`, which is not itself part of the exposed API.

Docs

0.22.1

Choose a tag to compare

@github-actions github-actions released this 01 Sep 00:48
Immutable release. Only release title and notes can be modified.

What's Changed

New Contributors

Full Changelog: 0.20.0...0.22.1

Roc API Changes

── ✗ cannot extract public api ─────────────────────────────────────────────────

roc bump failed.

The old package's public API references the type `InternalHttp.TransportErr`, which is not itself part of the exposed API.

Docs