Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/PROJECT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN

## Log

### 2026-09-30 — Opt-in exact-source asset import (#198)

- `process.mjs --only=<relative-image-path>` may be repeated for newly reserved staged sources; it retains and validates all registered runtime entries and the global budget but never consumes unselected legacy inputs. Absolute, traversal, runtime/package, duplicate, symlink, and nonexistent selections fail before mutation. The no-flag global behavior remains unchanged.
- Needed for #197 car-loadout art: global dry run currently fails on an unrelated tracked app icon. No existing icons or package textures were modified or moved by this tooling change.

### 2026-09-30 — Welcome hint no longer steals the phone Launch Attack tap (#193)

- At 375×812 the fixed welcome hint sits on top of the enabled OPS PLAN Launch Attack control. The hint stays visible so it can still teach the next step, and `pointer-events: none` lets the tap pass through to the button.
Expand Down
28 changes: 27 additions & 1 deletion frontend/scripts/assets/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
// node scripts/assets/process.mjs # dry run
// node scripts/assets/process.mjs --write
// node scripts/assets/process.mjs --write --quality-pass=high
// node scripts/assets/process.mjs --only=generated/environments/street/new_plate.png
// node scripts/assets/process.mjs --write --only=generated/environments/street/new_plate.png
Comment on lines +23 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the required asset workflow for --only

The new scoped command contradicts the repository's required asset-integration workflow: .agents/skills/slide-asset-integration-qa/SKILL.md:20 and its references/runtime-contracts-and-gates.md:56-69 still state that process.mjs has no per-file scope, document only the global commands, and instruct contributors to stop when unrelated inputs appear. Because contributors are required to use that skill, they will not discover or use this fix in precisely the blocked-import scenario it addresses; update the canonical workflow and reference alongside the CLI.

AGENTS.md reference: AGENTS.md:L10-L16

Useful? React with 👍 / 👎.

// ============================================================

import { promises as fs } from 'node:fs';
Expand All @@ -30,6 +32,7 @@ import sharp from 'sharp';
const ARGV = process.argv.slice(2);
const WRITE = ARGV.includes('--write');
const HIGH = ARGV.includes('--quality-pass=high');
const ONLY = ARGV.filter(arg => arg.startsWith('--only=')).map(arg => arg.slice('--only='.length));

const FRONTEND = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..');
const REPO = path.resolve(FRONTEND, '..');
Expand Down Expand Up @@ -240,7 +243,30 @@ async function cleanup(buf, needsAlpha) {
// ─── Main ────────────────────────────────────────────────────

async function main() {
const files = await walk(LEGACY_ASSETS);
if (ARGV.some(arg => arg === '--only' || (arg.startsWith('--only') && !arg.startsWith('--only=')))) {
throw new Error('Invalid --only source: expected --only=<exact relative image path>');
}
const seen = new Set();
const selected = [];
for (const rel of ONLY) {
if (!rel || rel.includes('\\') || path.posix.isAbsolute(rel) || path.isAbsolute(rel) ||
path.posix.normalize(rel) !== rel || rel.startsWith('.') ||
rel.startsWith('runtime/') || rel.startsWith('packages/') ||
!/\.(png|webp|jpe?g)$/i.test(rel) || seen.has(rel)) {
throw new Error(`Invalid --only source: ${rel}`);
}
const abs = path.resolve(LEGACY_ASSETS, rel);
if (!abs.startsWith(LEGACY_ASSETS + path.sep)) throw new Error(`Invalid --only source: ${rel}`);
try {
const stat = await fs.lstat(abs);
if (!stat.isFile() || await fs.realpath(abs) !== abs) throw new Error('not a regular source');
} catch {
throw new Error(`Invalid --only source: ${rel} is missing or not a regular file`);
}
seen.add(rel);
selected.push(abs);
}
const files = ONLY.length ? selected : await walk(LEGACY_ASSETS);
const mb = (b) => (b / 1048576).toFixed(2) + ' MB';
let previous = null;
try {
Expand Down
43 changes: 43 additions & 0 deletions frontend/scripts/assets/process.scoped.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test } from 'node:test';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the scoped regression in the standard test gate

This standalone node:test file is never exercised by the repository's normal validation: vitest.config.ts includes only src/**/*.{test,spec}.{ts,tsx}, npm run validate invokes only Vitest, and the inspected .github/workflows/ci.yml does not run node --test. Consequently, both new --only regressions are skipped by CI and can silently regress; move the test into the configured suite or add an explicit command to the validation script.

AGENTS.md reference: AGENTS.md:L12-L14

Useful? React with 👍 / 👎.

import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';

const frontend = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const source = path.join(frontend, 'public/assets/generated/environments/street/block_scoped_pipeline_test_v001.png');
const manifest = path.join(frontend, 'src/assets/runtimeManifest.json');
const runtime = path.join(frontend, 'public/assets/runtime/generated/environments/street/block_scoped_pipeline_test_v001.webp');
const rel = 'generated/environments/street/block_scoped_pipeline_test_v001.png';
function run(...args) {
return spawnSync(process.execPath, ['scripts/assets/process.mjs', ...args], {
cwd: frontend, encoding: 'utf8', timeout: 120000,
});
}

test('exact-source dry run ignores unrelated legacy art and retains the registered manifest without writes', async () => {
await fs.mkdir(path.dirname(source), { recursive: true });
const before = await fs.readFile(manifest);
try {
await sharp({ create: { width: 48, height: 32, channels: 3, background: '#23374a' } }).png().toFile(source);
const result = run(`--only=${rel}`);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /1 source images/);
assert.match(result.stdout, /Retaining \d+ registered runtime images/);
assert.match(result.stdout, /block_scoped_pipeline_test_v001/);
assert.doesNotMatch(result.stdout, /icons\/apps\/market\/hover\.png ->/);
assert.deepEqual(await fs.readFile(manifest), before);
await assert.rejects(fs.stat(runtime), { code: 'ENOENT' });
assert.ok((await fs.stat(source)).isFile());
} finally { await fs.rm(source, { force: true }); }
});

test('refuses traversal and runtime paths rather than expanding scope', () => {
for (const invalid of ['../outside.png', '/tmp/outside.png', 'runtime/private.png', 'generated/../../icons/apps/market/hover.png']) {
const result = run(`--only=${invalid}`);
assert.notEqual(result.status, 0, `unsafe source ${invalid} must fail`);
assert.match(result.stderr, /Invalid --only source/);
}
});
Loading