diff --git a/packages/gui/docs/GUI-PLATFORM.md b/packages/gui/docs/GUI-PLATFORM.md new file mode 100644 index 0000000..2905898 --- /dev/null +++ b/packages/gui/docs/GUI-PLATFORM.md @@ -0,0 +1,91 @@ +# GUI platform — from "the Flavian app" to a shared engine (Trajan) + +`@flavian/gui` started as a desktop shell hard-wired to drive **Flavian**. We are +evolving it into a generic engine that renders **any PMDS framework** (Flavian, +Aurelius, Nerva, …) from a typed descriptor. The unified app that ships all of them is +**Trajan**. + +This doc is a pointer for that work, not a spec. It describes the three-layer +architecture the codebase now follows and the staged plan it serves. + +## Three layers + +1. **Shared engine** — generic machinery that knows nothing about any product: + the task model (`src/core/task`), process running (`src/core/process`, + `src/core/shell`), the IPC handlers (`src/main/ipc`), the React shell + reusable + components/hooks (`src/renderer`), and the manifest interpreters + (`src/core/product/command-spec.ts`, `src/core/product/parsers.ts`). A new product + does not change this layer. + +2. **Per-product manifest** — one pure-data `ProductManifest` + (`src/shared/product/manifest.ts`) that declares **what a product exposes**: + identity, project-detection markers, and the screens → steps → (command, parser, + prerequisites) catalog. The Flavian manifest is `src/shared/product/flavian.ts`. + It speaks the engine's existing vocabulary — `TaskKind`, `DockerCommand`, + `QaScript`, `PipelineKind`, `ThemeStarter` — rather than inventing new terms, and + is node-free + serializable so the sandboxed renderer, main, and preload can all + import it. The active product is chosen in `src/shared/product/index.ts` + (`activeManifest`); main injects it into `registerHandlers`, and the renderer shell + reads its brand + nav from it. + +3. **Per-product specifics** — the bespoke React panels + (`src/renderer/components/*Panel.tsx`). Each is a custom UI for one screen; it now + reads its *step catalog* (which commands/kinds/themes exist, their labels) from the + manifest, while keeping its product-specific layout. As the platform matures, more + of each panel's surface migrates up into the manifest. + +The dependency rule: layer 1 reads layer 2; layer 2 is data; layer 3 reads layer 2. +Nothing in layers 1/3 hard-codes a product's scripts, screens, or identity. + +## Adding a product (the extension point) + +A second product is *only another manifest* — no engine/core/renderer change: + +1. author `src/shared/product/.ts` (another `ProductManifest`); +2. register it in `src/shared/product/index.ts` → `PRODUCTS`; +3. point `ACTIVE_PRODUCT_ID` at it (or, under Trajan, select per-product at runtime). + +A typed scaffold for this is `src/shared/product/aurelius.ts` — intentionally **not** +wired in yet. + +## Staged plan + +1. **Ship Flavian** — the hard-wired desktop app. ✅ (through v1.10.0) +2. **Manifest seam** — extract Flavian's implicit catalog into an explicit + `ProductManifest`; make the engine render from it with zero behaviour change. + ◀ **this step.** +3. **Prove reuse with Aurelius** — author a second manifest and drive it through the + same engine; fix whatever leaks a Flavian assumption. +4. **Extract the engine** — split the generic layer out so products can be packaged + independently of any one of them. +5. **Add Nerva** — a third manifest, validating the engine across three products. +6. **Ship Trajan** — one app that selects a product at runtime and renders any of them. + +## Scope notes (intentionally deferred) + +These remain product-specific for now and are tracked for later stages — they were out +of scope for the zero-behaviour-change manifest seam: + +- **Panel presentation prose** — explanatory copy in the panels (e.g. "runs + `wordpress-local.sh`") still lives in the components. The *operational* catalog + (scripts, commands, parsers, markers, links, chooser copy) is in the manifest; the + remaining prose is presentation and will follow. +- **Bridge / IPC namespace** — the renderer↔main bridge is still `window.flavian` and + the IPC channels are still `flavian:*`. That is shared transport vocabulary, not + product catalog; Trajan keeps a single bridge, so renaming is deferred. +- **Build identity** — the package name (`@flavian/gui`), window title, and installer + name still say "Flavian". These identify *this build*, analogous to + `ACTIVE_PRODUCT_ID`, and change when Trajan packaging lands. + +## Map + +| Concern | File | +| --- | --- | +| Manifest type | `src/shared/product/manifest.ts` | +| Flavian manifest | `src/shared/product/flavian.ts` | +| Registry + active product + selectors | `src/shared/product/index.ts` | +| Aurelius scaffold (extension point) | `src/shared/product/aurelius.ts` | +| Command interpreter | `src/core/product/command-spec.ts` | +| Output-parser registry | `src/core/product/parsers.ts` | +| Manifest injection point | `src/main/index.ts`, `src/main/ipc/register-handlers.ts` | +| Shell reads brand + nav | `src/renderer/App.tsx` | diff --git a/packages/gui/src/core/docker/docker-commands.ts b/packages/gui/src/core/docker/docker-commands.ts deleted file mode 100644 index debf119..0000000 --- a/packages/gui/src/core/docker/docker-commands.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { join } from 'node:path'; -import type { DockerCommand } from '../../shared/types/docker'; -import type { RunSpec } from '../process/runner-types'; -import type { CommandBuilder } from '../shell/command-builder'; - -const COMMANDS: readonly DockerCommand[] = [ - 'build', - 'start', - 'stop', - 'restart', - 'install', - 'logs', - 'list-themes', - 'activate-theme', -]; - -export function isDockerCommand(value: string): value is DockerCommand { - return (COMMANDS as readonly string[]).includes(value); -} - -/** Build a RunSpec for `bash wordpress-local.sh [arg]`. */ -export function dockerCommandSpec( - commands: CommandBuilder, - repoRoot: string, - command: DockerCommand, - arg?: string, -): Promise { - const script = join(repoRoot, 'wordpress-local.sh'); - const args = arg ? [command, arg] : [command]; - return commands.bashScript(script, args, repoRoot); -} diff --git a/packages/gui/src/core/init/run-init.ts b/packages/gui/src/core/init/run-init.ts index 07c8f03..06a09cc 100644 --- a/packages/gui/src/core/init/run-init.ts +++ b/packages/gui/src/core/init/run-init.ts @@ -31,6 +31,8 @@ type Apply = ( export interface RunInitDeps { repoRoot: string; + /** Repo-relative dir holding default-resolver.mjs + apply.mjs (from the manifest). */ + moduleDir: string; input: InitInput; } @@ -47,10 +49,11 @@ export interface InitRun { * with its logger wired to the task's stream. */ export async function createInitRun(deps: RunInitDeps): Promise { + const moduleParts = deps.moduleDir.split('/'); const resolverUrl = pathToFileURL( - join(deps.repoRoot, 'scripts', 'init', 'default-resolver.mjs'), + join(deps.repoRoot, ...moduleParts, 'default-resolver.mjs'), ).href; - const applyUrl = pathToFileURL(join(deps.repoRoot, 'scripts', 'init', 'apply.mjs')).href; + const applyUrl = pathToFileURL(join(deps.repoRoot, ...moduleParts, 'apply.mjs')).href; const { resolveDefaults } = (await import(resolverUrl)) as { resolveDefaults: ResolveDefaults }; const { apply } = (await import(applyUrl)) as { apply: Apply }; diff --git a/packages/gui/src/core/pipelines/pipeline-run.ts b/packages/gui/src/core/pipelines/pipeline-run.ts index 7d71147..a770de2 100644 --- a/packages/gui/src/core/pipelines/pipeline-run.ts +++ b/packages/gui/src/core/pipelines/pipeline-run.ts @@ -1,47 +1,19 @@ -import { join } from 'node:path'; +import type { CommandDescriptor } from '../../shared/product/manifest'; import type { PipelineInput, PipelineResult } from '../../shared/types/pipeline'; -import type { ProcessEvent, ProcessRunner, RunHandle, RunSpec } from '../process/runner-types'; +import type { ProcessEvent, ProcessRunner, RunHandle } from '../process/runner-types'; import type { CommandBuilder } from '../shell/command-builder'; +import { buildCommandSpec } from '../product/command-spec'; import { createInitRun } from '../init/run-init'; -/** The instruction handed to a headless Claude Code session for a Figma conversion. */ -export function figmaPrompt(figmaUrl: string, slug: string): string { - return ( - `Convert the Figma design at ${figmaUrl} into a WordPress FSE block theme using ` + - `the figma-to-fse-autonomous-workflow. Use the theme slug "${slug}" and write the ` + - `theme to themes/${slug}. Work autonomously through to a validated theme.` - ); -} - -/** Build the spec for the Figma pipeline: a headless `claude -p` session. */ -export async function figmaSpec( - commands: CommandBuilder, - repoRoot: string, - input: PipelineInput, -): Promise { - if (!input.figmaUrl?.trim()) throw new Error('A Figma file URL is required.'); - return commands.claude(['-p', figmaPrompt(input.figmaUrl.trim(), input.slug)], repoRoot); -} - -/** Build the spec for the InDesign pipeline: the deterministic `flavian pipeline indesign` CLI. */ -export function indesignSpec( - commands: CommandBuilder, - repoRoot: string, - input: PipelineInput, -): RunSpec { - if (!input.indesignFile?.trim()) throw new Error('An .idml or .pdf file is required.'); - return commands.nodeBin( - join(repoRoot, 'bin', 'flavian.mjs'), - ['pipeline', 'indesign', input.indesignFile.trim(), '--slug', input.slug], - repoRoot, - ); -} - export interface PipelineDeps { repoRoot: string; input: PipelineInput; runner: ProcessRunner; commands: CommandBuilder; + /** The pipeline step's command descriptor, from the manifest. */ + command: CommandDescriptor; + /** Repo-relative init module dir, for the Canva ⇒ wizard delegate path. */ + initModuleDir: string; } export interface PipelineRun { @@ -50,17 +22,21 @@ export interface PipelineRun { } /** - * Prepare a conversion run. Figma spawns a headless Claude session; InDesign runs - * the deterministic CLI; Canva reuses the init wizard's canva path (createInitRun) - * so there's a single Canva code path. Validation errors throw before any task. + * Prepare a conversion run from a manifest-declared command descriptor: + * - `claude` (Figma) → a headless `claude -p ""` session; + * - `nodeBin` (InDesign) → the deterministic `node bin/flavian.mjs pipeline indesign …`; + * - `delegate` (Canva) → the wizard's Canva path (createInitRun), so there is one + * Canva code path. + * Required-input validation runs first and throws before any task is created. */ export async function createPipelineRun(deps: PipelineDeps): Promise { const { kind, slug } = deps.input; - if (kind === 'canva') { + if (deps.command.exec === 'delegate') { if (!deps.input.canvaExport?.trim()) throw new Error('A Canva export directory is required.'); const init = await createInitRun({ repoRoot: deps.repoRoot, + moduleDir: deps.initModuleDir, input: { name: slug, title: '', @@ -80,10 +56,18 @@ export async function createPipelineRun(deps: PipelineDeps): Promise void; const result = new Promise((resolve) => { diff --git a/packages/gui/src/core/prerequisites/run-prerequisites.ts b/packages/gui/src/core/prerequisites/run-prerequisites.ts index 86bd0bb..074dd5d 100644 --- a/packages/gui/src/core/prerequisites/run-prerequisites.ts +++ b/packages/gui/src/core/prerequisites/run-prerequisites.ts @@ -1,20 +1,16 @@ -import { join } from 'node:path'; import type { PrereqReport } from '../../shared/types/prerequisites'; import type { ProcessEvent, ProcessRunner, RunHandle, RunSpec } from '../process/runner-types'; -import type { CommandBuilder } from '../shell/command-builder'; -import { parsePrereqOutput } from './prereq-parser'; export interface RunPrereqDeps { runner: ProcessRunner; - commands: CommandBuilder; - /** Absolute path to the repo's scripts/ directory. */ - scriptsDir: string; - /** Repo root, used as the working directory for the script. */ - repoRoot: string; + /** The command to run, already built from the manifest (engine: buildCommandSpec). */ + spec: RunSpec; + /** Turns the buffered output into a report — selected from the manifest's parser key. */ + parse: (raw: string, exitCode: number | null) => PrereqReport; } export interface PrereqRun { - /** The resolved spec (bash + script path), exposed for assertions/diagnostics. */ + /** The spec being run, exposed for assertions/diagnostics. */ spec: RunSpec; /** Sync run thunk for a Task: spawns the process, forwards events, buffers output. */ run: (onEvent: (event: ProcessEvent) => void) => RunHandle; @@ -23,15 +19,12 @@ export interface PrereqRun { } /** - * Prepares a prerequisite check. Resolves the bash command up front (async), then - * returns a synchronous `run` thunk suitable for TaskManager.create — keeping the - * Task abstraction unaware of shell resolution. Fully testable with a fake - * ProcessRunner replaying a fixture transcript and a fake ShellResolver. + * Prepare a prerequisite check. The product-specific parts — which script to run and + * how to parse it — arrive via `spec` and `parse` (both derived from the manifest by + * the caller), so this stays a generic "run a process, buffer its output, parse on + * exit" flow. Fully testable with a fake ProcessRunner replaying a fixture transcript. */ -export async function createPrereqRun(deps: RunPrereqDeps): Promise { - const scriptPath = join(deps.scriptsDir, 'check-prerequisites.sh'); - const spec = await deps.commands.bashScript(scriptPath, [], deps.repoRoot); - +export function createPrereqRun(deps: RunPrereqDeps): PrereqRun { let resolveResult!: (report: PrereqReport) => void; const result = new Promise((resolve) => { resolveResult = resolve; @@ -39,15 +32,15 @@ export async function createPrereqRun(deps: RunPrereqDeps): Promise { const run = (onEvent: (event: ProcessEvent) => void): RunHandle => { let buffer = ''; - const handle = deps.runner.run(spec, (event) => { + const handle = deps.runner.run(deps.spec, (event) => { if (event.type === 'stdout' || event.type === 'stderr') buffer += event.chunk; onEvent(event); }); void handle.done.then((res) => { - resolveResult(parsePrereqOutput(buffer, res.code)); + resolveResult(deps.parse(buffer, res.code)); }); return handle; }; - return { spec, run, result }; + return { spec: deps.spec, run, result }; } diff --git a/packages/gui/src/core/product/command-spec.ts b/packages/gui/src/core/product/command-spec.ts new file mode 100644 index 0000000..7c74584 --- /dev/null +++ b/packages/gui/src/core/product/command-spec.ts @@ -0,0 +1,54 @@ +import { join } from 'node:path'; +import type { CommandDescriptor } from '../../shared/product/manifest'; +import type { RunSpec } from '../process/runner-types'; +import type { CommandBuilder } from '../shell/command-builder'; + +/** Variables substituted into a command's `{name}` placeholders at run time. */ +export type CommandVars = Readonly>; + +/** Fill `{name}` placeholders in a template's parts from `vars` (missing → ''). */ +export function fillTemplate(template: readonly string[] | undefined, vars: CommandVars): string[] { + return (template ?? []).map((part) => + part.replace(/\{(\w+)\}/g, (_match, key: string) => vars[key] ?? ''), + ); +} + +/** + * Interpret a manifest CommandDescriptor into a concrete RunSpec via the generic + * CommandBuilder. This is the single place a step's declared command becomes a + * process — there is no per-product branch. Script/module paths are resolved against + * the active project root; `argsTemplate` placeholders are filled from `vars`. + * + * `module`/`delegate` descriptors are NOT runnable here — they have dedicated engine + * flows (the init wizard's in-process apply(); Canva delegating to the wizard). + */ +export async function buildCommandSpec( + commands: CommandBuilder, + repoRoot: string, + descriptor: CommandDescriptor, + vars: CommandVars = {}, +): Promise { + switch (descriptor.exec) { + case 'bashScript': + return commands.bashScript( + join(repoRoot, descriptor.script), + fillTemplate(descriptor.argsTemplate, vars), + repoRoot, + ); + case 'bashCommand': + return commands.bashCommand(descriptor.command, repoRoot); + case 'nodeBin': + return commands.nodeBin( + join(repoRoot, descriptor.script), + fillTemplate(descriptor.argsTemplate, vars), + repoRoot, + ); + case 'claude': + return commands.claude(fillTemplate(descriptor.argsTemplate, vars), repoRoot); + case 'module': + case 'delegate': + throw new Error( + `buildCommandSpec cannot run an '${descriptor.exec}' command — it has a dedicated engine flow.`, + ); + } +} diff --git a/packages/gui/src/core/product/parsers.ts b/packages/gui/src/core/product/parsers.ts new file mode 100644 index 0000000..4124789 --- /dev/null +++ b/packages/gui/src/core/product/parsers.ts @@ -0,0 +1,23 @@ +import type { ParserKey } from '../../shared/product/manifest'; +import type { PrereqReport } from '../../shared/types/prerequisites'; +import { parsePrereqOutput } from '../prerequisites/prereq-parser'; + +/** + * Engine-side binding for the manifest's `ParserKey`s. A step that turns its streamed + * output into a structured result names a parser key in the manifest; the engine looks + * the function up here instead of hard-coding it. (The `composePs` key documents the + * docker-status query parser, which is invoked directly from its query handler rather + * than through a streamed task.) + */ +export type StreamResultParser = (raw: string, exitCode: number | null) => PrereqReport; + +const STREAM_PARSERS: Partial> = { + prereq: parsePrereqOutput, +}; + +/** Resolve a step's stream-result parser from its manifest `parser` key. */ +export function streamResultParser(key: ParserKey | undefined): StreamResultParser { + const parser = key ? STREAM_PARSERS[key] : undefined; + if (!parser) throw new Error(`No stream-result parser registered for parser key "${key}".`); + return parser; +} diff --git a/packages/gui/src/core/project/locate-root.ts b/packages/gui/src/core/project/locate-root.ts index 53c9f78..d21415d 100644 --- a/packages/gui/src/core/project/locate-root.ts +++ b/packages/gui/src/core/project/locate-root.ts @@ -1,52 +1,47 @@ import { promises as fs } from 'node:fs'; import { dirname, join, parse } from 'node:path'; +import type { ProjectIdentity } from '../../shared/product/manifest'; import type { ProjectRef } from '../../shared/types/project'; -/** Files that must all be present for a directory to be a Flavian project root. */ -const MARKER_FILES = ['wordpress-local.sh', join('scripts', 'check-prerequisites.sh')]; +/** Resolve a project-root-relative marker (declared with '/') to an absolute path. */ +function markerPath(dir: string, marker: string): string { + return join(dir, ...marker.split('/')); +} -async function isFlavianRoot(dir: string): Promise { - for (const marker of MARKER_FILES) { +/** Whether `dir` is a valid checkout of the given product (all markers + package name). */ +async function isProductRoot(dir: string, project: ProjectIdentity): Promise { + for (const marker of project.markers) { try { - await fs.access(join(dir, marker)); + await fs.access(markerPath(dir, marker)); } catch { return false; } } try { const pkg = JSON.parse(await fs.readFile(join(dir, 'package.json'), 'utf8')) as { name?: string }; - if (pkg.name !== 'flavian') return false; + if (pkg.name !== project.packageName) return false; } catch { return false; } return true; } -/** Validate that a specific directory is a Flavian project root. */ -export async function validateRepoRoot(dir: string): Promise { - if (await isFlavianRoot(dir)) return { root: dir, valid: true }; - return { - root: dir, - valid: false, - reason: - 'Not a Flavian project — expected wordpress-local.sh, scripts/check-prerequisites.sh, and a package.json named "flavian".', - }; +/** Validate that a specific directory is a valid project root for the given product. */ +export async function validateRepoRoot(dir: string, project: ProjectIdentity): Promise { + if (await isProductRoot(dir, project)) return { root: dir, valid: true }; + return { root: dir, valid: false, reason: project.invalidReason }; } -/** Walk up from `startDir` to find the nearest Flavian project root. */ -export async function locateRepoRoot(startDir: string): Promise { +/** Walk up from `startDir` to find the nearest project root for the given product. */ +export async function locateRepoRoot(startDir: string, project: ProjectIdentity): Promise { const { root: fsRoot } = parse(startDir); let dir = startDir; for (let i = 0; i < 64; i += 1) { - if (await isFlavianRoot(dir)) return { root: dir, valid: true }; + if (await isProductRoot(dir, project)) return { root: dir, valid: true }; if (dir === fsRoot) break; const parent = dirname(dir); if (parent === dir) break; dir = parent; } - return { - root: startDir, - valid: false, - reason: 'No Flavian project found while walking up from the start directory.', - }; + return { root: startDir, valid: false, reason: project.notFoundReason }; } diff --git a/packages/gui/src/core/qa/qa-commands.ts b/packages/gui/src/core/qa/qa-commands.ts deleted file mode 100644 index ea2fe95..0000000 --- a/packages/gui/src/core/qa/qa-commands.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { QaScript } from '../../shared/types/qa'; -import type { RunSpec } from '../process/runner-types'; -import type { CommandBuilder } from '../shell/command-builder'; - -/** QA scripts run through bash so their pnpm pipelines (and shell redirection) work as written. */ -const SCRIPTS: Record = { - 'visual:diff': 'pnpm run visual:diff', - 'lighthouse:run': 'pnpm run lighthouse:run', -}; - -export function isQaScript(value: string): value is QaScript { - return Object.prototype.hasOwnProperty.call(SCRIPTS, value); -} - -export function qaCommandSpec( - commands: CommandBuilder, - repoRoot: string, - script: QaScript, -): Promise { - return commands.bashCommand(SCRIPTS[script], repoRoot); -} diff --git a/packages/gui/src/main/env.ts b/packages/gui/src/main/env.ts index b22d50b..7aa36da 100644 --- a/packages/gui/src/main/env.ts +++ b/packages/gui/src/main/env.ts @@ -1,25 +1,28 @@ import { app } from 'electron'; +import type { ProductManifest } from '../shared/product/manifest'; import type { ProjectRef } from '../shared/types/project'; import { locateRepoRoot, validateRepoRoot } from '../core/project/locate-root'; import { loadSettings } from './settings'; /** - * Resolve the Flavian project to open at startup: + * Resolve the project to open at startup for the active product: * 1. the last-used project saved in settings (the installed-app path), if still valid; * 2. otherwise locate the repo by walking up from the app path (dev: packages/gui) * and then the launch cwd. * - * If none validate, an invalid ProjectRef is returned and the renderer shows the - * "Open a Flavian project" gate. The GUI always operates on a real, writable project - * directory on disk — never the read-only app bundle. + * Detection criteria (markers, package name) come from the manifest, so this stays + * generic. If none validate, an invalid ProjectRef is returned and the renderer shows + * the "Open a project" gate. The GUI always operates on a real, writable + * project directory on disk — never the read-only app bundle. */ -export async function resolveInitialProject(): Promise { +export async function resolveInitialProject(manifest: ProductManifest): Promise { + const { project } = manifest; const { projectRoot } = await loadSettings(); if (projectRoot) { - const saved = await validateRepoRoot(projectRoot); + const saved = await validateRepoRoot(projectRoot, project); if (saved.valid) return saved; } - const fromAppPath = await locateRepoRoot(app.getAppPath()); + const fromAppPath = await locateRepoRoot(app.getAppPath(), project); if (fromAppPath.valid) return fromAppPath; - return locateRepoRoot(process.cwd()); + return locateRepoRoot(process.cwd(), project); } diff --git a/packages/gui/src/main/index.ts b/packages/gui/src/main/index.ts index cd31222..a33317e 100644 --- a/packages/gui/src/main/index.ts +++ b/packages/gui/src/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow } from 'electron'; import { join } from 'node:path'; +import { activeManifest } from '../shared/product'; import { TaskManager } from '../core/task/task-manager'; import { resolveInitialProject } from './env'; import { createTaskBridge } from './ipc/task-bridge'; @@ -21,11 +22,17 @@ app.whenReady().then( async () => { // The project context is mutable: the renderer's "Open project folder" action // updates `current`, and every handler reads it live. - const project = { current: await resolveInitialProject() }; + const project = { current: await resolveInitialProject(activeManifest) }; installCsp(app.isPackaged); // Registered once (handlers are global); windows are created/recreated freely. - registerHandlers({ taskManager: new TaskManager(), project, bridge: createTaskBridge() }); + // The active manifest is injected here — the single point that names the product. + registerHandlers({ + taskManager: new TaskManager(), + project, + bridge: createTaskBridge(), + manifest: activeManifest, + }); await openWindow(); app.on('activate', () => { @@ -33,7 +40,7 @@ app.whenReady().then( }); }, (err: unknown) => { - console.error('Failed to start Flavian GUI:', err); + console.error(`Failed to start ${activeManifest.displayName} GUI:`, err); app.quit(); }, ); diff --git a/packages/gui/src/main/ipc/register-handlers.ts b/packages/gui/src/main/ipc/register-handlers.ts index f9b6e87..51adde4 100644 --- a/packages/gui/src/main/ipc/register-handlers.ts +++ b/packages/gui/src/main/ipc/register-handlers.ts @@ -1,6 +1,8 @@ import { BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions } from 'electron'; import { isAbsolute, relative, resolve } from 'node:path'; import { IPC } from '../../shared/ipc-channels'; +import type { ProductManifest } from '../../shared/product/manifest'; +import { getScreen, getStep, initModuleDir, soleStep } from '../../shared/product'; import type { DockerCommand, DockerService } from '../../shared/types/docker'; import type { InitInput, InitResult } from '../../shared/types/init'; import type { PipelineInput, PipelineResult } from '../../shared/types/pipeline'; @@ -13,17 +15,16 @@ import { ChildProcessRunner } from '../../core/process/process-runner'; import { CommandBuilder } from '../../core/shell/command-builder'; import { DefaultShellResolver } from '../../core/shell/shell-resolver'; import { collectOutput } from '../../core/process/collect'; +import { buildCommandSpec } from '../../core/product/command-spec'; +import { streamResultParser } from '../../core/product/parsers'; import { createPrereqRun } from '../../core/prerequisites/run-prerequisites'; import { createInitRun } from '../../core/init/run-init'; import { createPipelineRun } from '../../core/pipelines/pipeline-run'; -import { dockerCommandSpec } from '../../core/docker/docker-commands'; import { parseComposePs } from '../../core/docker/docker-status'; import { listThemeDirs } from '../../core/project/list-themes'; import { validateRepoRoot } from '../../core/project/locate-root'; -import { qaCommandSpec } from '../../core/qa/qa-commands'; import { discoverQaArtifacts } from '../../core/qa/discover'; import { readImageDataUrl, readTextArtifact } from '../../core/fs/read-artifact'; -import { getScriptsDir } from '../paths'; import { saveSettings } from '../settings'; import type { TaskBridge } from './task-bridge'; @@ -36,16 +37,21 @@ export interface HandlerDeps { taskManager: TaskManager; project: ProjectContext; bridge: TaskBridge; + /** The active product manifest — the ONLY source of product-specific knowledge. */ + manifest: ProductManifest; } /** - * Wires the typed IPC surface to core. The renderer can only invoke these named - * operations — never an arbitrary command. Sub-issues 2–6 add a handler here per - * new typed FlavianBridge method. + * Wires the typed IPC surface to core. Every product specific — which script a step + * runs, how its output is parsed, the project-detection copy — is read from + * `deps.manifest`; this file is generic engine. A different product is driven by + * injecting a different manifest (see main/index.ts), not by editing this file. */ export function registerHandlers(deps: HandlerDeps): void { const runner = new ChildProcessRunner(); const commands = new CommandBuilder(new DefaultShellResolver()); + const { manifest } = deps; + const root = (): string => deps.project.current.root; // Results keyed by task id, awaited by the matching get*Result handler. const prereqResults = new Map>(); const initResults = new Map>(); @@ -61,13 +67,13 @@ export function registerHandlers(deps: HandlerDeps): void { ipcMain.handle(IPC.projectSelect, async (): Promise => { const result = await showOpen({ - title: 'Select your Flavian project folder', - message: 'Choose the directory that contains wordpress-local.sh and scripts/.', + title: manifest.project.selectTitle, + message: manifest.project.selectMessage, properties: ['openDirectory'], }); if (result.canceled || result.filePaths.length === 0) return deps.project.current; - const ref = await validateRepoRoot(result.filePaths[0]); + const ref = await validateRepoRoot(result.filePaths[0], manifest.project); if (ref.valid) { deps.project.current = ref; await saveSettings({ projectRoot: ref.root }); @@ -89,17 +95,14 @@ export function registerHandlers(deps: HandlerDeps): void { }); ipcMain.handle(IPC.prereqRun, async (): Promise<{ taskId: string }> => { - const prereq = await createPrereqRun({ - runner, - commands, - scriptsDir: getScriptsDir(deps.project.current.root), - repoRoot: deps.project.current.root, - }); + const step = soleStep(manifest, 'prereq'); + const spec = await buildCommandSpec(commands, root(), step.command); + const prereq = createPrereqRun({ runner, spec, parse: streamResultParser(step.parser) }); const task = deps.taskManager.create({ - kind: 'prereq-check', + kind: step.taskKind, run: prereq.run, // Exit 1 ("requirements missing") is a valid result, not a task failure. - isSuccess: (result) => result.code === 0 || result.code === 1, + isSuccess: (result) => (step.successExitCodes ?? [0]).includes(result.code ?? -1), }); deps.bridge.attach(task); prereqResults.set(task.id, prereq.result); @@ -116,8 +119,12 @@ export function registerHandlers(deps: HandlerDeps): void { ipcMain.handle(IPC.initRun, async (_event, input: InitInput): Promise<{ taskId: string }> => { // createInitRun validates via resolveDefaults and throws on bad input — that // rejection surfaces to the renderer before any task is created. - const init = await createInitRun({ repoRoot: deps.project.current.root, input }); - const task = deps.taskManager.create({ kind: 'init', run: init.run }); + const init = await createInitRun({ + repoRoot: root(), + moduleDir: initModuleDir(manifest), + input, + }); + const task = deps.taskManager.create({ kind: soleStep(manifest, 'wizard').taskKind, run: init.run }); deps.bridge.attach(task); initResults.set(task.id, init.result); task.start(); @@ -133,9 +140,10 @@ export function registerHandlers(deps: HandlerDeps): void { ipcMain.handle( IPC.dockerRun, async (_event, command: DockerCommand, arg?: string): Promise<{ taskId: string }> => { - const spec = await dockerCommandSpec(commands, deps.project.current.root, command, arg); + const step = getStep(getScreen(manifest, 'docker'), command); + const spec = await buildCommandSpec(commands, root(), step.command, arg !== undefined ? { theme: arg } : {}); const task = deps.taskManager.create({ - kind: `docker:${command}`, + kind: step.taskKind, run: (onEvent) => runner.run(spec, onEvent), }); deps.bridge.attach(task); @@ -145,21 +153,24 @@ export function registerHandlers(deps: HandlerDeps): void { ); ipcMain.handle(IPC.dockerStatus, async (): Promise => { - const spec = await commands.dockerCompose(['ps', '--format', 'json'], deps.project.current.root); + const spec = await commands.dockerCompose(['ps', '--format', 'json'], root()); const { stdout } = await collectOutput(runner, spec); return parseComposePs(stdout); }); - ipcMain.handle(IPC.listThemes, (): Promise => listThemeDirs(deps.project.current.root)); + ipcMain.handle(IPC.listThemes, (): Promise => listThemeDirs(root())); ipcMain.handle(IPC.pipelineRun, async (_event, input: PipelineInput): Promise<{ taskId: string }> => { + const step = getStep(getScreen(manifest, 'pipeline'), input.kind); const pipeline = await createPipelineRun({ - repoRoot: deps.project.current.root, + repoRoot: root(), input, runner, commands, + command: step.command, + initModuleDir: initModuleDir(manifest), }); - const task = deps.taskManager.create({ kind: `pipeline:${input.kind}`, run: pipeline.run }); + const task = deps.taskManager.create({ kind: step.taskKind, run: pipeline.run }); deps.bridge.attach(task); pipelineResults.set(task.id, pipeline.result); task.start(); @@ -173,9 +184,10 @@ export function registerHandlers(deps: HandlerDeps): void { }); ipcMain.handle(IPC.qaRun, async (_event, script: QaScript): Promise<{ taskId: string }> => { - const spec = await qaCommandSpec(commands, deps.project.current.root, script); + const step = getStep(getScreen(manifest, 'qa'), script); + const spec = await buildCommandSpec(commands, root(), step.command); const task = deps.taskManager.create({ - kind: `qa:${script}`, + kind: step.taskKind, run: (onEvent) => runner.run(spec, onEvent), }); deps.bridge.attach(task); @@ -183,14 +195,14 @@ export function registerHandlers(deps: HandlerDeps): void { return { taskId: task.id }; }); - ipcMain.handle(IPC.qaArtifacts, (): Promise => discoverQaArtifacts(deps.project.current.root)); + ipcMain.handle(IPC.qaArtifacts, (): Promise => discoverQaArtifacts(root())); ipcMain.handle(IPC.qaImage, (_event, relPath: string): Promise => - readImageDataUrl(deps.project.current.root, relPath), + readImageDataUrl(root(), relPath), ); ipcMain.handle(IPC.qaText, (_event, relPath: string): Promise => - readTextArtifact(deps.project.current.root, relPath), + readTextArtifact(root(), relPath), ); ipcMain.handle(IPC.openExternal, async (_event, url: string): Promise => { @@ -199,8 +211,8 @@ export function registerHandlers(deps: HandlerDeps): void { ipcMain.handle(IPC.openPath, async (_event, relPath: string): Promise => { // Open a project-relative doc; refuse traversal and non-text targets. - const target = resolve(deps.project.current.root, relPath); - const rel = relative(deps.project.current.root, target).replace(/\\/g, '/'); + const target = resolve(root(), relPath); + const rel = relative(root(), target).replace(/\\/g, '/'); if (rel === '' || rel.startsWith('../') || isAbsolute(rel) || !/\.(md|txt)$/i.test(rel)) { return 'Path not allowed'; } diff --git a/packages/gui/src/main/paths.ts b/packages/gui/src/main/paths.ts deleted file mode 100644 index 2c1508c..0000000 --- a/packages/gui/src/main/paths.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { join } from 'node:path'; - -/** Absolute path to the repo's scripts/ directory for a given project root. */ -export function getScriptsDir(repoRoot: string): string { - return join(repoRoot, 'scripts'); -} - -/** Absolute path to wordpress-local.sh for a given project root (used in later sub-issues). */ -export function getWordpressLocalScript(repoRoot: string): string { - return join(repoRoot, 'wordpress-local.sh'); -} diff --git a/packages/gui/src/renderer/App.tsx b/packages/gui/src/renderer/App.tsx index a48dec7..011951d 100644 --- a/packages/gui/src/renderer/App.tsx +++ b/packages/gui/src/renderer/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { activeManifest, type ScreenId } from '../shared/product'; import type { ProjectRef } from '../shared/types/project'; import { bridge } from './api/bridge'; import { DockerPanel } from './components/DockerPanel'; @@ -8,19 +9,19 @@ import { ProjectGate } from './components/ProjectGate'; import { QaPanel } from './components/QaPanel'; import { WizardPanel } from './components/WizardPanel'; -type View = 'prereq' | 'wizard' | 'docker' | 'pipeline' | 'qa'; +// The shell is generic: the brand and the nav come from the active manifest, not +// from hard-coded Flavian specifics. The screenId → panel map below is the only +// per-product-specifics binding the shell keeps (each panel is its own bespoke UI). +type View = ScreenId; -const VIEWS: { id: View; label: string }[] = [ - { id: 'prereq', label: 'Prerequisites' }, - { id: 'wizard', label: 'Setup wizard' }, - { id: 'docker', label: 'WordPress' }, - { id: 'pipeline', label: 'Convert design' }, - { id: 'qa', label: 'Visual QA' }, -]; +const VIEWS: { id: View; label: string }[] = activeManifest.screens.map((s) => ({ + id: s.id, + label: s.navLabel, +})); export function App() { const [project, setProject] = useState(null); - const [view, setView] = useState('prereq'); + const [view, setView] = useState(VIEWS[0]?.id ?? 'prereq'); const [loading, setLoading] = useState(true); useEffect(() => { @@ -49,7 +50,7 @@ export function App() { return (
diff --git a/packages/gui/src/renderer/components/PipelinePanel.tsx b/packages/gui/src/renderer/components/PipelinePanel.tsx index cc1d29a..8a174ae 100644 --- a/packages/gui/src/renderer/components/PipelinePanel.tsx +++ b/packages/gui/src/renderer/components/PipelinePanel.tsx @@ -1,25 +1,18 @@ import { useState } from 'react'; +import { activeManifest, getScreen } from '../../shared/product'; import type { PipelineInput, PipelineKind } from '../../shared/types/pipeline'; import { bridge } from '../api/bridge'; import { usePipeline } from '../hooks/usePipeline'; import { useTaskStream } from '../hooks/useTaskStream'; import { LogStream } from './LogStream'; -const KINDS: { value: PipelineKind; label: string }[] = [ - { value: 'figma', label: 'Figma' }, - { value: 'canva', label: 'Canva' }, - { value: 'indesign', label: 'InDesign' }, -]; +// The conversion kinds and their reference docs come from the manifest. +const PIPELINE_SCREEN = getScreen(activeManifest, 'pipeline'); +const KINDS = PIPELINE_SCREEN.steps.map((s) => ({ value: s.id as PipelineKind, label: s.label })); +const DOCS = (PIPELINE_SCREEN.extras?.docs ?? {}) as Record; const FIGMA_URL = /^https?:\/\/(www\.)?figma\.com\//i; -/** Per-pipeline reference docs (opened from the panel). */ -const DOCS: Record = { - figma: 'docs/figma-to-wordpress/README.md', - canva: 'docs/canva-to-wordpress/README.md', - indesign: 'docs/pipelines/indesign.md', -}; - export function PipelinePanel() { const [kind, setKind] = useState('figma'); const [slug, setSlug] = useState(''); diff --git a/packages/gui/src/renderer/components/PrereqPanel.tsx b/packages/gui/src/renderer/components/PrereqPanel.tsx index 5efaac6..aff2d3b 100644 --- a/packages/gui/src/renderer/components/PrereqPanel.tsx +++ b/packages/gui/src/renderer/components/PrereqPanel.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { activeManifest } from '../../shared/product'; import type { PrereqGroup, PrereqReport } from '../../shared/types/prerequisites'; import { usePrerequisites } from '../hooks/usePrerequisites'; import { LogStream } from './LogStream'; @@ -50,8 +51,8 @@ export function PrereqPanel() {

