Files
dotcom-arcade/docs/superpowers/plans/2026-08-13-ruffle-persistent-install.md
T

27 KiB

Persistent Ruffle Installation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Implementer assignments: each task names its implementer agent in an **Implementer:** line. When executing with superpowers:subagent-driven-development, REQUIRED SUB-SKILL: dcc-superpower-companions:dispatching-tiered-implementers. Under superpowers:executing-plans these lines are inert; ignore them.

Goal: A Ruffle version installed through Settings → Update survives container recreation.

Architecture: Ruffle installs into /app/data/ruffle, which sits on the already-mounted data volume, instead of /app/frontend/dist/ruffle, which lives in the container's throwaway writable layer. A fresh volume is seeded from the copy baked into the image, so Flash works immediately and offline. Express serves the persisted directory at /ruffle, ahead of the dist handler and with cache headers that let an updated loader actually reach browsers.

Tech Stack: Express + TypeScript, vitest + supertest, Docker Compose.

Spec: docs/superpowers/specs/2026-08-13-ruffle-persistent-install-design.md

Global Constraints

  • Backend tests run from the backend/ directory with npx vitest run. Do not use npm test from the repository root — it enters watch mode and hangs.
  • Never introduce any types or non-null assertions (!). Use ?? for defaults, never ||.
  • Use logger from ../utils/logger, never console.
  • Run npx prettier --write <files> on every changed file before committing.
  • The environment variable name is exactly RUFFLE_DATA_PATH. The production default is exactly /app/data/ruffle.
  • config.ruffleDataPath is the install directory itself, not its parent. path.join(config.ruffleDataPath, 'ruffle') is always wrong.
  • Ruffle's content-hashed files are detected by a hex run of 16 or more characters in the basename (core.ruffle.4ca82b563e9711217164.js, f2a570ccf4468b20d95a.wasm). Everything else — ruffle.js, ruffle.js.map, package.json — is stable-named and must revalidate.
  • Do not delete frontend/public/ruffle/. It is the seed source, and Vite serves it in development.
  • Do not add a release-note entry to docs/09-deployment/upgrade-guide.md. That file gains a version heading only when a release is cut, and inventing a version number here would be wrong.

Task 1: Persisted install path

Files:

  • Modify: backend/src/config.ts:118-119
  • Modify: backend/src/services/RuffleService.ts:41-52
  • Test: backend/src/services/RuffleService.test.ts:7,78-85,124-126,138-157

