From e2c517a015841e5148ca94d68a8d887d28b0c43f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:47:43 +0000 Subject: [PATCH] Runner: Projekterkennung Co-Authored-By: Claude Sonnet 5.5 --- .../task-2-report.md | 17 +++ runner/src/detect.js | 83 +++++++++++ runner/test/detect.test.js | 140 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 .superpowers/sdd/2026-09-30-preview-runner/task-2-report.md create mode 100644 runner/src/detect.js create mode 100644 runner/test/detect.test.js diff --git a/.superpowers/sdd/2026-09-30-preview-runner/task-2-report.md b/.superpowers/sdd/2026-09-30-preview-runner/task-2-report.md new file mode 100644 index 0000000..833f568 --- /dev/null +++ b/.superpowers/sdd/2026-09-30-preview-runner/task-2-report.md @@ -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: ` install --frozen-lockfile`; build ` 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. diff --git a/runner/src/detect.js b/runner/src/detect.js new file mode 100644 index 0000000..52952c4 --- /dev/null +++ b/runner/src/detect.js @@ -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 }); +} diff --git a/runner/test/detect.test.js b/runner/test/detect.test.js new file mode 100644 index 0000000..01ae6e7 --- /dev/null +++ b/runner/test/detect.test.js @@ -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': '' }), {}); + 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'); +});