SCUA

News

SCUA 0.31.0 Simply package your SCUA code for shipping

October 2, 2026

SCUA 0.31.0 makes a tool you wrote easy to hand to someone else. One command packs your scripts and the packages they use into a single file, and scua runs that file directly on macOS, Linux and Windows. This release also adds AES-192 and crypto.pbkdf2, lets a project say which scua it needs, and brings the compiler's first warnings. A few changes are worth reading before you upgrade.

#Packaging your SCUA code

In many languages, sending someone a script together with its dependencies means a separate bundling tool, and often a build for each operating system. In SCUA it is one command and one file.

Here is a small tool, spell, that points out the words in a file that aren't in your word list and suggests the closest one that is. It has a module of its own, lib/words.scua, and uses one package, @scua/fuzzy, added with scua-pkg add:

-- spell: point out words that aren't in your word list, and the closest one that is.
import fs
import sys
import fuzzy
import lib/words

let args = sys.args()
if args.len() != 2 then
  print("usage: spell WORDLIST FILE")
  sys.exit(1)
end

fn read(path)
  match fs.read_text(path)
    Ok(text) -> return text
    Error(e) -> sys.fail(`spell: {path}: {e}`)
  end
end

let known = words.words(read(args[0]))
let seen = {}
for w in known do seen[w] = true end

for w in words.words(read(args[1])) do
  if not seen[w] then
    match fuzzy.best(w, known, { cutoff = 0.7 })
      Ok(m) -> print(`{w} -> {m.value}?`)
      Error(_) -> print(`{w} -> ?`)
    end
  end
end

In the project folder, one command builds the bundle:

$ scua-pkg bundle --app main.scua
bundled app `spell` (entry main.scua): 2 app file(s), 1 package(s) with 11 file(s) into spell.scuapp (67819 bytes)
needs: fs — grant them when you run it: `scua-pkg run [--allow-…] spell.scuapp`

spell.scuapp is the whole tool, about 68 KB. Copy it to another machine that has scua installed and run it there like a script, with the tool's own arguments after it:

$ scua --allow-fs=. spell.scuapp words.txt note.txt
quik -> quick?
recieve -> receive?
seperate -> separate?
parcel -> ?

We ran that same file, byte for byte, on a Mac with Apple silicon, on x86-64 Linux and on x86-64 Windows, and got the same four lines on each. The other machine needs scua and nothing else: no project folder, no deps/ and no package manager. The packages travel inside the bundle. A package that calls a native library through ffi still needs that library on the machine.

Before anything compiles, scua checks the whole file against its digest and every file inside against its own, and runs nothing if one doesn't match. A bundle grants itself nothing. Capabilities come from your command line, as they do for a .scua file, and when one is missing, the message says which flag to pass:

$ scua spell.scuapp words.txt note.txt
scua: spell.scuapp:main.scua:2: this app needs capabilities this run was not granted: fs. Grant them before the file: `scua --allow-fs spell.scuapp` (or narrower: --allow-fs=DIR)

Building a bundle takes scua-pkg 0.3.20 or later. Ship a tool as one file has the details.

#crypto now supports AES-192 and PBKDF2

Every AES call now does AES-192. Counter mode, GCM and key wrap take a 24-byte key as well as a 16- or 32-byte one, and CBC with HMAC takes a 48-byte key for JWE's A192CBC-HS384.

crypto.pbkdf2 turns a password into a key, as encrypted zip, PDF and most password stores do. Here it makes a 24-byte key for AES-192:

import crypto
import bytes

fn seal(password, text)
  let salt = crypto.random_bytes(16)
  let key = crypto.pbkdf2("sha256", password, salt, 600000, 24)?
  let nonce = crypto.random_bytes(12)
  return Ok({ salt = salt, nonce = nonce, sealed = crypto.aes_gcm_encrypt(key, nonce, text) })
end

fn open(password, box)
  let key = crypto.pbkdf2("sha256", password, box.salt, 600000, 24)?
  let plain = crypto.aes_gcm_decrypt(key, box.nonce, box.sealed)?
  return bytes.to_string(plain)
end

fn try_password(password, box)
  match open(password, box)
    Ok(text) -> print(`opened: {text}`)
    Error(e) -> print(`refused: {e}`)
  end
