Compare commits

..

1 commit

Author SHA1 Message Date
marked
7544c4d920 Sync to upstream Jecs 0.5.5-nightly.20250302T042604Z 2025-03-02 04:26:11 +00:00
20 changed files with 120 additions and 1089 deletions

View file

@ -6,7 +6,7 @@ on:
- cron: "10 0 * * *" # Runs at 00:10 UTC every day - cron: "10 0 * * *" # Runs at 00:10 UTC every day
jobs: jobs:
release: sync:
name: Sync name: Sync
runs-on: docker runs-on: docker
container: container:
@ -20,53 +20,13 @@ jobs:
with: with:
token: ${{ secrets.githubtoken }} token: ${{ secrets.githubtoken }}
- name: Install Luau - name: Install fj
uses: https://github.com/EncodedVenom/install-luau@v4.3
with:
version: "latest"
verbose: "true"
- name: Install forgejo-cli
run: | run: |
wget https://codeberg.org/Cyborus/forgejo-cli/releases/download/v0.2.0/forgejo-cli-linux.gz -O fj.gz wget https://codeberg.org/Cyborus/forgejo-cli/releases/download/v0.2.0/forgejo-cli-linux.gz -O fj.gz
gunzip fj.gz gunzip fj.gz
mv fj /usr/local/bin/fj mv fj /usr/local/bin/fj
chmod +x /usr/local/bin/fj chmod +x /usr/local/bin/fj
- name: Synchronize
run: lune run src/init -- sync jecs
- name: Run Unit Tests
shell: bash
run: |
output=$(lune run src/init -- test jecs)
echo "$output"
if [[ "$output" == *"0 fails"* ]]; then
echo "Unit Tests Passed"
else
echo "Error: One or More Unit Tests Failed"
exit 1
fi
- name: Read Jecs Version
id: read_jecs_version
run: |
version=$(lune run src/read_version | tr '\n' ' ')
echo "JECS_VERSION=$version" >> $GITHUB_OUTPUT
- name: Build With Rojo
run: |
cd jecs
rojo build default.project.json -o build.rbxm
mv build.rbxm ../jecs_nightly.rbxm
cd ..
- name: Upload Build Artifact
uses: https://git.devmarked.win/actions/upload-artifact@v4
with:
name: build
path: jecs_nightly.rbxm
- name: Authorize Pesde - name: Authorize Pesde
run: pesde auth login --token "${{ secrets.pesde_auth_token }}" run: pesde auth login --token "${{ secrets.pesde_auth_token }}"
@ -76,8 +36,14 @@ jobs:
wally login --token "${{ secrets.wally_auth_token }}" wally login --token "${{ secrets.wally_auth_token }}"
rm wally.toml rm wally.toml
- name: Release - name: Sync & Release
run: lune run src/init -- release jecs --pesde-scope marked/jecs_nightly --wally-scope mark-marks/jecs-nightly run: lune run src/init
- name: Read Jecs Version
id: read_jecs_version
run: |
version=$(lune run src/read_version | tr '\n' ' ')
echo "JECS_VERSION=$version" >> $GITHUB_OUTPUT
- name: Create Pull Request - name: Create Pull Request
id: create_pull_request id: create_pull_request

View file

@ -1,2 +0,0 @@
modified = ["README.md", "LICENSE", ".luaurc", "CHANGELOG.md", "jecs.luau"]
version = "0.5.5-nightly.20250302T175658Z"

View file

@ -1 +1,6 @@
{"name":"jecs-nightly","tree":{"$path":"jecs.luau"}} {
"name": "jecs",
"tree": {
"$path": "jecs.luau"
}
}

View file

