Runner: Projekterkennung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:53:41 +00:00
1 parent bc5fddf13f
commit e2c517a015
3 files changed
+240

No files matched your search

@@ -0,0 +1,17 @@
# Task 2 Report: Projekterkennung
Implemented runner/src/detect.js (detectProject, detectFromDir) + runner/test/detect.test.js (19 tests).
TDD: RED = ERR_ASSERTION / module not found (detect.js missing); first GREEN attempt had 1 failure (devCmd null for build kind) -> fixed; final: runner npm test 27/27 pass.
## Return shape
{ kind, pm, install, buildCmd, devCmd, outputDirs, devPort, [index], [hint], [error] }
- buildCmd/devCmd are set whenever the script exists (also for kind 'build' -> devCmd = dev fallback after failed build, per spec).
- static: install [], buildCmd/devCmd null, outputDirs [], `index` = start file (opts.index > previewJson.index > 'index.html'); `hint` (German) when neither index nor scripts found.
- Forced mode without script: `{ kind:'build', ..., buildCmd:null, error:'Kein build-Skript in package.json' }` / `{ kind:'dev', ..., devCmd:null, error:'Kein dev-Skript in package.json' }`.
- auto: index && !build => static; build => build; dev => dev; else static + hint.
- Invalid package.json JSON => no scripts, never throws. devPort int 1024-65535 else 5173; output/index must be relative without '..' else ignored.
## Commands
npm: ci --include=dev (package-lock/npm-shrinkwrap) or install --include=dev --no-audit --no-fund; pnpm/yarn/bun: `<pm> install --frozen-lockfile`; build `<pm> run build`.
dev: npm/pnpm `run dev -- --host 0.0.0.0 --port N`; yarn/bun `run dev --host 0.0.0.0 --port N` (no `--`, they pass args through).
Note: pnpm/yarn in node:20 need corepack (`corepack enable`) — not handled here (later task).
## detectFromDir(dir, opts)
fs adapter (has = isFile); reads .webklar-preview.json as previewJson unless opts.previewJson given; tested with temp dir.
+83
View File
@@ -0,0 +1,83 @@
// Reine Projekterkennung (Spec 1.2 „Erkennung“): static | build | dev, Paketmanager, Befehle.
import { statSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const DEFAULT_OUTPUTS = ['dist', 'build', 'out', 'web-build'];
const DEFAULT_PORT = 5173;
const safeRel = (p) => typeof p === 'string' && p !== '' && p.length <= 200 && !p.startsWith('/')
&& !p.includes('\0') && !p.split(/[\\/]/).includes('..');
function scriptsOf(files) {
try {
const txt = files.read('package.json');
if (txt == null) return {};
const s = JSON.parse(txt)?.scripts;
return s && typeof s === 'object' && !Array.isArray(s) ? s : {};
} catch {
return {};
}
}
const hasScript = (scripts, name) => typeof scripts[name] === 'string' && scripts[name].trim() !== '';
function detectPm(files) {
if (files.has('pnpm-lock.yaml')) return 'pnpm';
if (files.has('yarn.lock')) return 'yarn';
if (files.has('bun.lockb') || files.has('bun.lock')) return 'bun';
return 'npm';
}
function installCmd(pm, files) {
if (pm === 'npm') {
return files.has('package-lock.json') || files.has('npm-shrinkwrap.json')
? ['npm', 'ci', '--include=dev']
: ['npm', 'install', '--include=dev', '--no-audit', '--no-fund'];
}
return [pm, 'install', '--frozen-lockfile'];
}
// npm/pnpm brauchen `--` vor den Skript-Argumenten, yarn/bun reichen sie direkt durch.
function devCmdFor(pm, port) {
const args = ['--host', '0.0.0.0', '--port', String(port)];
return pm === 'npm' || pm === 'pnpm' ? [pm, 'run', 'dev', '--', ...args] : [pm, 'run', 'dev', ...args];
}
export function detectProject(files, { mode, index, previewJson } = {}) {
const pj = previewJson && typeof previewJson === 'object' && !Array.isArray(previewJson) ? previewJson : {};
const scripts = scriptsOf(files);
const pm = detectPm(files);
const devPort = Number.isInteger(pj.devPort) && pj.devPort >= 1024 && pj.devPort <= 65535 ? pj.devPort : DEFAULT_PORT;
const outputDirs = safeRel(pj.output) ? [pj.output] : [...DEFAULT_OUTPUTS];
const indexFile = [index, pj.index].find(safeRel) || 'index.html';
const hasIndex = files.has(indexFile);
const hasBuild = hasScript(scripts, 'build');
const hasDev = hasScript(scripts, 'dev');
const m = mode === 'build' || mode === 'dev' ? mode : 'auto';
const base = { pm, install: installCmd(pm, files), buildCmd: hasBuild ? [pm, 'run', 'build'] : null, devCmd: hasDev ? devCmdFor(pm, devPort) : null, outputDirs, devPort };
const asBuild = () => ({ kind: 'build', ...base });
const asDev = () => ({ kind: 'dev', ...base });
const asStatic = (hint) => ({ kind: 'static', ...base, install: [], buildCmd: null, devCmd: null, outputDirs: [], index: indexFile, ...(hint ? { hint } : {}) });
if (m === 'build') return hasBuild ? asBuild() : { ...asBuild(), error: 'Kein build-Skript in package.json' };
if (m === 'dev') return hasDev ? asDev() : { ...asDev(), error: 'Kein dev-Skript in package.json' };
if (hasIndex && !hasBuild) return asStatic();
if (hasBuild) return asBuild();
if (hasDev) return asDev();
return asStatic(`Hinweis: Weder ${indexFile} noch ein build-/dev-Skript gefunden – Inhalt wird unverändert als statische Seite bereitgestellt.`);
}
// Dateisystem-Adapter für detectProject; previewJson wird aus .webklar-preview.json gelesen, wenn nicht übergeben.
export function detectFromDir(dir, opts = {}) {
const abs = (p) => join(dir, p);
const files = {
has: (p) => { try { return statSync(abs(p)).isFile(); } catch { return false; } },
read: (p) => { try { return readFileSync(abs(p), 'utf8'); } catch { return null; } },
};
let previewJson = opts.previewJson;
if (previewJson === undefined) {
try { previewJson = JSON.parse(files.read('.webklar-preview.json')); } catch { previewJson = {}; }
}
return detectProject(files, { ...opts, previewJson });
}
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { detectProject, detectFromDir } from '../src/detect.js';
const fake = (map) => ({
has: (p) => Object.hasOwn(map, p),
read: (p) => (Object.hasOwn(map, p) ? map[p] : null),
});
const pkg = (scripts) => JSON.stringify({ scripts });
test('HTML ohne package.json ⇒ static', () => {
const r = detectProject(fake({ 'index.html': '<html>' }), {});
assert.equal(r.kind, 'static');
assert.equal(r.buildCmd, null);
assert.equal(r.devCmd, null);
});
test('Vite mit build ⇒ build, npm', () => {
const r = detectProject(fake({ 'package.json': pkg({ build: 'vite build', dev: 'vite' }) }), { mode: 'auto' });
assert.equal(r.kind, 'build');
assert.equal(r.pm, 'npm');
assert.deepEqual(r.install, ['npm', 'install', '--include=dev', '--no-audit', '--no-fund']);
assert.deepEqual(r.buildCmd, ['npm', 'run', 'build']);
assert.deepEqual(r.outputDirs, ['dist', 'build', 'out', 'web-build']);
assert.equal(r.devPort, 5173);
});
test('npm mit Lock ⇒ npm ci', () => {
const r = detectProject(fake({ 'package.json': pkg({ build: 'x' }), 'package-lock.json': '{}' }), {});
assert.deepEqual(r.install, ['npm', 'ci', '--include=dev']);
});
test('pnpm-lock ⇒ pnpm', () => {
const r = detectProject(fake({ 'package.json': pkg({ build: 'x', dev: 'y' }), 'pnpm-lock.yaml': '' }), {});
assert.equal(r.pm, 'pnpm');
assert.deepEqual(r.install, ['pnpm', 'install', '--frozen-lockfile']);
assert.deepEqual(r.buildCmd, ['pnpm', 'run', 'build']);
assert.deepEqual(r.devCmd, ['pnpm', 'run', 'dev', '--', '--host', '0.0.0.0', '--port', '5173']);
});
test('yarn.lock und bun.lockb/bun.lock', () => {
assert.deepEqual(detectProject(fake({ 'package.json': pkg({ build: 'x' }), 'yarn.lock': '' }), {}).install, ['yarn', 'install', '--frozen-lockfile']);
for (const lock of ['bun.lockb', 'bun.lock']) {
const r = detectProject(fake({ 'package.json': pkg({ build: 'x' }), [lock]: '' }), {});
assert.equal(r.pm, 'bun');
assert.deepEqual(r.install, ['bun', 'install', '--frozen-lockfile']);
assert.deepEqual(r.buildCmd, ['bun', 'run', 'build']);
}
});
test('nur dev-Skript ⇒ dev', () => {
const r = detectProject(fake({ 'package.json': pkg({ dev: 'vite' }) }), {});
assert.equal(r.kind, 'dev');
assert.equal(r.buildCmd, null);
assert.deepEqual(r.devCmd, ['npm', 'run', 'dev', '--', '--host', '0.0.0.0', '--port', '5173']);
});
test('index.html + build-Skript ⇒ build', () => {
const r = detectProject(fake({ 'index.html': 'x', 'package.json': pkg({ build: 'vite build' }) }), {});
assert.equal(r.kind, 'build');
});
test('index.html + nur dev-Skript ⇒ static (kein build)', () => {
const r = detectProject(fake({ 'index.html': 'x', 'package.json': pkg({ dev: 'vite' }) }), {});
assert.equal(r.kind, 'static');
});
test('mode dev erzwingt dev, auch mit build', () => {
const r = detectProject(fake({ 'package.json': pkg({ build: 'b', dev: 'd' }) }), { mode: 'dev' });
assert.equal(r.kind, 'dev');
assert.ok(r.devCmd);
});
test('mode dev ohne dev-Skript ⇒ Fehler', () => {
const r = detectProject(fake({ 'package.json': pkg({ build: 'b' }) }), { mode: 'dev' });
assert.equal(r.kind, 'dev');
assert.equal(r.error, 'Kein dev-Skript in package.json');
});
test('mode build ohne build-Skript ⇒ Fehler', () => {
const r = detectProject(fake({ 'index.html': 'x', 'package.json': pkg({ dev: 'd' }) }), { mode: 'build' });
assert.equal(r.kind, 'build');
assert.equal(r.error, 'Kein build-Skript in package.json');
});
test('devPort 3000 aus previewJson landet im devCmd', () => {
const r = detectProject(fake({ 'package.json': pkg({ dev: 'd' }) }), { previewJson: { devPort: 3000 } });
assert.equal(r.devPort, 3000);
assert.deepEqual(r.devCmd.slice(-2), ['--port', '3000']);
});
test('ungültiger devPort ⇒ 5173', () => {
for (const p of [80, 70000, 3000.5, '3000', null]) {
assert.equal(detectProject(fake({ 'package.json': pkg({ dev: 'd' }) }), { previewJson: { devPort: p } }).devPort, 5173);
}
});
test('previewJson.output überschreibt, unsicher wird ignoriert', () => {
const f = fake({ 'package.json': pkg({ build: 'b' }) });
assert.deepEqual(detectProject(f, { previewJson: { output: 'public/site' } }).outputDirs, ['public/site']);
for (const o of ['../x', '/etc', 'a/../b', '', 5]) {
assert.deepEqual(detectProject(f, { previewJson: { output: o } }).outputDirs, ['dist', 'build', 'out', 'web-build']);
}
});
test('index aus opts/previewJson: eigene Startseite ⇒ static', () => {
const f = fake({ 'site/home.html': 'x', 'package.json': pkg({ dev: 'd' }) });
const r = detectProject(f, { previewJson: { index: 'site/home.html' } });
assert.equal(r.kind, 'static');
assert.equal(r.index, 'site/home.html');
assert.equal(detectProject(f, { index: '../evil.html' }).kind, 'dev');
});
test('kaputtes package.json wirft nicht', () => {
const r = detectProject(fake({ 'package.json': '{kaputt' }), {});
assert.equal(r.kind, 'static');
assert.ok(r.hint);
});
test('weder index noch Skripte ⇒ static mit Hinweis', () => {
const r = detectProject(fake({ 'README.md': 'x' }), {});
assert.equal(r.kind, 'static');
assert.match(r.hint, /Hinweis|index|Startseite/i);
});
test('detectFromDir liest das Dateisystem', () => {
const d = mkdtempSync(join(tmpdir(), 'detect-'));
writeFileSync(join(d, 'package.json'), pkg({ build: 'vite build' }));
writeFileSync(join(d, 'pnpm-lock.yaml'), '');
writeFileSync(join(d, '.webklar-preview.json'), JSON.stringify({ output: 'out2' }));
assert.equal(detectFromDir(d, {}).pm, 'pnpm');
assert.deepEqual(detectFromDir(d, {}).outputDirs, ['out2']);
const e = mkdtempSync(join(tmpdir(), 'detect-'));
mkdirSync(join(e, 'index.html'));
assert.equal(detectFromDir(e, {}).kind, 'static');
assert.equal(detectFromDir(join(e, 'nix'), {}).kind, 'static');
});