From b9c15be3d5403508a2f1b5ca506fd52467eb505c Mon Sep 17 00:00:00 2001 From: Infuseting Date: Thu, 15 Oct 2026 19:52:32 +0200 Subject: [PATCH] feat(deploy): replace mantle with bedrock and local state --- .env.example | 2 + .gitignore | 1 + .prettierignore | 1 + bedrock.config.example.json | 27 +++ eslint.config.mjs | 2 +- package.json | 7 +- pnpm-lock.yaml | 377 +++++++++++++++++++++++++++--- readme.md | 23 +- rokit.toml | 1 - scripts/bedrock-local-state.mjs | 204 ++++++++++++++++ scripts/setup.ps1 | 2 + scripts/setup.sh | 3 +- test/bedrock-local-state.test.mjs | 193 +++++++++++++++ 13 files changed, 808 insertions(+), 35 deletions(-) create mode 100644 .env.example create mode 100644 bedrock.config.example.json create mode 100644 scripts/bedrock-local-state.mjs create mode 100644 test/bedrock-local-state.test.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2543ef4 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +ASPHALT_API_KEY= +BEDROCK_API_KEY= diff --git a/.gitignore b/.gitignore index 356e64b..9d1993c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ game.rbxl .env .env.* !.env.example +.bedrock/state/ diff --git a/.prettierignore b/.prettierignore index 7091420..ba9bf5c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ assets/**/*.gltf +.bedrock/state/ diff --git a/bedrock.config.example.json b/bedrock.config.example.json new file mode 100644 index 0000000..1181ff0 --- /dev/null +++ b/bedrock.config.example.json @@ -0,0 +1,27 @@ +{ + "codegen": { + "enabled": false + }, + "environments": { + "production": { + "universe": { + "universeId": "REPLACE_WITH_UNIVERSE_ID" + }, + "places": { + "start": { + "placeId": "REPLACE_WITH_PLACE_ID" + } + } + } + }, + "places": { + "start": { + "filePath": "game.rbxl" + } + }, + "state": { + "backend": "local", + "directory": ".bedrock/state" + }, + "plugins": ["./scripts/bedrock-local-state.mjs"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index af2d8a0..33e555e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import prettier from "eslint-config-prettier"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["node_modules/**", "out/**", "include/**", "build.rbxlx"] }, + { ignores: ["node_modules/**", "out/**", "include/**", "build.rbxlx", ".bedrock/state/**"] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/package.json b/package.json index 9aeb349..949c0d0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "packageManager": "pnpm@11.21.0", "engines": { - "node": ">=24" + "node": ">=24.12.0" }, "scripts": { "compile": "rbxtsc", @@ -15,6 +15,9 @@ "open": "rbxts-build open", "stop": "rbxts-build stop", "serve": "rojo serve default.project.json", + "bedrock": "node --env-file-if-exists=.env node_modules/@bedrock-rbx/core/dist/cli/run.mjs", + "deploy:diff": "pnpm bedrock diff --env production", + "deploy": "pnpm build && pnpm bedrock deploy --env production", "lint": "eslint .", "format:check": "prettier --check .", "format": "prettier --write .", @@ -58,9 +61,11 @@ "@rbxts/ui-labs": "2.4.2" }, "devDependencies": { + "@bedrock-rbx/core": "0.3.4", "@eslint/js": "10.0.1", "@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/types": "1.0.957", + "arktype": "2.2.0", "eslint": "10.12.0", "eslint-config-prettier": "10.1.8", "husky": "9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f9b96c..31b0753 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,21 +74,27 @@ importers: specifier: 2.4.2 version: 2.4.2 devDependencies: + "@bedrock-rbx/core": + specifier: 0.3.4 + version: 0.3.4 "@eslint/js": specifier: 10.0.1 - version: 10.0.1(eslint@10.12.0(supports-color@7.2.0)) + version: 10.0.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) "@rbxts/compiler-types": specifier: 3.0.0-types.0 version: 3.0.0-types.0 "@rbxts/types": specifier: 1.0.957 version: 1.0.957 + arktype: + specifier: 2.2.0 + version: 2.2.0 eslint: specifier: 10.12.0 - version: 10.12.0(supports-color@7.2.0) + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: 10.1.8 - version: 10.1.8(eslint@10.12.0(supports-color@7.2.0)) + version: 10.1.8(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) husky: specifier: 9.1.7 version: 9.1.7 @@ -109,9 +115,36 @@ importers: version: 5.5.3 typescript-eslint: specifier: 8.71.2 - version: 8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + version: 8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) packages: + "@ark/schema@0.56.0": + resolution: + { + integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==, + } + + "@ark/util@0.56.0": + resolution: + { + integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==, + } + + "@bedrock-rbx/core@0.3.4": + resolution: + { + integrity: sha512-itlF1cSIvAZXbrFjxZOj1kT31D32ue79/Oz3aAcZdOcNVWHNEQ1seOl1WLyQcfVx2IYzwxnYRMfXB8OH93bieQ==, + } + engines: { bun: ">=1.3.0", node: ">=24.12.0" } + hasBin: true + + "@bedrock-rbx/ocale@0.3.4": + resolution: + { + integrity: sha512-tbhbx7K4fXSC/v8ByWL9oCdHq3WRT1zXuTNs/3OvkXNZfe0t1qYFns/A9ZELdv96JXsGgCBRqcpzSEZzrM3sVQ==, + } + engines: { bun: ">=1.3.0", node: ">=24.12.0" } + "@cacheable/memory@2.2.0": resolution: { @@ -124,6 +157,18 @@ packages: integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==, } + "@clack/core@1.2.0": + resolution: + { + integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==, + } + + "@clack/prompts@1.2.0": + resolution: + { + integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==, + } + "@eslint-community/eslint-utils@4.10.1": resolution: { @@ -757,6 +802,18 @@ packages: } engines: { node: ">= 8" } + arkregex@0.0.5: + resolution: + { + integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==, + } + + arktype@2.2.0: + resolution: + { + integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==, + } + balanced-match@1.0.2: resolution: { @@ -797,6 +854,17 @@ packages: } engines: { node: ">=8" } + c12@3.3.4: + resolution: + { + integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==, + } + peerDependencies: + magicast: "*" + peerDependenciesMeta: + magicast: + optional: true + cacheable@2.5.0: resolution: { @@ -817,6 +885,13 @@ packages: } engines: { node: ">= 8.10.0" } + chokidar@5.0.0: + resolution: + { + integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==, + } + engines: { node: ">= 20.19.0" } + cliui@8.0.1: resolution: { @@ -837,6 +912,18 @@ packages: integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, } + confbox@0.2.4: + resolution: + { + integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==, + } + + confbox@0.3.1: + resolution: + { + integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==, + } + cross-spawn@7.0.6: resolution: { @@ -862,6 +949,25 @@ packages: integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, } + defu@6.1.7: + resolution: + { + integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==, + } + + destr@2.0.5: + resolution: + { + integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==, + } + + dotenv@17.4.2: + resolution: + { + integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==, + } + engines: { node: ">=12" } + emoji-regex@8.0.0: resolution: { @@ -967,6 +1073,12 @@ packages: } engines: { node: ">=0.10.0" } + exsolve@1.1.3: + resolution: + { + integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==, + } + fast-deep-equal@3.1.3: resolution: { @@ -985,12 +1097,30 @@ packages: integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, } + fast-string-truncated-width@1.2.1: + resolution: + { + integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==, + } + + fast-string-width@1.1.0: + resolution: + { + integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==, + } + fast-uri@3.1.8: resolution: { integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==, } + fast-wrap-ansi@0.1.6: + resolution: + { + integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==, + } + fdir@6.5.0: resolution: { @@ -1076,6 +1206,13 @@ packages: } engines: { node: 6.* || 8.* || >= 10.* } + giget@3.3.1: + resolution: + { + integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==, + } + hasBin: true + glob-parent@5.1.2: resolution: { @@ -1227,6 +1364,13 @@ packages: integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, } + jiti@2.7.0: + resolution: + { + integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==, + } + hasBin: true + json-schema-traverse@0.4.1: resolution: { @@ -1319,6 +1463,13 @@ packages: } engines: { node: ">=16 || 14 >=14.17" } + mri@1.2.0: + resolution: + { + integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, + } + engines: { node: ">=4" } + ms@2.1.3: resolution: { @@ -1345,6 +1496,12 @@ packages: } engines: { node: ">=0.10.0" } + ohash@2.0.12: + resolution: + { + integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==, + } + optionator@0.9.4: resolution: { @@ -1393,6 +1550,18 @@ packages: } engines: { node: ">=16 || 14 >=14.18" } + pathe@2.0.3: + resolution: + { + integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, + } + + perfect-debounce@2.1.0: + resolution: + { + integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==, + } + picomatch@2.3.2: resolution: { @@ -1407,6 +1576,12 @@ packages: } engines: { node: ">=12" } + pkg-types@2.3.3: + resolution: + { + integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==, + } + prelude-ls@1.2.1: resolution: { @@ -1451,6 +1626,12 @@ packages: peerDependencies: typescript: "*" + rc9@3.1.0: + resolution: + { + integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==, + } + readdirp@3.6.0: resolution: { @@ -1458,6 +1639,13 @@ packages: } engines: { node: ">=8.10.0" } + readdirp@5.1.1: + resolution: + { + integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==, + } + engines: { node: ">= 20.19.0" } + require-directory@2.1.1: resolution: { @@ -1487,6 +1675,13 @@ packages: } hasBin: true + sade@1.8.1: + resolution: + { + integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==, + } + engines: { node: ">=6" } + semver@7.8.5: resolution: { @@ -1509,6 +1704,12 @@ packages: } engines: { node: ">=8" } + sisteransi@1.0.5: + resolution: + { + integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==, + } + spdx-correct@3.2.0: resolution: { @@ -1699,6 +1900,26 @@ packages: } snapshots: + "@ark/schema@0.56.0": + dependencies: + "@ark/util": 0.56.0 + + "@ark/util@0.56.0": {} + + "@bedrock-rbx/core@0.3.4": + dependencies: + "@bedrock-rbx/ocale": 0.3.4 + "@clack/prompts": 1.2.0 + arktype: 2.2.0 + c12: 3.3.4 + confbox: 0.2.4 + defu: 6.1.7 + sade: 1.8.1 + transitivePeerDependencies: + - magicast + + "@bedrock-rbx/ocale@0.3.4": {} + "@cacheable/memory@2.2.0": dependencies: "@cacheable/utils": 2.5.0 @@ -1711,9 +1932,21 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - "@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(supports-color@7.2.0))": + "@clack/core@1.2.0": dependencies: - eslint: 10.12.0(supports-color@7.2.0) + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + "@clack/prompts@1.2.0": + dependencies: + "@clack/core": 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + "@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))": + dependencies: + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 "@eslint-community/regexpp@4.12.2": {} @@ -1734,9 +1967,9 @@ snapshots: dependencies: "@types/json-schema": 7.0.15 - "@eslint/js@10.0.1(eslint@10.12.0(supports-color@7.2.0))": + "@eslint/js@10.0.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))": optionalDependencies: - eslint: 10.12.0(supports-color@7.2.0) + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) "@eslint/object-schema@3.0.5": {} @@ -2005,15 +2238,15 @@ snapshots: "@types/json-schema@7.0.15": {} - "@typescript-eslint/eslint-plugin@8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": + "@typescript-eslint/eslint-plugin@8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: "@eslint-community/regexpp": 4.12.2 - "@typescript-eslint/parser": 8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/parser": 8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/scope-manager": 8.71.2 - "@typescript-eslint/type-utils": 8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) - "@typescript-eslint/utils": 8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/type-utils": 8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/utils": 8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/visitor-keys": 8.71.2 - eslint: 10.12.0(supports-color@7.2.0) + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.5.3) @@ -2021,14 +2254,14 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/parser@8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": + "@typescript-eslint/parser@8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: "@typescript-eslint/scope-manager": 8.71.2 "@typescript-eslint/types": 8.71.2 "@typescript-eslint/typescript-estree": 8.71.2(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/visitor-keys": 8.71.2 debug: 4.4.3(supports-color@7.2.0) - eslint: 10.12.0(supports-color@7.2.0) + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 5.5.3 transitivePeerDependencies: - supports-color @@ -2051,13 +2284,13 @@ snapshots: dependencies: typescript: 5.5.3 - "@typescript-eslint/type-utils@8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": + "@typescript-eslint/type-utils@8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: "@typescript-eslint/types": 8.71.2 "@typescript-eslint/typescript-estree": 8.71.2(supports-color@7.2.0)(typescript@5.5.3) - "@typescript-eslint/utils": 8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/utils": 8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) debug: 4.4.3(supports-color@7.2.0) - eslint: 10.12.0(supports-color@7.2.0) + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@5.5.3) typescript: 5.5.3 transitivePeerDependencies: @@ -2080,13 +2313,13 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/utils@8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": + "@typescript-eslint/utils@8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: - "@eslint-community/eslint-utils": 4.10.1(eslint@10.12.0(supports-color@7.2.0)) + "@eslint-community/eslint-utils": 4.10.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) "@typescript-eslint/scope-manager": 8.71.2 "@typescript-eslint/types": 8.71.2 "@typescript-eslint/typescript-estree": 8.71.2(supports-color@7.2.0)(typescript@5.5.3) - eslint: 10.12.0(supports-color@7.2.0) + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 5.5.3 transitivePeerDependencies: - supports-color @@ -2127,6 +2360,16 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + arkregex@0.0.5: + dependencies: + "@ark/util": 0.56.0 + + arktype@2.2.0: + dependencies: + "@ark/schema": 0.56.0 + "@ark/util": 0.56.0 + arkregex: 0.0.5 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2145,6 +2388,21 @@ snapshots: dependencies: fill-range: 7.1.1 + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.3 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.3 + rc9: 3.1.0 + cacheable@2.5.0: dependencies: "@cacheable/memory": 2.2.0 @@ -2170,6 +2428,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2182,6 +2444,10 @@ snapshots: color-name@1.1.4: {} + confbox@0.2.4: {} + + confbox@0.3.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2196,6 +2462,12 @@ snapshots: deep-is@0.1.4: {} + defu@6.1.7: {} + + destr@2.0.5: {} + + dotenv@17.4.2: {} + emoji-regex@8.0.0: {} es-errors@1.3.0: {} @@ -2204,9 +2476,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.12.0(supports-color@7.2.0)): + eslint-config-prettier@10.1.8(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.12.0(supports-color@7.2.0) + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) eslint-scope@9.1.2: dependencies: @@ -2219,9 +2491,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.12.0(supports-color@7.2.0): + eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0): dependencies: - "@eslint-community/eslint-utils": 4.10.1(eslint@10.12.0(supports-color@7.2.0)) + "@eslint-community/eslint-utils": 4.10.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) "@eslint-community/regexpp": 4.12.2 "@eslint/config-array": 0.23.5(supports-color@7.2.0) "@eslint/config-helpers": 0.7.0 @@ -2251,6 +2523,8 @@ snapshots: minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -2272,14 +2546,26 @@ snapshots: esutils@2.0.3: {} + exsolve@1.1.3: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-string-truncated-width@1.2.1: {} + + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + fast-uri@3.1.8: {} + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -2326,6 +2612,8 @@ snapshots: get-caller-file@2.0.5: {} + giget@3.3.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2391,6 +2679,8 @@ snapshots: isexe@2.0.0: {} + jiti@2.7.0: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -2436,6 +2726,8 @@ snapshots: minipass@7.1.3: {} + mri@1.2.0: {} + ms@2.1.3: {} natural-compare@1.4.0: {} @@ -2449,6 +2741,8 @@ snapshots: normalize-path@3.0.0: {} + ohash@2.0.12: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2477,10 +2771,20 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + picomatch@2.3.2: {} picomatch@4.0.7: {} + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.3 + pathe: 2.0.3 + prelude-ls@1.2.1: {} prettier@3.9.10: {} @@ -2509,10 +2813,17 @@ snapshots: typescript: 5.5.3 uuid: 8.3.2 + rc9@3.1.0: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 + readdirp@5.1.1: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -2536,6 +2847,10 @@ snapshots: typescript: 5.5.3 yargs: 17.7.3 + sade@1.8.1: + dependencies: + mri: 1.2.0 + semver@7.8.5: {} shebang-command@2.0.0: @@ -2544,6 +2859,8 @@ snapshots: shebang-regex@3.0.0: {} + sisteransi@1.0.5: {} + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -2591,13 +2908,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3): + typescript-eslint@8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3): dependencies: - "@typescript-eslint/eslint-plugin": 8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) - "@typescript-eslint/parser": 8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/eslint-plugin": 8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/parser": 8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/typescript-estree": 8.71.2(supports-color@7.2.0)(typescript@5.5.3) - "@typescript-eslint/utils": 8.71.2(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) - eslint: 10.12.0(supports-color@7.2.0) + "@typescript-eslint/utils": 8.71.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 5.5.3 transitivePeerDependencies: - supports-color diff --git a/readme.md b/readme.md index 498ad34..738a155 100644 --- a/readme.md +++ b/readme.md @@ -25,6 +25,7 @@ $env:Path = "$($paths -join ';');$env:Path" pnpm --version rokit --version ``` + Le [script d'installation](scripts/setup.ps1) nécessite WinGet (App Installer du Microsoft Store). Il installe Git, [mise](https://mise.jdx.dev/installing-mise.html), Rokit, Roblox Studio et Blender via WinGet, puis les versions définies dans `mise.toml` et `rokit.toml`, et les dépendances pnpm verrouillées. Il termine par `pnpm check` et `pnpm build` et s'arrête dès qu'une commande échoue. Windows peut demander une élévation pour certains installateurs. Une relance conserve les applications déjà installées ; elle ne les met pas à jour automatiquement. Les applications WinGet utilisent la version disponible dans son catalogue, les outils du projet restent épinglés. ### Configuration complète dans Ubuntu WSL2 @@ -85,12 +86,32 @@ Pour choisir une approche lors d'une nouvelle fonctionnalité, le skill local [` - [rbxts-build](https://github.com/osyrisrblx/rbxts-build#readme) : commandes `pnpm start`, `pnpm watch`, `pnpm open`, `pnpm stop` et construction `game.rbxl`. - [Asphalt](https://github.com/jackTabsCode/asphalt#readme) : binaire épinglé avec Rokit ; aucun asset à synchroniser dans ce template vierge. - [darklua](https://darklua.com/docs/) : binaire épinglé avec Rokit ; aucune transformation de Lua requise pour le démarrage. -- [Mantle](https://mantledeploy.vercel.app/) : binaire épinglé avec Rokit ; le déploiement exige une expérience Roblox et des identifiants propres au projet. Son [dépôt](https://github.com/blake-mealey/mantle#readme) indique qu'il n'est plus maintenu. +- [Bedrock](https://github.com/christopher-buss/bedrock#readme) : CLI `@bedrock-rbx/core@0.3.4` épinglée avec pnpm ; les setups Windows et WSL l’installent et vérifient son démarrage. Voir la configuration de déploiement ci-dessous. - [mise](https://mise.jdx.dev/) et [pnpm](https://pnpm.io/) : versions du runtime et gestion reproductible des dépendances. - [ESLint](https://eslint.org/docs/latest/) : configuration flat dans [`eslint.config.mjs`](eslint.config.mjs), avec [typescript-eslint](https://typescript-eslint.io/) et [Prettier](https://prettier.io/docs/). - [Husky](https://typicode.github.io/husky/) : vérification Git locale. - [Renovate](https://docs.renovatebot.com/) : règles de mise à jour des dépendances ; l'activation du bot sur l'hébergeur reste à faire. +## Déploiement avec Bedrock + +Bedrock est installé par pnpm, avec Node.js 24.12 minimum (la version de `mise.toml` convient). Après une mise à jour du template, lancer `rokit install` puis `pnpm install --frozen-lockfile` et `pnpm bedrock --version`. Les scripts de setup réalisent aussi ces étapes. La version 0.3.4 est antérieure à la version 1.0 ; sa configuration peut évoluer lors des mises à jour. Voir le [README du paquet](https://github.com/christopher-buss/bedrock/tree/v0.3.4/packages/bedrock#readme). + +1. Créer ou choisir l’expérience Roblox cible. +2. Copier [`bedrock.config.example.json`](bedrock.config.example.json) vers `bedrock.config.json`, puis remplacer `REPLACE_WITH_UNIVERSE_ID` et `REPLACE_WITH_PLACE_ID`. Le fichier publie `game.rbxl` dans l’environnement `production`. La génération de code est désactivée pour conserver le build roblox-ts existant. +3. Copier [`.env.example`](.env.example) vers `.env` et renseigner `BEDROCK_API_KEY` (clé Roblox Open Cloud autorisée sur l’expérience cible). Adapter les permissions Open Cloud aux ressources gérées selon la [documentation Bedrock](https://github.com/christopher-buss/bedrock#readme). Les commandes ci-dessous chargent `.env` automatiquement ; les variables déjà exportées dans le terminal ont priorité. Ne jamais commiter les jetons. + +```bash +pnpm build +pnpm deploy:diff +pnpm deploy +``` + +Le [plugin local](scripts/bedrock-local-state.mjs) utilise le [contrat officiel de Bedrock 0.3.4](https://github.com/christopher-buss/bedrock/blob/v0.3.4/apps/website/docs/bedrock/guide/plugins.md). Il conserve l’état dans `.bedrock/state/production.json` (chemin relatif à la racine du dépôt), sans Gist, compte de stockage supplémentaire ou jeton GitHub. Ces fichiers sont ignorés par Git, ESLint et Prettier. Sauvegarder ce dossier après un déploiement et le conserver lors d’un changement de machine : il relie les ressources du projet aux IDs Roblox. Un état corrompu provoque une erreur et ne devient jamais un état vide. + +Un verrou local empêche deux déploiements simultanés sur le même environnement. Si un processus interrompu laisse un verrou, arrêter tous les déploiements concernés avant de lancer `pnpm bedrock state unlock --env production`. Ne pas partager ce dossier entre plusieurs machines ni le synchroniser pendant un déploiement. La publication contacte toujours Roblox et nécessite `BEDROCK_API_KEY`. + +`pnpm deploy:diff` prévisualise les opérations sans les appliquer ; `game.rbxl` doit déjà exister. `pnpm deploy` reconstruit le jeu puis applique les changements à l’expérience configurée. Le setup ne déploie rien. La configuration réelle `bedrock.config.json` peut être versionnée : les IDs ne sont pas des secrets. Aucun état de déploiement antérieur n’est présent dans ce template. + ## MCP : Codex, Claude, Cursor et Antigravity Les deux serveurs sont définis dans les fichiers de projet suivants. Chaque client lit son propre fichier, et les processus sont lancés à la demande : diff --git a/rokit.toml b/rokit.toml index cc646f4..b6907a3 100644 --- a/rokit.toml +++ b/rokit.toml @@ -7,4 +7,3 @@ rojo = "rojo-rbx/rojo@7.7.1" asphalt = "jackTabsCode/asphalt@2.0.2" darklua = "seaofvoices/darklua@0.19.0" -mantle = "blake-mealey/mantle@0.11.18" diff --git a/scripts/bedrock-local-state.mjs b/scripts/bedrock-local-state.mjs new file mode 100644 index 0000000..1a56d3e --- /dev/null +++ b/scripts/bedrock-local-state.mjs @@ -0,0 +1,204 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { hostname } from "node:os"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { parseStateContents, serializeStateFile, validateEnvironmentName } from "@bedrock-rbx/core"; +import { type } from "arktype"; + +const ok = (data) => ({ success: true, data }); +const fail = (reason) => ({ success: false, err: { reason } }); + +function environmentFile(directory, environment) { + const valid = validateEnvironmentName(environment); + if (!valid.success) throw new Error(valid.err.reason); + if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(environment)) { + throw new Error("Environment name is reserved on Windows."); + } + return join(directory, `${environment}.json`); +} + +function stateFailure(file, error) { + return { + success: false, + err: { + kind: error.code === "EACCES" || error.code === "EPERM" ? "stateAccessDenied" : "stateError", + file, + reason: error.message, + }, + }; +} + +/** Create a filesystem adapter without creating files during read-only commands. */ +export function createLocalStatePorts(directory) { + directory = resolve(directory); + const lockPath = (environment) => `${environmentFile(directory, environment)}.lock`; + + const statePort = { + async read(environment) { + let file = directory; + try { + file = environmentFile(directory, environment); + let raw; + try { + raw = readFileSync(file, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return ok({}); + throw error; + } + const parsed = parseStateContents(raw, file); + if (!parsed.success) return parsed; + if (parsed.data.environment !== environment) { + throw new Error(`State belongs to "${parsed.data.environment}", not "${environment}".`); + } + return ok({ state: parsed.data }); + } catch (error) { + return stateFailure(file, error); + } + }, + + async write(state) { + let file = directory; + let temporary; + try { + file = environmentFile(directory, state.environment); + const raw = serializeStateFile(state); + const parsed = parseStateContents(raw, file); + if (!parsed.success) return parsed; + mkdirSync(directory, { recursive: true }); + temporary = `${file}.${randomUUID()}.tmp`; + const handle = openSync(temporary, "wx", 0o600); + try { + writeFileSync(handle, `${raw}\n`, "utf8"); + fsyncSync(handle); + } finally { + closeSync(handle); + } + // Rename on the same filesystem keeps readers from seeing partial JSON. + renameSync(temporary, file); + return ok(undefined); + } catch (error) { + return stateFailure(file, error); + } finally { + if (temporary) { + try { + rmSync(temporary, { force: true }); + } catch { + // Keep the original failure if the filesystem also refuses cleanup. + } + } + } + }, + }; + + const stateLockPort = { + async acquire(environment, options = {}) { + let lock; + let created = false; + try { + lock = lockPath(environment); + mkdirSync(directory, { recursive: true }); + // Directory creation is exclusive across independent CLI processes. + mkdirSync(lock); + created = true; + const token = randomUUID(); + const holding = { + token, + owner: `${hostname()}:${process.pid}`, + since: new Date().toISOString(), + operation: options.operation, + }; + writeFileSync(join(lock, "owner.json"), JSON.stringify(holding), { mode: 0o600 }); + return ok({ + async release() { + try { + const current = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")); + if (current.token !== token) + return fail("The local state lock is held by another run."); + rmSync(lock, { recursive: true }); + return ok(undefined); + } catch (error) { + return fail(`Cannot release local state lock: ${error.message}`); + } + }, + }); + } catch (error) { + if (created) { + try { + rmSync(lock, { recursive: true, force: true }); + } catch { + return fail( + `Cannot initialize or remove local state lock at ${lock}: ${error.message}`, + ); + } + } + return fail( + error.code === "EEXIST" + ? `Local state is locked for "${environment}". Stop the other deploy before using bedrock state unlock --env ${environment}.` + : `Cannot acquire local state lock: ${error.message}`, + ); + } + }, + + async inspect(environment) { + try { + const lock = lockPath(environment); + try { + return ok(JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"))); + } catch (error) { + if (error.code === "ENOENT") { + return ok(existsSync(lock) ? { owner: "Unknown local run" } : undefined); + } + throw error; + } + } catch (error) { + return fail(`Cannot inspect local state lock: ${error.message}`); + } + }, + + async forceRelease(environment) { + try { + const lock = lockPath(environment); + if (!existsSync(lock)) return ok(undefined); + const holding = await stateLockPort.inspect(environment); + rmSync(lock, { recursive: true }); + return ok(holding.success ? holding.data : { owner: "Unknown local run" }); + } catch (error) { + return fail(`Cannot unlock local state: ${error.message}`); + } + }, + }; + + return { statePort, stateLockPort }; +} + +export default { + name: "crownstudio-local-state", + stateBackends: [ + { + name: "local", + schema: type({ directory: "string > 0" }), + createPort: ({ stateConfig }) => ok(createLocalStatePorts(stateConfig.directory).statePort), + createLockPort: ({ stateConfig }) => + ok(createLocalStatePorts(stateConfig.directory).stateLockPort), + migratePrompts: [ + { + key: "directory", + label: "Local state directory?", + placeholder: ".bedrock/state", + validationMessage: "A local state directory is required.", + }, + ], + }, + ], +}; diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 723392a..72e9082 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -54,8 +54,10 @@ try { Invoke-Tool 'mise' @('trust', 'mise.toml') Invoke-Tool 'mise' @('install') + Invoke-Tool 'rokit' @('trust', 'rojo-rbx/rojo', 'jackTabsCode/asphalt', 'seaofvoices/darklua') Invoke-Tool 'rokit' @('install') Invoke-Tool 'mise' @('exec', '--', 'pnpm', 'install', '--frozen-lockfile') + Invoke-Tool 'mise' @('exec', '--', 'pnpm', 'bedrock', '--version') if (-not $SkipChecks) { Invoke-Tool 'mise' @('exec', '--', 'pnpm', 'check') Invoke-Tool 'mise' @('exec', '--', 'pnpm', 'build') diff --git a/scripts/setup.sh b/scripts/setup.sh index f37d51a..649d2e2 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -91,7 +91,7 @@ if [[ ! -x "$rokit_bin" ]]; then (cd -- "$temporary_dir" && bash "${temporary_dir}/rokit-install.sh") fi [[ -x "$rokit_bin" ]] || fail "Rokit was not installed at ${rokit_bin}." -"$rokit_bin" trust rojo-rbx/rojo jackTabsCode/asphalt seaofvoices/darklua blake-mealey/mantle +"$rokit_bin" trust rojo-rbx/rojo jackTabsCode/asphalt seaofvoices/darklua "$rokit_bin" install if [[ ! -d "$blender_dir" ]]; then @@ -129,6 +129,7 @@ fi "$mise_bin" exec -- uv --version "$rokit_bin" --version "$mise_bin" exec -- pnpm install --frozen-lockfile +"$mise_bin" exec -- pnpm bedrock --version if [[ "$skip_checks" == false ]]; then "$mise_bin" exec -- pnpm check "$mise_bin" exec -- pnpm build diff --git a/test/bedrock-local-state.test.mjs b/test/bedrock-local-state.test.mjs new file mode 100644 index 0000000..3d7ab9a --- /dev/null +++ b/test/bedrock-local-state.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL, URL } from "node:url"; +import { buildStateBackend, loadProjectAsync } from "@bedrock-rbx/core"; +import { createLocalStatePorts } from "../scripts/bedrock-local-state.mjs"; + +const pluginUrl = new URL("../scripts/bedrock-local-state.mjs", import.meta.url); +const cli = fileURLToPath( + new URL("../node_modules/@bedrock-rbx/core/dist/cli/run.mjs", import.meta.url), +); +const example = JSON.parse( + readFileSync(new URL("../bedrock.config.example.json", import.meta.url)), +); +const state = (environment = "production") => ({ version: 1, environment, resources: [] }); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "bedrock-local-state-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const directory = join(root, "state"); + return { root, directory, ...createLocalStatePorts(directory) }; +} + +test("first reads are empty without creating state directories", async (t) => { + const { statePort, stateLockPort, directory } = fixture(t); + assert.deepEqual(await statePort.read("production"), { success: true, data: {} }); + assert.deepEqual(await stateLockPort.inspect("production"), { success: true, data: undefined }); + assert.equal(existsSync(directory), false); +}); + +test("state persists across adapter instances with Bedrock bookkeeping and environment isolation", async (t) => { + const { directory, statePort } = fixture(t); + const snapshot = { ...state(), pendingRebuild: new Set(["start"]), codegenHash: "a".repeat(64) }; + assert.equal((await statePort.write(snapshot)).success, true); + const fresh = createLocalStatePorts(directory).statePort; + assert.deepEqual((await fresh.read("production")).data.state, snapshot); + assert.equal((await fresh.write(state("staging"))).success, true); + assert.deepEqual((await fresh.read("production")).data.state, snapshot); + assert.deepEqual((await fresh.read("staging")).data.state, state("staging")); + assert.deepEqual(JSON.parse(readFileSync(join(directory, "production.json"))).$bedrock, { + version: 1, + pendingRebuild: ["start"], + codegenHash: "a".repeat(64), + }); + assert.deepEqual(readdirSync(directory).sort(), ["production.json", "staging.json"]); +}); + +test("atomic replacement leaves valid state and no temporary files", async (t) => { + const { directory, statePort } = fixture(t); + await statePort.write(state()); + for (let index = 0; index < 5; index++) { + assert.equal( + (await statePort.write({ ...state(), codegenHash: String(index).repeat(64) })).success, + true, + ); + assert.equal( + (await statePort.read("production")).data.state.codegenHash, + String(index).repeat(64), + ); + } + assert.deepEqual(readdirSync(directory), ["production.json"]); +}); + +test("corrupt, unsupported and mismatched state never becomes a first deployment", async (t) => { + const { statePort, directory } = fixture(t); + await statePort.write(state()); + const file = join(directory, "production.json"); + for (const raw of [ + "{broken", + JSON.stringify({ $bedrock: { version: 999 }, environment: "production", resources: [] }), + JSON.stringify({ $bedrock: { version: 1 }, environment: "staging", resources: [] }), + ]) { + writeFileSync(file, raw); + const result = await statePort.read("production"); + assert.equal(result.success, false); + assert.equal(result.err.kind, "stateError"); + assert.equal(readFileSync(file, "utf8"), raw); + } +}); + +test("invalid state cannot replace the last valid snapshot", async (t) => { + const { statePort, directory } = fixture(t); + await statePort.write(state()); + const before = readFileSync(join(directory, "production.json"), "utf8"); + assert.equal((await statePort.write({ ...state(), version: 999 })).success, false); + assert.equal(readFileSync(join(directory, "production.json"), "utf8"), before); +}); + +test("unsafe environment names cannot escape the state directory", async (t) => { + const { statePort, stateLockPort, directory } = fixture(t); + for (const environment of ["../outside", "..", "a/b", "a\\b", "CON", ""]) { + assert.equal((await statePort.read(environment)).success, false); + assert.equal((await statePort.write(state(environment))).success, false); + assert.equal((await stateLockPort.acquire(environment)).success, false); + } + assert.equal(existsSync(directory), false); +}); + +test("deployment locks exclude independent Node processes and release normally", async (t) => { + const { directory, stateLockPort } = fixture(t); + const first = await stateLockPort.acquire("production", { operation: "deploy" }); + assert.equal(first.success, true); + assert.equal((await stateLockPort.inspect("production")).data.operation, "deploy"); + const probe = () => + spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import { createLocalStatePorts } from ${JSON.stringify(pluginUrl.href)}; + const lock = await createLocalStatePorts(${JSON.stringify(directory)}).stateLockPort.acquire("production"); + if (!lock.success) process.exit(7); + await lock.data.release(); + `, + ], + { encoding: "utf8" }, + ); + assert.equal(probe().status, 7); + assert.equal((await first.data.release()).success, true); + const next = probe(); + assert.equal(next.status, 0, next.stderr); + assert.equal((await stateLockPort.inspect("production")).data, undefined); +}); + +test("explicit unlock recovers an abandoned lock without allowing its old holder to release a new one", async (t) => { + const { directory, stateLockPort } = fixture(t); + const old = await stateLockPort.acquire("production"); + const fresh = createLocalStatePorts(directory).stateLockPort; + assert.equal((await fresh.forceRelease("production")).success, true); + const next = await fresh.acquire("production"); + assert.equal(next.success, true); + assert.equal((await old.data.release()).success, false); + assert.ok((await fresh.inspect("production")).data); + assert.equal((await next.data.release()).success, true); +}); + +test("the real Bedrock loader and CLI diff work without GitHub credentials or network calls", async (t) => { + const { root, directory } = fixture(t); + const config = globalThis.structuredClone(example); + config.plugins = [pluginUrl.href]; + config.environments.production.universe.universeId = "123456789"; + config.environments.production.places.start.placeId = "987654321"; + config.places.start.filePath = join(root, "game.rbxl"); + config.state.directory = directory; + const configFile = join(root, "bedrock.config.json"); + writeFileSync(configFile, JSON.stringify(config)); + writeFileSync(config.places.start.filePath, "local test artifact"); + const loaded = await loadProjectAsync({ cwd: root }); + assert.equal(loaded.success, true, JSON.stringify(loaded)); + const built = buildStateBackend({ + stateConfig: loaded.data.config.state, + plugins: loaded.data.plugins, + getEnv: () => undefined, + }); + assert.equal(built.success, true, JSON.stringify(built)); + assert.equal(built.data.locking, "exclusive"); + await built.data.statePort.write(state()); + assert.deepEqual((await built.data.statePort.read("production")).data.state, state()); + rmSync(directory, { recursive: true }); + const guard = join(root, "offline.mjs"); + writeFileSync( + guard, + 'globalThis.fetch = () => { throw new Error("Unexpected network request"); };', + ); + const env = { ...process.env }; + delete env.BEDROCK_GITHUB_TOKEN; + delete env.BEDROCK_API_KEY; + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(guard).href, + cli, + "diff", + "--config", + configFile, + "--env", + "production", + ], + { + cwd: root, + env, + encoding: "utf8", + }, + ); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(existsSync(directory), false); +});