Skip to content

Releases: lask-task-runner/lask

v0.7.0

Choose a tag to compare

@Ikepeeee Ikepeeee released this 04 Oct 14:02
9757331

A task can now be trusted with what it should not leak, and stopped before what it should not do.

Until now a credential had to sit in the process environment in plain text for get_env to read it, and from there it could reach a JSON event, a final diagnostic or the host's process list. Nothing told destroy apart from build, a flaky step had to be written out again to be retried, and a command left behind by race kept running. The dependency cache was trusted on sight, and a URL in lask.json could be read by git as an option. v0.7.0 reads secrets from Vault without changing the program, asks for a typed confirmation where a project says so, gives tasks retry, polling and timeouts, lets a type parameter carry a bound — and closes every one of those leaks.

Secrets from Vault

  • A variable whose whole value is {scheme://reference} is a secret reference, resolved when get_env, find_env or get_env_or reads it: AWS_SECRET_ACCESS_KEY="{vault://secret/aws#secret_key}". The program stays the same; only the environment changes. has_env does not resolve it, and a malformed reference is an error, never a value.
  • LASK_SECRETS lists the stores a run may use; any other scheme is E-IO-SECRET-PROVIDER. The first is vault (Vault and OpenBao), configured from Vault's own variables: VAULT_ADDR, VAULT_TOKEN, AppRole through VAULT_ROLE_ID / VAULT_SECRET_ID, ~/.vault-token, VAULT_NAMESPACE and VAULT_CACERT. KV version 2 mounts are detected from the server, and ?version=n selects a version.
  • A resolved value is masked like a !! one. A run logs in once and reads each secret once, across async too, so the fields of a dynamic secret come from one lease. Nothing is written to disk and the process environment is never changed.
  • lask secrets list [fn] reports the references in the environment without reaching a store. lask secrets check [fn] [--read] checks configuration, reachability, login and read permission per store before a task runs, reads a value only with --read, revokes any lease it issued, and exits 3 on failure. Neither prints a value or a credential.
  • Five new external I/O error codes: E-IO-SECRET-PROVIDER, -REF, -UNREACHABLE, -AUTH and -NOT-FOUND. lask cmd --list notes an environment that reads a reference, without resolving it.

Typed confirmation

  • lask.json takes a confirm map from a public function of the entry module to an entry, so running destroy or deploy --env prod becomes a deliberate act: {"confirm": {"deploy": {"when": {"env": ["prod"]}}, "destroy": {}, "reset_db": {"phrase": "reset #{db}"}}}. when names the parameter values that require confirmation; phrase is what to type, interpolating parameters.
  • run and eval ask before anything is evaluated, and only about the function the CLI calls. At a terminal the phrase is typed; elsewhere --confirm approves, unless LASK_CONFIRM=tty says only a typed confirmation counts. A refusal is E-CLI-NOT-CONFIRMED, exit 4.
  • An entry belongs to the declaration the name resolves to, so a re-export is covered, and only the root project's file applies. check, run, eval and envs check every entry against the program — the function, the parameters it names, the types of the values — and a mismatch is E-MODULE-CONFIRM-TARGET, located at the key in lask.json.
  • --help gains a Confirmation section and JSON help a confirm field; the editor's hover shows the same, and the language server reports errors in lask.json on lask.json.

Retry, polling and timeouts

  • retry(delays, body) and retry_if(delays, when, body) run the body again after a failure, waiting the next delay each time. A strategy is just the array of delays, so its length bounds the attempts; once it runs out, or when refuses, the last failure is re-raised unchanged.
  • until(delays, done, body) polls until done accepts the body's value and returns that value. When the delays run out it fails with code 124, E-RUNTIME-UNTIL-EXHAUSTED, naming the last value.
  • timeout(seconds, body) abandons a body that outlives its limit: its command is stopped, the computations it started with async are cancelled, and the call fails with code 124, E-RUNTIME-TIMEOUT. The failure is raised at the call, so a try inside the body does not see it.
  • backoff_fixed, backoff_linear and backoff_exponential build strategies, and backoff_jitter randomizes one: retry(backoff_exponential(1, 2, 3), \() -> build()) waits 1, 2 and 4 seconds. Each retry and each missed check writes a line to the execution log.
  • An abandoned command is actually stopped. race cancelled the computations that lost, but the command one of them was running kept going, and a container outlived its docker run client. Now the process tree gets SIGTERM and, after 3 seconds, SIGKILL (on Windows, taskkill /T); containers are named lask-<random> and stopped with docker stop. The log records such a command as killed in place of its exit line.

Bounded type parameters

  • A type parameter takes one bound: a named bound — comparable, orderable, stringifiable — or a type bound, which admits what conforms to it. largest<T: orderable>(xs: Array<T>): T = last(sort(xs)) is now a function a user can write. A type alias makes a bound of a program's own.
  • Inside the body, a type parameter has what its bound gives: comparable gives ==, stringifiable gives #{...}, orderable gives sort and both of the others. A type bound gives what the bound has, except Any, which gives nothing.
  • The built-ins state their conditions as bounds: sort, sort_by, unique, contains_array, index_of_array, to_string and mark_secret. They used to check them at the call site only, so a built-in referenced as a function value skipped the check — s: Function<Array<Bool>, Array<Bool>> = sort was accepted. A use outside a bound is the new E-TYPE-BOUND, at a call, at a reference, and where a bounded alias is applied.
  • The CLI decodes a type-bounded parameter at its bound and instantiates a named-bounded one from the arguments it was given. Help, function lists and hovers show bounds, for built-ins too.

Async

  • A never-awaited async is no longer cut short. At the end of a run, run and eval wait for every handle that await, all or race did not consume, including any those start in turn, and report each as the runtime advisory W-ASYNC-UNAWAITED, with where it started and how it ended. The result and the exit code of the run are unchanged.
  • lask check reports W-ASYNC-UNUSED for a handle the text alone shows is never awaited: a discarded statement of type AsyncHandle<T> or Array<AsyncHandle<T>>, or such a binding that nothing refers to. It is the first advisory the implementation reports: check prints it and still exits 0, with severity: "warning" in JSON, and the editor shows it as a warning.
  • await re-raises a failure unchanged, as 6.3, 8.6 and 8.10 always said, so a command that fails inside async exits the process with its own code and catch (e) sees its e.code.

REPL

  • :reload / :r reads the target module again and re-applies the declarations typed at the prompt, in order. Re-applying compiles and does not evaluate, so a reload runs nothing.
  • A module that is missing or does not compile, or a typed declaration that no longer compiles against it, fails the reload by name and leaves the session as it was. A reload never discards what was typed.
  • Input starting with : is a session command; an unknown one is reported rather than parsed as code.

Hardening

  • A secret is masked everywhere lask writes to stderr, not only in the command log. With --format json a secret passed to a function appeared in its call event, and one a failed command printed to stderr appeared in the final diagnostic. Events, uncaught failures, W-ASYNC-UNAWAITED and REPL errors are now masked. A secret that spans lines is also masked line by line.
  • A docker environment's env values stay off the command line. --env NAME=value made a credential readable by every user of the host in the process list. Each variable is now named as --env NAME and its value handed to docker through the client's environment. Variables the client reads itself (DOCKER_*, *_PROXY, PATH, HOME, SSH_AUTH_SOCK) are passed as before.
  • The Vault token follows one standby redirect at most. A 307 or 308 is followed once, as the Vault CLI does; any other redirect, a second one, or one from https to http is E-IO-SECRET-UNREACHABLE rather than a token sent to another host.
  • A dependency source can no longer be read as an option of git or curl. A git URL such as --upload-pack=<command> ran that command during lask deps sync. git, rev and url may not start with - or contain whitespace, a url must be https://, http:// or file://, and both tools take the source where it cannot be an option. A lock entry's hash and rev must have their exact shapes, so neither can name a path outside the cache.
  • The dependency cache is verified where it is used. An entry used to be trusted from the moment deps sync put it there, so anything that could write to a shared LASK_CACHE_DIR could replace a dependency's code. The loader now checks each entry against the lock's hash once per resolution, and deps sync refetches one that does not verify.
  • A dependency may not contain a symbolic link, and a local import inside a dependency must stay inside its tree. A link made a dependency's content depend on the machine reading it, and let an import reach any file.

Dependencies and the lock

  • A git dependency's rev is the commit its content came from, recorded by whichever of deps add and deps sync fetched it, and a tag that has moved since is `E-MOD...
Read more

v0.6.0

Choose a tag to compare

@Ikepeeee Ikepeeee released this 26 Sep 02:43
cb32d81

An environment is now something a library can hand you — configured, pinned, and with its command words attached.

Until now a command declaration took an image written with literal arguments, its words never left the module that wrote them, and #docker(...) could say nothing about a container beyond memory and cpus. A project could pin a toolchain, but not share one: the credentials, variables and mounts a tool needs had to be repeated at every call site, or wrapped in a module that re-implemented the tool's CLI as functions. v0.6.0 lets a command's environment be any expression, lets command words be imported, gives the container constructor the rest of its options — and makes the lock pin every registry image by digest, so what a library hands you is the same image on every machine.

Command words across modules

  • import command brings in the command words another module declares. import command { "python", "pip" } from "tools" makes a bare $ pip install ... dispatch to the environment the tools module declared for it. export command { "helm" } from "./lib/k8s.lask" imports and publishes again. A command declaration takes export / internal like any other, and is exported by default.
  • No other import brings a command word in, and there is no renaming: the word is the program the environment runs. A named or namespace import changes nothing about where a command runs, so importing a module for its functions never reroutes your commands.
  • A command's environment is any expression of type Environment: command { "aws" } on tools.aws(profile = "dev"), a namespace member, a call with computed arguments. It is elaborated in the declaring module and evaluated when a command it selects runs.
  • It may not have an effect. No command, file, stdin, log or random value, directly or through what it calls (E-TYPE-COMMAND-EFFECT); get_env is fine. This is what lets lask cmd evaluate a declaration without running anything or consuming the program's stdin.
  • Dispatch compares where an environment comes from, not its value: the same literal, the same binding, or the same declaration. Two separate calls conflict, since only running them could show they agree — declare such words together.
  • The words of a declaration are written in braces, command { "go", "gofmt" } on e, for symmetry with the import form. With the braces the position of on is lexical, so the editor marks it as a keyword there and leaves it an identifier elsewhere.

Container options

  • #docker(...) configures the container it launches. Beyond memory and cpus: memory_swap memory_reservation cpu_shares cpuset_cpus cpuset_mems pids_limit shm_size blkio_weight ulimits; workdir user env platform hostname init; read_only tmpfs cap_drop; network dns dns_search add_hosts publish; volumes; and build_args on a recipe.
  • Only options that stay inside the permission boundary of 10.7 are offered. privileged, cap_add, devices and the host namespaces are left out: they would make that guarantee untrue rather than narrow it. publish and volumes widen it, and are written where the environment is.
  • An option given null is left out, as are the null elements of a list or table, so a function that builds an environment can pass an optional parameter straight through. "" is a value and is passed on — PAGER="" means something. A list or table already typed Array<String> or Map<String> is accepted as it is.
  • Environment values are keyed by argument name, not by the order the arguments were written in, so #docker("a:1", memory = "1g", cpus = 2) and the same options reordered are one environment for dispatch.
  • build_args closes a gap 10.3 had already written down: the recipe hash covers the declared build arguments, and there was no way to declare any. Like dockerfile and context, it must be a literal, since it decides which image lask env build builds.
  • The environment in the command log is masked too. A docker environment can carry variables into the container, so a secret reaches the log through the environment value as readily as through the command string.

Images pinned by digest

  • A run never pulls. Spec 10.3 and 10.4 always said so; none of it was implemented, and a run passed the tag to docker run, which pulled whatever the tag named that day. Now lask deps sync and lask env build pull every registry image the program references, record its digest in lask.lock.json, and run, eval, cmd and file access run <repository>@<digest>.
  • A fresh machine gets the very image the lock names, whatever the tag names upstream, and is checked to carry it (E-IO-IMAGE-DIGEST). A missing image is E-IO-IMAGE-MISSING, naming lask env build.
  • A registry reference may omit its tag — #alpine means latest — since the lock pins what the bare name resolved to, and it cannot move under a run.
  • deps sync materializes images after the modules, also in a project that declares no dependency, and --frozen covers images too. envs --check checks that each image is actually present, and env list reports registry references and their pins, with JSON.
  • A reference computed at run time cannot be pinned: it runs as written if present. The REPL may still pull — it evaluates what is typed at it, which no lock records.

Language

  • A secret binding may be String | Null. --token!!: String | Null = null says "not given" the way every other optional parameter does, and a credential that may be unset can be read with find_env and still be masked when present. A null registers nothing, so the text an absent value prints as is never masked elsewhere in a log. mark_secret becomes T -> T for String or String | Null.
  • run(env, cmd) is the core command-execution function, formerly run_command(cmd, env): the environment comes first, as it does in $[env] cmd.
  • A re-exported name works everywhere its declaration does. t.go() through import * as t failed with internal: missing declaration go when t published go by export { go } from ..., and lask run answered no such function for it. Both now follow the re-export — keyword arguments, --help built from the declaring file under the published name, the function list, and shell completion for local paths — so a multi-file library that publishes its API from its entry module, as chapter 5 describes, has a CLI surface.
  • A recipe resolves inside the tree of the module that declares it. #docker(dockerfile = "images/ansible/Dockerfile") written in a library read that path from the importing project and failed there. A library can now ship the recipes of its environments, built from the dependency cache.

In the editor

  • Hovering a builtin documents it: the call with parameter names, the failure it raises, and the spec section it summarizes, condensed from chapter 15. Polymorphic builtins show their type parameters, as user declarations do, and a local that shadows a builtin is no longer mistaken for it.

Breaking changes

  • run_command(cmd, env) is now run(env, cmd). Rename the call and swap the arguments. A builtin call with keyword arguments is always E-TYPE-KEYWORD.
  • A command declaration's words are written in braces. command "go" on e is now a syntax error that says how to write command { "go" } on e.
  • E-TYPE-COMMAND-DECL is replaced by E-TYPE-COMMAND-EFFECT. The literal-arguments rule it enforced is gone; what is refused now is an environment with an effect.
  • A project that runs containers needs lask env build (or lask deps sync) before its first run. A registry image the lock does not pin, or one not on the daemon, is E-IO-IMAGE-MISSING rather than a pull. Commit the images section lask.lock.json gains.
  • build_args must be a literal, like dockerfile and context. Spec 10.2's claim that every container option had to be a literal is corrected: only these three are.

Lask is pre-1.0 and every feature is experimental until 1.0, so breaking changes remain possible; see compatibility.md.

Fixes and internals

  • CI pins the image before running the containerized task in the colon-bearing-directory check, as the install test does, now that a run no longer pulls.

Examples

  • example/ is reorganized into two groups, each numbered in reading order. 01-projects keeps the end-to-end work: 01-hello-world (was 01-basic) and 02-webapp-on-aws (was 04-webapp). The Docker and Terraform projects are gone; what they showed is now in the language tour.
  • example/02-language is a tour of the whole language: fourteen topics, one runnable module each, covering the Quick Reference from values and types through to doc comments and the CLI. The commentary lives in the code beside the thing it describes, and because the comment above a task is also its help text, lask run --help in any of them is a second index into the same prose.
  • 02-webapp-on-aws takes its environments from lask-module-tools instead of wrapping Terraform and the AWS CLI through lask-terraform and lask-aws. The tools run as written — $ terraform -chdir=infra apply ..., $ aws s3 sync ... — and their image tags, credentials and region are given once, in the environment each command word is declared on.

Documentation

  • The Quick Reference, the specification and the READMEs follow every change above, and paragraphs are no longer hard-wrapped, so the docs pass cleanly through translation tools.

Full Changelog: v0.5.0...v0.6.0

v0.5.0

Choose a tag to compare

@Ikepeeee Ikepeeee released this 20 Sep 09:42
4094791

A value that may be absent now has a type — and a way back out of it.

Until now the only type for a value whose shape was not fixed was Any, and the only way out of Any was cast, which fails where a program wanted to test. A lookup that might find nothing had to fail rather than report it, and a published module could not write first without one copy per element type. v0.5.0 gives the language unions, a case that dispatches on them, type parameters and optional record fields — and nearly triples the built-in library, so that fewer tasks have to shell out to get ordinary work done.

Union types and type dispatch

  • T | Null is the type of a value that may be absent. find_env("PORT") returns String | Null instead of failing on an unset variable or inventing a sentinel; get_env remains the form for a variable the task requires in order to run at all.
  • A union is never inferred (4.3). It is the type of an expression only where an annotation or a callee's signature says so, so a heterogeneous array literal is still Array<Any> and two branches of differing types are still a type error. Every union in a program is one that someone wrote.
  • case is the multi-way conditional, in three forms: value heads compared by ==, type heads dispatched on the runtime type, and a scrutinee-less condition form in place of the chained else if the grammar never had. Exactly one else arm, last, is required — exhaustiveness is never inferred from the type of the scrutinee (E-SYNTAX-CASE-ELSE).
  • An arm narrows its scrutinee. Where the scrutinee is written as a plain local name, the else arm has the members that are left: after Null -> 8080, to_number(p) sees a String. case and cast run the same runtime check (15.8) and differ only in what happens when it does not hold — one tests, the other fails.
  • Every form of case normalizes to nested choose, with the scrutinee bound once and evaluated exactly once however many arms are tested, so it adds no evaluation rule of its own. Two new error codes: E-SYNTAX-CASE-ELSE and E-TYPE-CASE-DUPLICATE, the latter for a later arm that can never be selected.

Type parameters

  • A function declaration and a type alias may bind them: first_or<T>(xs: Array<T>, fallback: T): T, type Opt<A> = A | Null, type Pair<A, B>. The cost of not having them was measurable — 43 of the library's 112 signatures were polymorphic, and every one was a function a user could not have written.
  • They are never written at a use site. A call instantiates them from the argument types and the expected type, in no particular order, by the rules built-in symbols already followed; 4.4 stops being "built-in polymorphism" and becomes one set of rules for both.
  • Inside the body of its declaration a type parameter is rigid. It conforms only to itself and to Any, so it is not comparable, not ordered, not stringifiable, not a cast target and not a case type head. A body that needs an operation takes it as an argument, which is what makes the feature safe without bounded quantification.
  • A default value is checked once, at the declaration, with the parameters rigid, so it has to hold for every instantiation: --xs: Array<T> = [] and --y: T | Null = null are admitted, --y: T = 1 is not. For the same reason lask run can instantiate every parameter at Any — whatever it hands over, the body can only pass along.
  • E-TYPE-ARITY now also covers a type argument count that does not match an alias's parameters.

Optional record fields

  • ? after a field name makes the key optional — Record<name: String, tags?: Array<String>> — which is a different question from whether the value may be null. A JSON producer omits an optional field far more often than it writes an explicit null, and cast used to reject exactly that. The notation is TypeScript's, and so is the meaning: a?: T qualifies the key alone.
  • An absent key and a null value stay different where it matters. cast accepts a value whose optional keys are missing and still rejects one missing a required key (15.8); serialization omits an absent optional field while writing "b": null for a null one (13.1).
  • In memory the two read alike. An optional field reads as T | Null (6.8), an absent key reading as null, which is what keeps field access from failing at run time. A program that must tell them apart casts the record to a Map<T> and asks has_key.
  • Conformance stays invariant. The required set, the optional set and each field type must all be identical, so Record<a: String> conforms to Record<a?: String> in neither direction: whether a key has to be there is part of the type. Optionality is never inferred, as unions are not.

Built-in library

  • 42 functions to 115, with no existing signature changed. The additions sit where writing a task meant shelling out.
  • Filesystem and path operations, both new. read_file write_file file_exists remove_file make_dir list_dir glob, and path_join dirname basename extname normalize_path is_absolute_path. Each filesystem function takes the Environment as its last argument, so where it reads is as explicit as where a command runs. There is deliberately no recursive removal: a destructive traversal stays a command, where the execution log can see it.
  • Arrays: find find_index every any sort sort_by zip unique range enumerate flatten flat_map slice take drop reverse first last size is_empty contains_array index_of_array. range is how a for expression iterates a number of times and enumerate how it iterates with an index, since for traverses an array and has no numeric form.
  • Strings: to_string to_number lines substring pad_start pad_end repeat starts_with ends_with index_of contains, and regex_test regex_match regex_replace.
  • Maps: get_or set remove merge entries from_entries map_values. Environment: find_env has_env get_env_or. Data: base64_encode base64_decode sha256 md5. Also shell_quote, log, uuid, random_string, and min max sum pow sqrt clamp.
  • Absence is reported two ways, deliberately. A function that returns a position reports it as -1 (index_of, find_index); one that returns a value reports it as Null (find, find_env). Two new runtime error codes: E-RUNTIME-VALUE and E-RUNTIME-REGEX.

Language and editor

  • Type annotations on local bindings. A binding inside a block takes one in the same shape as a top-level declaration, !! marker included: cfg: Record<name: String> = cast(from_json(stdin)). This is how an expression that needs an expected type gets one inside a block (6.5), and cast is the case that motivates it.
  • Instantiation no longer depends on argument order. An argument whose own type comes from its position — a cast, a fail — is checked once the other arguments and the expected type have determined that position, and may stand anywhere in the call. Arguments still evaluate left to right.
  • Hover and --help show a declaration with its binder, first_or<T>. Where the name is instead something to type — the usage line, a completion candidate, the function argument of lask run — it stays plain (11.6). Record field completion shows an optional field at the type it reads as, and case is painted as a control keyword.

Breaking changes

  • case is a reserved word. A module using it as a declaration name or an identifier-form record field is now E-SYNTAX-UNEXPECTED-TOKEN; such a field is written {"case": ...} and read as x["case"]. No module in this repository was affected.
  • An expression of type Any is refused in a polymorphic position. 4.4 has always said the way out of Any is a runtime check, but the matcher for polymorphic signatures accepted one anyway, so map(v, f) on a v: Any was quietly admitted and the element type became whatever the lambda said. Insert a cast, or narrow with case, first.

Lask is pre-1.0 and every feature is experimental until 1.0, so breaking changes remain possible; see compatibility.md.

Fixes and internals

  • Running a command in a container now works on Windows. A bind mount was passed as -v <source>:<target>, which packs source, target and mode into one colon-separated field. Every Windows path carries a colon in its drive letter, so C:\proj was read as source C, target \proj and mode /work, which the daemon refused. Mounts now use --mount type=bind,source=...,target=..., where each field is named; the same break was reachable on POSIX, where a colon is legal in a directory name.
  • A CLI argument that does not match its parameter type says what did not fit. The one fact the user needs, expected Number, got String, used to arrive wrapped in the Show output of the internal failure record, so the line read like a crash rather than a usage error. It is the first thing a new user sees on a typo.
  • The mount regression is now exercised in CI rather than by hand, the install test drives a released binary the way the README tells a reader to install it, and the .deb is named the way the release page names it. The build is warning-free again.

Documentation

  • Quick Reference — the whole language and CLI on one page, ten minutes end to end, with every section linking to the chapter of the specification that defines it. spec.md is 4,300 lines and answers the questions nobody asks first; this is the way in.
  • The README documents the .deb attached to every release, distinguishes it from the APT repository that is still planned, and leads with what it costs to...
Read more

v0.4.0

Choose a tag to compare

@Ikepeeee Ikepeeee released this 15 Sep 13:05
8ebcc90

Where a command runs is now written down — or it does not run at all.

Until now a command execution expression with no $[...] fell back to the host. That fallback is the "works on my machine" failure the language exists to prevent: a deleted declaration or a mistyped program name went silent, and every line had to repeat the image it needed. v0.4.0 removes it, and gives the environment a place to be declared once.

Command declarations and explicit environments

  • command declares which image provides a program. command "python", "pip" on #python:3.12.14-alpine3.24 registers command words for the module; the tasks below then name only what they run: $ pip install -r api/requirements.txt && python -m unittest.
  • There is no default environment. An expression takes its environment from its $[...] or from the programs its command string invokes. One that determines neither is E-TYPE-COMMAND-NOENV, caught by lask check.
  • Dispatch decides only where the text decides. Spec 10.9 scans quoted and nested regions, separators and assignment words, and reports a command word only where the text alone determines it. Selection is unanimity over structural equality — #local included — so a line invoking both a host program and a containerised one is E-TYPE-COMMAND-CONFLICT, never a silent choice.
  • lask cmd runs a declared command by hand. lask cmd go test ./... runs the project's pinned toolchain outside any task, as an argument vector with no shell, attaching the terminal when there is one. Everything after the command name goes to the program verbatim, --help included; lask cmd --list shows what the module declares.
  • Six new error codes: E-TYPE-COMMAND-NOENV, -CONFLICT, -DECL, -NAME, -DUPLICATE, alongside the existing -ENV.

Shell completion

  • lask completion bash|zsh|fish prints a script whose only job is to ask the binary, so an installed script survives upgrades.
  • It knows your module, not just the CLI. lask run <TAB> completes the functions the entry module defines — less value bindings, @hidden, internal, and functions taking a positional Environment; lask run build --<TAB> completes that function's keyword parameters; lask cmd <TAB> completes declared commands. kebab and snake spellings mirror each other, and candidates come back in the style being typed.
  • Nothing is evaluated to answer a <TAB>. The index parses the entry module and stops there: no elaboration, no import resolution, no dependency cache, no network. Completion works before deps sync has ever run, and a module that does not parse still yields declaration heads. A request answers in ~22 ms, so there is no cache to go stale.
  • @complete steers a parameter (spec 3.1): static choices, @keys <binding> reading a top-level map literal without evaluating it, @file [*.ext], @dir.
  • lask run install_completion --shell fish writes the script into the directory that shell reads and prints the startup line rather than editing anyone's rc file. uninstall_completion is its counterpart.
  • The README now gives each shell a recipe that works on a stock machine — including that zsh loads nothing from $fpath until compinit has run, and that bash 3.2, still macOS's /bin/bash, silently reads nothing from source <(...).

In the editor

  • Semantic tokens paint a command word that carried the environment as the reference it is, inside the command string. Unmatched words stay string text, which makes a bracket-less command with nothing highlighted the visible form of E-TYPE-COMMAND-NOENV.
  • Inlay hints show the environment a bare command resolved to, written as the source the author could have written: $[#node:20.20.2-alpine3.23] cd web && npm ci. A line that already carries $[...] gets no hint, since it says where it runs.
  • The spans come from elaboration, so the editor shares dispatch's own table and procedure — a word that lights up is a word that voted, and no second, looser matcher exists to drift from it.
  • export and internal are surfaced like import: painted as keywords, offered by completion, and read by the token-stream fallback.

Language

  • Namespace-qualified type references. import * as tf from "terraform" now lets an annotation name that module's public type aliases directly: f(): tf.TfOutputs = .... Alias-cycle detection follows qualified edges across modules.
  • Every string and command chunk carries its source span, so anything downstream can name a location inside a command string.

Breaking changes

  • A command execution expression that determines no environment is a static error. Add a command ... on <Environment> declaration for the programs a module runs, or write $[...] at the site. The examples and main.lask in this repository show both.
  • run_command(cmd: String, env: Environment) takes the environment as a required positional argument, closing the path by which the core function could still default to the host.
  • export and internal are reserved words. A declaration, an identifier-form record field, or an unquoted object key by either name is now E-SYNTAX-UNEXPECTED-TOKEN; such a field is written {"internal": true} and read as x["internal"]. No module in this repository, lask-terraform or lask-aws was affected.
  • A command declaration's environment arguments must be literals: dispatch has to compare two environments structurally, and the environment must be enumerable and pinnable without evaluating the module.
  • Spec correction in 11.8: standard output is always passed through and only standard error is relayed as the command execution log — the previous wording would have made the output of lask cmd impossible to pipe.

Fixes and internals

  • stripSpansDecl had no DExportFrom case, so stripping spans from any module using export { .. } from ".." crashed at run time.
  • GHC 9.6.7 → 9.10.3 (Stackage LTS 24.58), with the build now warning-free: partial head uses replaced, shadowing binds renamed, dead code and unused imports dropped.
  • CoreProgram carries each module's command table, so lask envs and lask env build account for a declared command's image even when no task uses it.

Examples

  • example/04-webapp drives Terraform and AWS through the lask-terraform and lask-aws modules instead of hand-rolled command strings, and uses two official images — hashicorp/terraform and amazon/aws-cli — in place of a self-built combined Dockerfile, removing the build_infra_image() task and its "forgot to call it" failure mode. Exactly one line, test_e2e, still needs an explicit environment: it wants the Playwright image for its browsers rather than for the program it runs.
  • example/03-terraform bumps lask-terraform to v0.1.1.
  • The README says plainly what Lask does not replace: not GitHub Actions, GitLab CI or Jenkins, but what their jobs run — so switching providers rewrites one step rather than the pipeline.

Full Changelog: v0.3.0...v0.4.0

v0.3.0

Choose a tag to compare

@Ikepeeee Ikepeeee released this 06 Sep 14:30
6cf6499

A module can now carry the toolchain it needs, and a task can explain itself.

Until now a module could publish functions and types but not the environment they run in, so a module driving Terraform against AWS was unusable without an image nobody could ship. And finding out what a task took as arguments meant reading its source. v0.3.0 closes both.

Distributable modules

  • Environments travel with the module. #docker gains a recipe form — #docker(dockerfile = "infra/Dockerfile", context = ".") — so a module ships the Dockerfile for its toolchain inside its own hash-pinned tree. Registry references now require a tag or digest.
  • Nothing is built implicitly. Images are content-addressed by recipe hash and materialized only by lask deps sync or lask env build. A RUN line is arbitrary code, so no implicit path may reach one.
  • Intent and resolution are separate files. dependencies.lask.json becomes lask.json and records only where a dependency comes from; lask.lock.json records the resolved graph, transitive dependencies included, and is now required. What a project executes can be read from the project.
  • References are pinned and checked. Tags resolve to commit SHAs, a repointed tag is E-MODULE-REV-MOVED, and a project file that disagrees with the lock is E-MODULE-LOCK-STALE.
  • Dependencies live in the project. The cache moves to .lask/deps, so a copied project works offline.
  • New commands. lask deps sync|add|diff|why, --frozen, and lask env build|list.
  • Visibility. export / internal mark declarations, with omission meaning export. export { a } from "path" re-exports declarations rather than values, so keyword arguments survive. An external import reaches only a tree's entry module, so a public API spanning files is re-exported from main.lask.

Help and documentation comments

  • A documentation comment above a declaration supplies a summary, a description, and @param / @return / @example / @hidden.
  • lask run <function> --help prints the signature, the doc comment, each default value, and the environments the function reaches. With no function name it lists everything the module offers, so a repository's tasks are discoverable without reading the source.
  • Default values are shown as source text and never evaluated; the default of a !! parameter is shown as <secret>.
  • lask envs <function> is now limited to that function's call graph instead of enumerating the whole module.

Breaking changes

  • remote, #env, environments.lask.json, --env-file and --ssh-* are removed. A host cannot be pinned, carries state between runs, and admits no boundary; reach one with ssh inside a pinned image instead.
  • dependencies.lask.json is renamed to lask.json, and a committed lask.lock.json is required. A hash key left in an existing project file is accepted and ignored, so nothing has to be edited by hand.
  • An external import naming a path inside a dependency tree is rejected.
  • Registry image references without a tag or digest are rejected.
  • lask infer is removed. run / eval help is where a user reaches for a type now; type inference itself is untouched.

Fixes

  • The lock was only required to cover the project file, not to agree with it, so a reference edited in place left the pinned content behind unnoticed. check now compares both.
  • deps sync short-circuited on a cache hit keyed by content hash, so a changed rev over an unchanged hash never re-fetched. A reference differing from the locked one now forces a fetch, surfacing the drift as E-MODULE-HASH-MISMATCH.

Full Changelog: v0.2.0...v0.3.0

v0.2.0

Choose a tag to compare

@Ikepeeee Ikepeeee released this 30 Aug 03:02

Full Changelog: v0.1.0...v0.2.0

v0.1.0

Choose a tag to compare

@Ikepeeee Ikepeeee released this 10 Aug 15:00
Refactor release workflow to use workflow_dispatch and input paramete…