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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/gui/docs/GUI-PLATFORM.md
Original file line number Diff line number Diff line change
@@ -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/<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` |
31 changes: 0 additions & 31 deletions packages/gui/src/core/docker/docker-commands.ts

This file was deleted.

7 changes: 5 additions & 2 deletions packages/gui/src/core/init/run-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -47,10 +49,11 @@ export interface InitRun {
* with its logger wired to the task's stream.
*/
export async function createInitRun(deps: RunInitDeps): Promise<InitRun> {
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 };
Expand Down
70 changes: 27 additions & 43 deletions packages/gui/src/core/pipelines/pipeline-run.ts
Original file line number Diff line number Diff line change
@@ -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<RunSpec> {
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 {
Expand All @@ -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 "<prompt>"` 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<PipelineRun> {
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: '',
Expand All @@ -80,10 +56,18 @@ export async function createPipelineRun(deps: PipelineDeps): Promise<PipelineRun
};
}

const spec =
kind === 'figma'
? await figmaSpec(deps.commands, deps.repoRoot, deps.input)
: indesignSpec(deps.commands, deps.repoRoot, deps.input);
if (kind === 'figma' && !deps.input.figmaUrl?.trim()) throw new Error('A Figma file URL is required.');
if (kind === 'indesign' && !deps.input.indesignFile?.trim())
throw new Error('An .idml or .pdf file is required.');

// One vars bag for every conversion descriptor; fillTemplate uses whichever
// placeholders the manifest's argsTemplate actually references.
const vars = {
figmaUrl: deps.input.figmaUrl?.trim() ?? '',
file: deps.input.indesignFile?.trim() ?? '',
slug,
};
const spec = await buildCommandSpec(deps.commands, deps.repoRoot, deps.command, vars);

let resolveResult!: (result: PipelineResult) => void;
const result = new Promise<PipelineResult>((resolve) => {
Expand Down
33 changes: 13 additions & 20 deletions packages/gui/src/core/prerequisites/run-prerequisites.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,31 +19,28 @@ 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<PrereqRun> {
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<PrereqReport>((resolve) => {
resolveResult = resolve;
});

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 };
}
54 changes: 54 additions & 0 deletions packages/gui/src/core/product/command-spec.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;

/** 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<RunSpec> {
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.`,
);
}
}
23 changes: 23 additions & 0 deletions packages/gui/src/core/product/parsers.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ParserKey, StreamResultParser>> = {
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;
}
Loading
Loading