{"_id":"9am-build","_rev":"3-3b3e55ff91cb1cc2579a02055b2e66df","name":"9am-build","dist-tags":{"latest":"1.2.1"},"versions":{"1.0.0":{"name":"9am-build","version":"1.0.0","_id":"9am-build@1.0.0","maintainers":[{"name":"ata61","email":"caglayanmustafaata@gmail.com"}],"bin":{"9am-build":"bin/9am-build.js"},"dist":{"shasum":"28044f7def5ad8d346dfff25b00bff73bc8783ba","tarball":"https://registry.npmjs.org/9am-build/-/9am-build-1.0.0.tgz","fileCount":48,"integrity":"sha512-dnkRYoPfdSQ/U3I/TJBD2qTYFg5rUva2sgNDpI+kjmuewX+f27UA3QnCaEx0/UqsG2dMWA2xMP6tAZ+wxtN/8g==","signatures":[{"sig":"MEQCIC1hpml5Z295MTH+B7NWsJsgLdKeoJRZrUmB57QtCKOZAiBy8PAsqf55jJzN1aBj4xvRuLXbfUVFvWok9WAZU9vdyQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":131652},"type":"module","gitHead":"cf8464ffc2ca2db0c0485d2656f77fec51aaeae0","scripts":{"test":"bun test","build":"bun src/index.ts build","debug":"bun src/index.ts debug","deploy":"bun src/index.ts deploy","server":"bun src/index.ts server","release":"bun src/index.ts release","test:lua":"bun src/index.ts test","typecheck":"tsc --noEmit","register-passkey":"bun src/index.ts register"},"_npmUser":{"name":"ata61","email":"caglayanmustafaata@gmail.com"},"_npmVersion":"11.6.2","description":"FiveM Cfx Portal auto-upload tool","directories":{},"_nodeVersion":"24.12.0","dependencies":{"tsx":"^4.19.3","glob":"^11.0.1","chalk":"^5.4.1","archiver":"^7.0.1","playwright":"^1.61.1","@anthropic-ai/sdk":"^0.78.0"},"_hasShrinkwrap":false,"devDependencies":{"@types/bun":"^1.3.9","typescript":"^5.7.3","@types/node":"^22.13.5","@types/archiver":"^6.0.3"},"_npmOperationalInternal":{"tmp":"tmp/9am-build_1.0.0_1784824363102_0.3921859459455077","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"9am-build","version":"1.2.0","keywords":["fivem","cfx","cfxlua","lua","test","gta5"],"license":"MIT","_id":"9am-build@1.2.0","maintainers":[{"name":"ata61","email":"caglayanmustafaata@gmail.com"}],"homepage":"https://github.com/ilovehugetits/9am-build#readme","bugs":{"url":"https://github.com/ilovehugetits/9am-build/issues"},"bin":{"9am-build":"dist/cli.js"},"dist":{"shasum":"31b47e4335536aa307224ed3550ae0e75b85c902","tarball":"https://registry.npmjs.org/9am-build/-/9am-build-1.2.0.tgz","fileCount":14,"integrity":"sha512-JhLKvmg2nOBGYeX9fQsYS7QpssRjyKxq726J8N4PXfZWTCKsEnLIJtBdOQImhEbIxOQMFNysSozRdL+zWXJykQ==","signatures":[{"sig":"MEQCID0Xm1wz0OsxeULgxGJUOIB2ATb9cWeU7YXdj2ys9WbMAiBIdysa21DS+/vL9WC2W2h4a1Aa3K5NLAufMJLHTpImOg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":59126},"type":"module","engines":{"node":">=20.11"},"gitHead":"b04b62e3fcb5ad1f9a196863097c5e0f633cabb3","scripts":{"test":"bun test","build":"bun src/index.ts build","debug":"bun src/index.ts debug","deploy":"bun src/index.ts deploy","server":"bun src/index.ts server","release":"bun src/index.ts release","test:lua":"bun src/index.ts test","typecheck":"tsc --noEmit","build:dist":"tsc -p tsconfig.build.json && node scripts/copy-lua.mjs","prepublishOnly":"bun run build:dist","register-passkey":"bun src/index.ts register"},"_npmUser":{"name":"ata61","email":"caglayanmustafaata@gmail.com"},"repository":{"url":"git+https://github.com/ilovehugetits/9am-build.git","type":"git"},"_npmVersion":"11.6.2","description":"CfxLua test runner and build pipeline for FiveM (Cfx.re) resources","directories":{},"_nodeVersion":"24.12.0","dependencies":{"glob":"^11.0.1","chalk":"^5.4.1"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.19.3","archiver":"^7.0.1","@types/bun":"^1.3.9","playwright":"^1.61.1","typescript":"^5.7.3","@types/node":"^22.13.5","@types/archiver":"^6.0.3","@anthropic-ai/sdk":"^0.78.0"},"_npmOperationalInternal":{"tmp":"tmp/9am-build_1.2.0_1784824536842_0.07952487619249471","host":"s3://npm-registry-packages-npm-production"}},"1.2.1":{"name":"9am-build","version":"1.2.1","description":"CfxLua test runner and build pipeline for FiveM (Cfx.re) resources","type":"module","license":"MIT","bin":{"9am-build":"dist/cli.js"},"engines":{"node":">=20.11"},"keywords":["fivem","cfx","cfxlua","lua","test","gta5"],"repository":{"type":"git","url":"git+https://github.com/ilovehugetits/9am-build.git"},"scripts":{"deploy":"bun src/index.ts deploy","build":"bun src/index.ts build","release":"bun src/index.ts release","server":"bun src/index.ts server","register-passkey":"bun src/index.ts register","test:lua":"bun src/index.ts test","debug":"bun src/index.ts debug","test":"bun test","typecheck":"tsc --noEmit","build:dist":"tsc -p tsconfig.build.json && node scripts/copy-lua.mjs","prepublishOnly":"bun run build:dist"},"dependencies":{"chalk":"^5.4.1","glob":"^11.0.1"},"devDependencies":{"@anthropic-ai/sdk":"^0.78.0","@types/archiver":"^6.0.3","@types/bun":"^1.3.9","@types/node":"^22.13.5","archiver":"^7.0.1","playwright":"^1.61.1","tsx":"^4.19.3","typescript":"^5.7.3"},"gitHead":"b028c66ca39989ea1214839c87ce07709ebdb394","_id":"9am-build@1.2.1","bugs":{"url":"https://github.com/ilovehugetits/9am-build/issues"},"homepage":"https://github.com/ilovehugetits/9am-build#readme","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-s1hdDC2ccVMeRtrpjG84UsXrT6DyNyyOlSenWp+9Q0T0BbA7x3q2zjgH4SOY7GKmK4M7TvDGFflzsy2LlCt1Rw==","shasum":"cf4e467e66a0d8afadba136e58418a6765cff661","tarball":"https://registry.npmjs.org/9am-build/-/9am-build-1.2.1.tgz","fileCount":20,"unpackedSize":102496,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCZNLrBBh7eDn6tAK6xf11rduSWAnfJElNVihSuR/IbXAIhAMrEN7JgdNjCj00iAwxWFwRSRHBe8fCPxWWcIid8PKg6"}]},"_npmUser":{"name":"ata61","email":"caglayanmustafaata@gmail.com"},"directories":{},"maintainers":[{"name":"ata61","email":"caglayanmustafaata@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/9am-build_1.2.1_1784841239801_0.4364254420303033"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-23T16:32:43.049Z","modified":"2026-07-23T21:14:00.075Z","1.0.0":"2026-07-23T16:32:43.244Z","1.2.0":"2026-07-23T16:35:36.985Z","1.2.1":"2026-07-23T21:13:59.932Z"},"bugs":{"url":"https://github.com/ilovehugetits/9am-build/issues"},"license":"MIT","homepage":"https://github.com/ilovehugetits/9am-build#readme","keywords":["fivem","cfx","cfxlua","lua","test","gta5"],"repository":{"type":"git","url":"git+https://github.com/ilovehugetits/9am-build.git"},"description":"CfxLua test runner and build pipeline for FiveM (Cfx.re) resources","maintainers":[{"name":"ata61","email":"caglayanmustafaata@gmail.com"}],"readme":"# 9am-build\r\n\r\nAutomated build & deploy pipeline for FiveM (Cfx.re) resources. Push to GitHub, get your script on the Cfx.re Portal — with AI-powered changelogs posted to Discord and versioned GitHub releases.\r\n\r\n```\r\ngit push  -->  webhook  -->  build zip  -->  upload to portal  -->  GitHub release  -->  changelog to Discord\r\n```\r\n\r\nPlus a CfxLua test runner you can use in any resource folder, with no clone and no setup:\r\n\r\n```bash\r\ncd path/to/your-resource\r\nnpx 9am-build test        # or: bunx 9am-build test\r\n```\r\n\r\n## Quick Start\r\n\r\n```bash\r\ngit clone <repo-url> 9am-build && cd 9am-build\r\nbun install\r\nbunx playwright install chromium   # one-time browser download\r\ncp .env.example .env               # edit with your values\r\nbun run register-passkey            # one-time passkey setup\r\nbun run deploy my-resource          # build + upload to portal\r\nbun run release my-resource         # build + GitHub release only (no portal)\r\n```\r\n\r\n## Requirements\r\n\r\n- [Bun](https://bun.sh/) v1.0+ — runs the app (CLI, webhook server, portal API calls)\r\n- [Node.js](https://nodejs.org/) v20.11+ — runs the Playwright browser step (Bun cannot drive Playwright's pipe transport, so passkey login/registration runs under Node via a small subprocess). Must be on `PATH`, or set `NODE_BIN`.\r\n- Git\r\n- Playwright Chromium — after `bun install`, run `bunx playwright install chromium` once (the Docker image does this automatically)\r\n\r\n## Setup\r\n\r\n### 1. Install Dependencies\r\n\r\n```bash\r\nbun install\r\n```\r\n\r\n### 2. Configure Environment\r\n\r\n```bash\r\ncp .env.example .env\r\n```\r\n\r\nEdit `.env` with your values:\r\n\r\n```env\r\nWEBHOOK_SECRET=your-webhook-secret\r\nPORT=9000\r\nDISCORD_CHANGELOG_WEBHOOK=https://discord.com/api/webhooks/...\r\nANTHROPIC_API_KEY=sk-ant-...\r\nGITHUB_TOKEN=ghp_...\r\n```\r\n\r\n> **Generating a strong `WEBHOOK_SECRET`:**\r\n>\r\n> ```bash\r\n> openssl rand -hex 32\r\n> ```\r\n>\r\n> Use the same value in both `.env` and the GitHub webhook secret field.\r\n\r\n### 3. Register a Passkey (One-Time)\r\n\r\nThe Cfx.re Portal login is automated via a WebAuthn passkey. You register it once, then all future logins are automatic.\r\n\r\n> **Note:** This step requires a GUI browser, so do it on your local machine first. Transfer the credential file to your server afterwards.\r\n\r\n1. Run `bun run register-passkey`\r\n2. A Chromium window opens — log into the Cfx.re Forum if prompted\r\n3. It navigates to your security preferences automatically\r\n4. Click **\"Add Passkey\"**, confirm access with your password when prompted, name it (e.g. `9am-build`), and confirm\r\n5. Go back to the terminal and press **Enter**\r\n6. Credentials are saved to `passkey-credential.json`\r\n\r\n**Deploying to a remote server?** Copy the credential file:\r\n\r\n```bash\r\nscp passkey-credential.json user@your-server:/path/to/9am-build/\r\n```\r\n\r\nSession cookies are saved to `auth-state.json` and reused automatically. No GUI needed after initial registration.\r\n\r\n### 4. Add Your Repos\r\n\r\nEdit `repos.json` to register your FiveM resources:\r\n\r\n```json\r\n{\r\n  \"repos\": [\r\n    {\r\n      \"name\": \"my-resource\",\r\n      \"githubUrl\": \"git@github.com:username/my-resource.git\",\r\n      \"branch\": \"main\"\r\n    }\r\n  ]\r\n}\r\n```\r\n\r\n| Field | Description |\r\n|-------|-------------|\r\n| `name` | Resource name — used for CLI commands and webhook matching |\r\n| `githubUrl` | Git clone URL (SSH or HTTPS) |\r\n| `branch` | Branch to track (pushes to other branches are ignored) |\r\n\r\n### 5. Add `upload-config.json` to Each Resource\r\n\r\nEach FiveM resource needs an `upload-config.json` in its root. This tells 9am-build how to package and where to upload.\r\n\r\n```json\r\n{\r\n  \"name\": \"my-resource\",\r\n  \"exclude\": [\r\n    \"upload-config.json\",\r\n    \".gitignore\",\r\n    \".git/**\",\r\n    \".vscode/**\"\r\n  ],\r\n  \"frontend\": {\r\n    \"dir\": \"web\",\r\n    \"buildCommand\": \"bun run build\",\r\n    \"buildOutput\": \"build\"\r\n  },\r\n  \"versions\": {\r\n    \"escrow\": {\r\n      \"assetId\": 123456,\r\n      \"escrowIgnore\": [\"config.lua\", \"fxmanifest.lua\"]\r\n    }\r\n  }\r\n}\r\n```\r\n\r\n<details>\r\n<summary><strong>All upload-config.json fields</strong></summary>\r\n\r\n| Field | Required | Description |\r\n|-------|----------|-------------|\r\n| `name` | Yes | Resource name |\r\n| `exclude` | Yes | Glob patterns to exclude from all zips |\r\n| `frontend` | No | Frontend build settings |\r\n| `frontend.dir` | Yes* | Frontend directory (e.g. `web`) |\r\n| `frontend.buildCommand` | No | Build command (default: `bun run build`) |\r\n| `frontend.buildOutput` | No | Output directory (default: `build`). Use `dist` for Vue/Svelte |\r\n| `versions` | Yes | At least one version must be defined |\r\n| `versions.escrow.assetId` | Yes* | Cfx.re Portal asset ID |\r\n| `versions.escrow.escrowIgnore` | Yes* | Files to add to `fxmanifest.lua` escrow_ignore block |\r\n| `versions.open.assetId` | Yes* | Cfx.re Portal asset ID |\r\n\r\n*Required if parent field is defined.\r\n\r\n</details>\r\n\r\n<details>\r\n<summary><strong>Escrow vs Open versions</strong></summary>\r\n\r\n- **Escrow** — Source files excluded, only build output included. `escrowIgnore` patterns are injected into `fxmanifest.lua`.\r\n- **Open** — All files included. `escrow_ignore { \"**/*.*\", \"*\" }` is added automatically so nothing is encrypted.\r\n\r\nYou can define one or both versions. Each gets its own zip and asset upload.\r\n\r\n</details>\r\n\r\n<details>\r\n<summary><strong>Finding your Asset ID</strong></summary>\r\n\r\n1. Go to the [Cfx.re Portal](https://portal.cfx.re/assets/created-assets)\r\n2. Find your resource in the asset list\r\n3. The number in the **ID** column is your `assetId`\r\n\r\n</details>\r\n\r\n## Commands\r\n\r\n| Command | Description |\r\n|---------|-------------|\r\n| `npx 9am-build test` | Run `*.test.lua` in a resource folder — the only published command, needs no clone |\r\n| `bun run build <name>` | Build zip(s) only — no upload |\r\n| `bun run deploy <name>` | Build + upload to Cfx.re Portal + GitHub release |\r\n| `bun run release <name>` | Build + GitHub release only — never touches the portal or opens a browser |\r\n| `bun run server` | Start webhook server for automated deployments |\r\n| `bun run register-passkey` | One-time passkey registration |\r\n| `bun src/index.ts debug <name> <commit>` | Test changelog generation for a commit |\r\n\r\n## Webhook Mode (CI/CD)\r\n\r\nAutomate deployments on every push. The server receives GitHub webhooks, builds, uploads, and posts a changelog to Discord.\r\n\r\n### Setup\r\n\r\n1. Start the server:\r\n\r\n   ```bash\r\n   bun run server\r\n   ```\r\n\r\n2. Create a webhook on GitHub (**Settings > Webhooks > Add webhook**):\r\n\r\n   | Field | Value |\r\n   |-------|-------|\r\n   | Payload URL | `https://your-server:9000/webhook` |\r\n   | Content type | `application/json` |\r\n   | Secret | Same as `WEBHOOK_SECRET` in `.env` |\r\n   | Events | \"Just the push event\" |\r\n\r\n### How It Works\r\n\r\n1. You push to a tracked branch\r\n2. GitHub sends a webhook to your server\r\n3. Server verifies the HMAC-SHA256 signature\r\n4. Matches the repo/branch against `repos.json`\r\n5. Enqueues the build (one at a time, latest push wins)\r\n6. Builds zip(s) and uploads to the Cfx.re Portal\r\n7. Generates a changelog via Claude API and posts it to Discord\r\n\r\n### Endpoints\r\n\r\n| Method | Path | Description |\r\n|--------|------|-------------|\r\n| `GET` | `/health` | Health check — returns `{ status: \"ok\" }` |\r\n| `POST` | `/webhook` | GitHub push webhook receiver |\r\n\r\n### Running with PM2\r\n\r\nTo keep the server running in the background with auto-restart:\r\n\r\n```bash\r\nnpm install -g pm2\r\npm2 start bun --name 9am-build -- run server\r\npm2 save && pm2 startup\r\n```\r\n\r\n```bash\r\npm2 logs 9am-build      # view logs\r\npm2 restart 9am-build    # restart\r\npm2 stop 9am-build       # stop\r\npm2 delete 9am-build     # remove\r\n```\r\n\r\n## GitHub Releases\r\n\r\nAfter a successful portal upload, the pipeline creates a GitHub release on the resource's repo, tagged with the version from `fxmanifest.lua` (e.g. `v1.0.3`), with auto-generated release notes and the built zips attached as assets (`<name>-escrow.zip`, `<name>-open.zip`).\r\n\r\n1. Create a personal access token with `repo` scope (classic) or `Contents: Read and write` permission (fine-grained) for your resource repos\r\n2. Add it to `.env`:\r\n\r\n   ```env\r\n   GITHUB_TOKEN=ghp_...\r\n   ```\r\n\r\n- The release targets the exact commit that was built\r\n- If a release for the tag already exists, the zips are attached to it (existing assets with the same name are kept)\r\n- If `GITHUB_TOKEN` is not set, this step is skipped silently\r\n- Failures are non-fatal — the deploy still succeeds if the release fails\r\n\r\n## Discord Changelog\r\n\r\nAutomatically posts AI-generated changelogs to Discord after each deployment.\r\n\r\n1. Create a webhook: **Channel Settings > Integrations > Webhooks > New Webhook**\r\n2. Add the URL to `.env`:\r\n\r\n   ```env\r\n   DISCORD_CHANGELOG_WEBHOOK=https://discord.com/api/webhooks/...\r\n   ```\r\n\r\n- Changelogs are generated from commit diffs using **OpenRouter** (priority) or **Anthropic** (fallback)\r\n- Written as 1-5 bullet points from the end-user's perspective\r\n- Posted as a gold/yellow embed with the resource name\r\n- Model is configurable via `OPENROUTER_MODEL` (default: `anthropic/claude-sonnet-4.6`)\r\n- If `DISCORD_CHANGELOG_WEBHOOK` is not set, this step is skipped silently\r\n\r\n## Environment Variables\r\n\r\n| Variable | Required | Description |\r\n|----------|----------|-------------|\r\n| `WEBHOOK_SECRET` | Server mode | GitHub webhook HMAC secret |\r\n| `PORT` | Server mode | HTTP server port (default: `9000`) |\r\n| `ANTHROPIC_API_KEY` | Changelog* | Anthropic API key |\r\n| `OPENROUTER_API_KEY` | Changelog* | OpenRouter API key (takes priority over Anthropic) |\r\n| `OPENROUTER_MODEL` | No | OpenRouter model (default: `anthropic/claude-sonnet-4.6`) |\r\n| `DISCORD_CHANGELOG_WEBHOOK` | No | Discord webhook URL |\r\n| `GITHUB_TOKEN` | No | GitHub token for creating releases with build zips |\r\n| `CHROMIUM_NO_SANDBOX` | No | Set to `1` to disable the Chromium sandbox (needed only when running as root in a container; the Docker image sets it automatically). Leave unset locally. |\r\n\r\n*At least one API key required for changelog generation.\r\n\r\n## CfxLua Unit Tests\r\n\r\nRun FiveM resource tests **without a live FXServer** — powered by the [CfxLua CLI](https://github.com/VIRUXE/cfxlua-cli) runtime (LuaGLM 5.4 + mocked natives, `Citizen`, events, exports).\r\n\r\n```bash\r\n# From a resource directory — no clone, no setup\r\nnpx 9am-build test          # or: bunx 9am-build test\r\nnpx 9am-build test --json   # machine-readable results\r\nnpx 9am-build test --strict # exit 1 when no specs exist\r\n\r\n# From a clone of this repo\r\nbun run test:lua ./path/to/my-resource\r\nbunx 9am-build test my-resource          # repos.json name\r\n```\r\n\r\nThe published CLI is compiled to plain ESM and runs on Node ≥ 20.11, so `npx`\r\nworks on machines without Bun.\r\n\r\n### Writing tests\r\n\r\nSpec files are discovered at `tests/**/*.spec.lua`, `test/**/*.spec.lua`, or any\r\nco-located `*.test.lua` next to the code it covers (`web/` and `node_modules/`\r\nare skipped). Override the patterns with `9am-test.json`:\r\n\r\n```\r\nmy-resource/\r\n├── fxmanifest.lua\r\n├── server/\r\n│   └── main.lua\r\n├── tests/\r\n│   └── pricing.spec.lua\r\n└── 9am-test.json          # optional\r\n```\r\n\r\nExample spec (`tests/pricing.spec.lua`):\r\n\r\n```lua\r\nlocal pricing = require('server.main')\r\n\r\ndescribe('pricing.withTax', function()\r\n  it('adds tax to the base price', function()\r\n    expect(pricing.withTax(100, 0.2)).to.equal(120)\r\n  end)\r\nend)\r\n\r\ndescribe('events', function()\r\n  it('can spy on TriggerEvent', function()\r\n    local spy = TestHelpers.spy()\r\n    local restore = TestHelpers.mockGlobal('TriggerEvent', spy)\r\n    TriggerEvent('shop:open', 1)\r\n    restore()\r\n    expect(spy:call_count()).to.equal(1)\r\n  end)\r\nend)\r\n```\r\n\r\n### Test API\r\n\r\n| Global | Description |\r\n|--------|-------------|\r\n| `describe(name, fn)` | Group tests |\r\n| `it(name, fn)` | Define a test case |\r\n| `beforeEach(fn)` / `afterEach(fn)` | Per-test hooks |\r\n| `expect(value).to.equal(x)` | Equality assert |\r\n| `expect(value).to.deep_equal(x)` | Deep table compare |\r\n| `expect(fn).to.throw('msg')` | Error assert |\r\n| `TestHelpers.spy(fn?)` | Callable spy with `.calls`, `:call_count()` |\r\n| `TestHelpers.mockGlobal(name, value)` | Temporarily replace a global |\r\n\r\n`require('server.pricing')` resolves against the resource root through a custom\r\nsearcher that loads the module under a **resource-relative chunk name**, so\r\ntracebacks read `server/pricing.lua:4` rather than an absolute path that Lua\r\ntruncates into uselessness.\r\n\r\n### Framework batteries (ox_lib / QBCore / QBox / ESX)\r\n\r\nWorking fakes for the framework layer load before any resource or spec file,\r\nso a resource written against ox_lib, QBCore, QBox (`qbx_core`) or ESX\r\n(`es_extended`) loads with zero configuration: `lib.*`, `QBCore` /\r\n`exports['qb-core']:GetCoreObject()`, `exports.qbx_core`, and\r\n`exports['es_extended']:getSharedObject()` all exist and are backed by **one\r\nshared player state** — money removed through `QBCore.Functions.RemoveMoney`\r\nis visible through `xPlayer.getMoney()` and vice versa.\r\n\r\n`GetResourceState` reports exactly one framework as `started` (default:\r\n`qbx_core`), so `Bridge`-style load-time detection behaves as on a real\r\nserver, and can be switched per test:\r\n\r\n```lua\r\ndescribe('bridge', function()\r\n  it('charges through the ESX path', function()\r\n    TestHelpers.framework.use('esx')\r\n    local Bridge = TestHelpers.reload('server.bridge')   -- re-runs detection\r\n    TestHelpers.framework.addPlayer(1, { money = { bank = 1000 } })\r\n    expect(Bridge.Charge(1, 'bank', 400)).to.be_truthy()\r\n    expect(TestHelpers.framework.getState(1).money.bank).to.equal(600)\r\n  end)\r\nend)\r\n```\r\n\r\n| Helper | Description |\r\n|--------|-------------|\r\n| `TestHelpers.framework.use(name)` | Activate `'qbox'`, `'qbcore'`, `'esx'` or `'none'` |\r\n| `TestHelpers.framework.active()` | Currently active framework name |\r\n| `TestHelpers.framework.addPlayer(src, opts?)` | Seed a player (citizenid, job, money, items all overridable) |\r\n| `TestHelpers.framework.removePlayer(src)` | Remove a seeded player |\r\n| `TestHelpers.framework.getState(src)` | Canonical record for assertions |\r\n| `TestHelpers.framework.notifications()` | Every `lib.notify` / `Notify` / `showNotification`, one log |\r\n| `TestHelpers.framework.useItem(src, item)` | Trigger a registered useable item |\r\n| `TestHelpers.framework.reset()` | Clear players + notifications (registries survive) |\r\n| `TestHelpers.callback(name, src, ...)` | Invoke any registered callback (ox_lib, QBCore or ESX style) and get its results |\r\n| `TestHelpers.reload(module)` | Drop the `require` cache and load again |\r\n\r\nCallbacks registered via `lib.callback.register`,\r\n`QBCore.Functions.CreateCallback` and `ESX.RegisterServerCallback` land in one\r\nregistry; `lib.callback.await(name, false, ...)` — the client-side call shape —\r\ndispatches there with the default source (`TestHelpers.framework.defaultSource()`),\r\nso client-flow code exercises real server handlers. Jobs registered through\r\n`exports['qb-core']:AddJob`, `exports.qbx_core:CreateJob` or seeded directly are\r\nvisible through `QBCore.Shared.Jobs`, `exports.qbx_core:GetJobs()` and\r\n`ESX.GetJobs()` alike.\r\n\r\n### Reading a failure\r\n\r\nOutput is plain, greppable, and every location is a `path:line` anchor — no box\r\ndrawing, no status glyphs, and no colour when stdout is not a TTY:\r\n\r\n```\r\nFAIL server/pricing.test.lua:12  withTax > blows up inside resource code\r\n\r\n  error      server/pricing.lua:4: attempt to perform arithmetic on a nil value (local 'price')\r\n\r\n  traceback\r\n    server/pricing.lua:4: in function 'server.pricing.withTax'\r\n    server/pricing.test.lua:13: in field 'fn'\r\n\r\n  source server/pricing.lua:4\r\n      3 | function M.withTax(price, rate)\r\n    > 4 |   return price + (price * rate)\r\n      5 | end\r\n\r\n24 tests  22 passed  2 failed  26ms\r\n```\r\n\r\nA failed matcher reports `matcher` / `expected` / `actual` as separate lines\r\ninstead of a prose sentence. CfxLua's own `bootstrap.lua` and `scheduler.lua`\r\nframes are stripped, since they sit beneath every test and say nothing about the\r\nresource under test. `--json` emits the same data as one document.\r\n\r\nExit code is 0 when everything passes, 1 on any failure. \"No specs found\" exits\r\n0 unless you pass `--strict`.\r\n\r\n> Add `**/*.test.lua` and `tests/**` to `exclude` in your `upload-config.json`\r\n> so specs never ship inside the escrow or open zip.\r\n\r\n### Configuration (`9am-test.json`)\r\n\r\n```jsonc\r\n{\r\n  \"patterns\": [\"tests/**/*.spec.lua\", \"tests/**/*.test.lua\"],\r\n  \"include\": [\"tests/manual.spec.lua\"],\r\n  \"exclude\": [\"**/node_modules/**\"],\r\n  \"framework\": \"qbcore\",   // initial active framework: qbox (default) | qbcore | esx | none\r\n  \"batteries\": true         // false disables the framework batteries; or a list, e.g. [\"oxlib\"]\r\n}\r\n```\r\n\r\n### Toolchain\r\n\r\nOn first run, 9am-build downloads CfxLua v1.1.0 to `~/.9am-build/cfxlua/`. Override with:\r\n\r\n| Variable | Description |\r\n|----------|-------------|\r\n| `CFXLUA_VM` | Path to `cfxlua-vm` binary |\r\n| `CFXLUA_RUNTIME` | Path to cfxlua `runtime/` directory |\r\n| `CFXLUA_TIMEOUT` | Script timeout in ms (default: `30000`) |\r\n| `NINEAM_CFXLUA_CACHE` | Custom cache directory |\r\n\r\nOn Windows, if the native VM fails to start, 9am-build automatically falls back to the Linux binary via **WSL**.\r\n\r\n### Testing the runner itself\r\n\r\n`bun test` covers discovery, config parsing and WSL path translation. The end-to-end\r\ntests spawn the real CfxLua VM; they run automatically once the toolchain is cached\r\nand can be forced with `NINEAM_CFXLUA_E2E=1 bun test`.\r\n\r\n## Project Structure\r\n\r\n```\r\n9am-build/\r\n├── src/\r\n│   ├── cli.ts                 # Published npm entry — `9am-build test` only\r\n│   ├── index.ts               # Repo-only CLI entry point & command router\r\n│   ├── cfx/                   # Cfx portal layer (browser only for login)\r\n│   │   ├── api.ts             # portal-api REST client + chunking/error helpers\r\n│   │   ├── upload.ts          # Chunked asset upload + version-cap recovery\r\n│   │   ├── requester.ts       # fetch-based Requester (Cookie header from session)\r\n│   │   ├── session.ts         # 3-tier ensureSession (jwt → SSO → passkey)\r\n│   │   ├── login.ts           # Passkey + SSO-only portal login flows\r\n│   │   ├── passkey.ts         # WebAuthn virtual authenticator + credential store\r\n│   │   ├── storage-state.ts   # Playwright storageState + legacy migration\r\n│   │   ├── run-browser.ts     # Spawns the Node browser runner from Bun\r\n│   │   └── browser-runner.ts  # Node entry for login/register (Playwright)\r\n│   ├── core/                  # Build & repo primitives\r\n│   │   ├── config.ts          # Load & validate upload-config.json\r\n│   │   ├── build.ts           # Zip creation & frontend builds\r\n│   │   ├── git.ts             # Git clone / pull / diff\r\n│   │   └── manifest.ts        # Read version from fxmanifest.lua\r\n│   ├── integrations/          # External services\r\n│   │   ├── github.ts          # GitHub release creation & asset upload\r\n│   │   ├── discord.ts         # Discord webhook notifications\r\n│   │   └── changelog.ts       # AI changelog generation\r\n│   ├── commands/              # One file per CLI command\r\n│   │   ├── test.ts            # Run *.test.lua (published)\r\n│   │   ├── build.ts           # Zip only\r\n│   │   ├── deploy.ts          # Build + portal upload + GitHub release\r\n│   │   ├── release.ts         # Build + GitHub release only (no portal)\r\n│   │   ├── register.ts        # Passkey registration (headed)\r\n│   │   ├── server.ts          # GitHub webhook HTTP server\r\n│   │   ├── test.ts            # CfxLua unit test runner\r\n│   │   └── shared.ts          # Shared post-release Discord announcement\r\n│   ├── cfxlua/                # Offline FiveM Lua test runner (published)\r\n│   │   ├── discover.ts        # Find *.spec.lua / *.test.lua files\r\n│   │   ├── ensure-toolchain.ts # Download/cache CfxLua VM (+ WSL fallback)\r\n│   │   ├── run.ts             # Orchestrate execution, parse the JSON payload\r\n│   │   ├── report.ts          # Agent-first text and --json rendering\r\n│   │   ├── spawn.ts           # node:child_process wrapper (Node-compatible)\r\n│   │   ├── types.ts           # Result shapes\r\n│   │   └── test/              # Lua side: framework, helpers, runner\r\n│   │       └── batteries/     # ox_lib / QBCore / QBox / ESX fakes over one shared state\r\n│   └── server-support/\r\n│       ├── queue.ts           # Serial build queue (latest-wins)\r\n│       └── repos.ts           # repos.json loader\r\n├── repos.json                 # Managed repo list\r\n├── fixtures/                  # Sample resource for CfxLua test demos\r\n├── scripts/copy-lua.mjs       # Copy Lua assets into dist on build\r\n├── .env.example               # Environment template\r\n└── package.json\r\n```\r\n\r\n**Auto-generated files** (gitignored):\r\n\r\n| File | Purpose |\r\n|------|---------|\r\n| `auth-state.json` | Cached Cfx.re session cookies |\r\n| `passkey-credential.json` | WebAuthn passkey credentials |\r\n| `repos/` | Cloned repository working copies |\r\n\r\n## License\r\n\r\n[MIT](LICENSE)\r\n","readmeFilename":"README.md"}