cfx-uploader
v1.0.8
Published
Upload CFX Portal assets from GitHub releases
Maintainers
Readme
CFX Uploader
Upload CFX Portal asset versions directly from GitHub releases.
CFX Uploader is designed for automated FiveM/RedM resource publishing. It downloads a GitHub release archive, rebuilds the ZIP expected by CFX Portal, authenticates with a CFX passkey, and uploads the new asset version.
It can be used as:
- a Node.js library for server-side integrations such as Nuxt webhooks
- an HTTP CLI for VPS, CI, and automation
- a browser CLI for debugging the CFX Portal UI flow
The recommended production path is the library or HTTP CLI. Both use Puppeteer only for CFX passkey authentication, then upload through portal-api.cfx.re.
Temporary files are always cleaned up after a run, including failed runs: downloaded release archives, extraction folders, and generated upload ZIPs are not kept.
Table of Contents
- Install
- Quick Start
- Passkey Setup
- Resource Configuration
- Library Usage
- CLI Usage
- Release Types
- Version Rules
- CFX 5-Version Limit
- Changelog
- Environment Variables
- Security Notes
- Error Handling
- Troubleshooting
- Additional Documentation
- GitHub Actions
- Project Structure
- Scripts
Install
npm install cfx-uploaderRequirements:
- Node.js
18+ - npm
- a GitHub token with access to the target repository releases
- a CFX passkey credential generated by this package
Quick Start
- Generate a CFX passkey credential:
npx cfx-uploader-register-passkey- Add
cfx_uploader.jsonto the root of each resource repository:
{
"portalName": "Housing",
"foldersToZip": ["jo_housing"]
}- Upload from server-side code:
import { upload } from 'cfx-uploader';
await upload({
repository: 'Jump-On-Studios/RedM-jo_housing',
releaseTag: 'v1.1.2',
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
});Passkey Setup
Passkey registration is required before CFX Uploader can authenticate to CFX.
The passkey registration script is based on work by ilovehugetits/9am-build. Thanks for the original implementation.
From a project where cfx-uploader is installed, run:
npx cfx-uploader-register-passkeyWhen working inside this repository, the equivalent development script is:
npm run register-passkeyA visible browser opens on the CFX forum security page. In the browser:
- Sign in if prompted.
- Open the security page if the login flow redirects elsewhere.
- Click Add passkey.
- Enter a passkey name and confirm.
- Return to the terminal and press
Enter.
The command writes:
./passkey-credential.jsonTo replace an existing credential:
npx cfx-uploader-register-passkey --forcepasskey-credential.json contains private key material. Keep it secret. It is ignored by git and should only be used for local development or for copying values into a secure secret store.
For VPS/Nuxt usage, store the generated passkey as separate environment variables:
CFX_UPLOADER_CREDENTIAL_ID=
CFX_UPLOADER_RP_ID=forum.cfx.re
CFX_UPLOADER_PRIVATE_KEY=
CFX_UPLOADER_USER_HANDLE=
CFX_UPLOADER_SIGN_COUNT=1Resource Configuration
Each uploaded resource repository must contain cfx_uploader.json at its root:
{
"portalName": "Clothingstore",
"foldersToZip": ["jo_clothingstore"],
"deleteOldestVersionWhenCapped": false,
"maxPrereleaseVersionsToKeep": null
}Fields:
| Field | Required | Description |
|---|---:|---|
| portalName | yes | Exact CFX Portal asset name. |
| foldersToZip | yes | Folders from the GitHub release source ZIP to include in the final upload ZIP. |
| deleteOldestVersionWhenCapped | no | Destructive opt-in. When true, CFX Uploader may delete the oldest CFX asset version if the asset has reached the 5-version limit. |
| maxPrereleaseVersionsToKeep | no | Optional HTTP retention policy for prerelease versions. null or omitted keeps the default oldest-version deletion behavior. Values above 4 are capped to 4. |
githubRepository is not part of cfx_uploader.json. It is provided by the library call, CLI env, or GitHub Actions runtime.
Library mode is strict: if cfx_uploader.json is missing from the downloaded release, upload fails with Missing cfx_uploader.json at repository root.... The mock-config.js fallback is only for local CLI development.
Library Usage
upload()
upload() is the simplest API. It uses HTTP upload mode.
import { upload } from 'cfx-uploader';
const result = await upload({
repository: 'Jump-On-Studios/RedM-jo_clothingstore',
releaseTag: 'v1.1.2',
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
headless: true,
});createUploader()
Use createUploader() when several uploads share the same base config:
import { createUploader } from 'cfx-uploader';
const uploader = createUploader({
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
headless: true,
workDir: '/tmp/cfx-uploader',
});
await uploader.upload({
repository: payload.repository.full_name,
releaseTag: payload.release?.tag_name,
releaseCandidate: Boolean(payload.release?.prerelease),
changelog: payload.release?.body,
});Minimal webhook example
This example mirrors a common release-webhook integration: upload the published GitHub release to CFX, keep at most 2 prerelease versions, and expose the deleted CFX version for a private team notification.
import { createUploader } from 'cfx-uploader';
const uploader = createUploader({
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
headless: true,
workDir: process.env.CFX_UPLOADER_WORKDIR,
});
export async function handleGithubReleaseWebhook(payload) {
if (payload.action !== 'published') {
return { status: 'ignored' };
}
const result = await uploader.upload({
repository: payload.repository.full_name,
releaseTag: payload.release.tag_name,
releaseCandidate: Boolean(payload.release.prerelease),
changelog: payload.release.body || undefined,
deleteOldestVersionWhenCapped: true,
maxPrereleaseVersionsToKeep: 2,
});
if (result.deletedVersion) {
console.log(
`Deleted CFX version ${result.deletedVersion.version} before uploading ${result.version}`
);
}
return result;
}Options
| Option | Required | Description |
|---|---:|---|
| repository | yes | GitHub repository in owner/name format. |
| releaseTag | recommended | GitHub release tag to upload. If omitted, the latest release is used. |
| githubToken | yes | GitHub token used to read releases and download archives. |
| passkey | yes | Passkey credential object. |
| passkeyJson | no | Alternative JSON string form of the passkey credential. |
| headless | no | Browser auth mode. Defaults to true. |
| workDir | no | Working directory for temporary files. Defaults to an OS temp folder. |
| releaseCandidate | no | Overrides GitHub pre-release detection. |
| deleteOldestVersionWhenCapped | no | Overrides the project JSON capped-version behavior. |
| maxPrereleaseVersionsToKeep | no | Overrides the project JSON prerelease retention policy. Use null to delete the oldest version regardless of type. Values above 4 are capped to 4. |
| changelog | no | CFX release notes. Defaults to the GitHub release body. |
| onLog | no | Custom logger callback. Defaults to console.log. |
| onProgress | no | Structured progress callback for external UIs, webhooks, or Discord workflow cards. |
onProgress receives machine-readable events while onLog remains human-readable:
await upload({
repository,
releaseTag,
githubToken,
passkey,
onProgress(event) {
console.log(`[${event.index}/${event.total}] ${event.label}`);
},
});Result
The library returns a structured result on success:
{
success: true,
mode: 'http',
repository,
releaseTag,
resolvedReleaseTag,
releaseCandidate,
portalName,
version,
assetId,
versionId,
deletedVersion,
deletedOldestVersion
}deletedVersion is null unless deleteOldestVersionWhenCapped was enabled and CFX Uploader had to delete a capped asset version before retrying the upload.
When present:
{
id,
version,
created_at,
releaseCandidate,
reason: 'asset-version-cap'
}deletedOldestVersion is kept as a backward-compatible alias. New integrations should use deletedVersion.
The package also exports lower-level helpers:
import { uploadHttp, uploadBrowser } from 'cfx-uploader';These are mainly intended for advanced usage and CLI wrappers.
CLI Usage
After installing the package, the CLI commands are available through npx:
npx cfx-uploader-register-passkey
npx cfx-uploader-http
npx cfx-uploader-browserWhen working inside this repository, install dependencies first:
npm installCreate a local .env:
GITHUB_TOKEN=your_github_tokenUse .env.example as a template for all supported variables.
Local CLI runs use:
src/config/mock-config.jsforgithubRepositorypasskey-credential.jsonfor local passkey authcfx_uploader.jsonfrom the downloaded release when present
HTTP Mode
Recommended automation mode:
npx cfx-uploader-httpUse a specific release tag:
npx cfx-uploader-http --release-tag=v1.1.2Show the browser during authentication:
npx cfx-uploader-http --show-browserForce the CFX release type:
npx cfx-uploader-http --release-candidate
npx cfx-uploader-http --full-releaseControl capped-version cleanup:
npx cfx-uploader-http --delete-oldest-version-when-capped
npx cfx-uploader-http --no-delete-oldest-version-when-capped
npx cfx-uploader-http --max-prerelease-versions-to-keep=2
npx cfx-uploader-http --no-prerelease-retentionBrowser Mode
Browser mode drives the full CFX Portal UI with Puppeteer. It is useful for debugging portal changes.
npx cfx-uploader-browserBrowser mode supports the same flags:
npx cfx-uploader-browser --show-browser
npx cfx-uploader-browser --release-candidate
npx cfx-uploader-browser --full-release
npx cfx-uploader-browser --delete-oldest-version-when-capped
npx cfx-uploader-browser --no-delete-oldest-version-when-capped
npx cfx-uploader-browser --max-prerelease-versions-to-keep=2
npx cfx-uploader-browser --no-prerelease-retentionRelease Types
CFX Uploader maps GitHub pre-releases to CFX Release Candidate / Beta uploads.
Default behavior:
- GitHub normal release -> CFX full release
- GitHub pre-release -> CFX release candidate / beta
Library usage can override the GitHub release type:
await upload({
repository,
releaseTag,
githubToken,
passkey,
releaseCandidate: true,
});CLI usage can also force the type:
npx cfx-uploader-http --release-candidate
npx cfx-uploader-http --full-releaseWhen releaseCandidate is omitted, CFX Uploader uses the resolved GitHub release metadata. This automatic behavior works best when a specific release tag is provided, because GitHub /releases/latest does not return pre-releases.
Version Rules
Release tags can be written with or without a leading v.
Examples:
1.0.0v1.0.0
If one form is requested and only the other exists, CFX Uploader tries the alternate form automatically.
After downloading the release, the resolved tag is compared with fxmanifest.lua version. The leading v is ignored, but all other differences fail before authentication or upload.
CFX 5-Version Limit
CFX Portal currently allows a maximum of 5 versions per asset.
By default, CFX Uploader does not delete anything automatically. If the asset is capped, the upload fails with a clear MAX_VERSIONS_REACHED message.
You can opt in to automatic cleanup from cfx_uploader.json:
{
"portalName": "CFX Uploader Test",
"foldersToZip": ["cfx_uploader_test"],
"deleteOldestVersionWhenCapped": true,
"maxPrereleaseVersionsToKeep": 2
}Or from library code:
await upload({
repository,
releaseTag,
githubToken,
passkey,
deleteOldestVersionWhenCapped: true,
maxPrereleaseVersionsToKeep: 2,
});When enabled, CFX Uploader:
- fails first if the version from
fxmanifest.luaalready exists; - detects CFX
409 MAX_VERSIONS_REACHEDduring version creation; - refetches the asset details;
- deletes the version with the oldest
created_at; - waits until the asset is below the limit;
- retries version creation once.
By default, maxPrereleaseVersionsToKeep is null, which means CFX Uploader deletes the oldest version regardless of type. When a number is configured, HTTP mode applies prerelease-aware retention:
- new prerelease: delete the oldest prerelease if the current prerelease count is already at the limit, otherwise delete the oldest stable version;
- new stable release: delete the oldest stable version when possible;
- if the preferred group is empty, delete the oldest available version.
maxPrereleaseVersionsToKeep is capped at 4, even if a higher value is provided. This keeps at least one slot available for a full release on a 5-version CFX asset.
Browser mode still handles capped assets, but prerelease-aware retention is only guaranteed in HTTP mode because the CFX UI does not expose reliable version metadata.
This is intentionally destructive. Use it only for assets where deleting the oldest CFX version is acceptable.
Changelog
The changelog option is written to the CFX Portal version description.
When omitted, it defaults to the GitHub release body. If no release body is present, it falls back to:
Automated upload from cfx-uploader.The final ZIP filename is based on foldersToZip[0].
Environment Variables
Recommended server-side variables:
| Variable | Required | Description |
|---|---:|---|
| GITHUB_TOKEN | yes | GitHub token used by your integration code. |
| CFX_UPLOADER_CREDENTIAL_ID | yes | Passkey credential id. |
| CFX_UPLOADER_RP_ID | yes | Usually forum.cfx.re. |
| CFX_UPLOADER_PRIVATE_KEY | yes | Passkey private key. |
| CFX_UPLOADER_USER_HANDLE | yes | Passkey user handle. |
| CFX_UPLOADER_SIGN_COUNT | yes | Passkey sign count as a number. |
| CFX_UPLOADER_WORKDIR | no | Optional persistent working directory for integrations. |
CLI-only variables:
| Variable | Description |
|---|---|
| GITHUB_REPOSITORY | Overrides the local mock repository. |
| CFX_UPLOADER_RELEASE_TAG | Sets the release tag without CLI args. |
| CFX_SHOW_BROWSER | Set to true to show the auth browser. |
| PASSKEY_CREDENTIAL_PATH | Custom local passkey credential file path. |
Security Notes
- Never commit
passkey-credential.json. - Never log passkey values, GitHub tokens, or Discord webhook URLs.
- Store production passkey values in a secret manager or private server environment.
- Use the narrowest GitHub token scope that can read the target repository releases.
- Treat
deleteOldestVersionWhenCappedas destructive and enable it per asset only when acceptable. - Customer-facing notifications should not expose raw upload errors.
Error Handling
upload() throws on failure. In integrations such as Nuxt webhooks, catch the error and forward error.message to private logs or a private Discord channel. Do not expose these messages in customer-facing notifications.
Common errors:
| Area | Error message pattern | Meaning |
|---|---|---|
| Library options | Upload options are required. | upload() was called without an options object. |
| Library options | Upload option "repository" is required. | The GitHub repository was not provided. |
| Library options | Upload option "githubToken" is required. | The GitHub token was not provided. |
| Passkey | Missing CFX passkey. Provide passkey or passkeyJson. | No CFX passkey was provided to the library. |
| Passkey | Invalid passkey: ... | One passkey field is missing or invalid. |
| GitHub release | No downloadable release found for ... | The release/tag could not be found or has no downloadable archive. |
| GitHub release | GitHub API error... / Download failed... | GitHub rejected the release lookup or ZIP download. |
| Project config | Missing cfx_uploader.json at repository root... | The downloaded release does not contain the required config file. |
| Project config | Invalid cfx_uploader.json: portalName... | portalName is missing or invalid. |
| Project config | Invalid cfx_uploader.json: foldersToZip... | foldersToZip is missing or invalid. |
| Project config | Invalid cfx_uploader.json: deleteOldestVersionWhenCapped... | The capped-version option is not a boolean. |
| Project config | Invalid cfx_uploader.json: maxPrereleaseVersionsToKeep... | The prerelease retention option is not null or an integer >= 0. Values above 4 are accepted and capped. |
| ZIP workflow | Folder "..." not found in unzipped release... | A folder listed in foldersToZip does not exist in the release archive. |
| ZIP workflow | Path "..." is not a directory. | A configured upload path exists but is not a directory. |
| ZIP metadata | ZIP metadata missing: no fxmanifest.lua found... | The final ZIP does not contain an fxmanifest.lua. |
| ZIP metadata | ZIP metadata missing: no version detected... | The manifest does not expose a readable version. |
| Version check | release/manifest version mismatch | The GitHub release tag and fxmanifest.lua version differ, ignoring only a leading v. |
| CFX auth | Portal failed to load (timeout), last URL: ... | Browser passkey auth or portal redirect did not reach the created-assets page. |
| CFX auth | Puppeteer/WebAuthn errors | Chromium failed to launch, inject the virtual authenticator, or complete navigation. |
| CFX session | No CFX cookies found after browser authentication | Browser auth completed without usable CFX API cookies. |
| CFX session | CFX HTTP auth failed (401/403): ... | portal-api.cfx.re rejected the authenticated session. |
| CFX API | GET/POST https://portal-api.cfx.re/... failed (...): ... | A CFX API endpoint returned a non-2xx response. |
| CFX API | Invalid JSON response from ... | CFX returned non-JSON where JSON was expected. |
| Asset lookup | Unexpected CFX assets list shape: ... | The CFX assets list response format changed. |
| Asset lookup | CFX asset not found by exact name "...". First assets: ... | portalName does not exactly match an owned CFX asset. |
| Asset lookup | CFX asset "..." is missing an id... | The matching CFX asset response is malformed. |
| Upload validation | Version already exists on asset "...": ... | The version from fxmanifest.lua already exists on the CFX asset. |
| Upload create | CFX asset has reached the maximum of 5 versions... | The asset is capped and deleteOldestVersionWhenCapped is disabled. |
| Upload create | MAX_VERSIONS_REACHED | CFX rejected the upload because the asset already has 5 versions. Enable deleteOldestVersionWhenCapped if automatic deletion is desired. |
| Upload cleanup | DELETE .../versions/... failed (...): ... | Automatic oldest-version deletion was enabled, but CFX rejected the delete request. |
| Upload cleanup | Timed out waiting for CFX version deletion... | The oldest version was deleted but CFX did not report the asset below the cap before timeout. |
| Upload create | CFX re-upload rejected: ... | CFX rejected the version creation payload. |
| Upload create | CFX re-upload response missing version_id: ... | CFX did not return the expected version id. |
| Chunk upload | Chunk upload failed for chunk_id=... (...): ... | One ZIP chunk failed to upload. |
| Complete upload | POST .../complete-upload failed (...): ... | CFX rejected upload finalization. |
| Polling | CFX poll timeout waiting for ACTIVE: ... | The asset/version did not become active before timeout. |
| Local files | ENOENT, permission, or filesystem errors | A downloaded or generated ZIP file could not be read or written. |
Local ZIPs and downloaded archives are not kept after failures. To inspect an upload ZIP, reproduce the run locally with a debugger or inspect the GitHub release source.
Troubleshooting
Puppeteer Chrome dependencies on Ubuntu
CFX Uploader uses Puppeteer to authenticate with CFX before the HTTP upload starts. On a minimal Ubuntu server, Chromium may fail to start if system libraries are missing.
Typical error:
Failed to launch the browser process: Code: 127
error while loading shared libraries: libatk-1.0.so.0: cannot open shared object file: No such file or directoryInstall the common Chromium runtime dependencies:
sudo apt update
sudo apt install -y \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libexpat1 \
libgbm1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2Then restart the process running your integration:
pm2 restart <app-name>To verify missing libraries for the Chrome binary downloaded by Puppeteer:
ldd /path/to/puppeteer/chrome/chrome-linux64/chrome | grep "not found"If ldd returns no missing libraries but Chrome still fails, check the error message again. Sandbox-related errors are a separate issue from missing shared libraries.
Additional Documentation
Additional maintainer and integration notes are available in docs/.
GitHub Actions
GitHub Actions usage is documented in:
docs/github-actions-workflow.mdThe recommended short-term integration is the Node library mode from a server that can hold the CFX passkey securely. GitHub Actions becomes more convenient when organization-level secrets are available.
Project Structure
index.js package entrypoint
src/cli CLI entrypoints
src/core reusable upload flows
src/auth CFX auth and passkey resolution
src/github GitHub release lookup and download
src/cfx CFX browser and HTTP upload adapters
src/zip ZIP workflow and metadata parsing
src/config runtime config resolution and local dev config
docs operational and integration documentationScripts
Development scripts available when working in this repository:
npm run register-passkey
npm run orchestrate-http
npm run orchestrate