@ -3,7 +3,7 @@ includes = ["init.luau", "pesde.toml", "README.md", "CHANGELOG.md", "LICENSE", "
license = "MIT" license = "MIT"
name = "marked/jecs_nightly" name = "marked/jecs_nightly"
repository = "https://git.devmarked.win/jecs-nightly" repository = "https://git.devmarked.win/jecs-nightly"
version = "0.5.5-nightly.20250302T175658Z" version = "0.5.5-nightly.20250302T042604Z"
[indices] [indices]
default = "https://github.com/daimond113/pesde-index" default = "https://github.com/daimond113/pesde-index"

View file

@ -1,2 +0,0 @@
passed = true
timestamp = "20250302T175659Z"

View file

@ -1,114 +0,0 @@
8.2 us  3 kB│ delete children of entity
9.4 us  2 kB│ remove friends of entity
328 ns  0  B│ simple deletion of entity
removing
archetype
PASS│ 
world:cleanup()
PASS│ 
world:entity()
PASS│ unique IDs
PASS│ generations
PASS│ pairs
PASS│ Recycling
PASS│ Recycling max generation
world:set()
PASS│ archetype move
PASS│ pairs
world:remove()
PASS│ should allow remove a component that doesn't exist on entity
world:add()
PASS│ idempotent
PASS│ archetype move
world:query()
PASS│ cached
PASS│ multiple iter
PASS│ tag
PASS│ pairs
PASS│ query single component
PASS│ query missing component
PASS│ query more than 8 components
PASS│ should be able to get next results
PASS│ should query all matching entities when irrelevant component is removed
PASS│ should query all entities without B
PASS│ should allow querying for relations
PASS│ should allow wildcards in queries
PASS│ should match against multiple pairs
PASS│ should only relate alive entities
NONE│ should error when setting invalid pair
PASS│ should find target for ChildOf
PASS│ despawning while iterating
NONE│ iterator invalidation
SKIP│ adding
PASS│ spawning
PASS│ should not find any entities
PASS│ without
world:each
PASS│ 
world:children
PASS│ 
world:clear()
PASS│ should remove its components
PASS│ should move last record
world:has()
PASS│ should find Tag on entity
PASS│ should return false when missing one tag
world:component()
PASS│ only components should have EcsComponent trait
PASS│ tag
world:delete
PASS│ invoke OnRemove hooks
PASS│ delete recycled entity id used as component
PASS│ bug: Empty entity does not respect cleanup policy
PASS│ should allow deleting components
PASS│ delete entities using another Entity as component with Delete cleanup action
PASS│ delete children
PASS│ fast delete
PASS│ cycle
world:target
PASS│ nth index
PASS│ infer index when unspecified
PASS│ loop until no target
world:contains
PASS│ 
PASS│ should not exist after delete
Hooks
PASS│ OnAdd
PASS│ OnSet
PASS│ OnRemove
change tracking
PASS│ #1
PASS│ #2
repro
PASS│ #1
PASS│ #2
wildcard query
PASS│ #1
PASS│ #2
PASS│ #3
world:delete() invokes OnRemove hook
PASS│ #1
PASS│ #2
PASS│ #3
68/68 test cases passed in 31.100 ms.
0 fails

View file

@ -5,4 +5,4 @@ license = "MIT"
name = "mark-marks/jecs-nightly" name = "mark-marks/jecs-nightly"
realm = "shared" realm = "shared"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
version = "0.5.5-nightly.20250302T175658Z" version = "0.5.5-nightly.20250302T042604Z"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -9,4 +9,3 @@ luau-lsp = "johnnymorganz/luau-lsp@1.37.0" # https://discord.com/channels/385151
stylua = "johnnymorganz/stylua@2.0.2" stylua = "johnnymorganz/stylua@2.0.2"
wally = "upliftgames/wally@0.3.2" wally = "upliftgames/wally@0.3.2"
pesde = "pesde-pkg/pesde@0.6.0+registry.0.2.0" pesde = "pesde-pkg/pesde@0.6.0+registry.0.2.0"
rojo = "rojo-rbx/rojo@7.4.4"

View file

@ -1,80 +1,16 @@
--!strict --!strict
local fs = require("@lune/fs")
local process = require("@lune/process")
local frkcli = require("./util/frkcli")
local release = require("./release") local release = require("./release")
local sync = require("./sync") local sync = require("./sync")
local test = require("./test")
local cli = frkcli.new_subcommands("nightly_cli", "A CLI to sync & release jecs nightly") local res = sync("jecs")
if not res.ok then
local command_sync = cli:add_subcommand("sync", "Synchronize files from the jecs repository") print(`Can't continue: {res.err}`)
command_sync:add_positional( return
"to",
{ help = "Directory to synchronize the files in, defaults to current working directory", default = process.cwd }
)
local command_release = cli:add_subcommand("release", "Publish a release from the given directory")
command_release:add_positional(
"from",
{ help = "Directory to release from, defaults to current working directory", default = process.cwd }
)
command_release:add_flag("dry", { help = "Dry run the publishes" })
command_release:add_option(
"pesde-scope",
{ help = "The pesde scope to publish under (eg. marked/jecs_nightly)", default = "nil" }
)
command_release:add_option(
"wally-scope",
{ help = "The wally scope to publish under (eg. mark-marks/jecs-nightly)", default = "nil" }
)
local command_test = cli:add_subcommand("test", "Run unit tests on the stored jecs source")
command_test:add_positional("in", {
help = "Directory with the jecs source to run tests in, defaults to current working directory",
default = process.cwd,
})
local parsed, err = cli:parse(process.args)
if err ~= nil then
error(err)
end end
assert(parsed ~= nil)
local values = parsed.result.values if res.val == false then
local flags = parsed.result.flags print(`No changes made. Aborting.`)
if parsed.command == "sync" then return
sync(values.to)
elseif parsed.command == "release" then
local scopes = {}
if values["pesde-scope"] ~= "nil" then
scopes.pesde = values["pesde-scope"]
end
if values["wally-scope"] ~= "nil" then
scopes.wally = values["wally-scope"]
end
local metadata = fs.metadata(values.from)
if not metadata.exists or metadata.kind ~= "dir" then
error(`The path {values.from} doesn't exist or isn't a valid directory.`)
end
local fpath = process.cwd
if values.from ~= process.cwd then
fpath ..= values.from
end
if flags.dry then
release(fpath, scopes, true)
else
release(fpath, scopes, false)
end
elseif parsed.command == "test" then
local fpath = process.cwd
if values["in"] ~= process.cwd then
fpath ..= values["in"]
end
test(fpath)
end end
release("jecs", { pesde = "marked/jecs_nightly", wally = "mark-marks/jecs-nightly" }, false)

View file

@ -1,14 +1,20 @@
--!strict --!strict
local datetime = require("@lune/datetime")
local fs = require("@lune/fs") local fs = require("@lune/fs")
local net = require("@lune/net")
local process = require("@lune/process") local process = require("@lune/process")
local serde = require("@lune/serde") local serde = require("@lune/serde")
local stdio = require("@lune/stdio") local stdio = require("@lune/stdio")
local progress_bar = require("./util/progress") local progress_bar = require("./util/progress")
local result = require("./util/result") local result = require("./util/result")
local shared = require("./shared")
local types = require("./types") local types = require("./types")
-- Returns an ISO 8601 date (YYYYmmddThhmmssZ)
local function iso_date_light(now: datetime.DateTime): string
return now:formatUniversalTime("%Y%m%dT%H%M%SZ")
end
local function make_pesde_manifest(version: string, scope: string): types.PesdeManifest local function make_pesde_manifest(version: string, scope: string): types.PesdeManifest
return { return {
name = scope, name = scope,
@ -57,17 +63,32 @@ local function make_wally_manifest(version: string, scope: string): types.WallyM
} }
end end
local function release(origin: string, scopes: { wally: string?, pesde: string? }, dry: boolean?): result.Identity<nil> local function round_to(n: number, places: number)
local x = 10 ^ (places or 0)
return math.round(n * x) / x
end
--- Fetches the given file raw from the jecs github
local function fetch_raw(file: string): result.Identity<string>
local res = net.request(`https://raw.githubusercontent.com/Ukendio/jecs/refs/heads/main/{file}`)
if not res.ok then
return result(false, `Not ok: {res.statusMessage} @ {res.statusCode}`)
end
return result(true, res.body)
end
local function release(origin: string, scopes: { wally: string, pesde: string }, dry: boolean?): result.Identity<nil>
local begin = os.clock() local begin = os.clock()
local now = datetime.now()
local progress = progress_bar local progress = progress_bar
.new() .new()
:withStage("init", "Initializing") :withStage("init", "Initializing")
:withStage("version", "Pulling version from build metadata") :withStage("pull", "Pull latest version")
:withStage("prepare", "Preparing manifests") :withStage("prepare", "Preparing manifests")
:withStage("release (pesde)", "Releasing on pesde") :withStage("release (pesde)", "Releasing on pesde")
:withStage("release (wally)", "Releasing on wally") :withStage("release (wally)", "Releasing on wally")
progress:start() -- init progress:start()
if dry == nil then if dry == nil then
dry = true dry = true
@ -79,96 +100,62 @@ local function release(origin: string, scopes: { wally: string?, pesde: string?
return result(false, `{origin} is not a valid directory which exists.`) return result(false, `{origin} is not a valid directory which exists.`)
end end
progress:nextStage() -- version progress:nextStage()
local version local wally_contents = fetch_raw("wally.toml")
do if not wally_contents.ok then
local encoded_metadata = fs.readFile(`{origin}/build.txt`) progress:stop()
local metadata: { version: string } = serde.decode("toml", encoded_metadata) stdio.ewrite(`🔥 Couldn't get the jecs wally manifest:\n{wally_contents.err}\n`)
version = metadata.version return result(false, `Couldn't get the jecs wally manifest:\n{wally_contents.err}`)
end end
progress:nextStage() -- prepare local parsed: types.WallyManifest = serde.decode("toml", wally_contents.val)
local version = `{parsed.package.version}-nightly.{iso_date_light(now)}`
if scopes.pesde then progress:nextStage()
do
local manifest = make_pesde_manifest(version, scopes.pesde) local manifest = make_pesde_manifest(version, scopes.pesde)
local encoded = serde.encode("toml", manifest) local encoded = serde.encode("toml", manifest)
fs.writeFile(`{origin}/pesde.toml`, encoded) fs.writeFile(`{origin}/pesde.toml`, encoded)
end end
if scopes.wally then do
local manifest = make_wally_manifest(version, scopes.wally) local manifest = make_wally_manifest(version, scopes.wally)
local encoded = serde.encode("toml", manifest) local encoded = serde.encode("toml", manifest)
fs.writeFile(`{origin}/wally.toml`, encoded) fs.writeFile(`{origin}/wally.toml`, encoded)
end end
progress:nextStage() -- release (pesde) progress:nextStage()
local cwd = origin --process.cwd .. origin local cwd = process.cwd .. origin
local res_pesde
if scopes.pesde then
if not dry then if not dry then
res_pesde = process.spawn("pesde", { "publish", "-y" }, { cwd = cwd }) process.spawn("pesde", { "publish", "-y" }, { cwd = cwd })
else else
res_pesde = process.spawn("pesde", { "publish", "-d", "-y" }, { cwd = cwd }) process.spawn("pesde", { "publish", "-d", "-y" }, { cwd = cwd })
end end
if not res_pesde.ok then progress:nextStage()
progress:stop()
print(`-- Pesde error:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`)
return result(false, `Pesde error:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`)
end
end
progress:nextStage() -- release (wally)
local res_wally
if scopes.wally then
if not dry then if not dry then
res_wally = process.spawn("wally", { "publish" }, { cwd = cwd }) process.spawn("wally", { "publish" }, { cwd = cwd })
else else
res_wally = process.spawn("wally", { "package", "--output", "wally.tar.gz" }, { cwd = cwd }) process.spawn("wally", { "package", "--output", "wally.tar.gz" }, { cwd = cwd })
end end
if not res_wally.ok then
progress:stop() progress:stop()
print(`-- Wally error:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`)
return result(false, `Wally error:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`)
end
end
progress:stop() -- finish local took = round_to((os.clock() - begin) * 1_000, 2)
if res_pesde then
print(`-- Pesde out:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`)
end
if res_wally then
print(`-- Wally out:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`)
end
local took = shared.round_to((os.clock() - begin) * 1_000, 2)
if not dry then if not dry then
print(`🚀 Published packages {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`) print(`🚀 Published packages {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`)
else else
print(`📦 Packaged packages {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`) print(`📦 Packaged packages {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`)
end end
if scopes.pesde and scopes.wally then
print({ print({
pesde = `{scopes.pesde}@{version}`, pesde = `{scopes.pesde}@{version}`,
wally = `{scopes.wally}@{version}`, wally = `{scopes.wally}@{version}`,
}) })
elseif scopes.pesde then
print({
pesde = `{scopes.pesde}@{version}`,
})
elseif scopes.wally then
print({
wally = `{scopes.wally}@{version}`,
})
end
return result(true, nil) return result(true, nil)
end end

View file

@ -1,47 +0,0 @@
--!strict
local datetime = require("@lune/datetime")
local fs = require("@lune/fs")
local net = require("@lune/net")
local result = require("./util/result")
--- Fetches the given file raw from the jecs github
local function fetch_raw(file: string): result.Identity<string>
local res = net.request(`https://raw.githubusercontent.com/Ukendio/jecs/refs/heads/main/{file}`)
if not res.ok then
return result(false, `Not ok: {res.statusMessage} @ {res.statusCode}`)
end
return result(true, res.body)
end
local function save_if_diff(filepath: string, contents: string): result.Identity<nil>
if not fs.metadata(filepath).exists then
fs.writeFile(filepath, contents)
return result(true, nil)
end
local existing = fs.readFile(filepath)
if existing == contents then
return result(false, "Contents are the same.")
end
fs.writeFile(filepath, contents)
return result(true, nil)
end
local function round_to(n: number, places: number)
local x = 10 ^ (places or 0)
return math.round(n * x) / x
end
-- Returns an ISO 8601 date (YYYYmmddThhmmssZ)
local function iso_date_light(date: datetime.DateTime): string
return date:formatUniversalTime("%Y%m%dT%H%M%SZ")
end
return {
fetch_raw = fetch_raw,
save_if_diff = save_if_diff,
round_to = round_to,
iso_date_light = iso_date_light,
}

View file

@ -1,17 +1,42 @@
--!strict --!strict
local datetime = require("@lune/datetime")
local fs = require("@lune/fs") local fs = require("@lune/fs")
local serde = require("@lune/serde") local net = require("@lune/net")
local stdio = require("@lune/stdio") local stdio = require("@lune/stdio")
local progress_bar = require("./util/progress") local progress_bar = require("./util/progress")
local result = require("./util/result") local result = require("./util/result")
local shared = require("./shared")
local types = require("./types") --- Fetches the given file raw from the jecs github
local function fetch_raw(file: string): result.Identity<string>
local res = net.request(`https://raw.githubusercontent.com/Ukendio/jecs/refs/heads/main/{file}`)
if not res.ok then
return result(false, `Not ok: {res.statusMessage} @ {res.statusCode}`)
end
return result(true, res.body)
end
local function save_if_diff(filepath: string, contents: string): result.Identity<nil>
if not fs.metadata(filepath).exists then
fs.writeFile(filepath, contents)
return result(true, nil)
end
local existing = fs.readFile(filepath)
if existing == contents then
return result(false, "Contents are the same.")
end
fs.writeFile(filepath, contents)
return result(true, nil)
end
local function round_to(n: number, places: number)
local x = 10 ^ (places or 0)
return math.round(n * x) / x
end
--- Synchronizes the required files from the jecs main branch. --- Synchronizes the required files from the jecs main branch.
local function sync(to: string): result.Identity<boolean> local function sync(to: string): result.Identity<boolean>
local now = datetime.now()
local begin = os.clock() local begin = os.clock()
local progress = progress_bar local progress = progress_bar
@ -19,15 +44,13 @@ local function sync(to: string): result.Identity<boolean>
:withStage("init", "Initializing") :withStage("init", "Initializing")
:withStage("fetch", "Fetching latest files") :withStage("fetch", "Fetching latest files")
:withStage("save", "Saving files") :withStage("save", "Saving files")
:withStage("version", "Pulling latest jecs version") progress:start()
:withStage("metadata", "Writing build metadata")
progress:start() -- init
if not fs.metadata(to).exists then if not fs.metadata(to).exists then
fs.writeDir(to) fs.writeDir(to)
end end
progress:nextStage() -- fetch progress:nextStage()
local includes = { local includes = {
"jecs.luau", "jecs.luau",
@ -35,12 +58,13 @@ local function sync(to: string): result.Identity<boolean>
"CHANGELOG.md", "CHANGELOG.md",
"LICENSE", "LICENSE",
".luaurc", ".luaurc",
"default.project.json",
} }
local sources = {} local sources = {}
for _, file in includes do for _, file in includes do
local contents = shared.fetch_raw(file) local contents = fetch_raw(file)
if not contents.ok then if not contents.ok then
progress:stop() progress:stop()
stdio.ewrite(`🔥 Couldn't get the latest source for {file}:\n{contents.err}\n`) stdio.ewrite(`🔥 Couldn't get the latest source for {file}:\n{contents.err}\n`)
@ -50,12 +74,12 @@ local function sync(to: string): result.Identity<boolean>
sources[file] = contents.val sources[file] = contents.val
end end
progress:nextStage() -- save progress:nextStage()
local sources_modified = {} local sources_modified = {}
local any_changed = false local any_changed = false
for file, contents in sources do for file, contents in sources do
local res = shared.save_if_diff(`{to}/{file}`, contents) local res = save_if_diff(`{to}/{file}`, contents)
if res.ok then if res.ok then
any_changed = true any_changed = true
table.insert(sources_modified, file) table.insert(sources_modified, file)
@ -64,7 +88,7 @@ local function sync(to: string): result.Identity<boolean>
if not any_changed then if not any_changed then
progress:stop() progress:stop()
local took = shared.round_to((os.clock() - begin) * 1_000, 2) local took = round_to((os.clock() - begin) * 1_000, 2)
print( print(
`🕛 Finished synchronizing, no changes since latest source {stdio.style("dim")}(took {took}ms){stdio.style( `🕛 Finished synchronizing, no changes since latest source {stdio.style("dim")}(took {took}ms){stdio.style(
"reset" "reset"
@ -73,39 +97,9 @@ local function sync(to: string): result.Identity<boolean>
return result(true, false) return result(true, false)
end end
local project_json = {
name = "jecs-nightly",
tree = {
["$path"] = "jecs.luau",
},
}
local encoded_project_json = serde.encode("json", project_json)
fs.writeFile(`{to}/default.project.json`, encoded_project_json)
progress:nextStage() -- version
local wally_contents = shared.fetch_raw("wally.toml")
if not wally_contents.ok then
progress:stop() progress:stop()
stdio.ewrite(`🔥 Couldn't get the jecs wally manifest:\n{wally_contents.err}\n`)
return result(false, `Couldn't get the jecs wally manifest:\n{wally_contents.err}`)
end
local parsed: types.WallyManifest = serde.decode("toml", wally_contents.val) local took = round_to((os.clock() - begin) * 1_000, 2)
progress:nextStage() -- metadata
local version = `{parsed.package.version}-nightly.{shared.iso_date_light(now)}`
local metadata = {
version = version,
modified = sources_modified,
}
local encoded_metadata = serde.encode("toml", metadata)
fs.writeFile(`{to}/build.txt`, encoded_metadata)
progress:stop() -- finish
local took = shared.round_to((os.clock() - begin) * 1_000, 2)
print(`🪨 Finished synchronizing {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`) print(`🪨 Finished synchronizing {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`)
print(`Changed files:`) print(`Changed files:`)
print(sources_modified) print(sources_modified)

View file

@ -1,105 +0,0 @@
--!strict
local datetime = require("@lune/datetime")
local fs = require("@lune/fs")
local process = require("@lune/process")
local serde = require("@lune/serde")
local stdio = require("@lune/stdio")
local progress_bar = require("./util/progress")
local result = require("./util/result")
local shared = require("./shared")
local function test(origin: string): result.Identity<boolean>
local now = datetime.now()
local begin = os.clock()
local progress = progress_bar
.new()
:withStage("init", "Initializing")
:withStage("fetch", "Fetching latest tests")
:withStage("test", "Running tests")
:withStage("cleanup", "Cleaning up")
:withStage("metadata", "Writing test metadata")
progress:start() -- init
if not fs.metadata(origin).exists then
progress:stop()
stdio.ewrite(`🔥 {origin} is not a valid directory which exists.\n`)
return result(false, `{origin} is not a valid directory which exists.`)
end
if not fs.metadata(`{origin}/jecs.luau`).exists then
progress:stop()
stdio.ewrite(`🔥 {origin}/jecs.luau is not a vald file which exists.\n`)
return result(false, `{origin}/jecs.luau is not a vald file which exists.`)
end
if fs.metadata(`{origin}/test`).exists then
fs.removeDir(`{origin}/test`)
end
fs.writeDir(`{origin}/test`)
progress:nextStage() -- fetch
do
local contents = shared.fetch_raw("test/testkit.luau")
if not contents.ok then
progress:stop()
stdio.ewrite(`🔥 Couldn't get the latest source for test/testkit.luau:\n{contents.err}\n`)
return result(false, "Couldn't get the latest source for test/testkit.luau.")
end
fs.writeFile(`{origin}/test/testkit.luau`, contents.val)
end
do
local contents = shared.fetch_raw("test/tests.luau")
if not contents.ok then
progress:stop()
stdio.ewrite(`🔥 Couldn't get the latest source for test/tests.luau:\n{contents.err}\n`)
return result(false, "Couldn't get the latest source for test/tests.luau.")
end
fs.writeFile(`{origin}/test/tests.luau`, contents.val)
end
progress:nextStage() -- test
local test_result = process.spawn("luau", { "test/tests.luau" }, { cwd = origin })
if not test_result.ok then
progress:stop()
stdio.ewrite(`🔥 Tests failed to run:\n{test_result.stderr}\n`)
return result(false, `Tests failed to run.`)
end
local passed = true
if not string.find(test_result.stdout, "0 fails") then
passed = false
end
progress:nextStage() -- cleanup
fs.removeDir(`{origin}/test`)
progress:nextStage() -- metadata
local metadata = {
timestamp = shared.iso_date_light(now),
passed = passed,
}
local encoded_metadata = serde.encode("toml", metadata)
fs.writeFile(`{origin}/test.txt`, encoded_metadata)
fs.writeFile(`{origin}/test_fulllog.txt`, test_result.stdout)
progress:stop() -- finish
print(test_result.stdout)
local passed_txt = if passed
then `{stdio.style("bold")}{stdio.color("green")}passed{stdio.color("reset")}{stdio.style("reset")}`
else `{stdio.style("bold")}{stdio.color("red")}failed{stdio.color("reset")}{stdio.style("reset")}`
local took = shared.round_to((os.clock() - begin) * 1_000, 2)
print(`🧪 Finished testing, {passed_txt} {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`)
return result(true, true)
end
return test

View file

@ -1,586 +0,0 @@
--!nolint LocalShadow
-- https://raw.githubusercontent.com/itsfrank/frkcli/refs/heads/main/src/frkcli.luau
local process = require("@lune/process")
local M = {}
type ArgKind = "POSITIONAL" | "FLAG" | "OPTION"
type ArgOptions = {
help: string?,
aliases: { string }?,
default: string?, -- if this is not nil, the arg will be optional
}
-- this is what is stored, we guarante non nullity when args are added so this types makes Luau feel better
type ArgOptionsSafe = {
help: string,
aliases: { string },
default: string?,
}
type ArgData = {
name: string,
kind: ArgKind,
options: ArgOptionsSafe,
}
type ParseResult = {
values: { [string]: string },
flags: { [string]: boolean },
fwd_args: { string }, -- all args after `--`
}
type SubcommandParseResult = {
command: string,
result: ParseResult,
}
local DEFAULT_OPTIONS: ArgOptionsSafe = {
help = "",
default = nil,
aliases = {},
}
local DEFAULT_helpset = {
["-h"] = true,
["--help"] = true,
}
local function validate_key_or_error(key: string)
if (key:sub(1, 2) == "--" or key:sub(1, 1) == "-") and not key:find(" ") then
return
end
error(`arg key {key} is invalid. Keys must start with either '-' or '--' and may not contain spaces`)
end
local function validate_subcommand_name_or_error(name: string)
if name:sub(1, 2) == "--" or name:sub(1, 1) == "-" or name:find(" ") then
error(`subcommand name '{name}' is invalid. Name must not start with '-' or '--' and may not contain spaces`)
end
end
type HelpSection = { title: string, lines: { { string } | string }? }
local function make_help(sections: { HelpSection }, indent: number?): string
local function align_cols(rows: { { string } }, sep: string?): { { string } }
local sep = if sep == nil then " " else sep
local max_col_lengths: { number } = {}
for _, row in rows do
for i, s in row do
if max_col_lengths[i] == nil or s:len() > max_col_lengths[i] then
max_col_lengths[i] = s:len()
end
end
end
local aligned_rows: { { string } } = {}
for _, row in rows do
local line: { string } = {}
for i, col in row do
table.insert(line, col)
if i < #row then
local spacing = sep
local diff = max_col_lengths[i] - col:len()
if diff > 0 then
spacing = string.rep(" ", diff) .. sep
end
table.insert(line, spacing)
end
end
table.insert(aligned_rows, line)
end
return aligned_rows
end
local function append_list<T>(dest: { T }, src: { T })
for _, v in src do
table.insert(dest, v)
end
end
local ind = " "
if indent ~= nil then
ind = string.rep(" ", indent)
end
local help_lines: { { string } } = {}
for _, s in sections do
table.insert(help_lines, { s.title })
if s.lines ~= nil then
local section_lines: { { string } } = {}
for _, l in s.lines do
if typeof(l) == "string" then
table.insert(section_lines, { ind, l })
else
table.insert(section_lines, { ind, table.unpack(l) })
end
end
append_list(help_lines, align_cols(section_lines))
end
table.insert(help_lines, {})
end
-- remove last empty line
table.remove(help_lines)
local help_text = ""
for _, line in help_lines do
help_text ..= table.concat(line, " ") .. "\n"
end
return help_text
end
-- subcommand cli, must be first positional arg
function M.new_subcommands(name, description: string?, helpkeys: { string }?)
-- args that trigger print help + abort
local helpset: { [string]: boolean } = DEFAULT_helpset
if helpkeys ~= nil then
helpset = {}
for _, k in helpkeys do
helpset[k] = true
end
end
local cli = {}
cli.name = name
cli.description = description
cli._helpkeys = helpkeys
cli._helpset = helpset
cli._subcommands = {}
function cli:add_subcommand(name: string, description: string?)
validate_subcommand_name_or_error(name)
local sc = M.new(name, description, cli._helpkeys)
cli._subcommands[name] = sc
return sc
end
function cli:parse(args: { string }): (SubcommandParseResult?, string?)
if #args == 0 then
return nil, "insufficient arguments, required at least 1, got 0"
end
local command = args[1]
assert(command ~= nil)
-- asking for help?
if cli._helpset[command] ~= nil then
print(cli:help())
process.exit(0)
end
local subcommand = cli._subcommands[command]
if subcommand == nil then
return nil, `'{command} is not a valid subcommand`
end
local sub_args = { table.unpack(args, 2) }
local res, err = subcommand:parse(sub_args)
if err ~= nil then
return nil, err
end
assert(res ~= nil)
return {
command = command,
result = res,
}, nil
end
function cli:help(indent: number?): string
local help_sections: { HelpSection } = {}
table.insert(help_sections, { title = `usage: {cli.name} <command>` })
if cli.description ~= nil then
table.insert(help_sections, {
title = "description:",
lines = { { cli.description } },
})
end
local subcommand_lines = {}
for _, c in cli._subcommands do
local desc = if c.description then c.description else ""
table.insert(subcommand_lines, { c.name, desc })
end
table.insert(help_sections, {
title = "commands:",
lines = subcommand_lines,
})
return make_help(help_sections, indent)
end
return cli
end
function M.new(name: string, description: string?, helpkeys: { string }?)
-- args that trigger print help + abort
local helpset: { [string]: boolean } = DEFAULT_helpset
if helpkeys ~= nil then
helpset = {}
for _, k in helpkeys do
helpset[k] = true
end
end
-- I'm fairly certain this is not the right pattern for making objects, but it results in single definition + great LSP so I'm happy
local cli = {}
cli.name = name
cli.description = description
cli._helpset = helpset
cli._positionals = {} :: { ArgData }
cli._flags = {} :: { ArgData }
cli._options = {} :: { ArgData }
cli._argdata_set = {} :: { [string]: ArgData }
cli._required_list = {} :: { string }
cli._lookup = {} :: { [string]: ArgData }
cli._required_positional_count = 0
cli._default_result = { values = {}, flags = {} } :: ParseResult
local function add_arg_lookups(keys: { string }, arg_data: ArgData)
for _, k in keys do
if cli._helpset[k] ~= nil then
error(`key '{k}' is already used as a help key.`)
end
validate_key_or_error(k)
if cli._lookup[k] ~= nil then
error(`key '{k}' already exists.`)
end
cli._lookup[k] = arg_data
end
end
local function add_name_error_on_duplicate(name: string, data: ArgData)
if cli._argdata_set[name] ~= nil then
error(`arg with name {name} already exists`)
end
cli._argdata_set[name] = data
end
local function make_safe_options(options: ArgOptions?): ArgOptionsSafe
if options == nil then
return DEFAULT_OPTIONS
end
assert(options)
options.aliases = if options.aliases == nil then {} else options.aliases
return options :: ArgOptionsSafe
end
-- return is guaranteed to start with at least one '-', if none, adds '--'
local function to_lookup_name(name: string): string
if name:sub(1, 1) == "-" then
return name
end
return "--" .. name
end
-- return is guaranted to not start with '-'
local function to_result_name(name: string): string
while name:sub(1, 1) == "-" do
name = name:sub(2, -1)
end
return name
end
function cli:add_positional(name: string, options: ArgOptions?)
if name:sub(1, 1) == "-" then
error(`invalid arg name '{name}', positional cannot start with dashes`)
end
local options = make_safe_options(options)
local arg_data: ArgData = {
name = name,
kind = "POSITIONAL",
options = options,
}
add_name_error_on_duplicate(name, arg_data)
if options.default == nil then
-- check positional required ordering, can't have a required pos after an optional one
local last = cli._positionals[#cli._positionals]
if last ~= nil and last.options.default ~= nil then
error(
`{name} is required, but {last.name} is optional. Cannot have required positional after optional positional`
)
end
table.insert(cli._required_list, name)
else
cli._default_result.values[name] = options.default
end
table.insert(cli._positionals, arg_data)
end
function cli:add_flag(name: string, options: ArgOptions?)
if options and options.default ~= nil then
-- these are called constants :)
error(`flag {name} has non nil default, default value is not supported for flags`)
end
local options = make_safe_options(options)
local lookup_name = to_lookup_name(name)
name = to_result_name(name)
for i, v in options.aliases do
options.aliases[i] = to_lookup_name(v)
end
local arg_data: ArgData = {
name = name,
kind = "FLAG",
options = options,
}
add_name_error_on_duplicate(name, arg_data)
add_arg_lookups({ lookup_name, table.unpack(options.aliases) }, arg_data)
if options.default ~= nil then
error(`flag {name} has non nil default value, defaults are not supported for flag args`)
end
cli._default_result.flags[name] = false
table.insert(cli._flags, arg_data)
end
function cli:add_option(name: string, options: ArgOptions?)
local options = make_safe_options(options)
local lookup_name = to_lookup_name(name)
name = to_result_name(name)
for i, v in options.aliases do
options.aliases[i] = to_lookup_name(v)
end
local arg_data: ArgData = {
name = name,
kind = "OPTION",
options = options,
}
add_name_error_on_duplicate(name, arg_data)
add_arg_lookups({ lookup_name, table.unpack(options.aliases) }, arg_data)
if options.default == nil then
table.insert(cli._required_list, name)
else
if options.default == nil then
error(`optional arg {name} must have a default value`)
end
assert(options.default ~= nil)
cli._default_result.values[name] = options.default
end
table.insert(cli._options, arg_data)
end
-- return: data, err - where data is a table, and err is a string
-- errors early, first encountered error ends the parse
-- if a help key is found will print help text and exit the process
function cli:parse(args: { string }): (ParseResult?, string?)
local parsed: ParseResult = { values = {}, flags = {}, fwd_args = {} } :: ParseResult
local positional_idx = 1
local skip_next = false -- used for options
for i, arg in args do
-- asking for help?
if cli._helpset[arg] ~= nil then
print(cli:help())
process.exit(0)
end
if skip_next then
skip_next = false
continue
end
-- rest of args should be fwd args
if arg == "--" then
parsed.fwd_args = { table.unpack(args, i + 1) }
break
end
local has_dash = arg:sub(1, 1) == "-"
local arg_data: ArgData? = nil
local value: string? = nil
if not has_dash then -- positional
if positional_idx > #cli._positionals then
return nil, `too many positional arguments, expected {#cli._positionals}.`
end
arg_data = cli._positionals[positional_idx]
positional_idx += 1
value = arg
else -- flag or option
arg_data = cli._lookup[arg]
end
if arg_data == nil then
return nil, `unknown flag or option: '{arg}'`
end
assert(arg_data ~= nil)
-- handle flags early
if arg_data.kind == "FLAG" then
parsed.flags[arg_data.name] = true
continue
end
-- get option value
if arg_data.kind == "OPTION" then
if i < #args then
value = args[i + 1]
skip_next = true
end
end
-- resolve positionals and options
if value == nil then
return nil, `no value provided for option '{arg}'`
end
assert(value ~= nil)
parsed.values[arg_data.name] = value
end
-- check all required options are provided
for _, name in cli._required_list do
if parsed.values[name] == nil and parsed.flags[name] == nil then
local kind = cli._argdata_set[name].kind:lower()
return nil, `required {kind} arg '{name}' was not found`
end
end
-- apply defaults for missing optional
for k, v in cli._default_result.flags do
if parsed.flags[k] == nil then
parsed.flags[k] = v
end
end
for k, v in cli._default_result.values do
if parsed.values[k] == nil then
parsed.values[k] = v
end
end
return parsed, nil
end
function cli:help(indent: number?): string
local function align_cols(rows: { { string } }, sep: string?): { { string } }
local sep = if sep == nil then " " else sep
local max_col_lengths: { number } = {}
for _, row in rows do
for i, s in row do
if max_col_lengths[i] == nil or s:len() > max_col_lengths[i] then
max_col_lengths[i] = s:len()
end
end
end
local aligned_rows: { { string } } = {}
for _, row in rows do
local line: { string } = {}
for i, col in row do
table.insert(line, col)
if i < #row then
local spacing = sep
local diff = max_col_lengths[i] - col:len()
if diff > 0 then
spacing = string.rep(" ", diff) .. sep
end
table.insert(line, spacing)
end
end
table.insert(aligned_rows, line)
end
return aligned_rows
end
local help_sections: { HelpSection } = {}
-- usage
local usage = `usage: {cli.name}`
if #cli._flags > 0 or #cli._options > 0 then
usage ..= " [options]"
end
for _, arg in cli._positionals do
if arg.options.default == nil then
usage ..= ` <{arg.name}>`
else
usage ..= ` [{arg.name}]`
end
end
table.insert(help_sections, { title = usage })
-- description
if cli.description ~= nil then
table.insert(help_sections, { title = "description:", lines = { cli.description } })
end
local function make_arg_line(arg: ArgData): { string }
local keys = arg.name
if arg.kind ~= "POSITIONAL" then
keys = `--{arg.name}`
for _, a in arg.options.aliases do
keys ..= `, {a}`
end
end
local reqdef = ""
-- optional & default
if arg.kind ~= "FLAG" then
if arg.options.default == nil then
reqdef ..= "[required]"
else
assert(arg.options.default)
reqdef ..= `[default: '{arg.options.default}']`
end
end
local help = ""
if arg.options.help then
if help ~= "" then
help ..= " "
end
help ..= arg.options.help
end
return { keys, reqdef, help }
end
-- positionals
if #cli._positionals > 0 then
local positional_lines: { { string } } = {}
for _, arg in cli._positionals do
table.insert(positional_lines, make_arg_line(arg))
end
table.insert(help_sections, { title = "positional arguments:", lines = positional_lines })
end
-- flags
if #cli._flags > 0 then
local flag_lines: { { string } } = {}
for _, arg in cli._flags do
table.insert(flag_lines, make_arg_line(arg))
end
table.insert(help_sections, { title = "flags:", lines = flag_lines })
end
-- options
if #cli._options > 0 then
local option_lines: { { string } } = {}
for _, arg in cli._options do
table.insert(option_lines, make_arg_line(arg))
end
table.insert(help_sections, { title = "options:", lines = option_lines })
end
return make_help(help_sections, indent)
end
return cli
end
export type Cli = typeof(M.new(""))
export type CliSubcommands = typeof(M.new_subcommands(""))
return M