npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cfx-uploader

v1.3.2

Published

Upload CFX Portal assets from GitHub releases

Readme

CFX Uploader

npm version npm downloads

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 or username/password plus 2FA, 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 the initial CFX authentication, then upload through portal-api.cfx.re. An optional encrypted session cache avoids reopening Puppeteer while the CFX session remains valid.

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

npm install cfx-uploader

The package is CommonJS. The documentation uses ESM imports, but CommonJS applications can use:

const { createUploader, checkAuthentication, upload } = require('cfx-uploader');

Requirements:

  • Node.js 18+
  • npm
  • a GitHub token with access to the target repository releases
  • a CFX passkey credential generated by this package, or CFX username/password credentials with 2FA

Quick Start

  1. Generate a CFX passkey credential:
npx cfx-uploader-register-passkey
  1. Add cfx_uploader.json to the root of each resource repository:
{
  "portalName": "Housing",
  "foldersToZip": ["jo_housing"]
}
  1. 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),
  },
});

For password authentication, configure asynchronous email-verification and 2FA providers. The providers can wait for a console, Discord workflow, webhook, or any other external input:

import path from 'node:path';
import { createUploader } from 'cfx-uploader';

const uploader = createUploader({
  githubToken: process.env.GITHUB_TOKEN,
  auth: {
    method: 'password',
    email: process.env.CFX_UPLOADER_EMAIL,
    password: process.env.CFX_UPLOADER_PASSWORD,
    emailVerificationLinkProvider: async ({ attempt, timeoutMs }) => {
      return await getEmailLoginLinkFromYourWorkflow({ attempt, timeoutMs });
    },
    twoFactorCodeProvider: async ({ attempt, timeoutMs }) => {
      return await getCodeFromYourWorkflow({ attempt, timeoutMs });
    },
  },
  sessionCachePath: path.resolve('.cfx-uploader/session.enc'),
  sessionEncryptionKey: process.env.CFX_UPLOADER_SESSION_KEY,
  twoFactorTimeoutMs: 10 * 60 * 1000,
  emailVerificationTimeoutMs: 10 * 60 * 1000,
  browserProfilePath: path.resolve('.cfx-uploader/browser-profile'),
  headless: true,
});

await uploader.upload({
  repository: 'Jump-On-Studios/RedM-jo_housing',
  releaseTag: 'v1.1.2',
});

See Password Authentication for the complete provider contracts, first-login flow, cache behavior, error codes, and production guidance.

To validate authentication without downloading or uploading a release, run:

npx cfx-uploader-auth --auth-method=password

The command uses the configured encrypted session cache first, then performs one fresh browser authentication when necessary.

Password Authentication

Password authentication is supported by the public upload(), createUploader(), and checkAuthentication() APIs, and by the HTTP and authentication-check CLIs. It is not part of the legacy uploadBrowser flow.

Required configuration

Library mode requires an auth object:

const auth = {
  method: 'password',
  email: process.env.CFX_UPLOADER_EMAIL,
  password: process.env.CFX_UPLOADER_PASSWORD,

  async emailVerificationLinkProvider({ attempt, timeoutMs }) {
    return await waitForCfxEmailLoginLink({ attempt, timeoutMs });
  },

  async twoFactorCodeProvider({ attempt, timeoutMs }) {
    return await waitForCfxTwoFactorCode({ attempt, timeoutMs });
  },
};

Provider contracts:

| Provider | When called | Required return value | |---|---|---| | emailVerificationLinkProvider | Once when CFX reports a new device or location. Strongly recommended for headless and server environments. | An HTTPS URL on the exact hostname forum.cfx.re, with path /session/email-login/<32 hexadecimal characters>. A query string or trailing slash is accepted; credentials and fragments are rejected. | | twoFactorCodeProvider | Once after either supported CFX 2FA screen becomes visible. | A string containing exactly six digits. |

Providers may remain pending while another process collects input from a private Discord interaction, admin UI, secret inbox integration, or terminal. They must not retry the CFX login themselves. The package controls the browser transition and calls each provider at most once per authentication attempt.

emailVerificationLinkProvider is technically optional because known browsers may go directly from password login to 2FA. In production, configure it anyway: CFX can require email verification whenever it considers the Chromium profile a new device or location.