Interfaces:

  • Consumes: nothing.
  • Produces: config.ruffleDataPath: string — the absolute directory Ruffle is installed into. RuffleService gains a private installPath (the install target) and a private bundledPath (the image's baked copy at <frontendDistPath>/ruffle), both read by Task 2.

Implementer: dcc-superpower-companions:impl-sonnet-max Evaluation: files 1 - spec 0 - coupling 2 - risk 2 = 5

  • Step 1: Add the config value

In backend/src/config.ts, find this block:

  // __dirname is backend/dist at runtime, so this resolves to the frontend build
  // copied next to the backend in the image (/app/frontend/dist).
  frontendDistPath:
    process.env.FRONTEND_DIST_PATH ?? path.resolve(__dirname, '../../frontend/dist'),

Insert directly after it:

  // Ruffle installs here rather than under the served frontend build. That build
  // is part of the image, so it lives in the container's writable layer and an
  // in-app update was discarded whenever the container was recreated.
  ruffleDataPath:
    process.env.RUFFLE_DATA_PATH ??
    (process.env.NODE_ENV === 'production'
      ? '/app/data/ruffle'
      : path.resolve(__dirname, '../data/ruffle')),
  • Step 2: Update the test fixtures to a separate data directory

In backend/src/services/RuffleService.test.ts, change the declaration on line 7:

let distDir: string;

to:

let distDir: string;
let dataDir: string;

Replace the config mock (lines 78-85):

vi.mock('../config', () => ({
  config: {
    serveFrontend: true,
    get frontendDistPath() {
      return distDir;
    },
  },
}));

with:

vi.mock('../config', () => ({
  config: {
    serveFrontend: true,
    get frontendDistPath() {
      return distDir;
    },
    get ruffleDataPath() {
      return nodePath.join(dataDir, 'ruffle');
    },
  },
}));

Replace the ruffleDir helper (lines 124-126):

function ruffleDir(): string {
  return nodePath.join(distDir, 'ruffle');
}

with:

function ruffleDir(): string {
  return nodePath.join(dataDir, 'ruffle');
}

In beforeEach, replace the single distDir assignment:

  distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-'));

with:

  distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-dist-'));
  dataDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-data-'));

In afterEach, replace:

  nodeFs.rmSync(distDir, { recursive: true, force: true });

with:

  nodeFs.rmSync(distDir, { recursive: true, force: true });
  nodeFs.rmSync(dataDir, { recursive: true, force: true });
  • Step 3: Run the tests to verify they fail

Run from backend/: npx vitest run src/services/RuffleService.test.ts

Expected: FAIL. The suite now asserts against dataDir/ruffle while the service still installs into distDir/ruffle, so assertions such as expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build') throw ENOENT.

  • Step 4: Point the service at the persisted path

In backend/src/services/RuffleService.ts, replace the field declaration and constructor (lines 41-52):

  private readonly frontendPublicPath: string;
  private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';

  constructor() {
    // Install into the directory that is actually served. In a built deployment
    // that is the frontend's dist output; in development Vite serves public/.
    // Pointing at public/ in a container made every fresh start re-download
    // Ruffle even though the image already ships it in dist/.
    this.frontendPublicPath = config.serveFrontend
      ? path.join(config.frontendDistPath, 'ruffle')
      : path.resolve(__dirname, '../../../frontend/public/ruffle');
  }

with:

  private readonly installPath: string;
  private readonly bundledPath: string;
  private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';

  constructor() {
    // A built deployment installs onto the mounted data volume and serves it
    // from there: the frontend build is part of the image, so anything written
    // into it is discarded when the container is recreated. In development Vite
    // serves public/, which is the only directory reachable there.
    this.installPath = config.serveFrontend
      ? config.ruffleDataPath
      : path.resolve(__dirname, '../../../frontend/public/ruffle');
    this.bundledPath = path.join(config.frontendDistPath, 'ruffle');
  }

Then rename the remaining nine references. Every other occurrence of this.frontendPublicPath in the file becomes this.installPath — lines 81, 447, 495, 520, 526, 527, 532, 546, and 576. A whole-word replace of frontendPublicPath with installPath across the file is exact; there are no other matches.

  • Step 5: Run the tests to verify they pass

Run from backend/: npx vitest run src/services/RuffleService.test.ts

Expected: PASS, all existing cases green. They now exercise install, backup, rollback, and the rename/copy fallbacks against the persisted directory.

  • Step 6: Typecheck

Run from the repository root: npm run typecheck

Expected: no errors.

  • Step 7: Commit
npx prettier --write backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git add backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git commit -m "fix(ruffle): install onto the persisted data volume"

Task 2: Seed a fresh volume from the bundled copy

Files:

  • Modify: backend/src/services/RuffleService.ts (add two methods after verifyInstallation)
  • Modify: backend/src/server.ts:296-314
  • Test: backend/src/services/RuffleService.test.ts (append a new describe block)

Interfaces:

  • Consumes: this.installPath and this.bundledPath from Task 1; the existing private copyDirectory(source: string, target: string): void.
  • Produces: RuffleService.ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'>, called by backend/src/server.ts.

Implementer: dcc-superpower-companions:impl-sonnet-xhigh Evaluation: files 1 - spec 0 - coupling 2 - risk 1 = 4

  • Step 1: Write the failing tests

Append to backend/src/services/RuffleService.test.ts:

function installBundledRuffle(): void {
  const bundled = nodePath.join(distDir, 'ruffle');
  nodeFs.mkdirSync(bundled, { recursive: true });
  nodeFs.writeFileSync(nodePath.join(bundled, 'ruffle.js'), 'bundled build');
  nodeFs.writeFileSync(
    nodePath.join(bundled, 'package.json'),
    JSON.stringify({ version: '0.2.0-nightly.2026.1.29' })
  );
}

describe('RuffleService.ensureInstalled', () => {
  it('leaves an existing installation alone', async () => {
    installExistingRuffle();
    installBundledRuffle();

    const result = await new RuffleService().ensureInstalled();

    expect(result).toBe('present');
    expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('old build');
    expect(axios.get).not.toHaveBeenCalled();
  });

  it('seeds from the bundled copy when nothing is installed', async () => {
    installBundledRuffle();
    const service = new RuffleService();

    const result = await service.ensureInstalled();

    expect(result).toBe('seeded');
    expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe(
      'bundled build'
    );
    expect(service.getCurrentVersion()).toBe('0.2.0-nightly.2026.1.29');
    expect(axios.get).not.toHaveBeenCalled();
  });

  it('downloads when neither an installation nor a bundled copy exists', async () => {
    const result = await new RuffleService().ensureInstalled();

    expect(result).toBe('downloaded');
    expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build');
    expect(axios.get).toHaveBeenCalled();
  });
});
  • Step 2: Run the tests to verify they fail

Run from backend/: npx vitest run src/services/RuffleService.test.ts -t ensureInstalled

Expected: FAIL with ensureInstalled is not a function.

  • Step 3: Implement the methods

In backend/src/services/RuffleService.ts, add both methods directly after verifyInstallation(), inside the class:

  /**
   * Copy the Ruffle shipped inside the image into the install directory. A fresh
   * data volume otherwise has no player at all until a download finishes — and
   * never gets one on a host that cannot reach GitHub.
   */
  private seedFromBundle(): boolean {
    if (this.bundledPath === this.installPath) {
      return false;
    }
    if (!fs.existsSync(path.join(this.bundledPath, 'ruffle.js'))) {
      return false;
    }
    try {
      this.copyDirectory(this.bundledPath, this.installPath);
      logger.info(
        `✅ Ruffle seeded from the bundled copy (version: ${this.getCurrentVersion() ?? 'unknown'})`
      );
      return true;
    } catch (error: unknown) {
      logger.warn('[RuffleService] Could not seed Ruffle from the bundled copy:', error);
      return false;
    }
  }

  /**
   * Make a Ruffle available, preferring what is already installed, then the copy
   * baked into the image, and only then a download.
   */
  async ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'> {
    if (this.verifyInstallation()) {
      logger.info(`✅ Ruffle verified (version: ${this.getCurrentVersion() ?? 'unknown'})`);
      return 'present';
    }

    if (this.seedFromBundle()) {
      return 'seeded';
    }

    logger.info('🎮 Ruffle not found, downloading latest version...');
    await this.updateRuffle();
    logger.info('✅ Ruffle installation complete');
    return 'downloaded';
  }
  • Step 4: Run the tests to verify they pass