- Verifies the tools Flavian needs (Claude Code, Docker, Git, Node) by running the - repo’s check-prerequisites.sh and showing the result here. + Verifies the tools {activeManifest.displayName} needs (Claude Code, Docker, Git, Node) by + running the repo’s check-prerequisites.sh and showing the result here.

{error &&
Could not run the check: {error}
} diff --git a/packages/gui/src/renderer/components/ProjectGate.tsx b/packages/gui/src/renderer/components/ProjectGate.tsx index b61c9aa..3345f1e 100644 --- a/packages/gui/src/renderer/components/ProjectGate.tsx +++ b/packages/gui/src/renderer/components/ProjectGate.tsx @@ -1,12 +1,20 @@ -export function ProjectGate({ reason, onChoose }: { reason?: string; onChoose: () => void }) { +export function ProjectGate({ + productName, + reason, + onChoose, +}: { + productName: string; + reason?: string; + onChoose: () => void; +}) { return (
-

Open a Flavian project

+

Open a {productName} project

- Point Flavian at your project folder — the directory that contains{' '} + Point {productName} at your project folder — the directory that contains{' '} wordpress-local.sh and scripts/. The app reads and writes that - folder (themes, .env, conversion output), so it must be a real Flavian + folder (themes, .env, conversion output), so it must be a real {productName}{' '} checkout.

{reason && ( diff --git a/packages/gui/src/renderer/components/QaPanel.tsx b/packages/gui/src/renderer/components/QaPanel.tsx index cda5403..5c0992c 100644 --- a/packages/gui/src/renderer/components/QaPanel.tsx +++ b/packages/gui/src/renderer/components/QaPanel.tsx @@ -1,10 +1,14 @@ import { useCallback, useEffect, useState } from 'react'; +import { activeManifest, getScreen } from '../../shared/product'; import type { QaArtifacts, QaScript, VisualResult } from '../../shared/types/qa'; import { bridge } from '../api/bridge'; import { useTaskStream } from '../hooks/useTaskStream'; import { ArtifactImage } from './ArtifactImage'; import { LogStream } from './LogStream'; +// The runnable QA scripts come from the manifest (`label` = heading, `cta` = button). +const QA_STEPS = getScreen(activeManifest, 'qa').steps; + function VisualDiffRow({ result }: { result: VisualResult }) { return (
@@ -99,12 +103,16 @@ export function QaPanel() {

- - + {QA_STEPS.map((s) => ( + + ))}
{active && ( diff --git a/packages/gui/src/renderer/components/WizardPanel.tsx b/packages/gui/src/renderer/components/WizardPanel.tsx index e4d4957..cb4f647 100644 --- a/packages/gui/src/renderer/components/WizardPanel.tsx +++ b/packages/gui/src/renderer/components/WizardPanel.tsx @@ -1,16 +1,12 @@ import { useEffect, useState, type FormEvent } from 'react'; +import { activeManifest, getScreen } from '../../shared/product'; import type { InitInput, MultisiteMode, ThemeStarter } from '../../shared/types/init'; import { bridge } from '../api/bridge'; import { useInit } from '../hooks/useInit'; import { LogStream } from './LogStream'; -const THEMES: { value: ThemeStarter; label: string }[] = [ - { value: 'blank', label: 'Blank FSE theme' }, - { value: 'flavian-shop', label: 'flavian-shop (WooCommerce-ready)' }, - { value: 'canva', label: 'Canva export → FSE theme' }, - { value: 'figma', label: 'Figma import placeholder' }, - { value: 'indesign', label: 'InDesign import placeholder' }, -]; +// The offered theme starters come from the manifest's wizard screen. +const THEMES = getScreen(activeManifest, 'wizard').extras?.themeStarters ?? []; const DEFAULT_INPUT: InitInput = { name: '', diff --git a/packages/gui/src/shared/product/aurelius.ts b/packages/gui/src/shared/product/aurelius.ts new file mode 100644 index 0000000..e4b9a90 --- /dev/null +++ b/packages/gui/src/shared/product/aurelius.ts @@ -0,0 +1,54 @@ +/** + * ╔══════════════════════════════════════════════════════════════════════════════╗ + * ║ EXTENSION-POINT SCAFFOLD — NOT WIRED IN, NOT FUNCTIONAL (yet). ║ + * ║ ║ + * ║ This file exists to show, concretely, how the "prove reuse with Aurelius" ║ + * ║ stage (see docs/GUI-PLATFORM.md) lands: a second product is *only* another ║ + * ║ ProductManifest — no engine, core, or renderer change. To activate it: ║ + * ║ 1. fill in the real screens/steps/commands below (delete the placeholders); ║ + * ║ 2. register it in ./index.ts → PRODUCTS; ║ + * ║ 3. point ACTIVE_PRODUCT_ID at it (or select per-product at runtime under ║ + * ║ the unified Trajan shell). ║ + * ║ ║ + * ║ It is deliberately NOT imported anywhere, so it ships in no bundle; it is ║ + * ║ type-checked only, to keep the manifest contract honest. ║ + * ╚══════════════════════════════════════════════════════════════════════════════╝ + */ +import type { ProductManifest } from './manifest'; + +export const aureliusManifest: ProductManifest = { + id: 'aurelius', + displayName: 'Aurelius', + + // TODO(aurelius): replace with the real checkout markers + chooser copy. + project: { + markers: ['TODO-root-marker'], + packageName: 'aurelius', + invalidReason: 'TODO: not an Aurelius project.', + notFoundReason: 'TODO: no Aurelius project found.', + selectTitle: 'Select your Aurelius project folder', + selectMessage: 'TODO: describe the Aurelius checkout to choose.', + }, + + // TODO(aurelius): declare Aurelius's real screens and steps. One placeholder screen + // is shown so the shape is obvious; the engine renders whatever is listed here. + screens: [ + { + id: 'prereq', + navLabel: 'Prerequisites', + title: 'Prerequisites', + prerequisites: [{ kind: 'project' }], + steps: [ + { + id: 'check', + label: 'Re-check', + taskKind: 'prereq-check', + command: { exec: 'bashScript', script: 'scripts/check-prerequisites.sh' }, + parser: 'prereq', + successExitCodes: [0, 1], + prerequisites: [{ kind: 'project' }], + }, + ], + }, + ], +}; diff --git a/packages/gui/src/shared/product/flavian.ts b/packages/gui/src/shared/product/flavian.ts new file mode 100644 index 0000000..fed8f00 --- /dev/null +++ b/packages/gui/src/shared/product/flavian.ts @@ -0,0 +1,215 @@ +/** + * The Flavian product manifest — an exact, declarative reproduction of the behaviour + * the GUI shipped hard-coded through v1.10.0. Every screen, step, command, parser and + * piece of project-detection copy that used to live scattered across core/main/renderer + * now originates here. Changing the app's catalog means editing this object; the engine + * is untouched. + * + * Faithfulness notes (these MUST match the prior hard-coded values byte-for-byte to keep + * the user-visible behaviour identical): + * - prereq runs `bash scripts/check-prerequisites.sh`; exit 0 OR 1 is task-success. + * - docker maps 1:1 to `bash wordpress-local.sh [arg]`. + * - qa runs the pnpm scripts via `bash -c`. + * - pipeline: Figma ⇒ headless `claude -p`, InDesign ⇒ `node bin/flavian.mjs …`, + * Canva ⇒ the wizard's Canva path (delegate). + * - wizard imports scripts/init/{default-resolver,apply}.mjs and runs apply() in-process. + */ +import type { DockerCommand } from '../types/docker'; +import type { PipelineKind } from '../types/pipeline'; +import type { QaScript } from '../types/qa'; +import type { ProductManifest } from './manifest'; + +/** Headless-Claude instruction for a Figma conversion. `{figmaUrl}`/`{slug}` are filled at run time. */ +const FIGMA_PROMPT = + 'Convert the Figma design at {figmaUrl} into a WordPress FSE block theme using ' + + 'the figma-to-fse-autonomous-workflow. Use the theme slug "{slug}" and write the ' + + 'theme to themes/{slug}. Work autonomously through to a validated theme.'; + +/** Helper to keep docker step ids honestly typed against the shared vocabulary. */ +const docker = ( + id: DockerCommand, + label: string, + group: 'lifecycle' | 'logs' | 'themes', + argsTemplate: readonly string[], +) => + ({ + id, + label, + group, + taskKind: `docker:${id}`, + command: { exec: 'bashScript', script: 'wordpress-local.sh', argsTemplate }, + }) as const; + +export const flavianManifest: ProductManifest = { + id: 'flavian', + displayName: 'Flavian', + + project: { + markers: ['wordpress-local.sh', 'scripts/check-prerequisites.sh'], + packageName: 'flavian', + invalidReason: + 'Not a Flavian project — expected wordpress-local.sh, scripts/check-prerequisites.sh, and a package.json named "flavian".', + notFoundReason: 'No Flavian project found while walking up from the start directory.', + selectTitle: 'Select your Flavian project folder', + selectMessage: 'Choose the directory that contains wordpress-local.sh and scripts/.', + }, + + screens: [ + { + id: 'prereq', + navLabel: 'Prerequisites', + title: 'Prerequisites', + prerequisites: [{ kind: 'project' }], + steps: [ + { + id: 'check', + label: 'Re-check', + taskKind: 'prereq-check', + command: { exec: 'bashScript', script: 'scripts/check-prerequisites.sh' }, + parser: 'prereq', + // Exit 1 ("requirements missing") is a valid result, not a task failure. + successExitCodes: [0, 1], + prerequisites: [{ kind: 'project' }], + }, + ], + }, + + { + id: 'wizard', + navLabel: 'Setup wizard', + title: 'Setup wizard', + prerequisites: [{ kind: 'project' }], + extras: { + themeStarters: [ + { value: 'blank', label: 'Blank FSE theme' }, + { value: 'flavian-shop', label: 'flavian-shop (WooCommerce-ready)' }, + { value: 'canva', label: 'Canva export → FSE theme' }, + { value: 'figma', label: 'Figma import placeholder' }, + { value: 'indesign', label: 'InDesign import placeholder' }, + ], + }, + steps: [ + { + id: 'apply', + label: 'Run setup', + taskKind: 'init', + // The engine imports /default-resolver.mjs + /apply.mjs and + // runs apply() in-process — the same code path as `pnpm run init`. + command: { exec: 'module', module: 'scripts/init' }, + prerequisites: [{ kind: 'project' }], + }, + ], + }, + + { + id: 'docker', + navLabel: 'WordPress', + title: 'WordPress (Docker)', + prerequisites: [{ kind: 'project' }], + extras: { + links: [ + { label: 'Site', url: 'http://localhost:8080' }, + { label: 'wp-admin', url: 'http://localhost:8080/wp-admin' }, + { label: 'Database (phpMyAdmin)', url: 'http://localhost:8081' }, + ], + }, + steps: [ + docker('build', 'Build', 'lifecycle', ['build']), + docker('start', 'Start', 'lifecycle', ['start']), + docker('stop', 'Stop', 'lifecycle', ['stop']), + docker('restart', 'Restart', 'lifecycle', ['restart']), + docker('install', 'Install WP', 'lifecycle', ['install']), + // `label` is the running-task heading ("Streaming logs"); `cta` is the button text. + { ...docker('logs', 'Streaming logs', 'logs', ['logs']), cta: 'Stream logs' }, + docker('list-themes', 'List themes', 'themes', ['list-themes']), + { + ...docker('activate-theme', 'Activate', 'themes', ['activate-theme', '{theme}']), + prerequisites: [ + { kind: 'project' }, + { kind: 'service', service: 'wordpress', hint: 'Start WordPress first, then activate a theme.' }, + ], + }, + ], + }, + + { + id: 'pipeline', + navLabel: 'Convert design', + title: 'Convert design', + prerequisites: [{ kind: 'project' }], + extras: { + docs: { + figma: 'docs/figma-to-wordpress/README.md', + canva: 'docs/canva-to-wordpress/README.md', + indesign: 'docs/pipelines/indesign.md', + }, + }, + steps: [ + { + id: 'figma' satisfies PipelineKind, + label: 'Figma', + taskKind: 'pipeline:figma', + command: { exec: 'claude', argsTemplate: ['-p', FIGMA_PROMPT] }, + prerequisites: [ + { kind: 'project' }, + { + kind: 'tool', + tool: 'claude', + hint: 'Figma conversion opens a headless Claude Code session with Figma access.', + }, + ], + }, + { + id: 'canva' satisfies PipelineKind, + label: 'Canva', + taskKind: 'pipeline:canva', + // Canva reuses the wizard's Canva path so there is one Canva code path. + command: { exec: 'delegate', toScreen: 'wizard' }, + prerequisites: [{ kind: 'project' }], + }, + { + id: 'indesign' satisfies PipelineKind, + label: 'InDesign', + taskKind: 'pipeline:indesign', + command: { + exec: 'nodeBin', + script: 'bin/flavian.mjs', + argsTemplate: ['pipeline', 'indesign', '{file}', '--slug', '{slug}'], + }, + prerequisites: [{ kind: 'project' }], + }, + ], + }, + + { + id: 'qa', + navLabel: 'Visual QA', + title: 'Visual QA', + prerequisites: [{ kind: 'project' }], + steps: [ + { + id: 'visual:diff' satisfies QaScript, + label: 'Visual diff', + cta: 'Run visual diff', + taskKind: 'qa:visual:diff', + command: { exec: 'bashCommand', command: 'pnpm run visual:diff' }, + prerequisites: [ + { kind: 'project' }, + { kind: 'service', service: 'wordpress', hint: 'QA needs a running, seeded WordPress site.' }, + ], + }, + { + id: 'lighthouse:run' satisfies QaScript, + label: 'Lighthouse', + cta: 'Run Lighthouse', + taskKind: 'qa:lighthouse:run', + command: { exec: 'bashCommand', command: 'pnpm run lighthouse:run' }, + prerequisites: [ + { kind: 'project' }, + { kind: 'service', service: 'wordpress', hint: 'QA needs a running, seeded WordPress site.' }, + ], + }, + ], + }, + ], +}; diff --git a/packages/gui/src/shared/product/index.ts b/packages/gui/src/shared/product/index.ts new file mode 100644 index 0000000..de58eeb --- /dev/null +++ b/packages/gui/src/shared/product/index.ts @@ -0,0 +1,67 @@ +/** + * Product registry + the single switch point for which product the shell drives. + * + * This is the engine's entry into the per-product layer: main and the renderer both + * import `activeManifest` from here, and `getScreen`/`getStep` are the typed lookups + * the engine uses instead of hard-coding Flavian specifics. + * + * ── Extension point ───────────────────────────────────────────────────────────── + * Adding a second product (the staged plan's "prove reuse with Aurelius" step) is: + * 1. author packages/gui/src/shared/product/aurelius.ts (another ProductManifest); + * 2. register it in PRODUCTS below; + * 3. flip ACTIVE_PRODUCT_ID (or, for the unified Trajan app, choose it at runtime). + * No engine/core/renderer change is required. A scaffold lives in ./aurelius — it is + * intentionally NOT wired in here yet. + * // import { aureliusManifest } from './aurelius'; + * // export const PRODUCTS = { flavian: flavianManifest, aurelius: aureliusManifest }; + * ──────────────────────────────────────────────────────────────────────────────── + */ +import { flavianManifest } from './flavian'; +import type { ProductId, ProductManifest, ProductScreen, ProductStep, ScreenId } from './manifest'; + +export type { ProductManifest, ProductScreen, ProductStep, ScreenId, ProductId } from './manifest'; + +/** Every product the engine knows how to render. */ +export const PRODUCTS: Readonly> = { + flavian: flavianManifest, +}; + +/** The product this build ships. The unified Trajan app will select this at runtime. */ +export const ACTIVE_PRODUCT_ID: ProductId = 'flavian'; + +/** The manifest the engine renders. */ +export const activeManifest: ProductManifest = PRODUCTS[ACTIVE_PRODUCT_ID]; + +/** Look up a screen by id (throws on a misconfigured manifest — a programming error). */ +export function getScreen(manifest: ProductManifest, id: ScreenId): ProductScreen { + const screen = manifest.screens.find((s) => s.id === id); + if (!screen) throw new Error(`Product "${manifest.id}" has no "${id}" screen.`); + return screen; +} + +/** Look up a step within a screen by id (throws if absent). */ +export function getStep(screen: ProductScreen, id: string): ProductStep { + const step = screen.steps.find((s) => s.id === id); + if (!step) throw new Error(`Screen "${screen.id}" has no "${id}" step.`); + return step; +} + +/** The sole step of a single-operation screen (prereq, wizard). */ +export function soleStep(manifest: ProductManifest, screenId: ScreenId): ProductStep { + const screen = getScreen(manifest, screenId); + const step = screen.steps[0]; + if (!step) throw new Error(`Screen "${screenId}" has no steps.`); + return step; +} + +/** + * The repo-relative directory the init wizard imports its resolver/apply from. + * Read from the wizard step's `module` command so the path lives only in the manifest. + */ +export function initModuleDir(manifest: ProductManifest): string { + const { command } = soleStep(manifest, 'wizard'); + if (command.exec !== 'module') { + throw new Error(`Product "${manifest.id}" wizard step is not a 'module' command.`); + } + return command.module; +} diff --git a/packages/gui/src/shared/product/manifest.ts b/packages/gui/src/shared/product/manifest.ts new file mode 100644 index 0000000..6c8cb07 --- /dev/null +++ b/packages/gui/src/shared/product/manifest.ts @@ -0,0 +1,146 @@ +/** + * Product manifest — the typed descriptor the GUI engine renders a product from. + * + * This is the seam that turns the shell from "the Flavian app" into a generic + * engine: the engine (core + main + the renderer shell) reads *what to show and + * what to run* from a ProductManifest, and the only product-specific knowledge + * lives in the per-product manifest object (see ./flavian). A second product + * (Aurelius, Nerva, …) is added by supplying another manifest — not by editing + * the engine. See docs/GUI-PLATFORM.md. + * + * It deliberately speaks the vocabulary the engine already uses — TaskKind, + * DockerCommand, QaScript, PipelineKind, ThemeStarter — rather than inventing a + * parallel language. A manifest is PURE, SERIALIZABLE DATA (no functions, no + * node imports) so it is safe to import from the sandboxed renderer as well as + * from main/preload; behaviour (building a RunSpec, parsing output) is bound in + * core via the typed keys below. + */ +import type { ThemeStarter } from '../types/init'; +import type { TaskKind } from '../types/task'; + +/** Stable identifier for a product the engine can drive. */ +export type ProductId = 'flavian' | 'aurelius' | 'nerva' | (string & {}); + +/** + * The screens a product exposes in the shell. These mirror the renderer's views; + * `(string & {})` keeps literal autocomplete while letting a product add its own. + */ +export type ScreenId = 'prereq' | 'wizard' | 'docker' | 'pipeline' | 'qa' | (string & {}); + +/** + * How a step's command is executed. The generic engine (core/product/command-spec) + * interprets a descriptor into a RunSpec via CommandBuilder — there is no + * per-product code path. Script/module paths are PROJECT-ROOT-RELATIVE; the engine + * resolves them against the active project root. `argsTemplate` entries may contain + * `{name}` placeholders filled from the step's runtime variables (e.g. a theme + * slug, a Figma URL); see buildCommandSpec. + */ +export type CommandDescriptor = + | { readonly exec: 'bashScript'; readonly script: string; readonly argsTemplate?: readonly string[] } + | { readonly exec: 'bashCommand'; readonly command: string } + | { readonly exec: 'nodeBin'; readonly script: string; readonly argsTemplate?: readonly string[] } + | { readonly exec: 'claude'; readonly argsTemplate: readonly string[] } + /** Dynamic-import a repo module and run it in-process (the init wizard's apply()). */ + | { readonly exec: 'module'; readonly module: string } + /** Reuse another screen's flow rather than spawning a command (Canva ⇒ the wizard). */ + | { readonly exec: 'delegate'; readonly toScreen: ScreenId }; + +/** + * Key into the engine's output-parser registry (core/product/parsers). A step that + * turns streamed text into a structured result names its parser here instead of the + * engine hard-coding one. + */ +export type ParserKey = 'prereq' | 'composePs'; + +/** + * A precondition the engine/shell can reason about. Only `project` is *enforced* + * today (the shell disables a screen until a valid project is open); `tool` and + * `service` reproduce the app's existing advisory guidance so the manifest captures + * it without changing behaviour. + */ +export type Prerequisite = + | { readonly kind: 'project' } + | { readonly kind: 'tool'; readonly tool: string; readonly hint: string } + | { readonly kind: 'service'; readonly service: string; readonly hint: string }; + +/** + * One operation a screen can run. `id` reuses the screen's own vocabulary — a + * DockerCommand for the WordPress screen, a QaScript for QA, a PipelineKind for + * conversions — so a step *is* the thing the engine already knows how to dispatch. + */ +export interface ProductStep { + /** Stable id; for docker/qa/pipeline screens this is the DockerCommand/QaScript/PipelineKind. */ + readonly id: string; + /** Canonical human label (headings, task names). */ + readonly label: string; + /** Optional call-to-action text where a panel's button differs from `label`. */ + readonly cta?: string; + /** The task kind this step produces — reuses the shared TaskKind vocabulary. */ + readonly taskKind: TaskKind; + /** Optional grouping hint for panels that lay steps out in sections (e.g. docker). */ + readonly group?: string; + /** How to build and run the command. */ + readonly command: CommandDescriptor; + /** Which parser turns the command's output into a structured result (if any). */ + readonly parser?: ParserKey; + /** Exit codes the engine treats as task-success. Default `[0]`; prereq allows `[0, 1]`. */ + readonly successExitCodes?: readonly number[]; + /** Preconditions for this step. */ + readonly prerequisites?: readonly Prerequisite[]; +} + +/** + * Declarative, presentation-only extras a bespoke panel reads from the manifest. + * These stay data (not behaviour) so the catalog lives in one place even though the + * panel that renders them is product-specific. + */ +export interface ScreenExtras { + /** WordPress screen: external links surfaced when a container is up. */ + readonly links?: readonly { readonly label: string; readonly url: string }[]; + /** Wizard screen: the theme starters offered. */ + readonly themeStarters?: readonly { readonly value: ThemeStarter; readonly label: string }[]; + /** Pipeline screen: per-kind reference docs (repo-relative). */ + readonly docs?: Readonly>; +} + +/** A screen in the shell: a nav entry plus the steps it can run. */ +export interface ProductScreen { + readonly id: ScreenId; + /** Label shown in the sidebar nav. */ + readonly navLabel: string; + /** Heading shown at the top of the screen. */ + readonly title: string; + readonly steps: readonly ProductStep[]; + /** Screen-level preconditions (every screen is project-gated today). */ + readonly prerequisites?: readonly Prerequisite[]; + readonly extras?: ScreenExtras; +} + +/** + * What makes a directory a valid checkout of this product, plus the copy the shell + * shows when detection fails / a folder is being chosen. Owned by the manifest so + * the engine's project detection (core/project/locate-root) stays generic. + */ +export interface ProjectIdentity { + /** Files (project-root-relative) that must all be present. */ + readonly markers: readonly string[]; + /** Required `name` in the root package.json. */ + readonly packageName: string; + /** Reason surfaced when a chosen folder is not a valid checkout. */ + readonly invalidReason: string; + /** Reason surfaced when no checkout is found while walking up from a start dir. */ + readonly notFoundReason: string; + /** Native folder-picker title. */ + readonly selectTitle: string; + /** Native folder-picker message. */ + readonly selectMessage: string; +} + +/** The single typed descriptor the engine renders any product from. */ +export interface ProductManifest { + readonly id: ProductId; + /** Brand shown in the shell. */ + readonly displayName: string; + readonly project: ProjectIdentity; + readonly screens: readonly ProductScreen[]; +} diff --git a/packages/gui/tests/core/docker/docker.test.mts b/packages/gui/tests/core/docker/docker.test.mts index 1c440cd..35139cd 100644 --- a/packages/gui/tests/core/docker/docker.test.mts +++ b/packages/gui/tests/core/docker/docker.test.mts @@ -1,14 +1,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { parseComposePs } from '../../../src/core/docker/docker-status'; -import { dockerCommandSpec, isDockerCommand } from '../../../src/core/docker/docker-commands'; -import { CommandBuilder } from '../../../src/core/shell/command-builder'; -import type { ShellResolver } from '../../../src/core/shell/shell-resolver'; -const fakeShell: ShellResolver = { - resolveBash: async () => 'bash', - resolveTool: async () => null, -}; +// NOTE: building the `wordpress-local.sh ` spec is now manifest-driven and +// covered in tests/core/product/command-spec.test.mts (docker steps). This file keeps +// the `docker compose ps` JSON parsing, which is generic engine. test('parseComposePs handles a JSON array (Publishers → ports)', () => { const raw = JSON.stringify([ @@ -50,21 +46,3 @@ test('parseComposePs returns [] for empty or non-JSON input', () => { assert.deepEqual(parseComposePs(''), []); assert.deepEqual(parseComposePs('not json'), []); }); - -test('dockerCommandSpec builds a bash wordpress-local.sh invocation', async () => { - const spec = await dockerCommandSpec(new CommandBuilder(fakeShell), '/repo', 'start'); - assert.equal(spec.command, 'bash'); - assert.ok(spec.args[0].endsWith('wordpress-local.sh')); - assert.equal(spec.args[1], 'start'); -}); - -test('dockerCommandSpec forwards an argument (activate-theme)', async () => { - const spec = await dockerCommandSpec(new CommandBuilder(fakeShell), '/repo', 'activate-theme', 'my-theme'); - assert.deepEqual(spec.args.slice(1), ['activate-theme', 'my-theme']); -}); - -test('isDockerCommand validates the command set', () => { - assert.ok(isDockerCommand('start')); - assert.ok(isDockerCommand('activate-theme')); - assert.ok(!isDockerCommand('rm -rf')); -}); diff --git a/packages/gui/tests/core/pipelines/pipeline-run.test.mts b/packages/gui/tests/core/pipelines/pipeline-run.test.mts index b45ea08..7435bc3 100644 --- a/packages/gui/tests/core/pipelines/pipeline-run.test.mts +++ b/packages/gui/tests/core/pipelines/pipeline-run.test.mts @@ -1,8 +1,17 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { figmaPrompt, figmaSpec, indesignSpec } from '../../../src/core/pipelines/pipeline-run'; +import { createPipelineRun } from '../../../src/core/pipelines/pipeline-run'; import { CommandBuilder } from '../../../src/core/shell/command-builder'; +import { getScreen, getStep, initModuleDir } from '../../../src/shared/product'; +import { flavianManifest } from '../../../src/shared/product/flavian'; import type { ShellResolver } from '../../../src/core/shell/shell-resolver'; +import type { + ProcessEvent, + ProcessResult, + ProcessRunner, + RunHandle, + RunSpec, +} from '../../../src/core/process/runner-types'; import type { PipelineInput } from '../../../src/shared/types/pipeline'; const fakeShell: ShellResolver = { @@ -11,32 +20,108 @@ const fakeShell: ShellResolver = { }; const commands = new CommandBuilder(fakeShell); -test('figmaPrompt embeds the URL and slug', () => { - const prompt = figmaPrompt('https://figma.com/design/x', 'my-theme'); - assert.match(prompt, /https:\/\/figma\.com\/design\/x/); - assert.match(prompt, /my-theme/); -}); +const pipeline = getScreen(flavianManifest, 'pipeline'); +const figmaCommand = getStep(pipeline, 'figma').command; +const indesignCommand = getStep(pipeline, 'indesign').command; +const canvaCommand = getStep(pipeline, 'canva').command; +const moduleDir = initModuleDir(flavianManifest); + +/** A ProcessRunner that records the spec it was handed, then exits with a code. */ +class CaptureRunner implements ProcessRunner { + lastSpec: RunSpec | null = null; + constructor(private readonly code = 0) {} + run(spec: RunSpec, onEvent: (e: ProcessEvent) => void): RunHandle { + this.lastSpec = spec; + let settle!: (r: ProcessResult) => void; + const done = new Promise((resolve) => { + settle = resolve; + }); + queueMicrotask(() => { + onEvent({ type: 'exit', code: this.code, signal: null }); + settle({ code: this.code, signal: null }); + }); + return { pid: 1, done, cancel: () => {} }; + } +} -test('figmaSpec builds a `claude -p` invocation', async () => { +test('figma pipeline renders a `claude -p` spec embedding the URL and slug', async () => { + const runner = new CaptureRunner(0); const input: PipelineInput = { kind: 'figma', slug: 'my-theme', figmaUrl: 'https://figma.com/x' }; - const spec = await figmaSpec(commands, '/repo', input); - assert.equal(spec.command, 'claude'); // fake resolver → null → bare 'claude' - assert.equal(spec.args[0], '-p'); - assert.match(spec.args[1], /my-theme/); + const run = await createPipelineRun({ + repoRoot: '/repo', + input, + runner, + commands, + command: figmaCommand, + initModuleDir: moduleDir, + }); + run.run(() => {}); + const result = await run.result; + assert.equal(result.ok, true); + assert.equal(runner.lastSpec?.command, 'claude'); + assert.equal(runner.lastSpec?.args[0], '-p'); + assert.match(String(runner.lastSpec?.args[1]), /https:\/\/figma\.com\/x/); + assert.match(String(runner.lastSpec?.args[1]), /my-theme/); +}); + +test('indesign pipeline renders the `node bin/flavian.mjs` CLI spec', async () => { + const runner = new CaptureRunner(0); + const input: PipelineInput = { kind: 'indesign', slug: 'broch', indesignFile: './a.idml' }; + const run = await createPipelineRun({ + repoRoot: '/repo', + input, + runner, + commands, + command: indesignCommand, + initModuleDir: moduleDir, + }); + run.run(() => {}); + await run.result; + assert.ok(runner.lastSpec?.args[0].endsWith('flavian.mjs')); + assert.deepEqual(runner.lastSpec?.args.slice(1), [ + 'pipeline', + 'indesign', + './a.idml', + '--slug', + 'broch', + ]); }); -test('figmaSpec rejects without a URL', async () => { - await assert.rejects(() => figmaSpec(commands, '/repo', { kind: 'figma', slug: 's' })); +test('figma rejects without a URL (before any task is created)', async () => { + await assert.rejects(() => + createPipelineRun({ + repoRoot: '/repo', + input: { kind: 'figma', slug: 's' }, + runner: new CaptureRunner(0), + commands, + command: figmaCommand, + initModuleDir: moduleDir, + }), + ); }); -test('indesignSpec builds a `flavian pipeline indesign` invocation', () => { - const input: PipelineInput = { kind: 'indesign', slug: 'broch', indesignFile: './a.idml' }; - const spec = indesignSpec(commands, '/repo', input); - assert.equal(spec.command, process.execPath); - assert.ok(spec.args[0].endsWith('flavian.mjs')); - assert.deepEqual(spec.args.slice(1), ['pipeline', 'indesign', './a.idml', '--slug', 'broch']); +test('indesign rejects without a file', async () => { + await assert.rejects(() => + createPipelineRun({ + repoRoot: '/repo', + input: { kind: 'indesign', slug: 's' }, + runner: new CaptureRunner(0), + commands, + command: indesignCommand, + initModuleDir: moduleDir, + }), + ); }); -test('indesignSpec throws without a file', () => { - assert.throws(() => indesignSpec(commands, '/repo', { kind: 'indesign', slug: 's' })); +test('canva (delegate) rejects without an export directory', async () => { + await assert.rejects(() => + createPipelineRun({ + repoRoot: '/repo', + input: { kind: 'canva', slug: 's' }, + runner: new CaptureRunner(0), + commands, + command: canvaCommand, + initModuleDir: moduleDir, + }), + ); }); diff --git a/packages/gui/tests/core/prerequisites/run-prerequisites.test.mts b/packages/gui/tests/core/prerequisites/run-prerequisites.test.mts index cc5642b..466ea02 100644 --- a/packages/gui/tests/core/prerequisites/run-prerequisites.test.mts +++ b/packages/gui/tests/core/prerequisites/run-prerequisites.test.mts @@ -4,7 +4,11 @@ import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { createPrereqRun } from '../../../src/core/prerequisites/run-prerequisites'; +import { buildCommandSpec } from '../../../src/core/product/command-spec'; +import { streamResultParser } from '../../../src/core/product/parsers'; import { CommandBuilder } from '../../../src/core/shell/command-builder'; +import { soleStep } from '../../../src/shared/product'; +import { flavianManifest } from '../../../src/shared/product/flavian'; import type { ShellResolver } from '../../../src/core/shell/shell-resolver'; import type { ProcessEvent, @@ -23,6 +27,14 @@ const fakeShell: ShellResolver = { resolveTool: async () => null, }; +const prereqStep = soleStep(flavianManifest, 'prereq'); + +/** Build the prereq spec + run the way the engine does — from the manifest step. */ +async function makePrereqRun(runner: ProcessRunner) { + const spec = await buildCommandSpec(new CommandBuilder(fakeShell), '/repo', prereqStep.command); + return createPrereqRun({ runner, spec, parse: streamResultParser(prereqStep.parser) }); +} + /** A ProcessRunner that replays a fixed transcript, then exits with a code. */ class FakeRunner implements ProcessRunner { lastSpec: RunSpec | null = null; @@ -48,12 +60,7 @@ class FakeRunner implements ProcessRunner { test('builds a bash RunSpec targeting check-prerequisites.sh and parses the report', async () => { const runner = new FakeRunner(await fixture('prereq-all-pass.txt'), 0); - const prereq = await createPrereqRun({ - runner, - commands: new CommandBuilder(fakeShell), - scriptsDir: '/repo/scripts', - repoRoot: '/repo', - }); + const prereq = await makePrereqRun(runner); assert.equal(prereq.spec.command, 'bash'); assert.ok(prereq.spec.args[0]?.endsWith('check-prerequisites.sh')); @@ -66,12 +73,7 @@ test('builds a bash RunSpec targeting check-prerequisites.sh and parses the repo test('exit code 1 (missing requirements) still yields a parsed, not-ready report', async () => { const runner = new FakeRunner(await fixture('prereq-with-failures.txt'), 1); - const prereq = await createPrereqRun({ - runner, - commands: new CommandBuilder(fakeShell), - scriptsDir: '/repo/scripts', - repoRoot: '/repo', - }); + const prereq = await makePrereqRun(runner); prereq.run(() => {}); const report = await prereq.result; diff --git a/packages/gui/tests/core/product/command-spec.test.mts b/packages/gui/tests/core/product/command-spec.test.mts new file mode 100644 index 0000000..9ebf52d --- /dev/null +++ b/packages/gui/tests/core/product/command-spec.test.mts @@ -0,0 +1,94 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildCommandSpec, fillTemplate } from '../../../src/core/product/command-spec'; +import { CommandBuilder } from '../../../src/core/shell/command-builder'; +import { getScreen, getStep, soleStep } from '../../../src/shared/product'; +import { flavianManifest } from '../../../src/shared/product/flavian'; +import type { ShellResolver } from '../../../src/core/shell/shell-resolver'; + +// Proves the generic engine renders a concrete command from a manifest's declarative +// CommandDescriptor — no per-product code path. Uses a fake shell so resolution is +// deterministic, then drives the REAL Flavian manifest steps. +const fakeShell: ShellResolver = { + resolveBash: async () => 'bash', + resolveTool: async () => null, +}; +const commands = new CommandBuilder(fakeShell); + +test('fillTemplate substitutes {placeholders} and passes through the rest', () => { + assert.deepEqual(fillTemplate(['a', '{x}', 'c-{y}'], { x: 'B', y: 'D' }), ['a', 'B', 'c-D']); + assert.deepEqual(fillTemplate(undefined, {}), []); + assert.deepEqual(fillTemplate(['{missing}'], {}), ['']); // unknown placeholder → '' +}); + +test('bashScript descriptor → bash /