Browser flow

A fresh password authentication follows these states:

  1. Open CFX Portal and enter the Forum SSO handoff.
  2. Fill the visible modern Forum password form and submit it once.
  3. Stop immediately if CFX reports a login rate limit.
  4. If CFX reports a new device or location, request one email-login link and open it in the same Chromium page.
  5. Reject an expired or already-consumed email link without retrying it.
  6. Detect either the classic password-login 2FA form or the email-link 2FA form.
  7. Request one six-digit code and submit the owning form once.
  8. Require an authenticated Forum or Portal DOM state.
  9. Validate the session with GET /v1/me/assets.
  10. If Portal cookies are not ready or the API returns 401/403, perform one Portal SSO handoff and validate once more.
  11. Write the encrypted session cache only after API validation succeeds.

The browser may briefly display an unrecognized empty Forum shell between opening the email link and rendering the 2FA form. The workflow keeps observing DOM state and does not treat that transient page as success.

Session cache and browser profile

For unattended use, configure both an encrypted session cache and a persistent Chromium profile:

const uploader = createUploader({
  githubToken: process.env.GITHUB_TOKEN,
  auth,
  sessionCachePath: '/var/lib/cfx-uploader/session.enc',
  sessionEncryptionKey: process.env.CFX_UPLOADER_SESSION_KEY,
  browserProfilePath: '/var/lib/cfx-uploader/chromium-profile',
  headless: true,
});
  • sessionCachePath stores encrypted CFX cookies and their User-Agent. No cache is created when this option is omitted.
  • sessionEncryptionKey is required whenever a cache path is configured.
  • browserProfilePath preserves the browser identity CFX saw during fresh authentication. It does not replace the encrypted session cache.
  • Use one cache path and one browser profile per CFX account and environment.
  • Do not run multiple unrelated accounts against the same paths.

Use a high-entropy cache key from your secret manager. For example:

openssl rand -base64 32

On every run, the cache is decrypted and validated against the CFX API. A valid cache skips Puppeteer and sets sessionReused: true; checkAuthentication() reports authMethod: 'cached', while an upload may retain the configured fresh-auth method in authMethod. An authentication 401/403 invalidates the cache and starts one fresh browser authentication. Other API and network failures are surfaced without deleting a potentially valid cache.

Authentication check

Validate credentials and refresh the cache without downloading a GitHub release:

import { checkAuthentication } from 'cfx-uploader';

const result = await checkAuthentication({
  auth,
  sessionCachePath: '/var/lib/cfx-uploader/session.enc',
  sessionEncryptionKey: process.env.CFX_UPLOADER_SESSION_KEY,
  browserProfilePath: '/var/lib/cfx-uploader/chromium-profile',
  headless: true,
});

console.log(result);
// Fresh login: { authMethod: 'password', sessionReused: false }
// Valid cache: { authMethod: 'cached', sessionReused: true }

For an interactive terminal:

npx cfx-uploader-auth --auth-method=password

The CLI reads password configuration from environment variables and supplies console providers for both the email-login link and six-digit 2FA code. It requires a real TTY when fresh authentication needs either value.

Production checklist

  • Keep the password, session encryption key, email-login link, and 2FA code in private secret or interaction channels.
  • Persist writable cache and Chromium-profile directories across process restarts.
  • Give each CFX account/environment its own cache and profile paths.
  • Implement both asynchronous providers even if a known browser currently skips email verification.
  • Set provider timeouts long enough for the operator or external workflow to respond.
  • Run checkAuthentication() during deployment or operational checks, before handling a release event.
  • Handle the stable error codes below without automatic login retries.

Password authentication errors

Errors are thrown without automatic password retries. Check error.code for stable authentication conditions:

try {
  await uploader.upload({ repository, releaseTag });
} catch (error) {
  if (error.code === 'CFX_LOGIN_RATE_LIMITED') {
    // Wait for the CFX cooldown. Do not retry immediately.
  } else if (error.code === 'CFX_EMAIL_LOGIN_LINK_INVALID') {
    // Ask the user for a newly generated link, then start a new authentication.
  } else if (error.code === 'CFX_EMAIL_VERIFICATION_TIMEOUT') {
    // The email challenge was not completed before the configured timeout.
  }
  throw error;
}

