Compare commits
1 commit
b20a2ed128
...
7544c4d920
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7544c4d920 |
20 changed files with 120 additions and 1089 deletions
|
@ -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
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
modified = ["README.md", "LICENSE", ".luaurc", "CHANGELOG.md", "jecs.luau"]
|
|
||||||
version = "0.5.5-nightly.20250302T175658Z"
|
|
|
@ -1 +1,6 @@
|
||||||
{"name":"jecs-nightly","tree":{"$path":"jecs.luau"}}
|
{
|
||||||
|
"name": "jecs",
|
||||||
|
"tree": {
|
||||||
|
"$path": "jecs.luau"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
passed = true
|
|
||||||
timestamp = "20250302T175659Z"
|
|
|
@ -1,114 +0,0 @@
|
||||||
[38;1m8.2[0m [33;1mus[0m [38;1m 3[0m [33;1mkB[0m[38;1m│[0m [38;1mdelete children of entity[0m
|
|
||||||
[38;1m9.4[0m [33;1mus[0m [38;1m 2[0m [33;1mkB[0m[38;1m│[0m [38;1mremove friends of entity[0m
|
|
||||||
[38;1m328[0m [32;1mns[0m [38;1m 0[0m [32;1m B[0m[38;1m│[0m [38;1msimple deletion of entity[0m
|
|
||||||
removing
|
|
||||||
[37;1marchetype[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m[0m
|
|
||||||
|
|
||||||
[37;1mworld:cleanup()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m[0m
|
|
||||||
|
|
||||||
[37;1mworld:entity()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1munique IDs[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mgenerations[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mpairs[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mRecycling[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mRecycling max generation[0m
|
|
||||||
|
|
||||||
[37;1mworld:set()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1marchetype move[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mpairs[0m
|
|
||||||
|
|
||||||
[37;1mworld:remove()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould allow remove a component that doesn't exist on entity[0m
|
|
||||||
|
|
||||||
[37;1mworld:add()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1midempotent[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1marchetype move[0m
|
|
||||||
|
|
||||||
[37;1mworld:query()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mcached[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mmultiple iter[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mtag[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mpairs[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mquery single component[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mquery missing component[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mquery more than 8 components[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould be able to get next results[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould query all matching entities when irrelevant component is removed[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould query all entities without B[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould allow querying for relations[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould allow wildcards in queries[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould match against multiple pairs[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould only relate alive entities[0m
|
|
||||||
[38;5;208mNONE[0m[38;1m│[0m [38;1mshould error when setting invalid pair[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould find target for ChildOf[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mdespawning while iterating[0m
|
|
||||||
[38;5;208mNONE[0m[38;1m│[0m [38;1miterator invalidation[0m
|
|
||||||
[33;1mSKIP[0m[38;1m│[0m [38;1madding[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mspawning[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould not find any entities[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mwithout[0m
|
|
||||||
|
|
||||||
[37;1mworld:each[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m[0m
|
|
||||||
|
|
||||||
[37;1mworld:children[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m[0m
|
|
||||||
|
|
||||||
[37;1mworld:clear()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould remove its components[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould move last record[0m
|
|
||||||
|
|
||||||
[37;1mworld:has()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould find Tag on entity[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould return false when missing one tag[0m
|
|
||||||
|
|
||||||
[37;1mworld:component()[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1monly components should have EcsComponent trait[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mtag[0m
|
|
||||||
|
|
||||||
[37;1mworld:delete[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1minvoke OnRemove hooks[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mdelete recycled entity id used as component[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mbug: Empty entity does not respect cleanup policy[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould allow deleting components[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mdelete entities using another Entity as component with Delete cleanup action[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mdelete children[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mfast delete[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mcycle[0m
|
|
||||||
|
|
||||||
[37;1mworld:target[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mnth index[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1minfer index when unspecified[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mloop until no target[0m
|
|
||||||
|
|
||||||
[37;1mworld:contains[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mshould not exist after delete[0m
|
|
||||||
|
|
||||||
[37;1mHooks[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mOnAdd[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mOnSet[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1mOnRemove[0m
|
|
||||||
|
|
||||||
[37;1mchange tracking[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#1[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#2[0m
|
|
||||||
|
|
||||||
[37;1mrepro[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#1[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#2[0m
|
|
||||||
|
|
||||||
[37;1mwildcard query[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#1[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#2[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#3[0m
|
|
||||||
|
|
||||||
[37;1mworld:delete() invokes OnRemove hook[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#1[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#2[0m
|
|
||||||
[32;1mPASS[0m[38;1m│[0m [38;1m#3[0m
|
|
||||||
|
|
||||||
[38;1m68/68 test cases passed in 31.100 ms.[0m
|
|
||||||
[32;1m0 fails[0m
|
|
|
@ -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.
|
@ -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"
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
103
src/release.luau
103
src/release.luau
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
|
105
src/test.luau
105
src/test.luau
|
@ -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
|
|
|
@ -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
|
|
Loading…
Reference in a new issue