From c89e485af4a3eb6a23987b3212de23092ae64261 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 2 Mar 2026 14:01:12 -0800 Subject: [PATCH 1/5] Squash MUS-2390-rewrite-with-lute --- .env.template | 2 + .github/workflows/ci.yml | 32 +-- .github/workflows/release.yml | 4 +- .gitignore | 3 + .luaurc | 8 +- .lune/analyze.luau | 9 - .lune/build.luau | 33 --- .lune/install.luau | 32 --- .lune/test-e2e.luau | 28 -- .lune/test.luau | 28 -- .vscode/settings.json | 7 + README.md | 21 +- examples/package/deploy.luau | 17 +- examples/package/e2e.spec.luau | 77 +++--- examples/plugin/deploy.luau | 17 +- examples/plugin/e2e.spec.luau | 73 +++--- examples/workspace/deploy.luau | 25 +- examples/workspace/e2e.spec.luau | 151 +++++------ foreman.toml | 4 +- scripts/analyze.luau | 10 + scripts/build.luau | 38 +++ scripts/install.luau | 81 ++++++ scripts/lib/appendTextForTesting.luau | 10 + scripts/lib/dotenv.luau | 32 +++ scripts/lib/retryAsync.luau | 48 ++++ {.lune => scripts}/lint.luau | 4 +- scripts/test.luau | 30 +++ src/lib/bufferToHex.luau | 9 + src/lib/join.luau | 2 +- src/lib/run.luau | 26 +- src/lib/toml.luau | 248 ++++++++++++++++++ src/lib/toml.spec.luau | 85 ++++++ src/logging.luau | 31 +-- src/manifest/getOrCreateAssetLockfile.luau | 20 +- .../getOrCreateAssetLockfile.spec.luau | 9 +- src/manifest/readManifest.luau | 21 +- src/manifest/writeLockfile.luau | 12 +- src/requests/createImageAsync.luau | 16 +- src/requests/fetch.luau | 19 +- src/requests/forms/createFormData.luau | 10 +- src/requests/forms/createFormData.spec.luau | 18 +- src/requests/getAssetDetailsAsync.luau | 8 +- src/requests/publishAssetAsync.luau | 24 +- src/requests/publishImagesAsync.luau | 40 ++- src/requests/publishPackageAsync.luau | 5 +- src/requests/publishWorkspaceAsync.luau | 4 +- src/requests/setAssetDetailsAsync.luau | 5 +- src/requests/setAssetIconAsync.luau | 14 +- src/requests/waitForAssetOperationAsync.luau | 8 +- src/types.luau | 20 +- src/types.spec.luau | 33 ++- 51 files changed, 1016 insertions(+), 495 deletions(-) create mode 100644 .env.template delete mode 100644 .lune/analyze.luau delete mode 100644 .lune/build.luau delete mode 100644 .lune/install.luau delete mode 100644 .lune/test-e2e.luau delete mode 100644 .lune/test.luau create mode 100644 .vscode/settings.json create mode 100644 scripts/analyze.luau create mode 100644 scripts/build.luau create mode 100644 scripts/install.luau create mode 100644 scripts/lib/appendTextForTesting.luau create mode 100644 scripts/lib/dotenv.luau create mode 100644 scripts/lib/retryAsync.luau rename {.lune => scripts}/lint.luau (93%) create mode 100644 scripts/test.luau create mode 100644 src/lib/bufferToHex.luau create mode 100644 src/lib/toml.luau create mode 100644 src/lib/toml.spec.luau diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..280204d --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +ROBLOX_API_KEY= +LOG_LEVEL=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3f2739..f8dd4b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,27 +19,10 @@ jobs: allow-external-github-orgs: true - name: Install dependencies - run: lune run install + run: lute scripts/install.luau - name: Run unit tests - run: lune run test - - test-e2e: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - - uses: Roblox/setup-foreman@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - allow-external-github-orgs: true - - - name: Install dependencies - run: lune run install - - - name: Run end-to-end tests - run: lune run test-e2e + run: lute scripts/test.luau --e2e env: ROBLOX_API_KEY: ${{ secrets.ROBLOX_API_KEY }} @@ -55,10 +38,10 @@ jobs: allow-external-github-orgs: true - name: Install dependencies - run: lune run install + run: lute scripts/install.luau - name: Lint - run: lune run lint + run: lute scripts/lint.luau analyze: runs-on: ubuntu-latest @@ -72,12 +55,9 @@ jobs: allow-external-github-orgs: true - name: Install dependencies - run: lune run install - - - name: Setup Lune typedefs - run: lune setup + run: lute scripts/install.luau # MUS-2103 TODO: This will error in CI until Foreman is updated to install # the correct linux binary for luau-lsp - name: Analyze - run: lune run analyze + run: lute scripts/analyze.luau diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 136fa19..486fa39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,10 +23,10 @@ jobs: allow-external-github-orgs: true - name: Install dependencies - run: lune run install + run: lute scripts/install.luau - name: Build - run: lune run build + run: lute scripts/build.luau - uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 98ac10b..f80a535 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Secrets +.env + # Build artifacts build temp diff --git a/.luaurc b/.luaurc index 2123a55..c9777a1 100644 --- a/.luaurc +++ b/.luaurc @@ -1,9 +1,13 @@ { "languageMode": "strict", "aliases": { - "root": "src", + "lint": "~/.lute/typedefs/0.1.0/lint", + "lute": "~/.lute/typedefs/0.1.0/lute", + "std": "~/.lute/typedefs/0.1.0/std", + "examples": "examples", "pkg": "pkg", - "lune": "~/.lune/.typedefs/0.9.3" + "root": "src", + "scripts": "scripts" } } diff --git a/.lune/analyze.luau b/.lune/analyze.luau deleted file mode 100644 index 630bb10..0000000 --- a/.lune/analyze.luau +++ /dev/null @@ -1,9 +0,0 @@ -local run = require("@root/lib/run") - -local foldersToAnalyze = { - ".lune", - "src", - "examples", -} - -run("luau-lsp", { "analyze", `--ignore="**/pkg/**"`, "--platform=standard", table.unpack(foldersToAnalyze) }) diff --git a/.lune/build.luau b/.lune/build.luau deleted file mode 100644 index ec5f171..0000000 --- a/.lune/build.luau +++ /dev/null @@ -1,33 +0,0 @@ -local fs = require("@lune/fs") - -local run = require("@root/lib/run") - -local INCLUDES = { - ".luaurc", - "init.luau", - "LICENSE", - "README.md", - - "src/", - "pkg/", -} - -local BUILD_PATH = "build" -local ARTIFACT_NAME = "rbxasset" -local ARCHIVE_NAME = "rbxasset.zip" -local ARTIFACT_PATH = `{BUILD_PATH}/{ARTIFACT_NAME}/` - -run("rm", { "-rf", BUILD_PATH }) -run("mkdir", { "-p", ARTIFACT_PATH }) - -for _, includeName in INCLUDES do - if fs.isDir(includeName) then - run("cp", { "-R", includeName, `{ARTIFACT_PATH}/{includeName}` }) - else - run("cp", { includeName, ARTIFACT_PATH }) - end -end - -run("zip", { "-r", ARCHIVE_NAME, ARTIFACT_NAME }, { - cwd = BUILD_PATH, -}) diff --git a/.lune/install.luau b/.lune/install.luau deleted file mode 100644 index 99f3afd..0000000 --- a/.lune/install.luau +++ /dev/null @@ -1,32 +0,0 @@ -local fs = require("@lune/fs") - -local run = require("@root/lib/run") - -local packages = "pkg" -local packageIndex = "_Index" - -local repos = { - { git = "itsfrank/frktest@31770327", main = "src" }, - { git = "osyrisrblx/t@1dbfccc1", main = "lib" }, -} - -run("rm", { "-rf", packages }) - -for _, repo in repos do - local parts = repo.git:split("@") - local repoPath = parts[1] - local rev = parts[2] - - local repoParts = repoPath:split("/") - local projectName = repoParts[2] - local dest = `{packages}/{packageIndex}/{projectName}` - - run("git", { "clone", `https://github.com/{repoPath}`, dest }) - - run("git", { "reset", "--hard", rev }, { - cwd = dest, - }) - - local linkerPath = `{packages}/{projectName}.luau` - fs.writeFile(linkerPath, `return require("./{packageIndex}/{projectName}/{repo.main}")`) -end diff --git a/.lune/test-e2e.luau b/.lune/test-e2e.luau deleted file mode 100644 index 0a1f959..0000000 --- a/.lune/test-e2e.luau +++ /dev/null @@ -1,28 +0,0 @@ -local process = require("@lune/process") - -local frktest = require("@pkg/frktest") - -local logging = require("@root/logging") -local run = require("@root/lib/run") - -local reporter = frktest._reporters.lune_console_reporter - -local function loadTestCases(path: string, alias: string) - local output = run("find", { path, "-iname", "*.spec.luau" }) - output = output:gsub(path, alias) - - local files = output:split("\n") - - logging.debug("files", files) - - for _, file in files do - (require :: any)(file) - end -end - -loadTestCases("examples", "@examples") - -reporter.init() -local success = frktest.run() - -process.exit(if success then 0 else 1) diff --git a/.lune/test.luau b/.lune/test.luau deleted file mode 100644 index 378bca8..0000000 --- a/.lune/test.luau +++ /dev/null @@ -1,28 +0,0 @@ -local process = require("@lune/process") - -local frktest = require("@pkg/frktest") - -local logging = require("@root/logging") -local run = require("@root/lib/run") - -local reporter = frktest._reporters.lune_console_reporter - -local function loadTestCases(path: string, alias: string) - local output = run("find", { path, "-iname", "*.spec.luau" }) - output = output:gsub(path, alias) - - local files = output:split("\n") - - logging.debug("files", files) - - for _, file in files do - (require :: any)(file) - end -end - -loadTestCases("src", "@root") - -reporter.init() -local success = frktest.run() - -process.exit(if success then 0 else 1) diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ad90855 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "luau-lsp.fflags.override": { + "LuauSolverV2": "true" + }, + "luau-lsp.platform.type": "standard", + "luau-lsp.ignoreGlobs": ["**/pkg/**", "**/.lute/**"] +} diff --git a/README.md b/README.md index 96dc4f6..edf29f3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Deploy rbxm files from GitHub to the Creator Store. # Installation > [!NOTE] -> [Lune](https://github.com/lune-org/lune) v0.9.3+ is required. +> [Lute](https://github.com/luau-lang/lute) v0.1.0-nightly.20251217+ is required. ## Prebuilt zip (recommended) @@ -24,8 +24,8 @@ Run the following commands to clone the repo, install dependencies, and build rb git clone https://github.com/Roblox/rbxasset.git cd rbxasset foreman install -lune run install -lune run build +lute scripts/install.luau +lute scripts/build.luau ``` From there, drag and drop `build/rbxasset` to a place where you will require it via a Luau script. @@ -54,25 +54,27 @@ This defines a `default` asset and a `production` environment to deploy to. Then create a Luau script to handle the deployment: ```luau --- .lune/publish.luau -local process = require("@lune/process") +-- scripts/publish.luau +local process = require("@std/process") local rbxasset = require("./path/to/rbxasset") -local apiKey = process.args[1] +local args = { ... } + +local apiKey = args[1] assert(apiKey, "argument #1 must be a valid Open Cloud API key") -- The rbxm file needs to be built manually. rbxasset makes no assumptions about -- how your project is setup, it only cares about having a file to upload. Note -- the filename `build.rbxm` matches the `model` field in rbxasset.toml -process.exec("rojo", { "build", "-o", "build.rbxm" }) +process.system("rojo build -o build.rbxm") -- Publish the `default` asset defined in rbxasset.toml -rbxasset.publishPackageAsync(process.cwd, "default", apiKey) +rbxasset.publishPackageAsync(process.cwd(), "default", apiKey) ``` ```sh -$ lune run publish +$ lute scripts/publish.luau ``` Where `` represents an Open Cloud API key that has the following scopes: @@ -94,7 +96,6 @@ Assets define how the asset will be deployed and shown on the Creator Store. | `environment` | `string` | Defines which environment to deploy to. This value must equal one of the environments defined in the `environments` object | | `description` | `string?` | The description of the asset on the Creator Store | | `icon` | `string?` | Path to the icon (png only) to display on the Creator Store | -| `description` | `string?` | The description of the asset on the Creator Store | | `type` | `"Package" \| "Plugin"` | The type of asset to upload to the Creator Store. This must be set before the first publish as asset type is immutable once uploaded. Defaults to `"Package"` | ## Environments diff --git a/examples/package/deploy.luau b/examples/package/deploy.luau index c730c26..7099233 100644 --- a/examples/package/deploy.luau +++ b/examples/package/deploy.luau @@ -1,12 +1,13 @@ -local rbxasset = require("@root/") +local path = require("@std/path") +local process = require("@std/process") -local process = require("@lune/process") +local rbxasset = require("@root") -local apiKey = process.args[1] -assert(apiKey, "argument #1 must be a valid Open Cloud API key") +local args = { ... } +local cwd = path.dirname(args[1]) -assert(process.cwd:match("examples/package"), "you must be in the `examples/package` folder when running this script") +process.system("rojo build -o asset.rbxm", { + cwd = cwd, +}) -process.exec("rojo", { "build", "-o", "asset.rbxm" }) - -rbxasset.publishPackageAsync(process.cwd, "package", apiKey) +rbxasset.publishPackageAsync(cwd, "package", process.env.ROBLOX_API_KEY) diff --git a/examples/package/e2e.spec.luau b/examples/package/e2e.spec.luau index 3107828..917601b 100644 --- a/examples/package/e2e.spec.luau +++ b/examples/package/e2e.spec.luau @@ -1,24 +1,19 @@ -local frktest = require("@pkg/frktest") -local fs = require("@lune/fs") -local process = require("@lune/process") -local serde = require("@lune/serde") - +local expect = require("@std/test/assert") +local fs = require("@std/fs") +local path = require("@std/path") +local process = require("@std/process") +local test = require("@std/test") +local toml = require("@root/lib/toml") + +local appendTextForTesting = require("@scripts/lib/appendTextForTesting") local getAssetDetailsAsync = require("@root/requests/getAssetDetailsAsync") local getOrCreateAssetLockfile = require("@root/manifest/getOrCreateAssetLockfile") local readManifest = require("@root/manifest/readManifest") local run = require("@root/lib/run") -local test = frktest.test -local check = frktest.assert.check - -local ROOT_PATH = "examples/package" -local MANIFEST_PATH = `{ROOT_PATH}/rbxasset.toml` - -local function appendTextForTesting(path: string, text: string) - local content = fs.readFile(path) - content ..= text - fs.writeFile(path, content) -end +local ROOT_PATH = path.resolve("examples/package") +local MANIFEST_PATH = path.join(ROOT_PATH, "rbxasset.toml") +local DEPLOY_SCRIPT = path.join(ROOT_PATH, "deploy.luau") local apiKey = process.env.ROBLOX_API_KEY assert(apiKey, "End-to-end tests can only be run when the ROBLOX_API_KEY envvar is set to a valid Open Cloud API key") @@ -26,30 +21,27 @@ assert(apiKey, "End-to-end tests can only be run when the ROBLOX_API_KEY envvar local lockfile = getOrCreateAssetLockfile(ROOT_PATH) local assetId = lockfile.assets.package.assetId -run("rojo", { "build", "-o", "asset.rbxm" }, { - cwd = ROOT_PATH, -}) - test.case("uploads rbxm to the Creator Store", function() local assetDetails = getAssetDetailsAsync(assetId, apiKey) local prevRevisionId = assetDetails.revisionId - local path = `{ROOT_PATH}/src/init.luau` - appendTextForTesting(path, `\nprint("New!")`) + local filePath = path.join(ROOT_PATH, "src/init.luau") + appendTextForTesting(filePath, `\nprint("New!")`) - local success, result = pcall(function() - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + local success, err = pcall(function() + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) + return nil end) - run("git", { "restore", path }) + run("git", { "restore", tostring(filePath) }) - assert(success, result) + assert(success, err) assetDetails = getAssetDetailsAsync(assetId, apiKey) - check.not_equal(assetDetails.revisionId, prevRevisionId) + expect.neq(assetDetails.revisionId, prevRevisionId) end) test.case("text fields in rbxasest.toml get mirrored to the asset details", function() @@ -62,31 +54,36 @@ test.case("text fields in rbxasest.toml get mirrored to the asset details", func manifest.assets.package.name = `{manifest.assets.package.name}\n({id})` manifest.assets.package.description = `{manifest.assets.package.description}\n({id})` - fs.writeFile(MANIFEST_PATH, serde.encode("toml", manifest)) + fs.writestringtofile(MANIFEST_PATH, toml.serialize(manifest :: { [unknown]: unknown })) - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, - }) + local success, err = pcall(function() + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", + }) + return nil + end) + + run("git", { "restore", tostring(MANIFEST_PATH) }) - run("git", { "restore", MANIFEST_PATH }) + assert(success, err) assetDetails = getAssetDetailsAsync(assetId, apiKey) - check.equal(assetDetails.displayName, manifest.assets.package.name) - check.equal(assetDetails.description, manifest.assets.package.description) - check.not_equal(assetDetails.revisionId, prevRevisionId) + expect.eq(assetDetails.displayName, manifest.assets.package.name) + expect.eq(assetDetails.description, manifest.assets.package.description) + expect.neq(assetDetails.revisionId, prevRevisionId) end) -test.case("does not publish a new version when there are no changes", function() +test.case("only publishes a new version when there are new changes", function() local assetDetails = getAssetDetailsAsync(assetId, apiKey) local prevRevisionId = assetDetails.revisionId - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) assetDetails = getAssetDetailsAsync(assetId, apiKey) - check.equal(assetDetails.revisionId, prevRevisionId) + expect.eq(assetDetails.revisionId, prevRevisionId) end) diff --git a/examples/plugin/deploy.luau b/examples/plugin/deploy.luau index fa5fcd0..36996a7 100644 --- a/examples/plugin/deploy.luau +++ b/examples/plugin/deploy.luau @@ -1,12 +1,13 @@ -local rbxasset = require("@root/") +local path = require("@std/path") +local process = require("@std/process") -local process = require("@lune/process") +local rbxasset = require("@root") -local apiKey = process.args[1] -assert(apiKey, "argument #1 must be a valid Open Cloud API key") +local args = { ... } +local cwd = path.dirname(args[1]) -assert(process.cwd:match("examples/plugin"), "you must be in the `examples/plugin` folder when running this script") +process.system("rojo build -o plugin.rbxm", { + cwd = cwd, +}) -process.exec("rojo", { "build", "-o", "plugin.rbxm" }) - -rbxasset.publishPackageAsync(process.cwd, "plugin", apiKey) +rbxasset.publishPackageAsync(cwd, "plugin", process.env.ROBLOX_API_KEY) diff --git a/examples/plugin/e2e.spec.luau b/examples/plugin/e2e.spec.luau index 8c06f90..b2dd19d 100644 --- a/examples/plugin/e2e.spec.luau +++ b/examples/plugin/e2e.spec.luau @@ -1,24 +1,19 @@ -local frktest = require("@pkg/frktest") -local fs = require("@lune/fs") -local process = require("@lune/process") -local serde = require("@lune/serde") - +local expect = require("@std/test/assert") +local fs = require("@std/fs") +local path = require("@std/path") +local process = require("@std/process") +local test = require("@std/test") +local toml = require("@root/lib/toml") + +local appendTextForTesting = require("@scripts/lib/appendTextForTesting") local getAssetDetailsAsync = require("@root/requests/getAssetDetailsAsync") local getOrCreateAssetLockfile = require("@root/manifest/getOrCreateAssetLockfile") local readManifest = require("@root/manifest/readManifest") local run = require("@root/lib/run") -local test = frktest.test -local check = frktest.assert.check - -local ROOT_PATH = "examples/plugin" -local MANIFEST_PATH = `{ROOT_PATH}/rbxasset.toml` - -local function appendTextForTesting(path: string, text: string) - local content = fs.readFile(path) - content ..= text - fs.writeFile(path, content) -end +local ROOT_PATH = path.resolve("examples/plugin") +local MANIFEST_PATH = path.join(ROOT_PATH, "rbxasset.toml") +local DEPLOY_SCRIPT = path.join(ROOT_PATH, "deploy.luau") local apiKey = process.env.ROBLOX_API_KEY assert(apiKey, "End-to-end tests can only be run when the ROBLOX_API_KEY envvar is set to a valid Open Cloud API key") @@ -26,31 +21,28 @@ assert(apiKey, "End-to-end tests can only be run when the ROBLOX_API_KEY envvar local lockfile = getOrCreateAssetLockfile(ROOT_PATH) local assetId = lockfile.assets.plugin.assetId -run("rojo", { "build", "-o", "plugin.rbxm" }, { - cwd = ROOT_PATH, -}) - test.case("uploads to the Creator Store", function() local assetDetails = getAssetDetailsAsync(assetId, apiKey) local prevRevisionId = assetDetails.revisionId - local path = `{ROOT_PATH}/src/init.plugin.luau` - appendTextForTesting(path, `\nprint("New!")`) + local filePath = path.join(ROOT_PATH, "src/init.plugin.luau") + appendTextForTesting(filePath, `\nprint("New!")`) - local success, result = pcall(function() - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + local success, err = pcall(function() + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) + return nil end) - run("git", { "restore", path }) + run("git", { "restore", tostring(filePath) }) - assert(success, result) + assert(success, err) assetDetails = getAssetDetailsAsync(assetId, apiKey) - check.not_equal(assetDetails.revisionId, prevRevisionId) + expect.neq(assetDetails.revisionId, prevRevisionId) end) test.case("text fields in rbxasest.toml get mirrored to the asset details", function() @@ -63,23 +55,24 @@ test.case("text fields in rbxasest.toml get mirrored to the asset details", func manifest.assets.plugin.name = `{manifest.assets.plugin.name}\n({id})` manifest.assets.plugin.description = `{manifest.assets.plugin.description}\n({id})` - fs.writeFile(MANIFEST_PATH, serde.encode("toml", manifest)) + fs.writestringtofile(MANIFEST_PATH, toml.serialize(manifest :: { [unknown]: unknown })) - local success, result = pcall(function() - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + local success, err = pcall(function() + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) + return nil end) - run("git", { "restore", MANIFEST_PATH }) + run("git", { "restore", tostring(MANIFEST_PATH) }) - assert(success, result) + assert(success, err) assetDetails = getAssetDetailsAsync(assetId, apiKey) - check.equal(assetDetails.displayName, manifest.assets.plugin.name) - check.equal(assetDetails.description, manifest.assets.plugin.description) - check.not_equal(assetDetails.revisionId, prevRevisionId) + expect.eq(assetDetails.displayName, manifest.assets.plugin.name) + expect.eq(assetDetails.description, manifest.assets.plugin.description) + expect.neq(assetDetails.revisionId, prevRevisionId) end) test.case("does not publish a new version when there are no changes", function() @@ -87,11 +80,11 @@ test.case("does not publish a new version when there are no changes", function() local prevRevisionId = assetDetails.revisionId - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) assetDetails = getAssetDetailsAsync(assetId, apiKey) - check.equal(assetDetails.revisionId, prevRevisionId) + expect.eq(assetDetails.revisionId, prevRevisionId) end) diff --git a/examples/workspace/deploy.luau b/examples/workspace/deploy.luau index 2ee01a2..a0b0eb1 100644 --- a/examples/workspace/deploy.luau +++ b/examples/workspace/deploy.luau @@ -1,21 +1,16 @@ -local rbxasset = require("@root/") +local path = require("@std/path") +local process = require("@std/process") -local process = require("@lune/process") +local rbxasset = require("@root") -local apiKey = process.args[1] -assert(apiKey, "argument #1 must be a valid Open Cloud API key") +local args = { ... } +local cwd = path.dirname(args[1]) -assert( - process.cwd:match("examples/workspace"), - "you must be in the `examples/workspace` folder when running this script" -) - -process.exec("rojo", { "build", "-o", "../package.rbxm" }, { - cwd = "package", +process.system("rojo build package -o package.rbxm", { + cwd = cwd, }) - -process.exec("rojo", { "build", "-o", "../plugin.rbxm" }, { - cwd = "plugin", +process.system("rojo build plugin -o plugin.rbxm", { + cwd = cwd, }) -rbxasset.publishWorkspaceAsync(process.cwd, apiKey) +rbxasset.publishWorkspaceAsync(cwd, process.env.ROBLOX_API_KEY) diff --git a/examples/workspace/e2e.spec.luau b/examples/workspace/e2e.spec.luau index 8477485..ee49554 100644 --- a/examples/workspace/e2e.spec.luau +++ b/examples/workspace/e2e.spec.luau @@ -1,18 +1,20 @@ -local frktest = require("@pkg/frktest") -local fs = require("@lune/fs") -local process = require("@lune/process") -local serde = require("@lune/serde") - +local expect = require("@std/test/assert") +local fs = require("@std/fs") +local path = require("@std/path") +local process = require("@std/process") +local test = require("@std/test") +local toml = require("@root/lib/toml") + +local appendTextForTesting = require("@scripts/lib/appendTextForTesting") local getAssetDetailsAsync = require("@root/requests/getAssetDetailsAsync") local getOrCreateAssetLockfile = require("@root/manifest/getOrCreateAssetLockfile") local readManifest = require("@root/manifest/readManifest") +local retryAsync = require("@scripts/lib/retryAsync") local run = require("@root/lib/run") -local test = frktest.test -local check = frktest.assert.check - -local ROOT_PATH = "examples/workspace" -local MANIFEST_PATH = `{ROOT_PATH}/rbxasset.toml` +local ROOT_PATH = path.resolve("examples/workspace") +local MANIFEST_PATH = path.join(ROOT_PATH, "rbxasset.toml") +local DEPLOY_SCRIPT = path.join(ROOT_PATH, "deploy.luau") local apiKey = process.env.ROBLOX_API_KEY assert(apiKey, "End-to-end tests can only be run when the ROBLOX_API_KEY envvar is set to a valid Open Cloud API key") @@ -21,113 +23,114 @@ local lockfile = getOrCreateAssetLockfile(ROOT_PATH) local packageAssetId = lockfile.assets.package.assetId local pluginAssetId = lockfile.assets.plugin.assetId -local function appendTextForTesting(path: string, text: string) - local content = fs.readFile(path) - content ..= text - fs.writeFile(path, content) -end - -run("rojo", { "build", `plugin/default.project.json`, "-o", "plugin.rbxm" }, { - cwd = ROOT_PATH, -}) -run("rojo", { "build", `package/default.project.json`, "-o", "package.rbxm" }, { - cwd = ROOT_PATH, -}) - test.case("uploads each workspace member to the Creator Store", function() - local packageAssetDetails = getAssetDetailsAsync(packageAssetId, apiKey) - local pluginAssetDetails = getAssetDetailsAsync(pluginAssetId, apiKey) - - local prevPackageRevisionId = packageAssetDetails.revisionId - local prevPluginRevisionId = pluginAssetDetails.revisionId + local initialAssetDetails = { + package = getAssetDetailsAsync(packageAssetId, apiKey), + plugin = getAssetDetailsAsync(pluginAssetId, apiKey), + } local filesToChange = { - `{ROOT_PATH}/package/src/init.luau`, - `{ROOT_PATH}/plugin/src/init.plugin.luau`, + path.join(ROOT_PATH, "package/src/init.luau"), + path.join(ROOT_PATH, "plugin/src/init.plugin.luau"), } - for _, path in filesToChange do - appendTextForTesting(path, `\nprint("New!")`) + for _, filePath in filesToChange do + appendTextForTesting(filePath, `\nprint("New!")`) end - local success, result = pcall(function() - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + local success, err = pcall(function() + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) + return nil end) - run("git", { "restore", table.unpack(filesToChange) }) + do + local filePaths = {} + for _, filePath in filesToChange do + table.insert(filePaths, tostring(filePath)) + end + run("git", { "restore", table.unpack(filePaths) }) + end - assert(success, result) + assert(success, err) - packageAssetDetails = getAssetDetailsAsync(packageAssetId, apiKey) - pluginAssetDetails = getAssetDetailsAsync(pluginAssetId, apiKey) + retryAsync(function() + local assetDetails = { + package = getAssetDetailsAsync(packageAssetId, apiKey), + plugin = getAssetDetailsAsync(pluginAssetId, apiKey), + } - check.not_equal(packageAssetDetails.revisionId, prevPackageRevisionId) - check.not_equal(pluginAssetDetails.revisionId, prevPluginRevisionId) + expect.neq(assetDetails.package.revisionId, initialAssetDetails.package.revisionId) + expect.neq(assetDetails.plugin.revisionId, initialAssetDetails.plugin.revisionId) + end) end) test.case("text fields in rbxasest.toml get mirrored to the asset details", function() - local packageAssetDetails = getAssetDetailsAsync(packageAssetId, apiKey) - local pluginAssetDetails = getAssetDetailsAsync(pluginAssetId, apiKey) - local manifest = readManifest(ROOT_PATH) local id = os.time() - manifest.assets.package.name = `{manifest.assets.package.name}\n({id})` + manifest.assets.package.name = `{manifest.assets.package.name} ({id})` manifest.assets.package.description = `{manifest.assets.package.description}\n({id})` - manifest.assets.plugin.name = `{manifest.assets.plugin.name}\n({id})` + manifest.assets.plugin.name = `{manifest.assets.plugin.name} ({id})` manifest.assets.plugin.description = `{manifest.assets.plugin.description}\n({id})` - fs.writeFile(MANIFEST_PATH, serde.encode("toml", manifest)) + fs.writestringtofile(MANIFEST_PATH, toml.serialize(manifest :: { [unknown]: unknown })) - local success, result = pcall(function() - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + local success, err = pcall(function() + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) + return nil end) - run("git", { "restore", MANIFEST_PATH }) + run("git", { "restore", tostring(MANIFEST_PATH) }) - assert(success, result) + assert(success, err) - packageAssetDetails = getAssetDetailsAsync(packageAssetId, apiKey) - pluginAssetDetails = getAssetDetailsAsync(pluginAssetId, apiKey) + retryAsync(function() + local assetDetails = { + package = getAssetDetailsAsync(packageAssetId, apiKey), + plugin = getAssetDetailsAsync(pluginAssetId, apiKey), + } - check.equal(packageAssetDetails.displayName, manifest.assets.package.name) - check.equal(packageAssetDetails.description, manifest.assets.package.description) + expect.eq(assetDetails.package.displayName, manifest.assets.package.name) + expect.eq(assetDetails.package.description, manifest.assets.package.description) - check.equal(pluginAssetDetails.displayName, manifest.assets.plugin.name) - check.equal(pluginAssetDetails.description, manifest.assets.plugin.description) + expect.eq(assetDetails.plugin.displayName, manifest.assets.plugin.name) + expect.eq(assetDetails.plugin.description, manifest.assets.plugin.description) + end) end) test.case("only publishes a new version for changed workspace members", function() - local packageAssetDetails = getAssetDetailsAsync(packageAssetId, apiKey) - local pluginAssetDetails = getAssetDetailsAsync(pluginAssetId, apiKey) - - local prevPackageRevisionId = packageAssetDetails.revisionId - local prevPluginRevisionId = pluginAssetDetails.revisionId + local initialAssetDetails = { + package = getAssetDetailsAsync(packageAssetId, apiKey), + plugin = getAssetDetailsAsync(pluginAssetId, apiKey), + } -- We make a change to a single workspace member so we can verify that only -- that member was given a new revision after publishing - local path = `{ROOT_PATH}/package/src/init.luau` - appendTextForTesting(path, `\nprint("New!")`) + local filePath = path.join(ROOT_PATH, "package/src/init.luau") + appendTextForTesting(filePath, `\nprint("New!")`) - local success, result = pcall(function() - run("lune", { "run", "deploy.luau", apiKey }, { - cwd = ROOT_PATH, + local success, err = pcall(function() + process.run({ "lute", tostring(DEPLOY_SCRIPT) }, { + stdio = "inherit", }) + return nil end) - run("git", { "restore", path }) + run("git", { "restore", tostring(filePath) }) - assert(success, result) + assert(success, err) - packageAssetDetails = getAssetDetailsAsync(packageAssetId, apiKey) - pluginAssetDetails = getAssetDetailsAsync(pluginAssetId, apiKey) + local assetDetails = { + package = getAssetDetailsAsync(packageAssetId, apiKey), + plugin = getAssetDetailsAsync(pluginAssetId, apiKey), + } - check.not_equal(packageAssetDetails.revisionId, prevPackageRevisionId) - check.equal(pluginAssetDetails.revisionId, prevPluginRevisionId) + expect.neq(assetDetails.package.revisionId, initialAssetDetails.package.revisionId) + expect.eq(assetDetails.plugin.revisionId, initialAssetDetails.plugin.revisionId) end) diff --git a/foreman.toml b/foreman.toml index 538f0f3..d0d3aee 100644 --- a/foreman.toml +++ b/foreman.toml @@ -1,6 +1,6 @@ [tools] -luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "=1.53.0" } -lune = { source = "lune-org/lune", version = "=0.9.4" } +luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "=1.62.0" } +lute = { source = "luau-lang/lute", version = "=0.1.0-nightly.20260225" } rojo = { source = "rojo-rbx/rojo", version = "=7.5.0" } selene = { source = "kampfkarren/selene", version = "=0.28.0" } stylua = { source = "johnnymorganz/stylua", version = "=2.1.0" } diff --git a/scripts/analyze.luau b/scripts/analyze.luau new file mode 100644 index 0000000..5edee22 --- /dev/null +++ b/scripts/analyze.luau @@ -0,0 +1,10 @@ +local run = require("@root/lib/run") + +run("luau-lsp", { + "analyze", + "--settings=.vscode/settings.json", + + "examples", + "scripts", + "src", +}) diff --git a/scripts/build.luau b/scripts/build.luau new file mode 100644 index 0000000..0b12d31 --- /dev/null +++ b/scripts/build.luau @@ -0,0 +1,38 @@ +local fs = require("@std/fs") +local path = require("@std/path") + +local run = require("@root/lib/run") + +local INCLUDES = { + ".luaurc", + "init.luau", + "LICENSE", + "README.md", + + "src/", + "pkg/", +} + +local BUILD_PATH = "build" +local ARTIFACT_NAME = "rbxasset" +local ARCHIVE_NAME = "rbxasset.zip" +local ARTIFACT_PATH = path.join(BUILD_PATH, ARTIFACT_NAME) + +fs.removedirectory(BUILD_PATH, { + recursive = true, +}) +fs.createdirectory(ARTIFACT_PATH, { + makeparents = true, +}) + +for _, includeName in INCLUDES do + if fs.type(includeName) == "dir" then + run("cp", { "-R", includeName, tostring(path.join(ARTIFACT_PATH, includeName)) }) + else + run("cp", { includeName, tostring(ARTIFACT_PATH) }) + end +end + +run("zip", { "-r", ARCHIVE_NAME, ARTIFACT_NAME }, { + cwd = BUILD_PATH, +}) diff --git a/scripts/install.luau b/scripts/install.luau new file mode 100644 index 0000000..be8e038 --- /dev/null +++ b/scripts/install.luau @@ -0,0 +1,81 @@ +local fs = require("@std/fs") +local path = require("@std/path") +local process = require("@std/process") + +local PACKAGES_PATH = "pkg" +local PACKAGE_INDEX_NAME = "_Index" + +-- Install Lute batteries first since some core functions (like `run`) +-- depend on them, and it makes it really hard to write install steps +-- without those functions +do + fs.removedirectory(PACKAGES_PATH, { recursive = true }) + fs.createdirectory(PACKAGES_PATH) + + local function installLuteBatteries() + local commit = "fc19ad0" + local batteries = { + "cli.luau", + "pp.luau", + --[[ + TODO: Install the TOML battery once this issue is fixed: + https://github.com/luau-lang/lute/issues/643 + + Currently we use a patched version of this module at + `src/lib/toml.luau` which supports quoted dictionary keys. + ]] + -- "toml.luau", + "richterm.luau", + } + + for _, battery in batteries do + print(`installing Lute battery: {battery}`) + + process.run({ + "curl", + "-LO", + `https://raw.githubusercontent.com/luau-lang/lute/{commit}/batteries/{battery}`, + }, { + stdio = "inherit", + cwd = PACKAGES_PATH, + }) + end + end + + installLuteBatteries() +end + +-- Now we can take care of everything else +do + local run = require("@root/lib/run") + + local function installGitDependencies() + local repos = { + { git = "osyrisrblx/t@1dbfccc1", main = "lib" }, + } + + for _, repo in repos do + local parts = repo.git:split("@") + local repoPath = parts[1] + local rev = parts[2] + + local repoParts = repoPath:split("/") + local projectName = repoParts[2] + local dest = path.join(PACKAGES_PATH, PACKAGE_INDEX_NAME, projectName) + + run("git", { "clone", `https://github.com/{repoPath}`, tostring(dest) }) + + run("git", { "reset", "--hard", rev }, { + cwd = tostring(dest), + }) + + local linkerPath = path.join(PACKAGES_PATH, `{projectName}.luau`) + fs.writestringtofile(linkerPath, `return require("./{PACKAGE_INDEX_NAME}/{projectName}/{repo.main}")`) + end + end + + -- Setup typedefs for the current Lute version + run("lute", { "setup" }) + + installGitDependencies() +end diff --git a/scripts/lib/appendTextForTesting.luau b/scripts/lib/appendTextForTesting.luau new file mode 100644 index 0000000..34be4fa --- /dev/null +++ b/scripts/lib/appendTextForTesting.luau @@ -0,0 +1,10 @@ +local fs = require("@std/fs") +local path = require("@std/path") + +local function appendTextForTesting(filePath: path.pathlike, text: string) + local content = fs.readfiletostring(tostring(filePath)) + content ..= text + fs.writestringtofile(tostring(filePath), content) +end + +return appendTextForTesting diff --git a/scripts/lib/dotenv.luau b/scripts/lib/dotenv.luau new file mode 100644 index 0000000..d7cc7dc --- /dev/null +++ b/scripts/lib/dotenv.luau @@ -0,0 +1,32 @@ +local fs = require("@std/fs") +local path = require("@std/path") +local process = require("@std/process") + +local DOTENV_FILE_NAME = ".env" + +local function dotenv() + local current = process.cwd() + local env: { [string]: string } = {} + + for _, file in fs.listdirectory(current) do + local filePath = path.join(current, file.name) + if file.name == DOTENV_FILE_NAME then + local content = fs.readfiletostring(filePath) + + for line in content:gmatch("[^\r\n]+") do + local key, value = line:match("([%u_]+)[%s+]?=[%s+]?(.*)") + + if key and value and value ~= "" then + value = value:gsub('"', "") + env[key] = value + end + end + end + end + + for key, value in env do + process.env[key] = value + end +end + +return dotenv diff --git a/scripts/lib/retryAsync.luau b/scripts/lib/retryAsync.luau new file mode 100644 index 0000000..294e14d --- /dev/null +++ b/scripts/lib/retryAsync.luau @@ -0,0 +1,48 @@ +local pp = require("@pkg/pp") +local tableext = require("@std/tableext") +local task = require("@lute/task") + +type RetryOptions = { + maxAttempts: number?, + waitTime: number?, +} + +local defaultOptions = { + maxAttempts = 3, + waitTime = 1, +} + +type Options = typeof(defaultOptions) & RetryOptions + +local function retry(callback: () -> (), retryOptions: RetryOptions?) + local options = tableext.combine(defaultOptions, retryOptions) :: Options + + local attempts = 0 + local start = os.clock() + + while true do + attempts += 1 + + local success, err = pcall(function() + callback() + return nil + end) + + if success then + break + end + + if attempts >= options.maxAttempts then + local elapsed = ("%.2f"):format(os.clock() - start) + error(`callback did not succeed over {options.maxAttempts} attempts in {elapsed} seconds: {pp(err)}`, 2) + end + + local waitTime = options.waitTime + if attempts > 1 then + waitTime *= 2 * attempts + end + task.wait(waitTime) + end +end + +return retry diff --git a/.lune/lint.luau b/scripts/lint.luau similarity index 93% rename from .lune/lint.luau rename to scripts/lint.luau index 7c5bcde..f6455f4 100644 --- a/.lune/lint.luau +++ b/scripts/lint.luau @@ -1,9 +1,9 @@ local run = require("@root/lib/run") local foldersToLint = { - ".lune", - "src", "examples", + "scripts", + "src", } run("selene", foldersToLint) diff --git a/scripts/test.luau b/scripts/test.luau new file mode 100644 index 0000000..a5a056d --- /dev/null +++ b/scripts/test.luau @@ -0,0 +1,30 @@ +local cli = require("@pkg/cli") +local process = require("@std/process") + +local dotenv = require("./lib/dotenv") +local run = require("@root/lib/run") + +local args = cli.parser() + +args:add("apiKey", "option", { + help = "Open Cloud API key for running end-to-end tests. This can also be supplied by setting the ROBLOX_API_KEY environment variable", +}) +args:add("e2e", "flag", { + help = "Run end-to-end tests", +}) + +args:parse({ ... }) + +dotenv() + +local apiKey = args:get("apiKey") or process.env.ROBLOX_API_KEY + +run("lute", { "test", "src" }) + +if args:has("e2e") then + -- We need to set the API key as an env variable so the E2E tests also have + -- access to it + process.env.ROBLOX_API_KEY = apiKey + + run("lute", { "test", "examples" }) +end diff --git a/src/lib/bufferToHex.luau b/src/lib/bufferToHex.luau new file mode 100644 index 0000000..377853d --- /dev/null +++ b/src/lib/bufferToHex.luau @@ -0,0 +1,9 @@ +local function bufferToHex(buf: buffer): string + local hex = "" + for i = 0, buffer.len(buf) - 1 do + hex ..= ("%02x"):format(buffer.readu8(buf, i)) + end + return hex +end + +return bufferToHex diff --git a/src/lib/join.luau b/src/lib/join.luau index 5c63601..428a8f5 100644 --- a/src/lib/join.luau +++ b/src/lib/join.luau @@ -10,7 +10,7 @@ local function join(...: any): T continue end - for key, value in pairs(dictionary) do + for key, value in dictionary do result[key] = value end end diff --git a/src/lib/run.luau b/src/lib/run.luau index 5b85a10..c3161cc 100644 --- a/src/lib/run.luau +++ b/src/lib/run.luau @@ -1,26 +1,32 @@ -local process = require("@lune/process") - -local logging = require("@root/logging") +local process = require("@std/process") +local richterm = require("@pkg/richterm") type Options = { cwd: string?, - env: { [string]: any }?, + env: { [string]: string }?, + stdio: any, } local function run(program: string, params: { string }, options: Options?): string - logging.debug(`RUN > {program} {table.concat(params, " ")}`) + local command = `{program} {table.concat(params, " ")}` + + print(richterm.bold(`> {command}`)) - local result = process.exec(program, params, { - stdio = "inherit", + local result = process.run({ + program, + table.unpack(params), + }, { shell = true, + stdio = if options and options.stdio then options.stdio else "inherit", cwd = if options then options.cwd else nil, env = if options then options.env else nil, }) - if result.code == 0 then - return result.stdout:gsub("\n$", "") + if result.ok then + local trimmed = result.stdout:gsub("^\n", ""):gsub("\n$", "") + return trimmed else - error(result.stderr) + error(result.stderr, 2) end end diff --git a/src/lib/toml.luau b/src/lib/toml.luau new file mode 100644 index 0000000..e1db4e6 --- /dev/null +++ b/src/lib/toml.luau @@ -0,0 +1,248 @@ +--!nonstrict + +-- upstream: https://github.com/luau-lang/lute/blob/d3ab7a81840aa62e887d5566fbe66f560b1f3227/batteries/toml.luau + +local toml = {} + +-- serialization +type SerializerState = { + buf: buffer, + cursor: number, +} + +local function serializeValue(value: string | number) + if typeof(value) == "string" then + value = string.gsub(value, "\\", "\\\\") + value = string.gsub(value, "\n", "\\n") + value = string.gsub(value, "\t", "\\t") + return '"' .. value .. '"' + elseif value == math.huge then + return "inf" + elseif value == -math.huge then + return "-inf" + elseif value ~= value then + return "nan" + else + return tostring(value) + end +end + +-- TOML bare keys may only contain [A-Za-z0-9_-]. Any other key must be quoted. +local function serializeKey(key: string): string + if string.match(key, "^[%w_%-]+$") then + return key + end + key = string.gsub(key, "\\", "\\\\") + key = string.gsub(key, '"', '\\"') + return '"' .. key .. '"' +end + +local function hasNestedTables(tbl: { [unknown]: unknown }) + for _, v in tbl do + if typeof(v) == "table" and next(v) ~= nil then + return true + end + end + return false +end + +local function tableToToml(tbl: { [any]: any }, parent: string?): string + local result = "" + local subTables = {} + local hasDirectValues = false + + for k, v in tbl do + if typeof(v) == "table" and next(v) ~= nil then + if #v > 0 then + for _, entry in v do + result ..= "\n[[" .. (parent and parent .. "." or "") .. serializeKey(k) .. "]]\n" + result ..= tableToToml(entry, nil) + end + else + subTables[k] = v + end + else + hasDirectValues = true + result ..= k .. " = " .. serializeValue(v) .. "\n" + end + end + + for k, v in subTables do + local key = parent and (parent .. "." .. serializeKey(k)) or serializeKey(k) + + if hasNestedTables(v) == true then + result ..= tableToToml(v, key) + continue + end + + if hasDirectValues or parent then + result ..= "\n[" .. key .. "]\n" + end + + result ..= tableToToml(v, key) + end + + return result +end + +local function serialize(tbl: { [any]: any }): string + return tableToToml(tbl, nil) +end + +-- deserialization +type DeserializerState = { + buf: string, + cursor: number, +} + +local function skipWhitespace(state: DeserializerState) + local pos = state.cursor + while pos <= string.len(state.buf) and string.match(string.sub(state.buf, pos, pos), "%s") do + pos += 1 + end + state.cursor = pos +end + +local function readLine(state: DeserializerState) + local nextLine = string.find(state.buf, "\n", state.cursor) or string.len(state.buf) + 1 + local line = string.sub(state.buf, state.cursor, nextLine - 1) + state.cursor = nextLine + 1 + return line +end + +-- Splits a TOML key path (e.g. `a."b.c".d`) into its component keys, +-- honouring both double- and single-quoted segments. +local function parseKeyPath(path: string): { string } + local keys = {} + local pos = 1 + + while pos <= #path do + pos = string.find(path, "[^%s]", pos) or (#path + 1) + + if pos > #path then + break + end + + local char = string.sub(path, pos, pos) + + if char == '"' or char == "'" then + local quote = char + local key = "" + pos += 1 + + while pos <= #path do + local c = string.sub(path, pos, pos) + if c == "\\" and quote == '"' then + pos += 1 + key ..= string.sub(path, pos, pos) + elseif c == quote then + pos += 1 + break + else + key ..= c + end + pos += 1 + end + + table.insert(keys, key) + else + local dotPos = string.find(path, "%.", pos) or (#path + 1) + local key = string.match(string.sub(path, pos, dotPos - 1), "^%s*(.-)%s*$") :: string + pos = dotPos + + if key ~= "" then + table.insert(keys, key) + end + end + + pos = string.find(path, "[^%s]", pos) or (#path + 1) + if pos <= #path and string.sub(path, pos, pos) == "." then + pos += 1 + end + end + + return keys +end + +local function deserialize(input: string) + -- Normalize line endings: Replace Windows (\r\n) and old Mac (\r) with Unix (\n) + input = string.gsub(input, "\r\n", "\n") + input = string.gsub(input, "\r", "\n") + + local state: DeserializerState = { + buf = input, + cursor = 1, + } + local result = {} + local currentTable = result + local arrayTables = {} + + while state.cursor <= string.len(state.buf) do + skipWhitespace(state) + local line = readLine(state) + + if line == "" or string.sub(line, 1, 1) == "#" then + continue + end + + if string.match(line, "^%[%[(.-)%]%]$") then + local tableName = string.match(line, "^%[%[(.-)%]%]$") + arrayTables[tableName] = arrayTables[tableName] or {} + + local newEntry = {} + table.insert(arrayTables[tableName], newEntry) + + result[tableName] = arrayTables[tableName] + currentTable = newEntry + elseif string.match(line, "^%[(.-)%]$") then + local tablePath = string.match(line, "^%[(.-)%]$") + local parent = result + + for _, section in parseKeyPath(tablePath :: string) do + if not parent[section] then + parent[section] = {} + end + parent = parent[section] + end + + currentTable = parent + elseif string.match(line, "^(.-)%s*=%s*(.-)$") then + local key, value = string.match(line, "^(.-)%s*=%s*(.-)$") + -- selene: allow(incorrect_standard_library_use) + assert(key and value) + key = string.match(key, "^%s*(.-)%s*$") + value = string.match(value, "^%s*(.-)%s*$") + -- selene: allow(incorrect_standard_library_use) + assert(key and value) + if string.match(value, '^"(.*)"$') or string.match(value, "^'(.*)'$") then + value = string.sub(value, 2, -2) + value = string.gsub(value, "\\\\", "\\") + value = string.gsub(value, "\\n", "\n") + value = string.gsub(value, "\\t", "\t") + elseif tonumber(value) then + value = tonumber(value) + elseif value == "true" then + value = true + elseif value == "false" then + value = false + elseif value == "inf" or value == "+inf" then + value = math.huge + elseif value == "-inf" then + value = -math.huge + elseif value == "nan" then + --lute-lint-ignore(divide_by_zero) + value = 0 / 0 + end + + currentTable[key] = value + end + end + + return result +end + +-- user-facing +toml.serialize = serialize +toml.deserialize = deserialize + +return table.freeze(toml) diff --git a/src/lib/toml.spec.luau b/src/lib/toml.spec.luau new file mode 100644 index 0000000..7ea9e9b --- /dev/null +++ b/src/lib/toml.spec.luau @@ -0,0 +1,85 @@ +--!nonstrict + +local expect = require("@std/test/assert") +local test = require("@std/test") + +local toml = require("./toml") + +test.case("should parse quoted keys with dots correctly", function() + local input = [[ +[assets.package] +assetId = "120786139379662" + +[images."icon.png"] +assetId = "95124518202499" +hash = "fac185770cf5e824d4c0a9b59e547df42dc10011c5ad139bfc2c7e1507eef631" +]] + + local result = toml.deserialize(input) + + expect.neq(result.assets, nil) + expect.neq(result.assets.package, nil) + expect.eq(result.assets.package.assetId, "120786139379662") + + expect.neq(result.images, nil) + expect.neq(result.images["icon.png"], nil) + expect.eq(result.images["icon.png"].assetId, "95124518202499") + expect.eq(result.images["icon.png"].hash, "fac185770cf5e824d4c0a9b59e547df42dc10011c5ad139bfc2c7e1507eef631") +end) + +test.case("should handle multiple quoted keys in path", function() + local input = [[ +["outer.key"."inner.key"] +value = "test" +]] + + local result = toml.deserialize(input) + + expect.neq(result["outer.key"], nil) + expect.neq(result["outer.key"]["inner.key"], nil) + expect.eq(result["outer.key"]["inner.key"].value, "test") +end) + +test.case("should handle mixed quoted and unquoted keys", function() + local input = [[ +[section."quoted.key".another] +value = "mixed" +]] + + local result = toml.deserialize(input) + + expect.neq(result.section, nil) + expect.neq(result.section["quoted.key"], nil) + expect.neq(result.section["quoted.key"].another, nil) + expect.eq(result.section["quoted.key"].another.value, "mixed") +end) + +test.case("should handle single-quoted keys", function() + local input = [[ +[images.'icon.png'] +assetId = "123" +]] + + local result = toml.deserialize(input) + + expect.neq(result.images, nil) + expect.neq(result.images["icon.png"], nil) + expect.eq(result.images["icon.png"].assetId, "123") +end) + +test.case("roundtrips quoted keys", function() + local input = { + images = { + ["icon.png"] = { + assetId = "123", + }, + }, + } + + local result = toml.serialize(input) + + local roundtrip = toml.deserialize(result) + + expect.neq(roundtrip.images, nil) + expect.neq(roundtrip.images["icon.png"], nil) +end) diff --git a/src/logging.luau b/src/logging.luau index 8979bea..7f59be7 100644 --- a/src/logging.luau +++ b/src/logging.luau @@ -1,10 +1,8 @@ -local process = require("@lune/process") -local stdio = require("@lune/stdio") +local process = require("@std/process") +local richterm = require("@pkg/richterm") type LogLevel = "info" | "warn" | "err" | "debug" -local LOG_LEVEL = if process.env.LOG_LEVEL then process.env.LOG_LEVEL:lower() else "info" - local LOG_LEVEL_ORDER = { "err", "info", @@ -12,8 +10,17 @@ local LOG_LEVEL_ORDER = { "debug", } +local function getLogLevel(): LogLevel + local logLevel = process.env.LOG_LEVEL + if logLevel and logLevel ~= "" then + return logLevel:lower() :: LogLevel + else + return "info" + end +end + local function canLog(logLevelToCheck: LogLevel): boolean - local maxPriority = table.find(LOG_LEVEL_ORDER, LOG_LEVEL) + local maxPriority = table.find(LOG_LEVEL_ORDER, getLogLevel()) local priorityToCheck = table.find(LOG_LEVEL_ORDER, logLevelToCheck) if maxPriority and priorityToCheck then @@ -27,31 +34,25 @@ local logging = {} function logging.info(...) if canLog("info") then - print(`[info]`, ...) + print(richterm.blue(`[info] {...}`)) end end function logging.warn(...) if canLog("warn") then - stdio.write(stdio.color("yellow")) - print(`[warn]`, ...) - stdio.write(stdio.color("reset")) + print(richterm.yellow(`[warn] {...}`)) end end function logging.err(...) if canLog("err") then - stdio.write(stdio.color("red")) - print(`[err]`, ...) - stdio.write(stdio.color("reset")) + print(richterm.red(`[err] {...}`)) end end function logging.debug(...) if canLog("debug") then - stdio.write(stdio.color("black")) - print(`[debug]`, ...) - stdio.write(stdio.color("reset")) + print(richterm.black(`[debug] {...}`)) end end diff --git a/src/manifest/getOrCreateAssetLockfile.luau b/src/manifest/getOrCreateAssetLockfile.luau index 1494ab7..e42bcf7 100644 --- a/src/manifest/getOrCreateAssetLockfile.luau +++ b/src/manifest/getOrCreateAssetLockfile.luau @@ -1,5 +1,7 @@ -local fs = require("@lune/fs") -local serde = require("@lune/serde") +local fs = require("@std/fs") +local path = require("@std/path") +local pp = require("@pkg/pp") +local toml = require("@root/lib/toml") local constants = require("@root/constants") local join = require("@root/lib/join") @@ -8,26 +10,26 @@ local types = require("@root/types") type Lockfile = types.Lockfile -local function getOrCreateAssetLockfile(packagePath: string): Lockfile - local lockfilePath = `{packagePath}/{constants.ASSET_LOCKFILE_FILENAME}` +local function getOrCreateAssetLockfile(packagePath: path.pathlike): Lockfile + local lockfilePath = path.join(packagePath, constants.ASSET_LOCKFILE_FILENAME) -- It's expected that the lockfile won't exist in certain situations, like -- on the first upload of the asset. So we just catch any error here local content: string local success = pcall(function() - content = fs.readFile(lockfilePath) + content = fs.readfiletostring(lockfilePath) end) - local parsedLockfile = if success then serde.decode("toml", content) else nil - local lockfile: Lockfile = join({ + local parsedLockfile = if success then toml.deserialize(content) else nil + local lockfile = join({ assets = {}, images = {}, - }, parsedLockfile) + }, parsedLockfile) :: Lockfile local isLockfileValid, message = types.validateLockfile(lockfile) assert(isLockfileValid, `failed to parse asset lockfile at {lockfilePath}: {message}`) - logging.debug(`loaded asset lockfile {lockfile}`) + logging.debug(`loaded asset lockfile {pp(lockfile)}`) return lockfile end diff --git a/src/manifest/getOrCreateAssetLockfile.spec.luau b/src/manifest/getOrCreateAssetLockfile.spec.luau index 5e33636..b97eb84 100644 --- a/src/manifest/getOrCreateAssetLockfile.spec.luau +++ b/src/manifest/getOrCreateAssetLockfile.spec.luau @@ -1,17 +1,16 @@ -local frktest = require("@pkg/frktest") -local test = frktest.test -local check = frktest.assert.check +local expect = require("@std/test/assert") +local test = require("@std/test") local getOrCreateAssetLockfile = require("./getOrCreateAssetLockfile") test.case("reads an asset lockfile from disk", function() local lockfile = getOrCreateAssetLockfile("examples/package") - check.equal(lockfile.assets["package"].assetId, "115689279560293") + expect.neq(lockfile.assets["package"].assetId, nil) end) test.case("creates a blank asset lockfile when not found on disk", function() local lockfile = getOrCreateAssetLockfile("/nowhere") - check.table.equal(lockfile, { + expect.tableeq(lockfile :: { [unknown]: unknown }, { assets = {}, images = {}, }) diff --git a/src/manifest/readManifest.luau b/src/manifest/readManifest.luau index acdf801..cd6e5e2 100644 --- a/src/manifest/readManifest.luau +++ b/src/manifest/readManifest.luau @@ -1,5 +1,7 @@ -local fs = require("@lune/fs") -local serde = require("@lune/serde") +local fs = require("@std/fs") +local path = require("@std/path") +local pp = require("@pkg/pp") +local toml = require("@root/lib/toml") local constants = require("@root/constants") local logging = require("@root/logging") @@ -7,20 +9,19 @@ local types = require("@root/types") type Manifest = types.Manifest -local function readManifest(packagePath: string): Manifest - local manifestPath = `{packagePath}/{constants.ASSET_MANIFEST_FILENAME}` - local content: string - local success, err = pcall(function() - content = fs.readFile(manifestPath) +local function readManifest(packagePath: path.pathlike): Manifest + local manifestPath = path.join(packagePath, constants.ASSET_MANIFEST_FILENAME) + local success, result = pcall(function() + return fs.readfiletostring(manifestPath) end) - assert(success, `failed to read asset manifest at {manifestPath}: {err}`) + assert(success, `failed to read asset manifest at {manifestPath}: {result}`) - local parsedContent = serde.decode("toml", content) + local parsedContent = toml.deserialize(result) local isManifestValid, message = types.validateManifest(parsedContent) assert(isManifestValid, `failed to parse asset manifest at {manifestPath}: {message}`) - logging.debug("loaded asset manifest", parsedContent) + logging.debug("loaded asset manifest", pp(parsedContent)) return parsedContent :: Manifest end diff --git a/src/manifest/writeLockfile.luau b/src/manifest/writeLockfile.luau index e15c6c9..1d0681d 100644 --- a/src/manifest/writeLockfile.luau +++ b/src/manifest/writeLockfile.luau @@ -1,5 +1,6 @@ -local fs = require("@lune/fs") -local serde = require("@lune/serde") +local fs = require("@std/fs") +local path = require("@std/path") +local toml = require("@root/lib/toml") local constants = require("@root/constants") local logging = require("@root/logging") @@ -7,10 +8,9 @@ local types = require("@root/types") type Lockfile = types.Lockfile -local function writeLockfile(packagePath: string, lockfile: Lockfile) - local lockfilePath = `{packagePath}/{constants.ASSET_LOCKFILE_FILENAME}` - fs.writeFile(lockfilePath, serde.encode("toml", lockfile)) - +local function writeLockfile(packagePath: path.pathlike, lockfile: Lockfile) + local lockfilePath = path.join(packagePath, constants.ASSET_LOCKFILE_FILENAME) + fs.writestringtofile(lockfilePath, toml.serialize(lockfile :: { [unknown]: unknown })) logging.debug(`wrote asset lockfile {lockfilePath}`) end diff --git a/src/requests/createImageAsync.luau b/src/requests/createImageAsync.luau index 5a38a11..1282fa9 100644 --- a/src/requests/createImageAsync.luau +++ b/src/requests/createImageAsync.luau @@ -1,5 +1,6 @@ -local fs = require("@lune/fs") -local serde = require("@lune/serde") +local fs = require("@std/fs") +local json = require("@std/json") +local path = require("@std/path") local createFormData = require("@root/requests/forms/createFormData") local fetch = require("@root/requests/fetch") @@ -8,13 +9,15 @@ local waitForAssetOperationAsync = require("@root/requests/waitForAssetOperation local function createImageAsync( displayName: string, - imagePath: string, + imagePath: path.pathlike, creatorId: number, creatorType: types.CreatorType, apiKey: string ) local creatorIdKey = if creatorType == types.CreatorType.User then "userId" else "groupId" + local imageContent = fs.readfiletostring(imagePath) + local formData = createFormData({ request = { value = { @@ -29,8 +32,8 @@ local function createImageAsync( contentType = "application/json", }, fileContent = { - filename = imagePath:match("[/\\](.+)$"), - value = fs.readFile(imagePath), + filename = path.basename(imagePath), + value = imageContent, contentType = "image/png", }, }) @@ -44,7 +47,8 @@ local function createImageAsync( body = formData.body, }) - local body = serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of casting to `any` + local body: any = json.deserialize(res.body) return waitForAssetOperationAsync(body.operationId, apiKey) end diff --git a/src/requests/fetch.luau b/src/requests/fetch.luau index fd386dc..148f82f 100644 --- a/src/requests/fetch.luau +++ b/src/requests/fetch.luau @@ -1,5 +1,5 @@ -local net = require("@lune/net") -local serde = require("@lune/serde") +local json = require("@std/json") +local net = require("@std/net") local logging = require("@root/logging") @@ -16,8 +16,7 @@ local function fetch(url: string, options: FetchOptions) logging.debug(`{method} {url}`) - local res = net.request({ - url = url, + local res = net.request(url, { method = method, headers = options.headers, body = options.body, @@ -26,19 +25,21 @@ local function fetch(url: string, options: FetchOptions) if res.ok then return res else - local body = serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of + -- casting to `any` + local body: any = json.deserialize(res.body) - local problems = {} + local problems: { string } = {} if body.errors then for _, err in body.errors do - table.insert(problems, err.message) + table.insert(problems, err.message :: string) end elseif body.message then - table.insert(problems, body.message) + table.insert(problems, body.message :: string) end error( - `{res.statusCode} {res.statusMessage} when attempting to {method} {url}` + `{res.status} {res.body} when attempting to {method} {url}` .. if #problems > 0 then `: {table.concat(problems, ", ")}` else "", 2 ) diff --git a/src/requests/forms/createFormData.luau b/src/requests/forms/createFormData.luau index 7d0e163..0188a65 100644 --- a/src/requests/forms/createFormData.luau +++ b/src/requests/forms/createFormData.luau @@ -1,4 +1,6 @@ -local serde = require("@lune/serde") +local bufferToHex = require("@root/lib/bufferToHex") +local crypto = require("@lute/crypto") +local json = require("@std/json") type FormField = { value: any, @@ -10,7 +12,7 @@ local function createFormDataBody(boundary: string, data: { [string]: FormField local body = {} for key, field in data do - local value = if field.contentType == "application/json" then serde.encode("json", field.value) else field.value + local value = if field.contentType == "application/json" then json.serialize(field.value) else field.value local lines = { `--{boundary}`, @@ -30,7 +32,9 @@ local function createFormDataBody(boundary: string, data: { [string]: FormField end local function createFormData(data: { [string]: FormField }) - local boundary = "LuauFormBoundary" .. serde.hash("md5", tostring(math.random())) + local hash = bufferToHex(crypto.digest(crypto.hash.md5, tostring(math.random()))) + + local boundary = "LuauFormBoundary" .. hash local body = createFormDataBody(boundary, data) return { diff --git a/src/requests/forms/createFormData.spec.luau b/src/requests/forms/createFormData.spec.luau index 3224f36..48b85c0 100644 --- a/src/requests/forms/createFormData.spec.luau +++ b/src/requests/forms/createFormData.spec.luau @@ -1,6 +1,5 @@ -local frktest = require("@pkg/frktest") -local test = frktest.test -local check = frktest.assert.check +local expect = require("@std/test/assert") +local test = require("@std/test") local createFormData = require("./createFormData") @@ -10,7 +9,7 @@ local function assertStringsMatch(base: string, expected: string) table.insert(baseLines, line) end - local expectedLines = {} + local expectedLines: { string } = {} for line in expected:gmatch("[^\r\n]+") do table.insert(expectedLines, line) end @@ -18,6 +17,7 @@ local function assertStringsMatch(base: string, expected: string) for index, baseLine in baseLines do local expectedLine = expectedLines[index] if baseLine ~= expectedLine then + print(baseLine, expectedLine) error(`strings do not match at line {index}\n- base: "{baseLine}"\n- expected: "{expectedLine}"`, 2) end end @@ -34,9 +34,9 @@ test.case("automatically converts objects to json", function() }, }) - local match = formData.body:match('{"bar":true,"baz":"str"}') + local match = formData.body:match('{ "baz": "str", "bar": true}') - check.not_nil(match) + expect.neq(match, nil) end) test.case("body supports multiple parts", function() @@ -64,7 +64,7 @@ test.case("body supports multiple parts", function() `Content-Disposition: form-data; name="request"`, "Content-Type: application/json", "", - '{"assetType":"Image","creationContext":{"creator":{"userId":12345678}},"displayName":"Display Name"}', + '{ "creationContext": { "creator": { "userId": 12345678 } }, "assetType": "Image", "displayName": "Display Name"}', `--{formData.boundary}`, "", 'Content-Disposition: form-data; name="fileContent"', @@ -84,6 +84,8 @@ test.case("the boundary contains LuauFormBoundary", function() contentType = "text/plain", }, }) + local match = formData.boundary:match("LuauFormBoundary") - check.not_nil(match) + + expect.neq(match, nil) end) diff --git a/src/requests/getAssetDetailsAsync.luau b/src/requests/getAssetDetailsAsync.luau index ad57cec..8b7c68e 100644 --- a/src/requests/getAssetDetailsAsync.luau +++ b/src/requests/getAssetDetailsAsync.luau @@ -1,4 +1,4 @@ -local serde = require("@lune/serde") +local json = require("@std/json") local fetch = require("@root/requests/fetch") @@ -11,7 +11,11 @@ local function getAssetDetailsAsync(assetId: string, apiKey: string) }, }) - return serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of casting + -- to `any` + local assetDetails: any = json.deserialize(res.body) + + return assetDetails end return getAssetDetailsAsync diff --git a/src/requests/publishAssetAsync.luau b/src/requests/publishAssetAsync.luau index b9d14ad..b102f73 100644 --- a/src/requests/publishAssetAsync.luau +++ b/src/requests/publishAssetAsync.luau @@ -1,5 +1,6 @@ -local fs = require("@lune/fs") -local serde = require("@lune/serde") +local fs = require("@std/fs") +local json = require("@std/json") +local path = require("@std/path") local createFormData = require("@root/requests/forms/createFormData") local fetch = require("@root/requests/fetch") @@ -16,7 +17,7 @@ type AssetConfig = types.AssetConfig type EnvironmentConfig = types.EnvironmentConfig local function publishAssetAsync( - projectPath: string, + projectPath: path.pathlike, assetName: string, asset: AssetConfig, environment: EnvironmentConfig, @@ -34,6 +35,9 @@ local function publishAssetAsync( local assetId + local modelPath = path.join(projectPath, asset.model) + local modelContent = fs.readfiletostring(modelPath) + if assetDetails and assetDetails.assetId then logging.info("publishing new version...") local formData = createFormData({ @@ -45,7 +49,7 @@ local function publishAssetAsync( }, fileContent = { filename = asset.model, - value = fs.readFile(asset.model), + value = modelContent, contentType = "model/x-rbxm", }, }) @@ -59,7 +63,9 @@ local function publishAssetAsync( body = formData.body, }) - local body = serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of casting + -- to `any` + local body: any = json.deserialize(res.body) local operationRes = waitForAssetOperationAsync(body.operationId, apiKey) @@ -83,7 +89,7 @@ local function publishAssetAsync( }, fileContent = { filename = asset.model, - value = fs.readFile(asset.model), + value = modelContent, contentType = "model/x-rbxm", }, }) @@ -97,7 +103,9 @@ local function publishAssetAsync( body = formData.body, }) - local body = serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of casting + -- to `any` + local body: any = json.deserialize(res.body) local operationRes = waitForAssetOperationAsync(body.operationId, apiKey) @@ -118,7 +126,7 @@ local function publishAssetAsync( assetId = assetId, }), }), - }) + }) :: types.Lockfile writeLockfile(projectPath, newLockfile) end diff --git a/src/requests/publishImagesAsync.luau b/src/requests/publishImagesAsync.luau index 0a84286..08643bf 100644 --- a/src/requests/publishImagesAsync.luau +++ b/src/requests/publishImagesAsync.luau @@ -1,5 +1,7 @@ -local fs = require("@lune/fs") -local serde = require("@lune/serde") +local bufferToHex = require("@root/lib/bufferToHex") +local crypto = require("@lute/crypto") +local fs = require("@std/fs") +local path = require("@std/path") local createImageAsync = require("@root/requests/createImageAsync") local getOrCreateAssetLockfile = require("@root/manifest/getOrCreateAssetLockfile") @@ -11,13 +13,17 @@ type AssetConfig = types.AssetConfig type EnvironmentConfig = types.EnvironmentConfig type PendingImage = { - path: string, + path: path.pathlike, filename: string, assetId: string, } +local function toSha256String(str: string): string + return bufferToHex(crypto.digest(crypto.hash.sha256, str)) +end + local function publishImagesAsync( - projectPath: string, + projectPath: path.pathlike, assetConfig: AssetConfig, environment: EnvironmentConfig, apiKey: string @@ -29,11 +35,12 @@ local function publishImagesAsync( local icon = assetConfig.icon if icon then - local iconPath = `{projectPath}/{icon}` + local iconPath = path.join(projectPath, icon) + local iconContent = fs.readfiletostring(iconPath) logging.info(`syncing package icon at {iconPath}`) - local iconHash = serde.hash("sha256", fs.readFile(iconPath)) + local iconHash = toSha256String(iconContent) if existingImages and existingImages[icon] and existingImages[icon].hash == iconHash then logging.debug(`hashes match for {iconPath}, skipping...`) @@ -51,11 +58,12 @@ local function publishImagesAsync( local assetId = if operation.response then operation.response.assetId else nil if assetId then - table.insert(pendingImages, { + local pendingImage: PendingImage = { path = iconPath, filename = icon, assetId = assetId, - }) + } + table.insert(pendingImages, pendingImage) logging.debug(`image uploaded successfully`) else @@ -69,8 +77,9 @@ local function publishImagesAsync( logging.info("syncing package thumbnails...") for index, thumbnailFilename in thumbnails do - local thumbnailPath = `{projectPath}/{thumbnailFilename}` - local thumbnailHash = serde.hash("sha256", fs.readFile(thumbnailPath)) + local thumbnailPath = path.join(projectPath, thumbnailFilename) + local thumbnailContent = fs.readfiletostring(thumbnailPath) + local thumbnailHash = toSha256String(thumbnailContent) local existingThumbnail = existingImages and existingImages[thumbnailFilename] if existingThumbnail and existingThumbnail.hash == thumbnailHash then @@ -92,11 +101,12 @@ local function publishImagesAsync( local assetId = operation.response.assetId if assetId then - table.insert(pendingImages, { + local pendingImage: PendingImage = { path = thumbnailPath, filename = thumbnailFilename, assetId = assetId, - }) + } + table.insert(pendingImages, pendingImage) logging.debug(`image uploaded successfully`) else @@ -107,13 +117,15 @@ local function publishImagesAsync( end if #pendingImages > 0 then - local newAssetLockfile = table.clone(assetLockfile or {}) + local newAssetLockfile = table.clone(assetLockfile or {} :: types.Lockfile) newAssetLockfile.images = newAssetLockfile.images or {} for _, pendingImage in pendingImages do + local pendingImageContent = fs.readfiletostring(pendingImage.path) + newAssetLockfile.images[pendingImage.filename] = { assetId = pendingImage.assetId, - hash = serde.hash("sha256", fs.readFile(pendingImage.path)), + hash = toSha256String(pendingImageContent), } logging.debug( diff --git a/src/requests/publishPackageAsync.luau b/src/requests/publishPackageAsync.luau index 07f727c..b51565b 100644 --- a/src/requests/publishPackageAsync.luau +++ b/src/requests/publishPackageAsync.luau @@ -1,4 +1,5 @@ -local process = require("@lune/process") +local path = require("@std/path") +local process = require("@std/process") local logging = require("@root/logging") local publishAssetAsync = require("@root/requests/publishAssetAsync") @@ -6,7 +7,7 @@ local publishImagesAsync = require("@root/requests/publishImagesAsync") local readManifest = require("@root/manifest/readManifest") local setAssetIconAsync = require("@root/requests/setAssetIconAsync") -local function publishPackageAsync(projectPath: string, assetName: string, apiKey: string) +local function publishPackageAsync(projectPath: path.pathlike, assetName: string, apiKey: string) local manifest = readManifest(projectPath) local asset = manifest.assets[assetName] diff --git a/src/requests/publishWorkspaceAsync.luau b/src/requests/publishWorkspaceAsync.luau index 31214b9..5595629 100644 --- a/src/requests/publishWorkspaceAsync.luau +++ b/src/requests/publishWorkspaceAsync.luau @@ -1,10 +1,12 @@ +local path = require("@std/path") + local logging = require("@root/logging") local publishAssetAsync = require("@root/requests/publishAssetAsync") local publishImagesAsync = require("@root/requests/publishImagesAsync") local readManifest = require("@root/manifest/readManifest") local setAssetIconAsync = require("@root/requests/setAssetIconAsync") -local function publishWorkspaceAsync(projectPath: string, apiKey: string) +local function publishWorkspaceAsync(projectPath: path.pathlike, apiKey: string) local manifest = readManifest(projectPath) for assetName, asset in manifest.assets do diff --git a/src/requests/setAssetDetailsAsync.luau b/src/requests/setAssetDetailsAsync.luau index d57aab8..2dc0225 100644 --- a/src/requests/setAssetDetailsAsync.luau +++ b/src/requests/setAssetDetailsAsync.luau @@ -1,4 +1,4 @@ -local serde = require("@lune/serde") +local json = require("@std/json") local createFormData = require("@root/requests/forms/createFormData") local fetch = require("@root/requests/fetch") @@ -33,7 +33,8 @@ local function setAssetDetailsAsync(assetId: string, apiKey: string, details: As body = formData.body, }) - local body: OperationResponse = serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of casting to `any` + local body: OperationResponse = json.deserialize(res.body) :: any if body.message then error(body.message) diff --git a/src/requests/setAssetIconAsync.luau b/src/requests/setAssetIconAsync.luau index 68152b2..5ac3bc1 100644 --- a/src/requests/setAssetIconAsync.luau +++ b/src/requests/setAssetIconAsync.luau @@ -1,4 +1,5 @@ -local serde = require("@lune/serde") +local json = require("@std/json") +local path = require("@std/path") local createFormData = require("@root/requests/forms/createFormData") local fetch = require("@root/requests/fetch") @@ -11,7 +12,12 @@ type AssetConfig = types.AssetConfig local ASSETS_BASE_URL = "https://apis.roblox.com/assets/v1" -local function setAssetIconAsync(projectPath: string, assetName: string, assetConfig: AssetConfig, apiKey: string) +local function setAssetIconAsync( + projectPath: path.pathlike, + assetName: string, + assetConfig: AssetConfig, + apiKey: string +) if not assetConfig.icon then logging.debug("no icon in the asset manifest. skipping...") return @@ -55,7 +61,9 @@ local function setAssetIconAsync(projectPath: string, assetName: string, assetCo body = formData.body, }) - local body = serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of casting + -- to `any` + local body: any = json.deserialize(res.body) if not body.operationId then logging.err(`failed to set asset icon`) diff --git a/src/requests/waitForAssetOperationAsync.luau b/src/requests/waitForAssetOperationAsync.luau index 47d09c7..bc1ab06 100644 --- a/src/requests/waitForAssetOperationAsync.luau +++ b/src/requests/waitForAssetOperationAsync.luau @@ -1,5 +1,5 @@ -local serde = require("@lune/serde") -local task = require("@lune/task") +local json = require("@std/json") +local task = require("@lute/task") local fetch = require("@root/requests/fetch") local types = require("@root/types") @@ -21,7 +21,9 @@ local function waitForAssetOperationAsync(operationId: string, apiKey: string): }, }) - local body = serde.decode("json", res.body) + -- FIXME: Narrow the return value of `json.deserialize` instead of + -- casting to `any` + local body: any = json.deserialize(res.body) if res.ok and body.done then return body diff --git a/src/types.luau b/src/types.luau index 5db4040..1d8dc03 100644 --- a/src/types.luau +++ b/src/types.luau @@ -1,8 +1,8 @@ -local t = require("@pkg/t") +local t: any = require("@pkg/t") local types = {} -local function countObject(obj: { [any]: any }): number +local function countObject(obj: { [unknown]: unknown }): number local count = 0 for _ in obj do count += 1 @@ -175,4 +175,20 @@ export type OperationResponse = { }?, } & ErrorStatusResponse +export type LuauTaskResponse = { + binaryInput: string, + binaryOutputUri: string, + createTime: string, + enableBinaryOutput: boolean, + output: { + results: { any }, + }, + path: string, + script: string, + state: string, + updateTime: string, + user: string, + error: { [string]: any }?, +} + return types diff --git a/src/types.spec.luau b/src/types.spec.luau index 934a0b0..0b26228 100644 --- a/src/types.spec.luau +++ b/src/types.spec.luau @@ -1,6 +1,5 @@ -local frktest = require("@pkg/frktest") -local test = frktest.test -local check = frktest.assert.check +local expect = require("@std/test/assert") +local test = require("@std/test") local types = require("./types") @@ -24,7 +23,7 @@ test.suite("validateManifest", function() }, }) - check.is_true(success) + expect.eq(success, true) end) test.case("accepts optional manifest values", function() @@ -49,7 +48,7 @@ test.suite("validateManifest", function() }, }) - check.is_true(success) + expect.eq(success, true) end) test.case("denies empty assets object", function() @@ -65,8 +64,8 @@ test.suite("validateManifest", function() }, }) - check.is_false(success) - check.equal(message, "at least one asset must be defined in the manifest") + expect.eq(success, false) + expect.eq(message, "at least one asset must be defined in the manifest") end) test.case("denies empty environments object", function() @@ -84,8 +83,8 @@ test.suite("validateManifest", function() environments = {}, }) - check.is_false(success) - check.equal(message, "at least one environment must be defined in the manifest") + expect.eq(success, false) + expect.eq(message, "at least one environment must be defined in the manifest") end) test.case("assets must map back to a valid environment", function() @@ -110,8 +109,8 @@ test.suite("validateManifest", function() }, }) - check.is_false(success) - check.equal(message, `asset "main" attempts to use the environment "does-not-exist" which does not exist`) + expect.eq(success, false) + expect.eq(message, `asset "main" attempts to use the environment "does-not-exist" which does not exist`) end) test.case("denies optional manifest values of the wrong type", function() @@ -135,13 +134,13 @@ test.suite("validateManifest", function() }, }) - check.is_false(success) + expect.eq(success, false) end) test.case("denies empty object", function() local success = types.validateManifest({}) - check.is_false(success) + expect.eq(success, false) end) test.case("denies invalid types", function() @@ -163,7 +162,7 @@ test.suite("validateManifest", function() }, }) - check.is_false(success) + expect.eq(success, false) end) end) @@ -178,7 +177,7 @@ test.suite("validateLockfile", function() images = {}, }) - check.is_true(success) + expect.eq(success, true) end) test.case("handles storing images", function() @@ -196,12 +195,12 @@ test.suite("validateLockfile", function() }, }) - check.is_true(success) + expect.eq(success, true) end) test.case("denies empty object", function() local success = types.validateLockfile({}) - check.is_false(success) + expect.eq(success, false) end) end) From 94019a10ef03b44846098dcf75d5932a5ef2f53c Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 2 Mar 2026 14:04:10 -0800 Subject: [PATCH 2/5] Create a CLI entrypoint --- .luaurc | 8 +++---- scripts/build.luau | 52 ++++++++++++++++++++++++++++++++++------------ src/cli/init.luau | 47 +++++++++++++++++++++++++++++++++++++++++ src/cli/types.luau | 10 +++++++++ 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 src/cli/init.luau create mode 100644 src/cli/types.luau diff --git a/.luaurc b/.luaurc index c9777a1..a75fbe2 100644 --- a/.luaurc +++ b/.luaurc @@ -5,9 +5,9 @@ "lute": "~/.lute/typedefs/0.1.0/lute", "std": "~/.lute/typedefs/0.1.0/std", - "examples": "examples", - "pkg": "pkg", - "root": "src", - "scripts": "scripts" + "examples": "./examples", + "pkg": "./pkg", + "root": "./src", + "scripts": "./scripts" } } diff --git a/scripts/build.luau b/scripts/build.luau index 0b12d31..02c15a0 100644 --- a/scripts/build.luau +++ b/scripts/build.luau @@ -1,5 +1,6 @@ local fs = require("@std/fs") local path = require("@std/path") +local system = require("@std/system") local run = require("@root/lib/run") @@ -13,26 +14,51 @@ local INCLUDES = { "pkg/", } -local BUILD_PATH = "build" local ARTIFACT_NAME = "rbxasset" local ARCHIVE_NAME = "rbxasset.zip" -local ARTIFACT_PATH = path.join(BUILD_PATH, ARTIFACT_NAME) + +local BUILD_PATH = path.resolve("./build") +local PACKAGE_BUILD_PATH = path.join(BUILD_PATH, ARTIFACT_NAME) +local CLI_BUILD_PATH = path.join(PACKAGE_BUILD_PATH, "src/cli/init.luau") +local BINARY_BUILD_PATH = path.join(BUILD_PATH, "bin") + +local function compilePackage() + fs.createdirectory(PACKAGE_BUILD_PATH, { + makeparents = true, + }) + + for _, includeName in INCLUDES do + if fs.type(includeName) == "dir" then + run("cp", { "-R", includeName, tostring(path.join(PACKAGE_BUILD_PATH, includeName)) }) + else + run("cp", { includeName, tostring(PACKAGE_BUILD_PATH) }) + end + end +end + +local function compileBinary() + fs.createdirectory(BINARY_BUILD_PATH, { + makeparents = true, + }) + + local binaryName = if system.win32 then `{ARTIFACT_NAME}.exe` else ARTIFACT_NAME + + run("lute", { + "compile", + tostring(CLI_BUILD_PATH), + "--output", + tostring(path.join(BINARY_BUILD_PATH, binaryName)), + "--bundle-stats", + }) +end fs.removedirectory(BUILD_PATH, { recursive = true, }) -fs.createdirectory(ARTIFACT_PATH, { - makeparents = true, -}) -for _, includeName in INCLUDES do - if fs.type(includeName) == "dir" then - run("cp", { "-R", includeName, tostring(path.join(ARTIFACT_PATH, includeName)) }) - else - run("cp", { includeName, tostring(ARTIFACT_PATH) }) - end -end +compilePackage() +compileBinary() run("zip", { "-r", ARCHIVE_NAME, ARTIFACT_NAME }, { - cwd = BUILD_PATH, + cwd = tostring(BUILD_PATH), }) diff --git a/src/cli/init.luau b/src/cli/init.luau new file mode 100644 index 0000000..2225514 --- /dev/null +++ b/src/cli/init.luau @@ -0,0 +1,47 @@ +local path = require("@std/path") +local process = require("@std/process") + +local types = require("@self/types") + +local ARGS = { ... } + +local SUBCOMMANDS: { [string]: types.Subcommand } = {} + +local function getPossibleSubcommands() + local names = {} + for subcommandName in SUBCOMMANDS do + table.insert(names, subcommandName) + end + return table.concat(names, ", ") +end + +do + --[[ + Lute CLI battery doesn't support subcommands yet, so to work around that + each of our subcommands is a new Parser instance, and this top-level + interface just routes to each subcommand. + ]] + + local scriptPath = path.resolve(ARGS[1]) + local subcommandName = ARGS[2] + local remainingArgs = table.unpack(ARGS, 2, #ARGS) + local userArgs = { scriptPath, remainingArgs } + + local subcommand = rawget(SUBCOMMANDS, subcommandName) + + if subcommand then + subcommand.parser:parse(table.unpack(userArgs)) + subcommand.run() + else + local message = { + `No command named {subcommandName}`, + `Possible subcommands: {getPossibleSubcommands()}`, + "Usage:", + " rbxasset [...options]", + } + print(table.concat(message, "\n")) + + process.exit(1) + return + end +end diff --git a/src/cli/types.luau b/src/cli/types.luau new file mode 100644 index 0000000..0bf30e1 --- /dev/null +++ b/src/cli/types.luau @@ -0,0 +1,10 @@ +local cli = require("@pkg/cli") + +export type Subcommand = { + parser: cli.Parser, + run: () -> (), + + help: string?, +} + +return nil From b3a751e20584fae7ea1fe295c86869d5a21b234f Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 2 Mar 2026 14:19:19 -0800 Subject: [PATCH 3/5] Add `check` command to validate the manifest --- src/cli/check.luau | 47 +++++++++++++++++++++++++++++++++++ src/cli/init.luau | 61 +++++++++++++++++++++++----------------------- 2 files changed, 77 insertions(+), 31 deletions(-) create mode 100644 src/cli/check.luau diff --git a/src/cli/check.luau b/src/cli/check.luau new file mode 100644 index 0000000..639eba0 --- /dev/null +++ b/src/cli/check.luau @@ -0,0 +1,47 @@ +local cli = require("@pkg/cli") +local fs = require("@std/fs") +local path = require("@std/path") +local process = require("@std/process") + +local constants = require("@root/constants") +local readManifest = require("@root/manifest/readManifest") +local types = require("./types") + +local ASSET_MANIFEST_FILENAME = constants.ASSET_MANIFEST_FILENAME + +local args = cli.parser() + +args:add("project-path", "option", { + help = `Path to a directory containing an {ASSET_MANIFEST_FILENAME} file`, + aliases = { "p" }, +}) + +local command: types.Subcommand = { + parser = args, + run = function() + local userProjectPathPath = args:get("project-path") + + local projectPath = if userProjectPathPath then path.resolve(userProjectPathPath) else process.cwd() + local manifestPath = path.join(projectPath, ASSET_MANIFEST_FILENAME) + + if not fs.exists(projectPath) then + print(`failed to find the project at {projectPath}. does the directory exist?`) + process.exit(1) + return + end + + local success, err = pcall(function() + readManifest(projectPath) + return nil + end) + + if not success then + print(err) + process.exit(1) + end + + print(`manifest at {manifestPath} is valid`) + end, +} + +return command diff --git a/src/cli/init.luau b/src/cli/init.luau index 2225514..ef286ff 100644 --- a/src/cli/init.luau +++ b/src/cli/init.luau @@ -1,11 +1,13 @@ -local path = require("@std/path") local process = require("@std/process") +local check = require("@self/check") local types = require("@self/types") local ARGS = { ... } -local SUBCOMMANDS: { [string]: types.Subcommand } = {} +local SUBCOMMANDS: { [string]: types.Subcommand } = { + check = check, +} local function getPossibleSubcommands() local names = {} @@ -15,33 +17,30 @@ local function getPossibleSubcommands() return table.concat(names, ", ") end -do - --[[ - Lute CLI battery doesn't support subcommands yet, so to work around that - each of our subcommands is a new Parser instance, and this top-level - interface just routes to each subcommand. - ]] - - local scriptPath = path.resolve(ARGS[1]) - local subcommandName = ARGS[2] - local remainingArgs = table.unpack(ARGS, 2, #ARGS) - local userArgs = { scriptPath, remainingArgs } - - local subcommand = rawget(SUBCOMMANDS, subcommandName) - - if subcommand then - subcommand.parser:parse(table.unpack(userArgs)) - subcommand.run() - else - local message = { - `No command named {subcommandName}`, - `Possible subcommands: {getPossibleSubcommands()}`, - "Usage:", - " rbxasset [...options]", - } - print(table.concat(message, "\n")) - - process.exit(1) - return - end +--[[ + Lute CLI battery doesn't support subcommands yet, so to work around that + each of our subcommands is a new Parser instance, and this top-level + interface just routes to each subcommand. +]] + +local scriptPath = ARGS[1] +local subcommandName = ARGS[2] +local userArgs = { scriptPath, table.unpack(ARGS, 2, #ARGS) } + +local subcommand = rawget(SUBCOMMANDS, subcommandName) + +if subcommand then + subcommand.parser:parse(userArgs) + subcommand.run() +else + local message = { + `No command named {subcommandName}`, + `Possible subcommands: {getPossibleSubcommands()}`, + "Usage:", + " rbxasset [...options]", + } + print(table.concat(message, "\n")) + + process.exit(1) + return end From be60d6b7efc785d5b95bc53997f30a1d31220c21 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 2 Mar 2026 15:50:20 -0800 Subject: [PATCH 4/5] Add `publish` command --- src/cli/{ => commands}/check.luau | 7 ++--- src/cli/commands/publish.luau | 41 +++++++++++++++++++++++++++ src/cli/init.luau | 6 ++-- src/cli/parsers/parseApiKey.luau | 22 ++++++++++++++ src/cli/parsers/parseProjectPath.luau | 22 ++++++++++++++ 5 files changed, 92 insertions(+), 6 deletions(-) rename src/cli/{ => commands}/check.luau (83%) create mode 100644 src/cli/commands/publish.luau create mode 100644 src/cli/parsers/parseApiKey.luau create mode 100644 src/cli/parsers/parseProjectPath.luau diff --git a/src/cli/check.luau b/src/cli/commands/check.luau similarity index 83% rename from src/cli/check.luau rename to src/cli/commands/check.luau index 639eba0..f18905d 100644 --- a/src/cli/check.luau +++ b/src/cli/commands/check.luau @@ -4,8 +4,9 @@ local path = require("@std/path") local process = require("@std/process") local constants = require("@root/constants") +local parseProjectPath = require("@root/cli/parsers/parseProjectPath") local readManifest = require("@root/manifest/readManifest") -local types = require("./types") +local types = require("@root/cli/types") local ASSET_MANIFEST_FILENAME = constants.ASSET_MANIFEST_FILENAME @@ -19,9 +20,7 @@ args:add("project-path", "option", { local command: types.Subcommand = { parser = args, run = function() - local userProjectPathPath = args:get("project-path") - - local projectPath = if userProjectPathPath then path.resolve(userProjectPathPath) else process.cwd() + local projectPath = parseProjectPath(args, "project-path") local manifestPath = path.join(projectPath, ASSET_MANIFEST_FILENAME) if not fs.exists(projectPath) then diff --git a/src/cli/commands/publish.luau b/src/cli/commands/publish.luau new file mode 100644 index 0000000..14a293a --- /dev/null +++ b/src/cli/commands/publish.luau @@ -0,0 +1,41 @@ +local cli = require("@pkg/cli") + +local parseApiKey = require("@root/cli/parsers/parseProjectPath") +local parseProjectPath = require("@root/cli/parsers/parseProjectPath") +local publishPackageAsync = require("@root/requests/publishPackageAsync") +local publishWorkspaceAsync = require("@root/requests/publishWorkspaceAsync") +local types = require("@root/cli/types") + +local args = cli.parser() + +args:add("asset", "option", { + help = "Name of one of the assets defined in rbxasset.toml that will be published", + aliases = { "a" }, +}) +args:add("project-path", "option", { + help = "Path to a directory containing an rbxasset.toml manifest", + aliases = { "c" }, +}) +args:add("api-key", "option", { + help = "Open Cloud API key with `asset:read` and `asset:write` scopes", + aliases = { "k" }, +}) + +local command: types.Subcommand = { + parser = args, + run = function() + local projectPath = parseProjectPath(args, "project-path") + local apiKey = parseApiKey(args, "api-key") + local assetName = args:get("asset") + + print(`using project at {projectPath}`) + + if assetName then + publishPackageAsync(projectPath, assetName, apiKey) + else + publishWorkspaceAsync(projectPath, apiKey) + end + end, +} + +return command diff --git a/src/cli/init.luau b/src/cli/init.luau index ef286ff..3d0228f 100644 --- a/src/cli/init.luau +++ b/src/cli/init.luau @@ -1,12 +1,14 @@ local process = require("@std/process") -local check = require("@self/check") +local check = require("@self/commands/check") +local publish = require("@self/commands/publish") local types = require("@self/types") local ARGS = { ... } local SUBCOMMANDS: { [string]: types.Subcommand } = { check = check, + publish = publish, } local function getPossibleSubcommands() @@ -18,7 +20,7 @@ local function getPossibleSubcommands() end --[[ - Lute CLI battery doesn't support subcommands yet, so to work around that + Lute's CLI battery doesn't support subcommands yet, so to work around that each of our subcommands is a new Parser instance, and this top-level interface just routes to each subcommand. ]] diff --git a/src/cli/parsers/parseApiKey.luau b/src/cli/parsers/parseApiKey.luau new file mode 100644 index 0000000..365d3d0 --- /dev/null +++ b/src/cli/parsers/parseApiKey.luau @@ -0,0 +1,22 @@ +local cli = require("@pkg/cli") +local process = require("@std/process") + +local function parseApiKey(parser: cli.Parser, flagName: string) + local apiKey = parser:get(flagName) + + if apiKey and apiKey ~= "" then + return apiKey + else + local apiKeyFromEnv = process.env.ROBLOX_API_KEY + + if apiKeyFromEnv and apiKeyFromEnv ~= "" then + return apiKeyFromEnv + end + end + + error( + `failed to parse Open Cloud API key. Pass the --{flagName} flag or set the ROBLOX_API_KEY environment variable` + ) +end + +return parseApiKey diff --git a/src/cli/parsers/parseProjectPath.luau b/src/cli/parsers/parseProjectPath.luau new file mode 100644 index 0000000..e0b5c20 --- /dev/null +++ b/src/cli/parsers/parseProjectPath.luau @@ -0,0 +1,22 @@ +local cli = require("@pkg/cli") +local fs = require("@std/fs") +local path = require("@std/path") +local process = require("@std/process") + +local function parseProjectPath(parser: cli.Parser, flagName: string): path.path + local userProjectPath = parser:get(flagName) + + if userProjectPath then + local projectPath = path.resolve(userProjectPath) + + if fs.exists(projectPath) then + return projectPath + else + error(`failed to find the project at {projectPath}. does the directory exist?`) + end + else + return process.cwd() + end +end + +return parseProjectPath From 9bf52b1ad017d7c30aa2f99ef99f90e338974119 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Thu, 5 Mar 2026 12:22:39 -0800 Subject: [PATCH 5/5] Fix analysis --- src/cli/init.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/init.luau b/src/cli/init.luau index 3d0228f..3c80fb6 100644 --- a/src/cli/init.luau +++ b/src/cli/init.luau @@ -12,7 +12,7 @@ local SUBCOMMANDS: { [string]: types.Subcommand } = { } local function getPossibleSubcommands() - local names = {} + local names: { string } = {} for subcommandName in SUBCOMMANDS do table.insert(names, subcommandName) end