Run from backend/: npx vitest run src/services/RuffleService.test.ts

Expected: PASS, including the three new cases and every pre-existing one.

  • Step 5: Call it from startup

In backend/src/server.ts, replace lines 296-314:

  // Ruffle installs in the background: it downloads from GitHub, and awaiting it
  // here kept the port closed for minutes on slow storage — indefinitely if the
  // download stalled. Flash games need it, everything else does not.
  void (async () => {
    try {
      const ruffleService = new RuffleService();
      if (!ruffleService.verifyInstallation()) {
        logger.info('🎮 Ruffle not found, downloading latest version...');
        await ruffleService.updateRuffle();
        logger.info('✅ Ruffle installation complete');
      } else {
        const version = ruffleService.getCurrentVersion();
        logger.info(`✅ Ruffle verified (version: ${version || 'unknown'})`);
      }
    } catch (error) {
      logger.error('Failed to install Ruffle:', error);
      logger.warn('⚠️  Continuing without Ruffle - Flash games will not work');
    }
  })();

with:

  // Ruffle resolves in the background: seeding copies ~20 MB and the download
  // fallback reaches GitHub, and awaiting either here kept the port closed for
  // minutes on slow storage. Flash games need it, everything else does not.
  void (async () => {
    try {
      await new RuffleService().ensureInstalled();
    } catch (error) {
      logger.error('Failed to install Ruffle:', error);
      logger.warn('⚠️  Continuing without Ruffle - Flash games will not work');
    }
  })();
  • Step 6: Typecheck and run the full backend suite