Never include the email-login URL, password, 2FA code, cookies, or cache contents in application logs. The package sanitizes diagnostic URLs to origin + pathname and redacts every /session/email-login/<token> path.

Passkey Setup

Passkey registration is required only when using the default/passkey authentication mode. Password mode uses the CFX email/username, password, asynchronous email-link provider, and asynchronous 2FA provider instead.

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-passkey

When working inside this repository, the equivalent development script is:

npm run register-passkey

A visible browser opens on the CFX forum security page. In the browser:

  1. Sign in if prompted.
  2. Open the security page if the login flow redirects elsewhere.
  3. Click Add passkey.
  4. Enter a passkey name and confirm.
  5. Return to the terminal and press Enter.

The command writes:

./passkey-credential.json

To replace an existing credential:

npx cfx-uploader-register-passkey --force

passkey-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=1

Resource 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,
});

Base options and per-upload options are shallow-merged. Keep authentication, cache, GitHub token, logging, and working-directory settings in createUploader(), then pass repository- and release-specific values to uploader.upload().

checkAuthentication()

Use checkAuthentication() to validate or refresh a CFX session without downloading a GitHub release or creating an asset version:

import { checkAuthentication } from 'cfx-uploader';

const result = await checkAuthentication({
  auth,
  sessionCachePath,
  sessionEncryptionKey,
  browserProfilePath,
  headless: true,
  onLog: console.log,
});

It returns only:

{
  authMethod: 'cached',
  sessionReused: true
}

checkAuthentication() uses the same cache validation, fresh browser authentication, Portal handoff, timeouts, and password providers as upload().

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 | conditional | Passkey credential object for the default/passkey mode. Required when neither password auth nor a reusable session cache is supplied. | | passkeyJson | no | Alternative JSON string form of the passkey credential. | | auth | conditional | Password authentication object with method: 'password', email, password, twoFactorCodeProvider, and a strongly recommended emailVerificationLinkProvider. Alternative to passkey credentials. | | headless | no | Browser auth mode. Defaults to true. | | browserProfilePath | no | Persistent Chromium user-data directory used to preserve the recognized browser identity. | | headlessFingerprint | no | native (default) or normalized for coherent UA, Client Hints, viewport, and screen metrics. | | workDir | no | Working directory for temporary files. Defaults to an OS temp folder. | | sessionCachePath | no | Explicit path for the encrypted CFX session cache. No cache is used when omitted. | | sessionEncryptionKey | conditional | Secret used to encrypt the session cache. Required with sessionCachePath. | | twoFactorTimeoutMs | no | Maximum time to wait for the 2FA provider. Defaults to 10 minutes. | | emailVerificationTimeoutMs | no | Maximum time to wait for CFX new-device email verification. Defaults to 10 minutes. | | 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. |

auth and passkey are alternative authentication inputs. When auth is omitted, the existing passkey/passkeyJson behavior remains the default. A configured session cache may also be reused before a fresh authentication is needed.

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,
  authMethod: 'password',
  sessionReused: false
}

authMethod is password, passkey, or cached. Because uploads retain the configured authentication source when available, use sessionReused—not authMethod alone—to determine whether Puppeteer was skipped in favor of the encrypted cache.

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';

When a session cache is configured, the first run authenticates with Puppeteer and saves the CFX cookies encrypted. After password/email 2FA returns to the authenticated Forum, the HTTP workflow first validates any Portal/API cookies already available through GET /v1/me/assets. It skips the Portal UI when that succeeds; otherwise, missing cookies or a 401/403 triggers one Portal SSO handoff before a second validation. The encrypted payload is written only after validation and contains only CFX cookies, User-Agent, and cache metadata; it never contains the password, email login link, 2FA code, passkey, or GitHub token. Later runs validate the cached session through the CFX API and skip Puppeteer while it remains valid.

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-auth
npx cfx-uploader-http
npx cfx-uploader-browser

When working inside this repository, install dependencies first:

npm install