end

let box = match seal("correct horse battery staple", "the vault code is 7741")
  Ok(b) -> b
  Error(e) -> error(e)
end
try_password("correct horse battery staple", box)
try_password("Correct horse battery staple", box)
opened: the vault code is 7741
refused: the data does not authenticate: it was changed, or the key, nonce or extra data is not the one it was encrypted with

pbkdf2 prepares the HMAC key once per call, which makes it about twice as fast as a textbook version that prepares it on every iteration. 600,000 iterations of SHA-256 take about 30 milliseconds on an Apple M4 Max.

It returns Ok(key) or an Error, and so does crypto.aes_ctr. A file you decrypt sets its own iteration count, key size and counter block, so when one of those is wrong you get an Error to match on and can go on to the next file.

#Projects can now say which scua they need

Put scua-version under [package] in scua.toml:

[package]
name = "report"
scua-version = "0.32"

A scua older than that stops before it reads any of your code, and exits with status 3:

$ scua main.scua
scua: this project needs scua 0.32 or later (scua-version in its scua.toml); this is 0.31.0. Upgrade scua.

Packages declare it the same way, and scua-pkg 0.3.20 copies their requirements into your lock, so a package that needs a newer scua is named in the message. A script with no scua.toml is never checked.

#The compiler can now warn

A warning prints on stderr and the program still runs. The first one catches tonumber given a table, a record, an array or a bool, which always gives nil:

let row = { qty = "3", price = "4.50" }
let qty = tonumber(row) ?? 0
print(qty)
$ scua total.scua
scua: total.scua:2: warning: `tonumber` is given a table (table {qty, price}), which is never a number, so this always gives nil — pass it a string or a number
0

The fix is tonumber(row.qty). A run now lists every compile error and warning it finds, so you can fix a file in one pass. For CI, --deny-warnings makes a warning stop the run before anything starts:

$ scua --deny-warnings total.scua
scua: total.scua:2: warning: `tonumber` is given a table (table {qty, price}), which is never a number, so this always gives nil — pass it a string or a number
scua: nothing ran: --deny-warnings makes a warning fail the run

Warnings from the packages you depend on are hidden unless you pass --dep-warnings, since only an upgrade can fix them.

#Smaller additions

  • A misspelt call to a built-in module is caught before the program runs, with a suggestion: scua: typo.scua:2: type error: `str` has no function `uper` (did you mean `str.upper`?).
  • json.decode says where the input went wrong: Error(invalid JSON at line 2, column 7).
  • An http.serve handler that uses ? can end with return Ok({ status = 200, body = … }), and a proxy can return what http.request gave it.
  • A {…} hole in a backtick string can hold any string or template, braces and all: `{str.join(names, "}, {")}` gives ana}, {bo.
  • Zed and VS Code highlight strings, escapes and template holes the way the compiler reads them.
  • When you can't run the debugger, --debug-values puts the value an Ok held back into fault messages.

#What changes when you upgrade

  • crypto.aes_ctr returns Ok(bytes) or Error(reason), where it used to return the bytes. Inside a function, add ?. Elsewhere, match the result.
  • An ask that times out gives Error({ kind = "timeout", message = "ask timeout" }), where it used to give Error("ask timeout"). Check e.kind == "timeout".
  • A handler or actor(fn, state) function that ends with an Error now keeps the old state. To store an Error as the state, wrap it: return { last = Error(e) }.
  • A save frozen while an actor held an Error as its state may not load. Revive from an earlier save.
  • Fault messages say got an Ok where they used to print what the Ok held. scua test, the debugger and --debug-values still show it.
  • A call to a function a built-in module doesn't have is a compile error, even on a line that never runs.
  • A Result used as a condition is now caught before the program runs when the function comes from another module. Test it with matches Ok, or match it.
  • json.decode's Error text gains a position. Test it with str.starts_with(e, "invalid JSON").
  • --max-ops counts every call as well as every loop iteration. A script that finished just under a tight budget may now reach it.

#What's next

More warnings are on the way, along with an option to turn compile warnings off, so a tool or an agent can keep its log to what it needs. After that, the compiler will tell you before a program runs when the Result a call returns is never looked at.

The full changelog has the rest.