Run from the repository root: npm run typecheck

Then run from backend/: npx vitest run

Expected: no type errors, no new test failures.

  • Step 7: Commit
npx prettier --write backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git add backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git commit -m "feat(ruffle): seed a fresh data volume from the bundled copy"

Task 3: Serve the persisted directory with correct cache headers

Files:

  • Modify: backend/src/middleware/frontendServing.ts:20-55
  • Modify: backend/src/middleware/startupOrdering.test.ts:12-17
  • Create: backend/src/middleware/ruffleServing.test.ts

Interfaces:

  • Consumes: config.ruffleDataPath from Task 1.
  • Produces: nothing consumed by later tasks. registerFrontendServing(app, distPath?) keeps its existing signature; the Ruffle mount reads config.ruffleDataPath directly.

Implementer: dcc-superpower-companions:impl-sonnet-max Evaluation: files 1 - spec 0 - coupling 2 - risk 2 = 5

  • Step 1: Write the failing test

Create backend/src/middleware/ruffleServing.test.ts:

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express, { Express } from 'express';
import request from 'supertest';
import fs from 'fs';
import os from 'os';
import path from 'path';

vi.mock('../utils/logger', () => ({
  logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));

let ruffleDir: string;

vi.mock('../config', () => ({
  config: {
    serveFrontend: true,
    get ruffleDataPath() {
      return ruffleDir;
    },
  },
}));

import { registerFrontendServing } from './frontendServing';

const HASHED_CHUNK = 'core.ruffle.4ca82b563e9711217164.js';

let distDir: string;
let app: Express;

beforeEach(() => {
  distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-dist-'));
  ruffleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-ruffle-'));

  fs.writeFileSync(path.join(distDir, 'index.html'), '<!doctype html><div id="root"></div>');
  fs.mkdirSync(path.join(distDir, 'ruffle'));
  fs.writeFileSync(path.join(distDir, 'ruffle', 'ruffle.js'), 'bundled loader');
  fs.writeFileSync(path.join(distDir, 'ruffle', 'only-in-bundle.js'), 'bundled extra');

  fs.writeFileSync(path.join(ruffleDir, 'ruffle.js'), 'updated loader');
  fs.writeFileSync(path.join(ruffleDir, HASHED_CHUNK), 'updated chunk');

  app = express();
  registerFrontendServing(app, distDir);
});

afterEach(() => {
  fs.rmSync(distDir, { recursive: true, force: true });
  fs.rmSync(ruffleDir, { recursive: true, force: true });
});

describe('Ruffle serving', () => {
  it('serves the persisted copy in preference to the bundled one', async () => {
    const response = await request(app).get('/ruffle/ruffle.js');

    expect(response.status).toBe(200);
    expect(response.text).toBe('updated loader');
  });

  it('falls back to the bundled copy for a file the persisted copy lacks', async () => {
    const response = await request(app).get('/ruffle/only-in-bundle.js');

    expect(response.status).toBe(200);
    expect(response.text).toBe('bundled extra');
  });

  it('makes the stable-named loader revalidate', async () => {
    const response = await request(app).get('/ruffle/ruffle.js');

    expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
  });

  it('makes a content-hashed chunk immutable', async () => {
    const response = await request(app).get(`/ruffle/${HASHED_CHUNK}`);

    expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
  });

  it('makes the bundled fallback loader revalidate too', async () => {
    const response = await request(app).get('/ruffle/only-in-bundle.js');

    expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
  });
});
  • Step 2: Run the test to verify it fails

Run from backend/: npx vitest run src/middleware/ruffleServing.test.ts

Expected: FAIL. /ruffle/ruffle.js returns bundled loader because no persisted mount exists, and its Cache-Control is public, max-age=31536000, immutable.

  • Step 3: Add the mount and the cache policy

In backend/src/middleware/frontendServing.ts, add this import alongside the existing ones:

import type { Response } from 'express';

Add these two declarations directly above export function registerFrontendServing:

/**
 * Ruffle content-hashes its chunks (`core.ruffle.4ca82b563e9711217164.js`,
 * `f2a570ccf4468b20d95a.wasm`) but not its entry points. Detecting the hash
 * rather than listing the stable names keeps this correct when a release adds a
 * file.
 */
const HASHED_RUFFLE_ASSET = /[0-9a-f]{16,}/i;

/**
 * `ruffle.js` keeps its name across versions, so serving it immutable pinned
 * every returning browser to the loader it first saw — an update reached the
 * disk and nothing else.
 */
function setRuffleCacheControl(res: Response, filePath: string): void {
  res.setHeader(
    'Cache-Control',
    HASHED_RUFFLE_ASSET.test(path.basename(filePath))
      ? 'public, max-age=31536000, immutable'
      : 'no-cache, no-store, must-revalidate'
  );
}

Inside registerFrontendServing, insert this immediately after the index.html existence guard and before the existing app.use(express.static(distPath, ...)) call:

  // Ahead of the dist handler so an updated Ruffle shadows the one baked into
  // the image. `fallthrough` lets the baked copy answer for anything the
  // persisted directory is missing, including before the first seed finishes.
  app.use(
    '/ruffle',
    express.static(config.ruffleDataPath, {
      index: false,
      fallthrough: true,
      setHeaders: setRuffleCacheControl,
    })
  );

Then replace the setHeaders option on the existing dist handler:

      setHeaders: (res, filePath) => {
        // Hashed assets are immutable; index.html must always be revalidated.
        if (filePath.endsWith('index.html')) {
          res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
        }
      },

with:

      setHeaders: (res, filePath) => {
        // Hashed assets are immutable; index.html must always be revalidated.
        if (filePath.endsWith('index.html')) {
          res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
          return;
        }
        if (filePath.startsWith(bundledRufflePath + path.sep)) {
          setRuffleCacheControl(res, filePath);
        }
      },

and declare bundledRufflePath at the top of the function body, directly below the existing const indexHtml = path.join(distPath, 'index.html');:

  const bundledRufflePath = path.join(distPath, 'ruffle');
  • Step 4: Run the test to verify it passes

Run from backend/: npx vitest run src/middleware/ruffleServing.test.ts

Expected: PASS, all five cases.

  • Step 5: Repair the sibling middleware test's config mock

backend/src/middleware/startupOrdering.test.ts mocks ../config without ruffleDataPath, and express.static(undefined) throws TypeError: root path required. Replace its mock (lines 12-17):

vi.mock('../config', () => ({
  config: {
    serveFrontend: true,
    frontendDistPath: '/unused-in-tests',
  },
}));

with:

vi.mock('../config', () => ({
  config: {
    serveFrontend: true,
    frontendDistPath: '/unused-in-tests',
    ruffleDataPath: '/unused-in-tests/ruffle',
  },
}));
  • Step 6: Run the full backend suite and typecheck

Run from backend/: npx vitest run

Then run from the repository root: npm run typecheck

Expected: no failures and no type errors. startupOrdering.test.ts in particular must stay green — a missing-directory static mount with fallthrough: true calls next() instead of throwing.

  • Step 7: Commit
npx prettier --write backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git add backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git commit -m "fix(ruffle): serve the persisted install ahead of the bundled one"

Task 4: Deployment configuration and documentation

Files:

  • Modify: docker-compose.dev.yml:28-40
  • Modify: docker-compose.yml:29-33
  • Modify: docs/09-deployment/environment-variables.md:113-116

Interfaces:

  • Consumes: the RUFFLE_DATA_PATH variable name and the /app/data/ruffle default from Task 1.
  • Produces: nothing.

Implementer: dcc-superpower-companions:impl-sonnet-medium Evaluation: files 1 - spec 0 - coupling 1 - risk 0 = 2

  • Step 1: Set the variable in the dev compose file

docker-compose.dev.yml runs NODE_ENV=development with SERVE_FRONTEND=true, so the production-keyed default resolves to a relative path inside the container and misses the backend-db:/app/data volume. In docker-compose.dev.yml, find:

      # Matches the read-only mount above. Set explicitly because the backend's
      # fallback only points here when NODE_ENV=production.
      - FLASHPOINT_PATH=/data/flashpoint

Insert directly after it:

      # Same reason as FLASHPOINT_PATH above: this container runs
      # NODE_ENV=development, so the production default would put Ruffle outside
      # the data volume and lose every update on recreate.
      - RUFFLE_DATA_PATH=/app/data/ruffle
  • Step 2: Update both volume comments

In docker-compose.yml, find:

      - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
      - ${DATA_PATH:-./data}:/app/data

Replace with:

      - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
      # Holds user.db and the installed Ruffle. Without this mount an in-app
      # Ruffle update is lost the next time the container is recreated.
      - ${DATA_PATH:-./data}:/app/data

In docker-compose.dev.yml, find:

      - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
      - backend-db:/app/data

Replace with:

      - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
      # Holds user.db and the installed Ruffle. Without this mount an in-app
      # Ruffle update is lost the next time the container is recreated.
      - backend-db:/app/data
  • Step 3: Document the variable

In docs/09-deployment/environment-variables.md, find this table:

| Variable             | Default                       | Description                                                    |
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
| `SERVE_FRONTEND`     | true in production, else false | Serve the built frontend and SPA fallback from the backend    |
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist`  | Location of the built frontend (rarely needed)                 |

Add one row to it:

| `RUFFLE_DATA_PATH`   | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
  • Step 4: Verify both compose files still parse

Run from the repository root:

docker compose -f docker-compose.yml config --quiet
docker compose -f docker-compose.dev.yml config --quiet

Expected: both exit 0 and print nothing. Each file requires FLASHPOINT_HOST_PATH and JWT_SECRET; if the repository has no .env, supply throwaway values inline:

FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.yml config --quiet
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.dev.yml config --quiet

If the Docker CLI is unavailable in this environment, say so rather than skipping silently, and parse both files directly instead:

node -e "const y=require('yaml');const fs=require('fs');for(const f of ['docker-compose.yml','docker-compose.dev.yml']){y.parse(fs.readFileSync(f,'utf8'));console.log(f,'ok')}"

The yaml package is already present in the repository's node_modules as a transitive dependency. This proves the YAML parses; it does not check Compose semantics, so report which of the two checks you ran.

  • Step 5: Commit
npx prettier --write docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git add docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git commit -m "docs(ruffle): document the persisted install path"

Verification

After Task 4, confirm the whole change from the repository root:

npm run typecheck
npm run build

Then from backend/: npx vitest run

Expected: typecheck clean, build succeeds, backend suite green.

Manual confirmation in a container, which is what the reported bug needs:

  1. docker compose -f docker-compose.dev.yml up -d --build
  2. Settings → Update → update Ruffle, and note the version.
  3. docker compose -f docker-compose.dev.yml up -d --force-recreate
  4. Settings → Update still reports the updated version, and a hard-refreshed browser loads it.