The commands read configuration from process.env, so inject production values through your shell, service manager, container platform, or secret manager. The authentication-check command additionally loads .env from the current working directory:

GITHUB_TOKEN=your_github_token

Use the published .env.example as a template. Do not depend on a consumer-project .env being auto-loaded by the upload CLI; either export those variables before invoking it or load them in the parent process.

Local CLI runs use:

  • src/config/mock-config.js for githubRepository
  • passkey-credential.json for local passkey auth
  • cfx_uploader.json from the downloaded release when present

HTTP Mode

Recommended automation mode:

npx cfx-uploader-http

Use a specific release tag:

npx cfx-uploader-http --release-tag=v1.1.2

Show the browser during authentication:

npx cfx-uploader-http --show-browser

Force the CFX release type:

npx cfx-uploader-http --release-candidate
npx cfx-uploader-http --full-release

To use username/password authentication from the CLI:

CFX_UPLOADER_AUTH_METHOD=password
CFX_UPLOADER_EMAIL=your_email
CFX_UPLOADER_PASSWORD="your_password"
CFX_UPLOADER_SESSION_CACHE_PATH=/var/lib/cfx-uploader/cfx-session.enc
CFX_UPLOADER_SESSION_KEY=your_secret_manager_key
CFX_UPLOADER_2FA_TIMEOUT_MS=600000
CFX_UPLOADER_EMAIL_VERIFICATION_TIMEOUT_MS=600000
CFX_UPLOADER_BROWSER_PROFILE_PATH=/var/lib/cfx-uploader/browser-profile
CFX_UPLOADER_HEADLESS_FINGERPRINT=native

Quote the password in .env when it contains # (for example, CFX_UPLOADER_PASSWORD="abc#123"). The CLI also preserves an unquoted # in this specific password variable.

Validate and populate the session cache before the first upload:

npx cfx-uploader-auth --auth-method=password

Then upload:

npx cfx-uploader-http --auth-method=password

On a fresh password login, the CLI may first ask for the email-login link sent by CFX and then for the six-digit authenticator code. These prompts appear only when required by the CFX browser state. When the encrypted cache remains valid, authentication completes without launching Chromium or prompting for either value.

Use --show-browser to debug a fresh login:

npx cfx-uploader-auth --auth-method=password --show-browser

The interactive providers require stdin and stdout connected to a TTY. For services, containers without an interactive terminal, webhooks, and Discord bots, use the library API and provide asynchronous providers instead.

Control 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-retention

Browser Mode

Browser mode drives the full CFX Portal UI with Puppeteer. It is useful for debugging portal changes. It remains dedicated to the existing passkey flow; username/password authentication and session caching are available through the library, HTTP CLI, and authentication-check CLI.

npx cfx-uploader-browser

Browser 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-retention

Release 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-release

When 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.0
  • v1.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:

  1. fails first if the version from fxmanifest.lua already exists;
  2. detects CFX 409 MAX_VERSIONS_REACHED during version creation;
  3. refetches the asset details;
  4. deletes the version with the oldest created_at;
  5. waits until the asset is below the limit;
  6. 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 | conditional | GitHub token used by uploads. It is not required by checkAuthentication() or cfx-uploader-auth. | | CFX_UPLOADER_CREDENTIAL_ID | conditional | Passkey credential id. Required for passkey mode. | | CFX_UPLOADER_RP_ID | conditional | Usually forum.cfx.re; required for passkey mode. | | CFX_UPLOADER_PRIVATE_KEY | conditional | Passkey private key; required for passkey mode. | | CFX_UPLOADER_USER_HANDLE | conditional | Passkey user handle; required for passkey mode. | | CFX_UPLOADER_SIGN_COUNT | conditional | Passkey sign count as a number; required for passkey mode. | | CFX_UPLOADER_AUTH_METHOD | no | Authentication method for cfx-uploader-http and cfx-uploader-auth: passkey (default) or password. An explicit --auth-method flag takes precedence. | | CFX_UPLOADER_EMAIL | conditional | CFX email; required for password mode. | | CFX_UPLOADER_PASSWORD | conditional | CFX password; required for password mode. | | CFX_UPLOADER_SESSION_CACHE_PATH | no | Explicit encrypted CFX session cache path. | | CFX_UPLOADER_SESSION_KEY | conditional | Encryption secret required whenever CFX_UPLOADER_SESSION_CACHE_PATH is set. | | CFX_UPLOADER_2FA_TIMEOUT_MS | no | CLI 2FA provider timeout in milliseconds. Defaults to 600000. | | CFX_UPLOADER_EMAIL_VERIFICATION_TIMEOUT_MS | no | CLI email-link provider timeout in milliseconds. Defaults to 600000. | | CFX_UPLOADER_BROWSER_PROFILE_PATH | no | Persistent Chromium profile directory used by fresh browser authentication. Recommended for password mode. | | CFX_UPLOADER_HEADLESS_FINGERPRINT | no | native (default) or normalized. Start with native; use normalized only when the deployment needs the normalized Chromium metadata profile. | | 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.
  • Never log passwords, email-login URLs, 2FA codes, CFX cookies, or session-cache contents.
  • Store production passkey values in a secret manager or private server environment.
  • Store CFX_UPLOADER_EMAIL and CFX_UPLOADER_PASSWORD in a secret manager. Do not embed them in source code.
  • Store CFX_UPLOADER_SESSION_KEY in a secret manager and use one session-cache path per CFX account/environment.
  • Restrict filesystem access to the session cache and persistent Chromium profile even though the session cache itself is encrypted.
  • The interactive CLI uses normal terminal prompts, so typed email-login links and 2FA codes may be visible in terminal capture, scrollback, or copied output. Use library providers backed by private interactions for production automation.
  • Diagnostic URLs omit query strings and redact email-login token paths. Application callbacks must apply the same rule to their own logs.
  • Use the narrowest GitHub token scope that can read the target repository releases.
  • Treat deleteOldestVersionWhenCapped as 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.

Stable password-authentication conditions are exposed through error.code; the code is not necessarily repeated in error.message.

Common errors:

| Area | Error or code | 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. | | Password auth | Upload auth... / Password authentication requires ... | Email, password, or an asynchronous 2FA provider is missing or invalid. | | Password auth | CFX email verification link is not an allowed... | The email provider returned a URL that failed the strict HTTPS, hostname, path, token, credentials, or fragment validation. | | Password auth | CFX email verification link provider failed. | The external email-link provider rejected or failed. Its underlying error is intentionally not exposed. | | Password auth | CFX_EMAIL_VERIFICATION_TIMEOUT | The new-device email challenge or its provider was not completed before emailVerificationTimeoutMs. No password retry is performed. | | Password auth | CFX 2FA provider must return exactly six digits. | The external 2FA provider returned an invalid value. | | Password auth | CFX 2FA code provider timed out. | No 2FA code was supplied before the configured timeout. | | Password auth | CFX_EMAIL_LOGIN_LINK_INVALID | The supplied email-login link is expired, already consumed, or invalid. Request a new link and restart authentication. | | Password auth | CFX_LOGIN_RATE_LIMITED | CFX is throttling login attempts. Wait for the cooldown; the library does not retry automatically. | | Password auth | CFX password login button #login-button was not found or was not actionable. | The current Forum password DOM no longer matches the supported modern form, or the button is hidden/disabled. Retry with headless: false and report the sanitized page state. | | Password auth | CFX 2FA submission did not reach an authenticated Forum or Portal state. | CFX rejected the code or the post-submit DOM did not reach a supported authenticated state. The package does not click a second time. | | Session cache | CFX session cache requires ... | A cache path was configured without an encryption key. | | Session cache | Unable to decrypt CFX session cache... | The cache key is wrong or the encrypted file is corrupted. | | 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 | CFX Portal ... did not reach Created Assets. State: ... | Browser authentication or the single Portal SSO handoff did not reach a visible authenticated Portal state. | | CFX auth | Puppeteer/WebAuthn errors | Chromium failed to launch, inject the virtual authenticator, or complete navigation. | | CFX session | No Portal/API 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 directory

Install 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 \
  libxrandr2

Then 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.md

The 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, 2FA provider, and encrypted session cache
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 documentation

Scripts

Development scripts available when working in this repository:

npm run register-passkey
npm run orchestrate-http
npm run orchestrate